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.
Files changed (241) hide show
  1. package/__blocklet__.js +37 -0
  2. package/api/ocap-1.30-subpath-shims.d.ts +35 -0
  3. package/api/src/crons/index.ts +32 -0
  4. package/api/src/crons/metering-subscription-detection.ts +12 -14
  5. package/api/src/crons/overdue-detection.ts +51 -74
  6. package/api/src/crons/retry-pending-events.ts +58 -0
  7. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  8. package/api/src/integrations/app-store/client.ts +369 -0
  9. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  10. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  11. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  12. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  13. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  14. package/api/src/integrations/arcblock/nft.ts +6 -2
  15. package/api/src/integrations/arcblock/stake.ts +3 -2
  16. package/api/src/integrations/arcblock/token.ts +4 -4
  17. package/api/src/integrations/blocklet/notification.ts +1 -1
  18. package/api/src/integrations/ethereum/tx.ts +29 -0
  19. package/api/src/integrations/google-play/client.ts +276 -0
  20. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  21. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  22. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  23. package/api/src/integrations/google-play/setup.ts +43 -0
  24. package/api/src/integrations/google-play/verify.ts +251 -0
  25. package/api/src/integrations/iap-reconcile.ts +415 -0
  26. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  27. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  28. package/api/src/integrations/stripe/resource.ts +8 -0
  29. package/api/src/libs/audit.ts +70 -24
  30. package/api/src/libs/auth.ts +49 -2
  31. package/api/src/libs/chain-error.ts +31 -0
  32. package/api/src/libs/entitlement.ts +399 -0
  33. package/api/src/libs/env.ts +2 -0
  34. package/api/src/libs/error.ts +15 -0
  35. package/api/src/libs/event.ts +42 -1
  36. package/api/src/libs/invoice.ts +69 -34
  37. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  38. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  39. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  40. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  41. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  42. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  43. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  44. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  45. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  46. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  47. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  48. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  49. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  50. package/api/src/libs/pagination.ts +14 -9
  51. package/api/src/libs/payment.ts +25 -10
  52. package/api/src/libs/security.ts +51 -0
  53. package/api/src/libs/session.ts +1 -1
  54. package/api/src/libs/subscription.ts +13 -1
  55. package/api/src/libs/timing.ts +35 -0
  56. package/api/src/libs/util.ts +29 -15
  57. package/api/src/libs/wallet-migration.ts +72 -53
  58. package/api/src/queues/auto-recharge.ts +1 -1
  59. package/api/src/queues/credit-consume.ts +94 -12
  60. package/api/src/queues/credit-grant.ts +4 -0
  61. package/api/src/queues/event.ts +39 -21
  62. package/api/src/queues/invoice.ts +1 -0
  63. package/api/src/queues/payment.ts +83 -15
  64. package/api/src/queues/refund.ts +84 -71
  65. package/api/src/queues/subscription.ts +1 -0
  66. package/api/src/queues/webhook.ts +12 -2
  67. package/api/src/routes/checkout-sessions.ts +82 -43
  68. package/api/src/routes/connect/change-payment.ts +2 -0
  69. package/api/src/routes/connect/change-plan.ts +2 -0
  70. package/api/src/routes/connect/pay.ts +12 -3
  71. package/api/src/routes/connect/setup.ts +3 -1
  72. package/api/src/routes/connect/shared.ts +52 -39
  73. package/api/src/routes/connect/subscribe.ts +4 -1
  74. package/api/src/routes/credit-grants.ts +25 -17
  75. package/api/src/routes/donations.ts +2 -2
  76. package/api/src/routes/entitlements.ts +105 -0
  77. package/api/src/routes/events.ts +2 -2
  78. package/api/src/routes/index.ts +12 -2
  79. package/api/src/routes/integrations/app-store.ts +267 -0
  80. package/api/src/routes/integrations/google-play.ts +324 -0
  81. package/api/src/routes/meter-events.ts +16 -6
  82. package/api/src/routes/payment-links.ts +1 -1
  83. package/api/src/routes/payment-methods.ts +131 -1
  84. package/api/src/routes/settings.ts +1 -1
  85. package/api/src/routes/tax-rates.ts +1 -1
  86. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  87. package/api/src/store/models/customer.ts +37 -1
  88. package/api/src/store/models/entitlement-grant.ts +118 -0
  89. package/api/src/store/models/entitlement-product.ts +48 -0
  90. package/api/src/store/models/entitlement.ts +86 -0
  91. package/api/src/store/models/index.ts +9 -0
  92. package/api/src/store/models/invoice.ts +20 -0
  93. package/api/src/store/models/payment-method.ts +66 -1
  94. package/api/src/store/models/price.ts +23 -14
  95. package/api/src/store/models/refund.ts +10 -0
  96. package/api/src/store/models/subscription.ts +14 -0
  97. package/api/src/store/models/types.ts +32 -0
  98. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  99. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  100. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  101. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  102. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  103. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  104. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  105. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  106. package/api/tests/libs/entitlement.spec.ts +347 -0
  107. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  108. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  109. package/api/tests/queues/credit-consume.spec.ts +8 -4
  110. package/api/tests/routes/credit-grants.spec.ts +1 -0
  111. package/blocklet.yml +1 -1
  112. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  113. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  114. package/cloudflare/README.md +499 -0
  115. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  116. package/cloudflare/build.ts +151 -0
  117. package/cloudflare/did-connect-auth.ts +527 -0
  118. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  119. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  120. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  121. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  122. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  123. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  124. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  125. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  126. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  127. package/cloudflare/frontend-shims/session.ts +24 -0
  128. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  129. package/cloudflare/index.html +40 -0
  130. package/cloudflare/migrate-to-d1.js +252 -0
  131. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  132. package/cloudflare/migrations/0002_indexes.sql +75 -0
  133. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  134. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  135. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  136. package/cloudflare/run-build.js +391 -0
  137. package/cloudflare/scripts/test-decrypt.js +102 -0
  138. package/cloudflare/shims/arcblock-ws.ts +20 -0
  139. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  140. package/cloudflare/shims/axios-lite.ts +117 -0
  141. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  142. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  143. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  144. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  145. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  146. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  147. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  148. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  149. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  150. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  151. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  152. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  153. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  154. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  155. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  156. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  157. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  158. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  159. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  160. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  161. package/cloudflare/shims/cookie-parser.ts +3 -0
  162. package/cloudflare/shims/cors.ts +21 -0
  163. package/cloudflare/shims/cron.ts +189 -0
  164. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  165. package/cloudflare/shims/did-space-js.ts +17 -0
  166. package/cloudflare/shims/did-space.ts +11 -0
  167. package/cloudflare/shims/error.ts +18 -0
  168. package/cloudflare/shims/express-compat/index.ts +80 -0
  169. package/cloudflare/shims/express-compat/types.ts +41 -0
  170. package/cloudflare/shims/fastq.ts +105 -0
  171. package/cloudflare/shims/lock.ts +115 -0
  172. package/cloudflare/shims/mime-types.ts +56 -0
  173. package/cloudflare/shims/nedb-storage.ts +9 -0
  174. package/cloudflare/shims/node-child-process.ts +9 -0
  175. package/cloudflare/shims/node-fs.ts +20 -0
  176. package/cloudflare/shims/node-http.ts +13 -0
  177. package/cloudflare/shims/node-https.ts +4 -0
  178. package/cloudflare/shims/node-misc.ts +15 -0
  179. package/cloudflare/shims/node-net.ts +8 -0
  180. package/cloudflare/shims/node-os.ts +14 -0
  181. package/cloudflare/shims/node-tty.ts +8 -0
  182. package/cloudflare/shims/node-zlib.ts +17 -0
  183. package/cloudflare/shims/noop.ts +26 -0
  184. package/cloudflare/shims/payment-vendor.ts +14 -0
  185. package/cloudflare/shims/querystring.ts +12 -0
  186. package/cloudflare/shims/queue.ts +611 -0
  187. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  188. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  189. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  190. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  191. package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
  192. package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
  193. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  194. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  195. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  196. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  197. package/cloudflare/shims/stripe-cf.ts +29 -0
  198. package/cloudflare/shims/ws-lite.ts +103 -0
  199. package/cloudflare/shims/xss.ts +3 -0
  200. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  201. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  202. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  203. package/cloudflare/vite.config.ts +162 -0
  204. package/cloudflare/worker.ts +1608 -0
  205. package/cloudflare/wrangler.json +63 -0
  206. package/cloudflare/wrangler.jsonc +75 -0
  207. package/cloudflare/wrangler.staging.json +67 -0
  208. package/cloudflare/wrangler.toml +28 -0
  209. package/jest.config.js +4 -12
  210. package/package.json +30 -22
  211. package/scripts/seed-google-play.ts +79 -0
  212. package/src/app.tsx +62 -4
  213. package/src/components/customer/link.tsx +9 -13
  214. package/src/components/customer/notification-preference.tsx +3 -2
  215. package/src/components/filter-toolbar.tsx +4 -0
  216. package/src/components/invoice/list.tsx +9 -1
  217. package/src/components/invoice-pdf/utils.ts +2 -1
  218. package/src/components/layout/admin.tsx +39 -5
  219. package/src/components/layout/user-cf.tsx +77 -0
  220. package/src/components/payment-intent/actions.tsx +23 -3
  221. package/src/components/payment-method/app-store.tsx +103 -0
  222. package/src/components/payment-method/form.tsx +7 -1
  223. package/src/components/payment-method/google-play.tsx +85 -0
  224. package/src/components/safe-did-address.tsx +75 -0
  225. package/src/components/subscription/list.tsx +20 -0
  226. package/src/libs/patch-user-card.ts +25 -0
  227. package/src/libs/util.ts +5 -7
  228. package/src/locales/en.tsx +63 -0
  229. package/src/locales/zh.tsx +63 -0
  230. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  231. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  232. package/src/pages/admin/customers/customers/detail.tsx +8 -2
  233. package/src/pages/admin/customers/customers/index.tsx +2 -2
  234. package/src/pages/admin/overview.tsx +3 -1
  235. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  236. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
  237. package/src/pages/customer/subscription/detail.tsx +4 -4
  238. package/tsconfig.api.json +1 -6
  239. package/tsconfig.json +3 -4
  240. package/tsconfig.types.json +2 -1
  241. 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
- // It should have partial consumption saved and be marked for retry
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.markAsRequiresCapture).toHaveBeenCalled();
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: schedules retry on partial failure', async () => {
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
- // Should schedule retry (attempt_count=0 < MAX_RETRY_COUNT=3)
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