from __future__ import annotations import secrets from dataclasses import dataclass from datetime import timedelta from decimal import Decimal from decimal import InvalidOperation from django.conf import settings from django.db import IntegrityError, transaction from django.utils import timezone from apps.licensing.models import ( ClientDevice, DeviceCredential, DeviceBindingAudit, DeviceSession, LegacyMigrationGrant, LicenseEvent, LicenseSeat, MigrationRequest, SoftwareEntitlement, SoftwareOrder, SoftwarePlan, ) class DeviceRegistrationError(Exception): def __init__(self, code: str, message: str): self.code = code self.message = message super().__init__(message) class DeviceSessionValidationError(DeviceRegistrationError): pass class LicensingError(Exception): def __init__(self, code: str, message: str): self.code = code self.message = message super().__init__(message) class SoftwareOrderError(LicensingError): pass class SoftwareOrderNotFoundError(SoftwareOrderError): def __init__(self, order_no: str): super().__init__("software_order_not_found", f"软件订单不存在:{order_no}") class SoftwareOrderAmountMismatchError(SoftwareOrderError): def __init__(self): super().__init__("amount_mismatch", "支付回调金额与本地软件订单金额不一致") class SoftwareOrderPayMethodMismatchError(SoftwareOrderError): def __init__(self): super().__init__("bad_request", "支付回调通道与本地软件订单不一致") class SoftwareOrderTransactionMismatchError(SoftwareOrderError): def __init__(self): super().__init__("bad_request", "支付交易号与已处理软件订单不一致") @dataclass(frozen=True) class SoftwarePaymentResult: order: SoftwareOrder entitlement: SoftwareEntitlement applied: bool @dataclass(frozen=True) class AuthorizationDecision: product_code: str allowed: bool code: str credential_id: int | None = None @property def would_reject(self) -> bool: return not self.allowed @dataclass(frozen=True) class SubscriptionAuthorizationDecision: product_code: str allowed: bool code: str entitlement: SoftwareEntitlement | None = None @property def would_reject(self) -> bool: return not self.allowed @dataclass(frozen=True) class DeviceRegistrationResult: device: ClientDevice session: DeviceSession session_token: str created: bool def _session_expiry(now): return now + timedelta(seconds=max(60, settings.DEVICE_SESSION_TTL_SECONDS)) def _find_or_create_device(*, user, product_code, device_id_version, device_id, public_key, platform, client_version, now): device_fingerprint = ClientDevice.fingerprint_device_id(device_id_version, device_id) public_key_fingerprint = ClientDevice.fingerprint_public_key(public_key) lookup = { "user": user, "product_code": product_code, "device_fingerprint": device_fingerprint, } device = ClientDevice.objects.select_for_update().filter(**lookup).first() created = False if device is None: try: with transaction.atomic(): device = ClientDevice.objects.create( **lookup, device_id_version=device_id_version, public_key_fingerprint=public_key_fingerprint, platform=platform, client_version=client_version, last_seen_at=now, ) created = True except IntegrityError: device = ClientDevice.objects.select_for_update().get(**lookup) if device.status != ClientDevice.Status.ACTIVE: raise DeviceRegistrationError("device_revoked", "设备已被吊销") if not device.public_key_fingerprint == public_key_fingerprint: raise DeviceRegistrationError("device_identity_mismatch", "设备身份校验失败") updates = [] if device.platform != platform: device.platform = platform updates.append("platform") if device.client_version != client_version: device.client_version = client_version updates.append("client_version") update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS)) if now - device.last_seen_at >= update_after: device.last_seen_at = now updates.append("last_seen_at") if updates: device.save(update_fields=[*updates, "updated_at"]) return device, created @transaction.atomic def register_device(*, user, api_key, product_code, device_id_version, device_id, public_key, platform, client_version): now = timezone.now() device, created = _find_or_create_device( user=user, product_code=product_code, device_id_version=device_id_version, device_id=device_id, public_key=public_key, platform=platform, client_version=client_version, now=now, ) # Registration rotates the short-lived session while keeping one device row. DeviceSession.objects.filter( device=device, revoked_at__isnull=True, expires_at__gt=now, ).update(revoked_at=now) raw_token = DeviceSession.generate_plaintext_token() session = DeviceSession.objects.create( device=device, token_hash=DeviceSession.hash_token(raw_token), expires_at=_session_expiry(now), ) if created: DeviceBindingAudit.objects.create( user=user, device=device, api_key=api_key, action=DeviceBindingAudit.Action.REGISTERED, client_version=client_version, ) DeviceBindingAudit.objects.create( user=user, device=device, api_key=api_key, action=DeviceBindingAudit.Action.SESSION_ISSUED, client_version=client_version, ) return DeviceRegistrationResult( device=device, session=session, session_token=raw_token, created=created, ) @transaction.atomic def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool: now = now or timezone.now() session = ( DeviceSession.objects.select_for_update() .select_related("device", "device__user") .get(pk=session.pk) ) if not session.is_active_at(now) or session.device.status != ClientDevice.Status.ACTIVE: raise DeviceRegistrationError("device_revoked", "设备会话不可用") update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS)) last_seen_at = session.device.last_seen_at if now - last_seen_at < update_after: return False ClientDevice.objects.filter(pk=session.device_id).update(last_seen_at=now) DeviceSession.objects.filter(pk=session.pk).update(last_used_at=now) DeviceBindingAudit.objects.create( user=session.device.user, device=session.device, action=DeviceBindingAudit.Action.HEARTBEAT, client_version=session.device.client_version, ) return True def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession | None: raw_token = str(raw_token or "").strip() if not raw_token: return None try: session = DeviceSession.objects.select_related("device", "device__user").get( token_hash=DeviceSession.hash_token(raw_token) ) except DeviceSession.DoesNotExist as exc: raise DeviceSessionValidationError( "device_session_invalid", "设备会话无效或已过期", ) from exc if not session.matches_token(raw_token) or not session.is_active_at(): raise DeviceSessionValidationError("device_session_invalid", "设备会话无效或已过期") if session.device.status != ClientDevice.Status.ACTIVE: raise DeviceSessionValidationError("device_revoked", "设备已被吊销") if session.device.user_id != user.id: raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号") return session def _required_reason(reason: str) -> str: normalized_reason = str(reason or "").strip() if not normalized_reason: raise LicensingError("reason_required", "必须填写操作原因") return normalized_reason def _create_license_event( *, entitlement: SoftwareEntitlement, action: str, reason: str, actor=None, seat: LicenseSeat | None = None, device: ClientDevice | None = None, metadata: dict | None = None, ) -> LicenseEvent: return LicenseEvent.objects.create( entitlement=entitlement, seat=seat, device=device, actor=actor, action=action, reason=_required_reason(reason), metadata=metadata or {}, ) @transaction.atomic def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None): reason = _required_reason(reason) if plan.status != SoftwarePlan.Status.ACTIVE: raise LicensingError("plan_inactive", "套餐已停用,不能授予权益") now = timezone.now() starts_at = starts_at or now expires_at = starts_at + timedelta(days=plan.duration_days) grace_expires_at = expires_at + timedelta(days=plan.grace_days) entitlement = SoftwareEntitlement.objects.create( user=user, product_code=plan.product_code, source_plan=plan, plan_name=plan.name, plan_duration_days=plan.duration_days, plan_price=plan.price, plan_device_limit=plan.device_limit, plan_grace_days=plan.grace_days, starts_at=starts_at, expires_at=expires_at, grace_expires_at=grace_expires_at, ) LicenseSeat.objects.bulk_create( [ LicenseSeat(entitlement=entitlement, seat_number=seat_number) for seat_number in range(1, plan.device_limit + 1) ] ) _create_license_event( entitlement=entitlement, action=LicenseEvent.Action.GRANTED, reason=reason, actor=actor, metadata={ "source_plan_id": plan.id, "expires_at": expires_at.isoformat(), "device_limit": plan.device_limit, }, ) return entitlement @transaction.atomic def renew_software_entitlement( *, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None, duration_days: int | None = None, grace_days: int | None = None, ): reason = _required_reason(reason) now = now or timezone.now() locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk) if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: raise LicensingError("entitlement_revoked", "已撤销权益不能续期") duration_days = duration_days or locked_entitlement.plan_duration_days grace_days = grace_days if grace_days is not None else locked_entitlement.plan_grace_days if duration_days <= 0 or grace_days < 0: raise LicensingError("invalid_plan_snapshot", "套餐快照无效") extension_start = max(now, locked_entitlement.expires_at) locked_entitlement.expires_at = extension_start + timedelta( days=duration_days ) locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta( days=grace_days ) locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE locked_entitlement.revoked_at = None locked_entitlement.save( update_fields=( "expires_at", "grace_expires_at", "status", "revoked_at", "updated_at", ) ) DeviceCredential.objects.filter( entitlement=locked_entitlement, revoked_at__isnull=True, ).update(expires_at=locked_entitlement.grace_expires_at) _create_license_event( entitlement=locked_entitlement, action=LicenseEvent.Action.RENEWED, reason=reason, actor=actor, metadata={ "extension_start": extension_start.isoformat(), "expires_at": locked_entitlement.expires_at.isoformat(), "duration_days": duration_days, "grace_days": grace_days, }, ) return locked_entitlement @transaction.atomic def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None): reason = _required_reason(reason) now = now or timezone.now() locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk) if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: return locked_entitlement active_credentials = list( DeviceCredential.objects.select_for_update() .filter(entitlement=locked_entitlement, revoked_at__isnull=True) .order_by("id") ) locked_entitlement.status = SoftwareEntitlement.Status.REVOKED locked_entitlement.revoked_at = now locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at")) _create_license_event( entitlement=locked_entitlement, action=LicenseEvent.Action.REVOKED, reason=reason, actor=actor, ) for credential in active_credentials: revoke_device_credential( credential=credential, reason=reason, actor=actor, now=now, ) return locked_entitlement @transaction.atomic def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None): reason = _required_reason(reason) now = now or timezone.now() locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk) if not locked_entitlement.is_usable_at(now): raise LicensingError("entitlement_unavailable", "软件权益当前不可用") if device.user_id != locked_entitlement.user_id: raise LicensingError("device_user_mismatch", "设备不属于权益用户") if device.product_code != locked_entitlement.product_code: raise LicensingError("device_product_mismatch", "设备产品与权益不匹配") if device.status != ClientDevice.Status.ACTIVE: raise LicensingError("device_revoked", "设备已被吊销") seats = list( LicenseSeat.objects.select_for_update() .filter(entitlement=locked_entitlement) .order_by("seat_number") ) existing_seat = next((seat for seat in seats if seat.device_id == device.id), None) if existing_seat is not None: return existing_seat available_seat = next((seat for seat in seats if seat.device_id is None), None) if available_seat is None: raise LicensingError("seat_limit_reached", "设备席位已用完") available_seat.device = device available_seat.bound_at = now available_seat.released_at = None available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at")) _create_license_event( entitlement=locked_entitlement, seat=available_seat, device=device, action=LicenseEvent.Action.SEAT_ASSIGNED, reason=reason, actor=actor, ) return available_seat @transaction.atomic def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None): reason = _required_reason(reason) now = now or timezone.now() locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get( pk=seat.pk ) if locked_seat.device_id is None: return locked_seat previous_device = locked_seat.device locked_seat.device = None locked_seat.released_at = now locked_seat.save(update_fields=("device", "released_at", "updated_at")) _create_license_event( entitlement=locked_seat.entitlement, seat=locked_seat, device=previous_device, action=LicenseEvent.Action.SEAT_RELEASED, reason=reason, actor=actor, ) return locked_seat @transaction.atomic def create_legacy_migration_grant( *, user, plan: SoftwarePlan, reason: str, actor=None, eligibility_snapshot: dict | None = None, ): reason = _required_reason(reason) if LegacyMigrationGrant.objects.filter( user=user, product_code=plan.product_code, ).exists(): raise LicensingError("migration_grant_exists", "该用户已有此产品的迁移资格") entitlement = grant_software_entitlement( user=user, plan=plan, reason=reason, actor=actor, ) snapshot = { "source": "manual", "plan_id": plan.id, "plan_name": plan.name, "plan_device_limit": plan.device_limit, "plan_duration_days": plan.duration_days, } snapshot.update(eligibility_snapshot or {}) grant = LegacyMigrationGrant.objects.create( user=user, product_code=plan.product_code, entitlement=entitlement, eligibility_snapshot=snapshot, reason=reason, actor=actor, ) _create_license_event( entitlement=entitlement, action=LicenseEvent.Action.MIGRATION_GRANTED, reason=reason, actor=actor, metadata={"migration_grant_id": grant.id}, ) return grant @transaction.atomic def create_migration_request(*, user, device: ClientDevice, now=None): now = now or timezone.now() if device.user_id != user.id: raise LicensingError("device_mismatch", "设备不属于当前账号") if device.status != ClientDevice.Status.ACTIVE: raise LicensingError("device_revoked", "设备已被吊销") grant = ( LegacyMigrationGrant.objects.select_related("entitlement") .select_for_update() .filter( user=user, product_code=device.product_code, status=LegacyMigrationGrant.Status.ACTIVE, ) .first() ) if grant is None: raise LicensingError("migration_not_eligible", "当前账号没有可用的存量迁移资格") if not grant.entitlement.is_usable_at(now): raise LicensingError("license_expired", "迁移权益已过期或不可用") if DeviceCredential.objects.filter( device=device, entitlement=grant.entitlement, revoked_at__isnull=True, ).exists(): raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定") raw_token = MigrationRequest.generate_plaintext_credential_token() request = MigrationRequest.objects.create( user=user, device=device, migration_grant=grant, credential_token_hash=MigrationRequest.hash_credential_token(raw_token), credential_token_prefix=raw_token[:20], expires_at=now + timedelta(seconds=max(60, settings.MIGRATION_REQUEST_TTL_SECONDS)), ) return request, raw_token @transaction.atomic def confirm_migration_request(*, request_id, user, now=None): now = now or timezone.now() request = ( MigrationRequest.objects.select_for_update() .select_related("user", "device", "migration_grant", "migration_grant__entitlement") .filter(request_id=request_id) .first() ) if request is None: raise LicensingError("migration_request_not_found", "迁移请求不存在") if request.user_id != user.id: raise LicensingError("migration_request_forbidden", "迁移请求不属于当前账号") existing_credential = DeviceCredential.objects.filter(migration_request=request).first() if existing_credential is not None: return request, existing_credential, False if not request.is_pending_at(now): raise LicensingError("migration_request_expired", "迁移请求已过期或不可用") grant = request.migration_grant if grant.status != LegacyMigrationGrant.Status.ACTIVE: raise LicensingError("migration_not_eligible", "迁移资格已撤销") if not grant.entitlement.is_usable_at(now): raise LicensingError("license_expired", "迁移权益已过期或不可用") seat = assign_license_seat( entitlement=grant.entitlement, device=request.device, reason="存量迁移网页登录确认绑定", actor=user, now=now, ) existing_device_credential = DeviceCredential.objects.filter( device=request.device, entitlement=grant.entitlement, revoked_at__isnull=True, ).first() if existing_device_credential is not None: raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定") credential = DeviceCredential.objects.create( user=user, product_code=request.device.product_code, device=request.device, entitlement=grant.entitlement, seat=seat, migration_request=request, token_hash=request.credential_token_hash, token_prefix=request.credential_token_prefix, expires_at=grant.entitlement.grace_expires_at, ) request.status = MigrationRequest.Status.CONFIRMED request.confirmed_at = now request.save(update_fields=("status", "confirmed_at", "updated_at")) _create_license_event( entitlement=grant.entitlement, seat=seat, device=request.device, action=LicenseEvent.Action.CREDENTIAL_ISSUED, reason="存量迁移网页登录确认签发设备凭证", actor=user, metadata={"migration_request_id": str(request.request_id)}, ) return request, credential, True @transaction.atomic def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor=None, now=None): reason = _required_reason(reason) now = now or timezone.now() locked_credential = ( DeviceCredential.objects.select_for_update() .select_related("seat", "entitlement", "device") .get(pk=credential.pk) ) if locked_credential.revoked_at is not None: return locked_credential locked_credential.revoked_at = now locked_credential.revoke_reason = reason locked_credential.save(update_fields=("revoked_at", "revoke_reason")) _create_license_event( entitlement=locked_credential.entitlement, seat=locked_credential.seat, device=locked_credential.device, action=LicenseEvent.Action.CREDENTIAL_REVOKED, reason=reason, actor=actor, ) if locked_credential.seat.device_id == locked_credential.device_id: release_license_seat( seat=locked_credential.seat, reason=reason, actor=actor, now=now, ) return locked_credential def _normalize_software_order_amount(value) -> Decimal: try: return Decimal(str(value)).quantize(Decimal("0.01")) except (InvalidOperation, TypeError, ValueError) as exc: raise SoftwareOrderError("bad_request", "软件订单金额无效") from exc def _generate_software_order_no() -> str: for _attempt in range(10): timestamp = timezone.now().strftime("%Y%m%d%H%M%S") order_no = f"S{timestamp}{secrets.token_hex(4).upper()}" if not SoftwareOrder.objects.filter(order_no=order_no).exists(): return order_no raise SoftwareOrderError("order_number_failed", "无法生成软件订单号") def _active_entitlement_for_software_order(*, user, product_code): return ( SoftwareEntitlement.objects.select_for_update() .filter( user=user, product_code=product_code, status=SoftwareEntitlement.Status.ACTIVE, ) .order_by("-expires_at", "-id") .first() ) def create_software_order(*, user, plan: SoftwarePlan, pay_method: str, payment_order_func=None): if plan.status != SoftwarePlan.Status.ACTIVE: raise SoftwareOrderError("plan_inactive", "套餐已停用,无法购买") if pay_method != SoftwareOrder.PayMethod.WEIXIN: raise SoftwareOrderError("bad_request", "当前软件订阅仅支持微信支付") existing_entitlement = ( SoftwareEntitlement.objects.filter( user=user, product_code=plan.product_code, status=SoftwareEntitlement.Status.ACTIVE, ) .order_by("-expires_at", "-id") .first() ) if existing_entitlement is not None and existing_entitlement.source_plan_id != plan.id: raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理") order = SoftwareOrder.objects.create( user=user, source_plan=plan, order_no=_generate_software_order_no(), product_code=plan.product_code, plan_name=plan.name, plan_duration_days=plan.duration_days, plan_price=plan.price, plan_device_limit=plan.device_limit, plan_grace_days=plan.grace_days, amount_money=plan.price, currency="CNY", pay_method=pay_method, ) if payment_order_func is None: from apps.billing.payment_gateways import create_payment_order def payment_order_func(payment_order): return create_payment_order( payment_order, description="虾皮圈软件订阅", wechat_notify_url=settings.SOFTWARE_WECHAT_PAY_NOTIFY_URL, ) try: payment_order = payment_order_func(order) code_url = str(getattr(payment_order, "code_url", "") or "").strip() if not code_url: raise SoftwareOrderError("payment_order_create_failed", "支付平台未返回二维码") except Exception: order.status = SoftwareOrder.Status.FAILED order.save(update_fields=("status", "updated_at")) raise order.code_url = code_url order.expires_at = getattr(payment_order, "expires_at", None) order.save(update_fields=("code_url", "expires_at", "updated_at")) return order def _grant_software_order_entitlement(*, order: SoftwareOrder, now): entitlement = SoftwareEntitlement.objects.create( user=order.user, product_code=order.product_code, source_plan=order.source_plan, plan_name=order.plan_name, plan_duration_days=order.plan_duration_days, plan_price=order.plan_price, plan_device_limit=order.plan_device_limit, plan_grace_days=order.plan_grace_days, starts_at=now, expires_at=now + timedelta(days=order.plan_duration_days), grace_expires_at=now + timedelta(days=order.plan_duration_days + order.plan_grace_days), ) LicenseSeat.objects.bulk_create( [ LicenseSeat(entitlement=entitlement, seat_number=seat_number) for seat_number in range(1, order.plan_device_limit + 1) ] ) _create_license_event( entitlement=entitlement, action=LicenseEvent.Action.GRANTED, reason="软件套餐订单首次发放权益", metadata={"software_order_no": order.order_no}, ) return entitlement @transaction.atomic def apply_software_payment(payment) -> SoftwarePaymentResult: order_no = str(getattr(payment, "order_no", "") or "").strip() transaction_id = str(getattr(payment, "transaction_id", "") or "").strip() callback_amount = _normalize_software_order_amount(getattr(payment, "amount", None)) callback_pay_method = str(getattr(payment, "pay_method", "") or "").strip().lower() paid_at = getattr(payment, "paid_at", None) or timezone.now() if not transaction_id: raise SoftwareOrderError("bad_request", "支付回调缺少交易号") try: order = ( SoftwareOrder.objects.select_for_update() .select_related("user", "source_plan", "entitlement") .get(order_no=order_no) ) except SoftwareOrder.DoesNotExist as exc: raise SoftwareOrderNotFoundError(order_no) from exc if order.status == SoftwareOrder.Status.PAID: if order.payment_txn_no != transaction_id: raise SoftwareOrderTransactionMismatchError() return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False) if order.status != SoftwareOrder.Status.PENDING: raise SoftwareOrderError("bad_request", "软件订单当前状态不能入账") if order.pay_method != callback_pay_method: raise SoftwareOrderPayMethodMismatchError() if _normalize_software_order_amount(order.amount_money) != callback_amount: raise SoftwareOrderAmountMismatchError() if SoftwareOrder.objects.filter( pay_method=order.pay_method, payment_txn_no=transaction_id, ).exclude(pk=order.pk).exists(): raise SoftwareOrderTransactionMismatchError() entitlement = _active_entitlement_for_software_order( user=order.user, product_code=order.product_code, ) if entitlement is None: entitlement = _grant_software_order_entitlement(order=order, now=paid_at) elif entitlement.source_plan_id == order.source_plan_id: entitlement = renew_software_entitlement( entitlement=entitlement, reason="软件套餐订单续订", now=paid_at, duration_days=order.plan_duration_days, grace_days=order.plan_grace_days, ) else: raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理") fulfillment_event = _create_license_event( entitlement=entitlement, action=LicenseEvent.Action.ORDER_FULFILLED, reason="软件套餐订单权益发放", metadata={ "software_order_no": order.order_no, "payment_txn_no": transaction_id, "pay_method": order.pay_method, }, ) order.status = SoftwareOrder.Status.PAID order.payment_txn_no = transaction_id order.paid_at = paid_at order.fulfilled_at = timezone.now() order.entitlement = entitlement order.fulfillment_event = fulfillment_event try: order.save( update_fields=( "status", "payment_txn_no", "paid_at", "fulfilled_at", "entitlement", "fulfillment_event", "updated_at", ) ) except IntegrityError as exc: raise SoftwareOrderTransactionMismatchError() from exc return SoftwarePaymentResult(order=order, entitlement=entitlement, applied=True) def query_and_apply_software_payment(order_no: str, query_func) -> SoftwarePaymentResult: order = SoftwareOrder.objects.filter(order_no=order_no).first() if order is None: raise SoftwareOrderNotFoundError(order_no) if order.status == SoftwareOrder.Status.PAID: return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False) payment = query_func(order) return apply_software_payment(payment) @transaction.atomic def expire_software_order(*, order: SoftwareOrder, now=None) -> SoftwareOrder: now = now or timezone.now() locked_order = SoftwareOrder.objects.select_for_update().get(pk=order.pk) if ( locked_order.status == SoftwareOrder.Status.PENDING and locked_order.expires_at is not None and locked_order.expires_at <= now ): locked_order.status = SoftwareOrder.Status.EXPIRED locked_order.save(update_fields=("status", "updated_at")) return locked_order def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None): now = now or timezone.now() if device is None: return AuthorizationDecision(product_code, False, "device_not_bound") if device.user_id != user.id or device.product_code != product_code: return AuthorizationDecision(product_code, False, "device_mismatch") raw_credential_token = str(raw_credential_token or "").strip() if not raw_credential_token: return AuthorizationDecision(product_code, False, "license_required") credential = ( DeviceCredential.objects.select_related("device", "entitlement", "seat") .filter(token_hash=MigrationRequest.hash_credential_token(raw_credential_token)) .first() ) if credential is None: return AuthorizationDecision(product_code, False, "license_required") if ( credential.user_id != user.id or credential.product_code != product_code or credential.device_id != device.id or credential.seat.device_id != device.id ): return AuthorizationDecision(product_code, False, "device_mismatch", credential.id) if not credential.is_active_at(now) or not credential.entitlement.is_usable_at(now): return AuthorizationDecision(product_code, False, "license_expired", credential.id) return AuthorizationDecision(product_code, True, "", credential.id) def evaluate_account_authorization(*, user, product_code: str, now=None): """Authorize a product by account entitlement, without device or credential checks.""" now = now or timezone.now() usable_entitlement = ( SoftwareEntitlement.objects.filter( user=user, product_code=product_code, status=SoftwareEntitlement.Status.ACTIVE, starts_at__lte=now, grace_expires_at__gt=now, ) .order_by("-expires_at", "-id") .first() ) if usable_entitlement is not None: return SubscriptionAuthorizationDecision( product_code=product_code, allowed=True, code="", entitlement=usable_entitlement, ) historical_entitlement = ( SoftwareEntitlement.objects.filter( user=user, product_code=product_code, ) .exclude(status=SoftwareEntitlement.Status.REVOKED) .order_by("-grace_expires_at", "-id") .first() ) if historical_entitlement is not None: return SubscriptionAuthorizationDecision( product_code=product_code, allowed=False, code="subscription_expired", entitlement=historical_entitlement, ) return SubscriptionAuthorizationDecision( product_code=product_code, allowed=False, code="subscription_required", ) def software_subscription_status(*, user, product_code: str, now=None) -> dict: decision = evaluate_account_authorization( user=user, product_code=product_code, now=now, ) entitlement = decision.entitlement return { "product_code": product_code, "status": ( "active" if decision.allowed else ("expired" if decision.code == "subscription_expired" else "required") ), "allowed": decision.allowed, "code": decision.code or None, "plan": { "name": entitlement.plan_name, "expires_at": entitlement.expires_at.isoformat(), "grace_expires_at": entitlement.grace_expires_at.isoformat(), } if entitlement is not None else None, }