payment-kit 1.27.2 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +32 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +70 -24
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +29 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +39 -21
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +131 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +37 -1
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +66 -1
- package/api/src/store/models/price.ts +23 -14
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +391 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +611 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1608 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +75 -0
- package/cloudflare/wrangler.staging.json +67 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +30 -22
- package/scripts/seed-google-play.ts +79 -0
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/safe-did-address.tsx +75 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +8 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/* eslint-disable require-await, import/first */
|
|
2
|
+
// Unit tests for iap-reconcile drift detection.
|
|
3
|
+
//
|
|
4
|
+
// The DB query path (Subscription.findAll + per-row update) is not exercised
|
|
5
|
+
// here — it's a thin loop over the drift functions. Drift detection is the
|
|
6
|
+
// only place where state-machine logic lives, so that's what we test.
|
|
7
|
+
|
|
8
|
+
const mockCreateEvent: jest.Mock<Promise<undefined>, any[]> = jest.fn(async () => undefined);
|
|
9
|
+
|
|
10
|
+
jest.mock('../../src/store/models', () => ({
|
|
11
|
+
Subscription: {},
|
|
12
|
+
PaymentMethod: {},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('../../src/libs/audit', () => ({
|
|
16
|
+
createEvent: (...args: any[]) => mockCreateEvent(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
applyAppStoreTransactionDrift,
|
|
21
|
+
applyGooglePlayPurchaseDrift,
|
|
22
|
+
pickIapMethodForSub,
|
|
23
|
+
} from '../../src/integrations/iap-reconcile';
|
|
24
|
+
import type { AppStoreTransactionPayload } from '../../src/integrations/app-store/client';
|
|
25
|
+
import type { GooglePlaySubscriptionPurchase } from '../../src/integrations/google-play/client';
|
|
26
|
+
|
|
27
|
+
type SubFake = {
|
|
28
|
+
id: string;
|
|
29
|
+
status: string;
|
|
30
|
+
current_period_end: number;
|
|
31
|
+
cancel_at_period_end?: boolean;
|
|
32
|
+
payment_details?: any;
|
|
33
|
+
metadata?: any;
|
|
34
|
+
update: jest.Mock;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const makeSub = (overrides: Partial<SubFake> = {}): SubFake => {
|
|
38
|
+
const sub: SubFake = {
|
|
39
|
+
id: 'sub_1',
|
|
40
|
+
status: 'active',
|
|
41
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400, // 30 days out
|
|
42
|
+
cancel_at_period_end: false,
|
|
43
|
+
payment_details: { app_store: { original_transaction_id: 'orig_1' } },
|
|
44
|
+
metadata: {},
|
|
45
|
+
update: jest.fn(async (patch: any) => Object.assign(sub, patch)) as any,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
return sub;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const makeAppStoreTxn = (overrides: Partial<AppStoreTransactionPayload> = {}): AppStoreTransactionPayload =>
|
|
52
|
+
({
|
|
53
|
+
transactionId: 'txn_1',
|
|
54
|
+
originalTransactionId: 'orig_1',
|
|
55
|
+
productId: 'pro_monthly',
|
|
56
|
+
bundleId: 'com.example.app',
|
|
57
|
+
environment: 'Sandbox',
|
|
58
|
+
expiresDate: Date.now() + 30 * 86400 * 1000,
|
|
59
|
+
purchaseDate: Date.now() - 60_000,
|
|
60
|
+
...overrides,
|
|
61
|
+
}) as AppStoreTransactionPayload;
|
|
62
|
+
|
|
63
|
+
const makeGooglePurchase = (overrides: Partial<GooglePlaySubscriptionPurchase> = {}): GooglePlaySubscriptionPurchase =>
|
|
64
|
+
({
|
|
65
|
+
expiryTimeMillis: String(Date.now() + 30 * 86400 * 1000),
|
|
66
|
+
autoRenewing: true,
|
|
67
|
+
...overrides,
|
|
68
|
+
}) as GooglePlaySubscriptionPurchase;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
mockCreateEvent.mockClear();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('pickIapMethodForSub', () => {
|
|
75
|
+
const m = (id: string) => ({ id }) as any;
|
|
76
|
+
|
|
77
|
+
it('selects the method bound via default_payment_method_id', () => {
|
|
78
|
+
const methods = [m('pm_a'), m('pm_b')];
|
|
79
|
+
const sub = { id: 's1', default_payment_method_id: 'pm_b' } as any;
|
|
80
|
+
expect(pickIapMethodForSub(methods, sub)?.id).toBe('pm_b');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns undefined (no arbitrary fallback) when bound method is absent and several exist', () => {
|
|
84
|
+
const methods = [m('pm_a'), m('pm_b')];
|
|
85
|
+
const sub = { id: 's1', default_payment_method_id: 'pm_missing' } as any;
|
|
86
|
+
expect(pickIapMethodForSub(methods, sub)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('defaults to the sole method when there is exactly one', () => {
|
|
90
|
+
const methods = [m('pm_only')];
|
|
91
|
+
const sub = { id: 's1', default_payment_method_id: undefined } as any;
|
|
92
|
+
expect(pickIapMethodForSub(methods, sub)?.id).toBe('pm_only');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('applyAppStoreTransactionDrift', () => {
|
|
97
|
+
it('no-ops when Apple state matches local', async () => {
|
|
98
|
+
const expires = Math.floor(Date.now() / 1000) + 30 * 86400;
|
|
99
|
+
const sub = makeSub({ current_period_end: expires });
|
|
100
|
+
const txn = makeAppStoreTxn({ expiresDate: expires * 1000 });
|
|
101
|
+
|
|
102
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
103
|
+
expect(changed).toBe(false);
|
|
104
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('updates current_period_end when Apple says we renewed (forward jump)', async () => {
|
|
108
|
+
const oldExpires = Math.floor(Date.now() / 1000) + 86400; // 1 day out
|
|
109
|
+
const newExpiresSec = oldExpires + 30 * 86400; // renewed for 30 more days
|
|
110
|
+
const sub = makeSub({ current_period_end: oldExpires });
|
|
111
|
+
const txn = makeAppStoreTxn({ expiresDate: newExpiresSec * 1000 });
|
|
112
|
+
|
|
113
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
114
|
+
expect(changed).toBe(true);
|
|
115
|
+
expect(sub.update).toHaveBeenCalledTimes(1);
|
|
116
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
117
|
+
expect(patch.current_period_end).toBe(newExpiresSec);
|
|
118
|
+
expect(patch.status).toBe('active');
|
|
119
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.updated', sub);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('marks canceled when transaction has revocationDate (refund/revoke)', async () => {
|
|
123
|
+
const sub = makeSub({ status: 'active' });
|
|
124
|
+
const revocationDate = Date.now() - 60_000;
|
|
125
|
+
const txn = makeAppStoreTxn({ revocationDate });
|
|
126
|
+
|
|
127
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
128
|
+
expect(changed).toBe(true);
|
|
129
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
130
|
+
expect(patch.status).toBe('canceled');
|
|
131
|
+
expect(patch.canceled_at).toBe(Math.floor(revocationDate / 1000));
|
|
132
|
+
expect(patch.cancelation_details.reason).toBe('app_store_revoked');
|
|
133
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.deleted', sub);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('idempotent: does not re-cancel already-canceled subscription', async () => {
|
|
137
|
+
const sub = makeSub({ status: 'canceled' });
|
|
138
|
+
const txn = makeAppStoreTxn({ revocationDate: Date.now() - 60_000 });
|
|
139
|
+
|
|
140
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
141
|
+
expect(changed).toBe(false);
|
|
142
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('marks canceled when Apple says expired but local still active (webhook miss on EXPIRED)', async () => {
|
|
146
|
+
const expiredSec = Math.floor(Date.now() / 1000) - 3600; // 1h in the past
|
|
147
|
+
const sub = makeSub({ status: 'active', current_period_end: expiredSec });
|
|
148
|
+
const txn = makeAppStoreTxn({ expiresDate: expiredSec * 1000 });
|
|
149
|
+
|
|
150
|
+
const changed = await applyAppStoreTransactionDrift(sub as any, txn);
|
|
151
|
+
expect(changed).toBe(true);
|
|
152
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
153
|
+
expect(patch.status).toBe('canceled');
|
|
154
|
+
expect(patch.cancelation_details.reason).toBe('app_store_expired');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('applyGooglePlayPurchaseDrift', () => {
|
|
159
|
+
it('no-ops when Google state matches local', async () => {
|
|
160
|
+
const expires = Math.floor(Date.now() / 1000) + 30 * 86400;
|
|
161
|
+
const sub = makeSub({
|
|
162
|
+
current_period_end: expires,
|
|
163
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
164
|
+
});
|
|
165
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(expires * 1000) });
|
|
166
|
+
|
|
167
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
168
|
+
expect(changed).toBe(false);
|
|
169
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('updates current_period_end when Google says we renewed', async () => {
|
|
173
|
+
const oldExpires = Math.floor(Date.now() / 1000) + 86400;
|
|
174
|
+
const newExpiresSec = oldExpires + 30 * 86400;
|
|
175
|
+
const sub = makeSub({
|
|
176
|
+
current_period_end: oldExpires,
|
|
177
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
178
|
+
});
|
|
179
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(newExpiresSec * 1000) });
|
|
180
|
+
|
|
181
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
182
|
+
expect(changed).toBe(true);
|
|
183
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
184
|
+
expect(patch.current_period_end).toBe(newExpiresSec);
|
|
185
|
+
expect(mockCreateEvent).toHaveBeenCalledWith('Subscription', 'customer.subscription.updated', sub);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('sets cancel_at_period_end when user disabled auto-renew (cancelReason set, autoRenewing=false)', async () => {
|
|
189
|
+
const sub = makeSub({
|
|
190
|
+
cancel_at_period_end: false,
|
|
191
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
192
|
+
});
|
|
193
|
+
const purchase = makeGooglePurchase({
|
|
194
|
+
expiryTimeMillis: String(sub.current_period_end * 1000), // not a renewal drift
|
|
195
|
+
cancelReason: 0,
|
|
196
|
+
autoRenewing: false,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
200
|
+
expect(changed).toBe(true);
|
|
201
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
202
|
+
expect(patch.cancel_at_period_end).toBe(true);
|
|
203
|
+
expect(patch.cancelation_details.reason).toBe('google_play_auto_renew_off');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('idempotent: does not re-flag cancel_at_period_end when already set', async () => {
|
|
207
|
+
const sub = makeSub({
|
|
208
|
+
cancel_at_period_end: true,
|
|
209
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
210
|
+
});
|
|
211
|
+
const purchase = makeGooglePurchase({
|
|
212
|
+
expiryTimeMillis: String(sub.current_period_end * 1000),
|
|
213
|
+
cancelReason: 0,
|
|
214
|
+
autoRenewing: false,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
218
|
+
expect(changed).toBe(false);
|
|
219
|
+
expect(sub.update).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('marks canceled when Google says expired but local still active', async () => {
|
|
223
|
+
const expiredSec = Math.floor(Date.now() / 1000) - 3600;
|
|
224
|
+
const sub = makeSub({
|
|
225
|
+
status: 'active',
|
|
226
|
+
current_period_end: expiredSec,
|
|
227
|
+
payment_details: { google_play: { purchase_token: 'tok_1' } },
|
|
228
|
+
});
|
|
229
|
+
const purchase = makeGooglePurchase({ expiryTimeMillis: String(expiredSec * 1000) });
|
|
230
|
+
|
|
231
|
+
const changed = await applyGooglePlayPurchaseDrift(sub as any, purchase);
|
|
232
|
+
expect(changed).toBe(true);
|
|
233
|
+
const patch = sub.update.mock.calls[0]![0];
|
|
234
|
+
expect(patch.status).toBe('canceled');
|
|
235
|
+
expect(patch.cancelation_details.reason).toBe('google_play_expired');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/* eslint-disable require-await, import/first */
|
|
2
|
+
// Unit tests for libs/entitlement.ts — cross-channel entitlement check.
|
|
3
|
+
// Models are mocked; DB is not touched.
|
|
4
|
+
|
|
5
|
+
import { checkEntitlement, listEntitlements } from '../../src/libs/entitlement';
|
|
6
|
+
|
|
7
|
+
const mockCustomerFindOne: jest.Mock<any, any[]> = jest.fn();
|
|
8
|
+
const mockSubscriptionFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
9
|
+
const mockCreditGrantFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
10
|
+
const mockPaymentMethodFindByPk: jest.Mock<any, any[]> = jest.fn();
|
|
11
|
+
const mockPriceFindAll: jest.Mock<any, any[]> = jest.fn();
|
|
12
|
+
|
|
13
|
+
jest.mock('../../src/store/models', () => ({
|
|
14
|
+
Customer: { findOne: (...args: any[]) => mockCustomerFindOne(...args) },
|
|
15
|
+
Subscription: { findAll: (...args: any[]) => mockSubscriptionFindAll(...args) },
|
|
16
|
+
SubscriptionItem: {},
|
|
17
|
+
Price: { findAll: (...args: any[]) => mockPriceFindAll(...args) },
|
|
18
|
+
Product: {},
|
|
19
|
+
CreditGrant: { findAll: (...args: any[]) => mockCreditGrantFindAll(...args) },
|
|
20
|
+
PaymentMethod: { findByPk: (...args: any[]) => mockPaymentMethodFindByPk(...args) },
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const makeSub = (overrides: Record<string, any> = {}) => ({
|
|
24
|
+
id: 'sub_1',
|
|
25
|
+
status: 'active',
|
|
26
|
+
channel: 'app_store',
|
|
27
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
28
|
+
default_payment_method_id: 'pm_1',
|
|
29
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const makeGrant = (overrides: Record<string, any> = {}) => ({
|
|
34
|
+
id: 'cg_1',
|
|
35
|
+
customer_id: 'cus_1',
|
|
36
|
+
status: 'granted',
|
|
37
|
+
remaining_amount: '100',
|
|
38
|
+
expires_at: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
39
|
+
metadata: { product_id: 'one_off_product', payment_method_id: 'pm_stripe' },
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockCustomerFindOne.mockReset();
|
|
45
|
+
mockSubscriptionFindAll.mockReset().mockResolvedValue([]);
|
|
46
|
+
mockCreditGrantFindAll.mockReset().mockResolvedValue([]);
|
|
47
|
+
mockPaymentMethodFindByPk.mockReset();
|
|
48
|
+
mockPriceFindAll.mockReset().mockResolvedValue([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('checkEntitlement', () => {
|
|
52
|
+
it('returns inactive when customer not found', async () => {
|
|
53
|
+
mockCustomerFindOne.mockResolvedValue(null);
|
|
54
|
+
const result = await checkEntitlement({ customer_did: 'did:unknown', product_id: 'pro_monthly' });
|
|
55
|
+
expect(result).toEqual({
|
|
56
|
+
active: false,
|
|
57
|
+
channel: null,
|
|
58
|
+
expires_at: null,
|
|
59
|
+
subscription_id: null,
|
|
60
|
+
source: null,
|
|
61
|
+
});
|
|
62
|
+
expect(mockSubscriptionFindAll).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns active result for active subscription', async () => {
|
|
66
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
67
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub()]);
|
|
68
|
+
|
|
69
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
70
|
+
expect(result.active).toBe(true);
|
|
71
|
+
expect(result.subscription_id).toBe('sub_1');
|
|
72
|
+
expect(result.channel).toBe('app_store');
|
|
73
|
+
expect(result.source).toBe('subscription');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns inactive=true subscription_id set for past_due (covers grace period)', async () => {
|
|
77
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
78
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ status: 'past_due' })]);
|
|
79
|
+
|
|
80
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
81
|
+
expect(result.active).toBe(false);
|
|
82
|
+
expect(result.subscription_id).toBe('sub_1');
|
|
83
|
+
expect(result.source).toBe('subscription');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('picks active over trialing over paused when multiple subs cover the same product', async () => {
|
|
87
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
88
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
89
|
+
makeSub({ id: 'sub_paused', status: 'paused' }),
|
|
90
|
+
makeSub({ id: 'sub_active', status: 'active' }),
|
|
91
|
+
makeSub({ id: 'sub_trial', status: 'trialing' }),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
95
|
+
expect(result.subscription_id).toBe('sub_active');
|
|
96
|
+
expect(result.active).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('breaks ties on equal status by latest current_period_end', async () => {
|
|
100
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
101
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
102
|
+
makeSub({ id: 'sub_older', status: 'active', current_period_end: 1700000000 }),
|
|
103
|
+
makeSub({ id: 'sub_newer', status: 'active', current_period_end: 1800000000 }),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
107
|
+
expect(result.subscription_id).toBe('sub_newer');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('skips subscriptions whose items do not cover the target product', async () => {
|
|
111
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
112
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ items: [{ price: { product_id: 'other_product' } }] })]);
|
|
113
|
+
mockCreditGrantFindAll.mockResolvedValue([]);
|
|
114
|
+
|
|
115
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
116
|
+
expect(result.active).toBe(false);
|
|
117
|
+
expect(result.subscription_id).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves IAP subscription live via Product channel mapping (no items, empty metadata.product_id)', async () => {
|
|
121
|
+
// Mirrors the real app_store case: subscription has no SubscriptionItems and
|
|
122
|
+
// an empty metadata.product_id (Product mapping was added after purchase),
|
|
123
|
+
// but reliably stores the channel SKU + bundle_id in payment_details.app_store.
|
|
124
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
125
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
126
|
+
makeSub({
|
|
127
|
+
id: 'sub_iap',
|
|
128
|
+
channel: 'app_store',
|
|
129
|
+
items: [],
|
|
130
|
+
metadata: {},
|
|
131
|
+
payment_details: { app_store: { product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
// Querying the local Product id; its Price.metadata maps to the App Store SKU
|
|
135
|
+
// (Stripe-style — SKU binding lives on Price, not Product). Multi-tenant:
|
|
136
|
+
// bundle_id is part of the lookup key so two apps with the same SKU string
|
|
137
|
+
// don't collide.
|
|
138
|
+
mockPriceFindAll.mockResolvedValue([
|
|
139
|
+
{ product_id: 'prod_X', metadata: { app_store_product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_X' });
|
|
143
|
+
expect(result.active).toBe(true);
|
|
144
|
+
expect(result.subscription_id).toBe('sub_iap');
|
|
145
|
+
expect(result.source).toBe('subscription');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does NOT match when the Price channel SKU differs from the subscription SKU', async () => {
|
|
149
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
150
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
151
|
+
makeSub({
|
|
152
|
+
id: 'sub_iap',
|
|
153
|
+
items: [],
|
|
154
|
+
metadata: {},
|
|
155
|
+
payment_details: { app_store: { product_id: 'pk_demo_monthly', bundle_id: 'com.example.app' } },
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
mockPriceFindAll.mockResolvedValue([
|
|
159
|
+
{ product_id: 'prod_Y', metadata: { app_store_product_id: 'pk_other_sku', bundle_id: 'com.example.app' } },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_Y' });
|
|
163
|
+
expect(result.active).toBe(false);
|
|
164
|
+
expect(result.subscription_id).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('IAP SKU rebind does not double-grant — live mapping wins over a stale SubscriptionItem', async () => {
|
|
168
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
169
|
+
// app_store sub: stale item points to prod_A, but the stored channel SKU is 'sku1'.
|
|
170
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
171
|
+
makeSub({
|
|
172
|
+
channel: 'app_store',
|
|
173
|
+
items: [{ price: { product_id: 'prod_A' } }],
|
|
174
|
+
payment_details: { app_store: { product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
175
|
+
}),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// Query prod_A: no Price for it bound to sku1 → must be inactive (stale
|
|
179
|
+
// item ignored).
|
|
180
|
+
mockPriceFindAll.mockResolvedValue([
|
|
181
|
+
{ product_id: 'prod_A', metadata: { app_store_product_id: 'other_sku', bundle_id: 'com.example.app' } },
|
|
182
|
+
]);
|
|
183
|
+
const a = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_A' });
|
|
184
|
+
expect(a.active).toBe(false);
|
|
185
|
+
expect(a.subscription_id).toBeNull();
|
|
186
|
+
|
|
187
|
+
// Query prod_B: a Price under prod_B is now bound to sku1 → active via
|
|
188
|
+
// live mapping.
|
|
189
|
+
mockPriceFindAll.mockResolvedValue([
|
|
190
|
+
{ product_id: 'prod_B', metadata: { app_store_product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
191
|
+
]);
|
|
192
|
+
const b = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_B' });
|
|
193
|
+
expect(b.active).toBe(true);
|
|
194
|
+
expect(b.subscription_id).toBe('sub_1');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('falls back to PaymentMethod.type when Subscription.channel is unset', async () => {
|
|
198
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
199
|
+
mockSubscriptionFindAll.mockResolvedValue([makeSub({ channel: undefined })]);
|
|
200
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
201
|
+
|
|
202
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
203
|
+
expect(result.channel).toBe('stripe');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('falls back to CreditGrant when no subscription matches', async () => {
|
|
207
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
208
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
209
|
+
mockCreditGrantFindAll.mockResolvedValue([makeGrant({ metadata: { product_id: 'pro_monthly' } })]);
|
|
210
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
211
|
+
|
|
212
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
213
|
+
expect(result.active).toBe(true);
|
|
214
|
+
expect(result.source).toBe('one_time');
|
|
215
|
+
expect(result.credit_remaining).toBe('100');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('CreditGrant with remaining_amount=0 is inactive', async () => {
|
|
219
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
220
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
221
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
222
|
+
makeGrant({ metadata: { product_id: 'pro_monthly' }, remaining_amount: '0' }),
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
226
|
+
expect(result.active).toBe(false);
|
|
227
|
+
expect(result.source).toBe('one_time');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('CreditGrant past expires_at is inactive', async () => {
|
|
231
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
232
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
233
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
234
|
+
makeGrant({ metadata: { product_id: 'pro_monthly' }, expires_at: 1000 }),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'pro_monthly' });
|
|
238
|
+
expect(result.active).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('listEntitlements', () => {
|
|
243
|
+
it('returns [] when customer not found', async () => {
|
|
244
|
+
mockCustomerFindOne.mockResolvedValue(null);
|
|
245
|
+
expect(await listEntitlements({ customer_did: 'did:unknown' })).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns one row per distinct product, picking best subscription', async () => {
|
|
249
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
250
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
251
|
+
makeSub({
|
|
252
|
+
id: 'sub_pro',
|
|
253
|
+
status: 'active',
|
|
254
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
255
|
+
}),
|
|
256
|
+
makeSub({
|
|
257
|
+
id: 'sub_paused_pro',
|
|
258
|
+
status: 'paused',
|
|
259
|
+
items: [{ price: { product_id: 'pro_monthly' } }],
|
|
260
|
+
}),
|
|
261
|
+
makeSub({
|
|
262
|
+
id: 'sub_basic',
|
|
263
|
+
status: 'trialing',
|
|
264
|
+
items: [{ price: { product_id: 'basic' } }],
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
269
|
+
expect(list).toHaveLength(2);
|
|
270
|
+
const pro = list.find((x) => x.product_id === 'pro_monthly');
|
|
271
|
+
expect(pro?.subscription_id).toBe('sub_pro');
|
|
272
|
+
const basic = list.find((x) => x.product_id === 'basic');
|
|
273
|
+
expect(basic?.subscription_id).toBe('sub_basic');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('merges CreditGrant entitlements for products not covered by subscriptions', async () => {
|
|
277
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
278
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
279
|
+
makeSub({ id: 'sub_pro', items: [{ price: { product_id: 'pro_monthly' } }] }),
|
|
280
|
+
]);
|
|
281
|
+
mockCreditGrantFindAll.mockResolvedValue([
|
|
282
|
+
makeGrant({ metadata: { product_id: 'one_off' } }),
|
|
283
|
+
// Already covered by subscription — should NOT duplicate
|
|
284
|
+
makeGrant({ id: 'cg_dup', metadata: { product_id: 'pro_monthly' } }),
|
|
285
|
+
]);
|
|
286
|
+
mockPaymentMethodFindByPk.mockResolvedValue({ type: 'stripe' });
|
|
287
|
+
|
|
288
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
289
|
+
expect(list).toHaveLength(2);
|
|
290
|
+
const oneOff = list.find((x) => x.source === 'one_time');
|
|
291
|
+
expect(oneOff?.product_id).toBe('one_off');
|
|
292
|
+
expect(oneOff?.active).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('lists an IAP subscription under its live-mapped product, not a stale item', async () => {
|
|
296
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
297
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
298
|
+
makeSub({
|
|
299
|
+
channel: 'app_store',
|
|
300
|
+
items: [{ price: { product_id: 'prod_A_stale' } }],
|
|
301
|
+
payment_details: { app_store: { product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
// Live catalogue: a Price under prod_B is bound to sku1.
|
|
305
|
+
mockPriceFindAll.mockResolvedValue([
|
|
306
|
+
{ product_id: 'prod_B', metadata: { app_store_product_id: 'sku1', bundle_id: 'com.example.app' } },
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
const list = await listEntitlements({ customer_did: 'did:1' });
|
|
310
|
+
expect(list).toHaveLength(1);
|
|
311
|
+
expect(list[0]!.product_id).toBe('prod_B');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('multi-tenant: same SKU string across two apps does not grant the wrong product (entitlement leak)', async () => {
|
|
315
|
+
// Two iOS apps, both legitimately using SKU 'pro_monthly' in their own
|
|
316
|
+
// App Store Connect namespaces (Apple SKUs are per-bundle-id, not global).
|
|
317
|
+
// App A's customer has a sub for App A's pro_monthly; App B has its own
|
|
318
|
+
// distinct Product/Price. Without (sku, bundle_id) scoping in entitlement.ts,
|
|
319
|
+
// App A's sub would incorrectly grant App B's product too.
|
|
320
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_appA_user' });
|
|
321
|
+
mockSubscriptionFindAll.mockResolvedValue([
|
|
322
|
+
makeSub({
|
|
323
|
+
channel: 'app_store',
|
|
324
|
+
items: [],
|
|
325
|
+
metadata: {},
|
|
326
|
+
payment_details: { app_store: { product_id: 'pro_monthly', bundle_id: 'com.appA' } },
|
|
327
|
+
}),
|
|
328
|
+
]);
|
|
329
|
+
// Querying App B's Product, whose Price uses the same SKU string under a
|
|
330
|
+
// different bundle_id — must NOT match App A's sub.
|
|
331
|
+
mockPriceFindAll.mockResolvedValue([
|
|
332
|
+
{ product_id: 'prod_appB_pro', metadata: { app_store_product_id: 'pro_monthly', bundle_id: 'com.appB' } },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const result = await checkEntitlement({ customer_did: 'did:1', product_id: 'prod_appB_pro' });
|
|
336
|
+
expect(result.active).toBe(false);
|
|
337
|
+
expect(result.subscription_id).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('skips CreditGrants without product_id metadata', async () => {
|
|
341
|
+
mockCustomerFindOne.mockResolvedValue({ id: 'cus_1' });
|
|
342
|
+
mockSubscriptionFindAll.mockResolvedValue([]);
|
|
343
|
+
mockCreditGrantFindAll.mockResolvedValue([makeGrant({ metadata: {} })]);
|
|
344
|
+
|
|
345
|
+
expect(await listEntitlements({ customer_did: 'did:1' })).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -79,7 +79,7 @@ describe('wallet-migration', () => {
|
|
|
79
79
|
|
|
80
80
|
it('should return stored address if available and valid', async () => {
|
|
81
81
|
const mockClient = createMockClient({
|
|
82
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
82
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
83
83
|
if (address === 'stored_delegation_address') {
|
|
84
84
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
85
85
|
}
|
|
@@ -105,7 +105,7 @@ describe('wallet-migration', () => {
|
|
|
105
105
|
it('should fallback if stored address has no valid state', async () => {
|
|
106
106
|
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
107
107
|
const mockClient = createMockClient({
|
|
108
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
108
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
109
109
|
// stored address has no state, but migratedFrom address has
|
|
110
110
|
if (address === oldAddress) {
|
|
111
111
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
@@ -131,7 +131,7 @@ describe('wallet-migration', () => {
|
|
|
131
131
|
it('should return current address if delegation exists on current wallet', async () => {
|
|
132
132
|
const currentAddress = toDelegateAddress(delegator, currentWalletAddress);
|
|
133
133
|
const mockClient = createMockClient({
|
|
134
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
134
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
135
135
|
if (address === currentAddress) {
|
|
136
136
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
137
137
|
}
|
|
@@ -154,7 +154,7 @@ describe('wallet-migration', () => {
|
|
|
154
154
|
it('should fallback to migratedFrom addresses', async () => {
|
|
155
155
|
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
156
156
|
const mockClient = createMockClient({
|
|
157
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
157
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
158
158
|
if (address === oldAddress) {
|
|
159
159
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
160
160
|
}
|
|
@@ -248,13 +248,16 @@ describe('credit-consume: handleBatchCreditConsumption', () => {
|
|
|
248
248
|
expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
|
|
249
249
|
|
|
250
250
|
// Event 2 should fail — only 20 remaining but needs 80
|
|
251
|
-
//
|
|
251
|
+
// Partial consumption is saved and event leaves the retry chain immediately
|
|
252
|
+
// (Insufficient balance is non-retryable per Plan 3, 2026-04-25 — retry
|
|
253
|
+
// would only re-trip the same shortage until external credit top-up).
|
|
252
254
|
expect(event2.update).toHaveBeenCalledWith(
|
|
253
255
|
expect.objectContaining({
|
|
254
256
|
credit_pending: expect.any(String),
|
|
255
257
|
})
|
|
256
258
|
);
|
|
257
|
-
expect(event2.
|
|
259
|
+
expect(event2.markAsRequiresAction).toHaveBeenCalled();
|
|
260
|
+
expect(event2.markAsRequiresCapture).not.toHaveBeenCalled();
|
|
258
261
|
|
|
259
262
|
expect(getLock().release).toHaveBeenCalled();
|
|
260
263
|
});
|
|
@@ -386,7 +386,11 @@ describe('credit-consume: handleCreditConsumption', () => {
|
|
|
386
386
|
// ==========================================
|
|
387
387
|
// Scenario 22/23: Retry scheduling + max retries
|
|
388
388
|
// ==========================================
|
|
389
|
-
it('scenario 22:
|
|
389
|
+
it('scenario 22: insufficient balance is non-retryable (goes straight to requires_action)', async () => {
|
|
390
|
+
// Insufficient credit balance only resolves via external top-up; retrying
|
|
391
|
+
// before that is guaranteed to fail again. Per Plan 3 (2026-04-25), this
|
|
392
|
+
// is classified as non-retryable so the event leaves the retry chain
|
|
393
|
+
// immediately rather than spawning N more delayed queue messages.
|
|
390
394
|
const event = makeMeterEvent({
|
|
391
395
|
getValue: jest.fn().mockReturnValue('500'),
|
|
392
396
|
attempt_count: 0,
|
|
@@ -397,10 +401,10 @@ describe('credit-consume: handleCreditConsumption', () => {
|
|
|
397
401
|
|
|
398
402
|
setupBasicMocks(event, meter, customer, [grant]);
|
|
399
403
|
|
|
400
|
-
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();
|
|
404
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow(/Insufficient credit balance/);
|
|
401
405
|
|
|
402
|
-
|
|
403
|
-
expect(event.markAsRequiresCapture).toHaveBeenCalled();
|
|
406
|
+
expect(event.markAsRequiresAction).toHaveBeenCalled();
|
|
407
|
+
expect(event.markAsRequiresCapture).not.toHaveBeenCalled();
|
|
404
408
|
});
|
|
405
409
|
|
|
406
410
|
it('scenario 23: marks as requires_action after max retries', async () => {
|
|
@@ -92,6 +92,7 @@ describe('GET /api/credit-grants/verify-availability', () => {
|
|
|
92
92
|
it('should return 404 if customer not found', async () => {
|
|
93
93
|
if (!routeHandler) return;
|
|
94
94
|
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue(null as any);
|
|
95
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'currency_123' } as any);
|
|
95
96
|
|
|
96
97
|
await routeHandler(mockReq as Request, mockRes as Response);
|
|
97
98
|
|