feat: add software subscription orders
This commit is contained in:
@@ -8,6 +8,8 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.billing.models import PointsLedger
|
||||
from apps.billing.payment_gateways import PaymentOrderCode, PaymentReceipt
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
@@ -18,14 +20,19 @@ from apps.licensing.models import (
|
||||
LicenseSeat,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwareOrder,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
SoftwareOrderAmountMismatchError,
|
||||
SoftwareOrderTransactionMismatchError,
|
||||
apply_software_payment,
|
||||
assign_license_seat,
|
||||
confirm_migration_request,
|
||||
create_legacy_migration_grant,
|
||||
create_migration_request,
|
||||
create_software_order,
|
||||
evaluate_device_authorization,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
@@ -345,6 +352,152 @@ class SoftwareEntitlementServiceTests(TestCase):
|
||||
grant_software_entitlement(user=self.user, plan=self.plan, reason="")
|
||||
|
||||
|
||||
class SoftwareOrderServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="software-order-user",
|
||||
email="software-order@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="月度订阅",
|
||||
duration_days=30,
|
||||
price=Decimal("19.90"),
|
||||
device_limit=1,
|
||||
grace_days=3,
|
||||
)
|
||||
|
||||
def create_order(self):
|
||||
return create_software_order(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
pay_method=SoftwareOrder.PayMethod.WEIXIN,
|
||||
payment_order_func=lambda _order: PaymentOrderCode(
|
||||
code_url="weixin://software-order-test",
|
||||
expires_at=timezone.now() + timedelta(minutes=10),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def payment_for(order, *, amount=None, transaction_id="wx-software-001"):
|
||||
return PaymentReceipt(
|
||||
order_no=order.order_no,
|
||||
pay_method=SoftwareOrder.PayMethod.WEIXIN,
|
||||
amount=amount or order.amount_money,
|
||||
transaction_id=transaction_id,
|
||||
paid_at=timezone.now(),
|
||||
)
|
||||
|
||||
def test_order_snapshots_payment_once_and_never_credits_points(self):
|
||||
order = self.create_order()
|
||||
self.plan.name = "已修改套餐"
|
||||
self.plan.price = Decimal("29.90")
|
||||
self.plan.save()
|
||||
|
||||
first = apply_software_payment(self.payment_for(order))
|
||||
second = apply_software_payment(self.payment_for(order))
|
||||
order.refresh_from_db()
|
||||
|
||||
self.assertTrue(first.applied)
|
||||
self.assertFalse(second.applied)
|
||||
self.assertEqual(order.status, SoftwareOrder.Status.PAID)
|
||||
self.assertEqual(order.plan_name, "月度订阅")
|
||||
self.assertEqual(order.amount_money, Decimal("19.90"))
|
||||
self.assertEqual(order.entitlement.plan_name, "月度订阅")
|
||||
self.assertEqual(order.fulfillment_event.action, LicenseEvent.Action.ORDER_FULFILLED)
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(user=self.user).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_duplicate_callback_with_changed_transaction_or_amount_is_rejected(self):
|
||||
order = self.create_order()
|
||||
with self.assertRaises(SoftwareOrderAmountMismatchError):
|
||||
apply_software_payment(self.payment_for(order, amount=Decimal("19.89")))
|
||||
self.assertEqual(SoftwareEntitlement.objects.count(), 0)
|
||||
|
||||
apply_software_payment(self.payment_for(order))
|
||||
with self.assertRaises(SoftwareOrderTransactionMismatchError):
|
||||
apply_software_payment(
|
||||
self.payment_for(order, transaction_id="wx-software-other")
|
||||
)
|
||||
|
||||
def test_second_paid_order_renews_same_plan_entitlement_once(self):
|
||||
first_order = self.create_order()
|
||||
first = apply_software_payment(self.payment_for(first_order))
|
||||
first_expiry = first.entitlement.expires_at
|
||||
|
||||
second_order = self.create_order()
|
||||
second = apply_software_payment(
|
||||
self.payment_for(second_order, transaction_id="wx-software-002")
|
||||
)
|
||||
|
||||
self.assertEqual(second.entitlement.pk, first.entitlement.pk)
|
||||
self.assertEqual(second.entitlement.expires_at, first_expiry + timedelta(days=30))
|
||||
self.assertEqual(
|
||||
SoftwareEntitlement.objects.filter(user=self.user).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
class SoftwareOrderConcurrencyTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="software-order-concurrency",
|
||||
email="software-order-concurrency@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="并发订阅套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("19.90"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.order = create_software_order(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
pay_method=SoftwareOrder.PayMethod.WEIXIN,
|
||||
payment_order_func=lambda _order: PaymentOrderCode(
|
||||
code_url="weixin://software-order-concurrency",
|
||||
expires_at=timezone.now() + timedelta(minutes=10),
|
||||
),
|
||||
)
|
||||
|
||||
def test_concurrent_same_order_callback_fulfills_once(self):
|
||||
def apply_callback():
|
||||
close_old_connections()
|
||||
try:
|
||||
order = SoftwareOrder.objects.get(pk=self.order.pk)
|
||||
result = apply_software_payment(
|
||||
PaymentReceipt(
|
||||
order_no=order.order_no,
|
||||
pay_method=SoftwareOrder.PayMethod.WEIXIN,
|
||||
amount=order.amount_money,
|
||||
transaction_id="wx-software-concurrency-001",
|
||||
paid_at=timezone.now(),
|
||||
)
|
||||
)
|
||||
return result.applied
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda _index: apply_callback(), range(2)))
|
||||
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(outcomes.count(True), 1)
|
||||
self.assertEqual(self.order.status, SoftwareOrder.Status.PAID)
|
||||
self.assertEqual(
|
||||
LicenseEvent.objects.filter(
|
||||
action=LicenseEvent.Action.ORDER_FULFILLED,
|
||||
metadata__software_order_no=self.order.order_no,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
class SoftwareEntitlementAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.operator = User.objects.create_user(
|
||||
@@ -506,6 +659,19 @@ class LegacyMigrationFlowTests(TestCase):
|
||||
"HTTP_X_DEVICE_SESSION": self.device_session_token,
|
||||
}
|
||||
|
||||
def issue_credential(self):
|
||||
grant = self.grant_migration()
|
||||
migration_request, raw_token = create_migration_request(
|
||||
user=self.user,
|
||||
device=self.device,
|
||||
)
|
||||
_request, credential, created = confirm_migration_request(
|
||||
request_id=migration_request.request_id,
|
||||
user=self.user,
|
||||
)
|
||||
self.assertTrue(created)
|
||||
return grant.entitlement, credential, raw_token
|
||||
|
||||
def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self):
|
||||
self.grant_migration()
|
||||
create_response = self.client.post(
|
||||
@@ -673,3 +839,52 @@ class LegacyMigrationFlowTests(TestCase):
|
||||
)
|
||||
self.assertTrue(expired.would_reject)
|
||||
self.assertEqual(expired.code, "license_expired")
|
||||
|
||||
def test_renewal_extends_active_credential_and_keeps_authorization_valid(self):
|
||||
entitlement, credential, raw_token = self.issue_credential()
|
||||
previous_credential_expiry = credential.expires_at
|
||||
|
||||
renewed = renew_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="用户续订",
|
||||
now=timezone.now(),
|
||||
)
|
||||
|
||||
credential.refresh_from_db()
|
||||
self.assertGreater(credential.expires_at, previous_credential_expiry)
|
||||
self.assertEqual(credential.expires_at, renewed.grace_expires_at)
|
||||
decision = evaluate_device_authorization(
|
||||
user=self.user,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device=self.device,
|
||||
raw_credential_token=raw_token,
|
||||
)
|
||||
self.assertTrue(decision.allowed)
|
||||
|
||||
def test_revoking_entitlement_revokes_credentials_and_releases_seats(self):
|
||||
entitlement, credential, _raw_token = self.issue_credential()
|
||||
|
||||
revoke_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="运营撤销套餐权益",
|
||||
)
|
||||
|
||||
credential.refresh_from_db()
|
||||
credential.seat.refresh_from_db()
|
||||
self.assertIsNotNone(credential.revoked_at)
|
||||
self.assertEqual(credential.revoke_reason, "运营撤销套餐权益")
|
||||
self.assertIsNone(credential.seat.device_id)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
|
||||
reason="运营撤销套餐权益",
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.SEAT_RELEASED,
|
||||
reason="运营撤销套餐权益",
|
||||
).exists()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user