payment-kit 1.29.8 → 1.29.10

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 (49) hide show
  1. package/api/src/host-node/serve-static-arc.ts +1 -0
  2. package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
  3. package/api/src/integrations/app-store/native-asn1.ts +1 -0
  4. package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
  5. package/api/src/integrations/google-play/handlers/subscription.ts +1 -1
  6. package/api/src/integrations/google-play/handlers/voided.ts +1 -1
  7. package/api/src/integrations/stripe/handlers/payment-intent.ts +4 -2
  8. package/api/src/integrations/stripe/handlers/subscription.ts +1 -1
  9. package/api/src/integrations/stripe/reconcile-checkout-core.ts +60 -0
  10. package/api/src/integrations/stripe/reconcile-checkout.ts +40 -0
  11. package/api/src/integrations/stripe/setup.ts +3 -3
  12. package/api/src/libs/arcblock-transfer.ts +27 -0
  13. package/api/src/libs/context.ts +10 -2
  14. package/api/src/libs/did-connect/tenant-identity.ts +19 -1
  15. package/api/src/libs/entitlement.ts +12 -0
  16. package/api/src/libs/env.ts +1 -0
  17. package/api/src/libs/notification/index.ts +4 -3
  18. package/api/src/libs/util.ts +31 -2
  19. package/api/src/middlewares/hono/csrf.ts +6 -0
  20. package/api/src/middlewares/hono/security.ts +47 -2
  21. package/api/src/middlewares/hono/session.ts +22 -0
  22. package/api/src/routes/connect/pay.ts +21 -9
  23. package/api/src/routes/hono/checkout-sessions.ts +71 -32
  24. package/api/src/routes/hono/entitlements.ts +8 -1
  25. package/api/src/routes/hono/payment-methods.ts +6 -2
  26. package/api/src/service.ts +42 -4
  27. package/api/src/store/models/payment-currency.ts +7 -1
  28. package/api/src/store/models/payment-method.ts +30 -6
  29. package/api/src/store/models/subscription.ts +3 -1
  30. package/api/tests/integrations/app-store/handlers.spec.ts +13 -2
  31. package/api/tests/integrations/stripe/reconcile-checkout.spec.ts +80 -0
  32. package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
  33. package/api/tests/libs/util.spec.ts +51 -0
  34. package/blocklet.yml +1 -1
  35. package/cloudflare/cf-adapter.ts +35 -4
  36. package/cloudflare/did-connect-runtime.ts +43 -5
  37. package/cloudflare/esbuild-cf-config.cjs +12 -7
  38. package/cloudflare/tests/cf-adapter.spec.ts +10 -1
  39. package/cloudflare/worker.ts +41 -1
  40. package/package.json +9 -9
  41. package/src/components/layout/user.tsx +7 -1
  42. package/src/libs/did.ts +27 -0
  43. package/src/pages/customer/credit-grant/detail.tsx +2 -1
  44. package/src/pages/customer/credit-transaction/detail.tsx +2 -1
  45. package/src/pages/customer/invoice/detail.tsx +2 -1
  46. package/src/pages/customer/recharge/subscription.tsx +2 -1
  47. package/src/pages/customer/subscription/detail.tsx +2 -1
  48. package/tests/libs/did.spec.ts +43 -0
  49. package/vite.arc.config.ts +19 -0
@@ -104,6 +104,28 @@ export function sessionMiddleware(options: SessionOptions = {}): MiddlewareHandl
104
104
  return c.json({ error: err.message }, 401);
105
105
  }
106
106
 
107
+ // Embedded / blocklet-server PRIMARY auth path. The host adapter (arc-node CF
108
+ // gate via cf-adapter.injectCaller, or blocklet-server) verifies the caller
109
+ // upstream, STRIPS any client-forged `x-user-*`, then injects the real caller as
110
+ // `x-user-*` headers — exactly what authenticate() (security.ts) already trusts.
111
+ // sessionMiddleware historically only checked the login_token cookie, so under
112
+ // embedded (where the login_token is arc's, NOT verifiable by payment's
113
+ // @blocklet/sdk verifyLoginToken) session-scoped routes like /customers/me saw
114
+ // no user and 403'd even for a logged-in caller — which left the checkout
115
+ // customer form unpopulated. Honor the injected identity as a fallback.
116
+ if (!result) {
117
+ const injectedDid = c.req.header('x-user-did');
118
+ if (injectedDid) {
119
+ result = {
120
+ did: injectedDid,
121
+ role: c.req.header('x-user-role'),
122
+ provider: c.req.header('x-user-provider'),
123
+ fullName: decodeURIComponent(c.req.header('x-user-fullname') || ''),
124
+ walletOS: c.req.header('x-user-wallet-os') || '',
125
+ };
126
+ }
127
+ }
128
+
107
129
  if (result) {
108
130
  c.set('user', result);
109
131
  }
@@ -5,6 +5,7 @@ import { fromAddress } from '@ocap/wallet';
5
5
  import { encodeTransferItx } from '../../integrations/ethereum/token';
6
6
  import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
7
7
  import { CallbackArgs, ethWallet } from '../../libs/auth';
8
+ import { encodeArcblockTransfer } from '../../libs/arcblock-transfer';
8
9
  import logger from '../../libs/logger';
9
10
  import { parseChainError } from '../../libs/chain-error';
10
11
  import { getGasPayerExtra } from '../../libs/payment';
@@ -127,25 +128,36 @@ export default {
127
128
 
128
129
  if (paymentMethod.type === 'arcblock') {
129
130
  try {
130
- await paymentIntent.update({ status: 'processing' });
131
131
  const client = paymentMethod.getOcapClient();
132
132
  const claim = claims.find((x) => x.type === 'prepareTx');
133
133
 
134
- // Warm up chain context cache to avoid setTimeout(resolve,0) hang in workerd
135
- await client.getContext();
136
-
137
134
  const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
138
135
  if (claim.delegator && claim.from) {
139
136
  tx.delegator = claim.delegator;
140
137
  tx.from = claim.from;
141
138
  }
142
139
 
143
- // @ts-ignore
144
- const { buffer } = await client.encodeTransferV3Tx({ tx });
140
+ const chainHost = paymentMethod.settings.arcblock?.api_host;
141
+ if (!chainHost) {
142
+ throw new Error('ArcBlock payment method API host is missing');
143
+ }
144
+ const signingWallet = fromAddress(userDid);
145
+ const { object: encodedTx, buffer } = await encodeArcblockTransfer({
146
+ chainHost,
147
+ tx,
148
+ wallet: signingWallet,
149
+ });
150
+ const gasPayerExtra = await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client);
151
+
152
+ // Only expose processing after all cold-start preparation has completed.
153
+ // If context fetching/encoding is canceled by workerd, the intent remains
154
+ // retryable instead of being stranded in processing without a tx hash.
155
+ await paymentIntent.update({ status: 'processing' });
145
156
  const txHash = await client.sendTransferV3Tx(
146
- // @ts-ignore
147
- { tx, wallet: fromAddress(userDid) },
148
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
157
+ // Passing the already-encoded signature skips the client's timer-backed
158
+ // encodeTransferV3Tx/getContext path in Cloudflare Workers.
159
+ { tx: encodedTx as any, wallet: signingWallet, signature: encodedTx.signature },
160
+ gasPayerExtra
149
161
  );
150
162
 
151
163
  const quoteValidation = await validateQuoteForPayment({
@@ -21,13 +21,18 @@ import { Hono } from 'hono';
21
21
  import { CustomError, formatError, getStatusFromError } from '@blocklet/error';
22
22
  import pAll from 'p-all';
23
23
  import { withQuery } from 'ufo';
24
- import { paymentRateVolatilityThreshold, updateDataConcurrency, stopAcceptingOrders } from '../../libs/env';
24
+ import {
25
+ paymentRateVolatilityThreshold,
26
+ updateDataConcurrency,
27
+ stopAcceptingOrders,
28
+ isBlockletServer,
29
+ } from '../../libs/env';
25
30
  import { MetadataSchema } from '../../libs/api';
26
31
  import { checkPassportForPaymentLink } from '../../integrations/blocklet/passport';
27
32
  import dayjs from '../../libs/dayjs';
28
33
  import logger from '../../libs/logger';
29
34
  import { trimDecimals } from '../../libs/math-utils';
30
- import { authenticate } from '../../middlewares/hono/security';
35
+ import { authenticate, isSameDid } from '../../middlewares/hono/security';
31
36
  import {
32
37
  buildSlippageSnapshot,
33
38
  DEFAULT_SLIPPAGE_PERCENT,
@@ -131,6 +136,7 @@ import { getExchangeRateService } from '../../libs/exchange-rate/service';
131
136
  import { getExchangeRateSymbol } from '../../libs/exchange-rate/token-address-mapping';
132
137
  import { sequelize } from '../../store/sequelize';
133
138
  import { sessionMiddleware } from '../../middlewares/hono/session';
139
+ import { reconcileStripeCheckoutSession } from '../../integrations/stripe/reconcile-checkout';
134
140
 
135
141
  const app = new Hono();
136
142
 
@@ -1475,6 +1481,29 @@ app.get('/status/:id', user, async (c) => {
1475
1481
  });
1476
1482
  });
1477
1483
 
1484
+ app.post('/:id/stripe-reconcile', user, async (c) => {
1485
+ const currentUser = c.get('user');
1486
+ if (!currentUser) {
1487
+ return c.json({ error: 'Unauthorized' }, 403);
1488
+ }
1489
+ const doc = await CheckoutSession.findByPk(c.req.param('id'), {
1490
+ attributes: ['id', 'customer_did'],
1491
+ });
1492
+ if (!doc) {
1493
+ return c.json({ error: 'Checkout session not found' }, 404);
1494
+ }
1495
+ if (!['owner', 'admin'].includes(currentUser.role) && !isSameDid(doc.customer_did, currentUser.did)) {
1496
+ return c.json({ error: 'Not authorized to reconcile this checkout session' }, 403);
1497
+ }
1498
+
1499
+ try {
1500
+ return c.json(await reconcileStripeCheckoutSession(doc.id));
1501
+ } catch (err: any) {
1502
+ logger.error('stripe checkout reconciliation failed', { checkoutSessionId: doc.id, error: err });
1503
+ return c.json({ error: err.message }, 502);
1504
+ }
1505
+ });
1506
+
1478
1507
  // Static prefix /retrieve/:id registered before /:id to avoid shadowing.
1479
1508
  app.get('/retrieve/:id', user, async (c) => {
1480
1509
  const doc = await CheckoutSession.findByPk(c.req.param('id'));
@@ -2808,21 +2837,27 @@ app.put('/:id/submit', user, ensureCheckoutSessionOpen, async (c) => {
2808
2837
  invoice_prefix: Customer.getInvoicePrefix(),
2809
2838
  });
2810
2839
  logger.info('customer created on checkout session submit', { did: c.get('user').did, id: customer.id });
2811
- try {
2812
- await blocklet.updateUserAddress(
2813
- {
2814
- did: customer.did,
2815
- address: Customer.formatAddressFromCustomer(customer),
2816
- },
2817
- {
2818
- headers: {
2819
- cookie: c.req.header('cookie') || '',
2840
+ // Sync address to the blocklet-server user service ONLY on blocklet-server.
2841
+ // arc/CF embedded has no such service — `blocklet.updateUserAddress` is undefined
2842
+ // there. The address is already persisted on the payment Customer model above,
2843
+ // so embedded hosts simply skip this (was a swallowed "is not a function" error).
2844
+ if (isBlockletServer()) {
2845
+ try {
2846
+ await blocklet.updateUserAddress(
2847
+ {
2848
+ did: customer.did,
2849
+ address: Customer.formatAddressFromCustomer(customer),
2820
2850
  },
2821
- }
2822
- );
2823
- logger.info('updateUserAddress success', { did: customer.did });
2824
- } catch (err) {
2825
- logger.error('updateUserAddress failed', { error: err, customerId: customer.id });
2851
+ {
2852
+ headers: {
2853
+ cookie: c.req.header('cookie') || '',
2854
+ },
2855
+ }
2856
+ );
2857
+ logger.info('updateUserAddress success', { did: customer.did });
2858
+ } catch (err) {
2859
+ logger.error('updateUserAddress failed', { error: err, customerId: customer.id });
2860
+ }
2826
2861
  }
2827
2862
  } else {
2828
2863
  const updates: Record<string, any> = {};
@@ -2850,23 +2885,27 @@ app.put('/:id/submit', user, ensureCheckoutSessionOpen, async (c) => {
2850
2885
  await customer.update(updates);
2851
2886
  logger.info('customer updated', { did: customer.did });
2852
2887
 
2853
- try {
2854
- await blocklet.updateUserAddress(
2855
- {
2856
- did: customer.did,
2857
- address: Customer.formatAddressFromCustomer(customer),
2858
- // @ts-ignore
2859
- phone: customer.phone,
2860
- },
2861
- {
2862
- headers: {
2863
- cookie: c.req.header('cookie') || '',
2888
+ // Blocklet-server only (see note above); embedded hosts already have the
2889
+ // address on the payment Customer model and have no user service to sync to.
2890
+ if (isBlockletServer()) {
2891
+ try {
2892
+ await blocklet.updateUserAddress(
2893
+ {
2894
+ did: customer.did,
2895
+ address: Customer.formatAddressFromCustomer(customer),
2896
+ // @ts-ignore
2897
+ phone: customer.phone,
2864
2898
  },
2865
- }
2866
- );
2867
- logger.info('updateUserAddress success', { did: customer.did });
2868
- } catch (err) {
2869
- logger.error('updateUserAddress failed', { error: err, customerId: customer.id });
2899
+ {
2900
+ headers: {
2901
+ cookie: c.req.header('cookie') || '',
2902
+ },
2903
+ }
2904
+ );
2905
+ logger.info('updateUserAddress success', { did: customer.did });
2906
+ } catch (err) {
2907
+ logger.error('updateUserAddress failed', { error: err, customerId: customer.id });
2908
+ }
2870
2909
  }
2871
2910
  }
2872
2911
  }
@@ -26,7 +26,14 @@ import { PaymentMethod } from '../../store/models';
26
26
  const app = new Hono();
27
27
  // component+owner/admin for cross-blocklet calls; ensureLogin for end users —
28
28
  // handler then enforces that non-admin users can only query their own DID.
29
- const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
29
+ // Any signed-in user can query their OWN entitlements (the handler bodies
30
+ // below already gate cross-customer reads via isSelf(...) + isAdminUser).
31
+ // A roles-list here would block normal wallet users at the middleware (their
32
+ // role is "user", not owner/admin) before isSelf ever runs, so the IAP
33
+ // "Subscribe + check my own entitlement" loop returned 403 for every real
34
+ // end user. Keep `component: true` so server-to-server signed calls bypass
35
+ // auth as before; keep `ensureLogin` so unauthenticated requests still 401.
36
+ const auth = authenticate<PaymentMethod>({ component: true, ensureLogin: true });
30
37
 
31
38
  function isAdminUser(role?: string): boolean {
32
39
  return role === 'owner' || role === 'admin';
@@ -109,7 +109,9 @@ app.post('/', auth, async (c) => {
109
109
 
110
110
  await method.update({ default_currency_id: currency.id });
111
111
 
112
- ensureWebhookRegistered().catch(console.error);
112
+ ensureWebhookRegistered().catch((err) =>
113
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
114
+ );
113
115
 
114
116
  return c.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
115
117
  }
@@ -532,7 +534,9 @@ app.put('/:id', auth, async (c) => {
532
534
 
533
535
  const updatedMethod = await method.update(updateData);
534
536
  if (method.type === 'stripe') {
535
- ensureWebhookRegistered().catch(console.error);
537
+ ensureWebhookRegistered().catch((err) =>
538
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
539
+ );
536
540
  }
537
541
  return c.json(updatedMethod);
538
542
  } catch (err) {
@@ -275,10 +275,30 @@ export function buildConnectRoutesHono(): Hono {
275
275
  // withTenant) we pass through; otherwise we resolve Host→tenant once and wrap.
276
276
  connectApp.use('*', async (c, next) => {
277
277
  const { context: requestCtx } = require('./libs/context');
278
- if (requestCtx.peekInstanceDid()) return next();
278
+ const { warmTenantIdentity } = require('./libs/did-connect/tenant-identity');
279
+ // Warm the tenant's signing identity + secrets keyring BEFORE the DID handler so
280
+ // the synchronous secrets hot path (connect/pay → getStripeClient →
281
+ // PaymentMethod.decryptSettings) resolves — else it throws "secrets: key for
282
+ // tenant <did> is not warmed". Mirrors contextMiddleware (context.ts); the native
283
+ // resource group warms there, but connectApp is mounted ALONGSIDE it (no shared
284
+ // pipeline), so DID routes must warm themselves. Best-effort + no-op on
285
+ // blocklet-server (hasDynamicIdentity() === false).
286
+ const preResolved = requestCtx.peekInstanceDid();
287
+ if (preResolved) {
288
+ // Host already scoped this request (e.g. the CF worker wrapped /api/* in
289
+ // withTenant before the core ran) — run inside that context; warm is idempotent
290
+ // (cache hit if the host already warmed).
291
+ await warmTenantIdentity(preResolved);
292
+ return next();
293
+ }
294
+ // arc-node embedded (svc.http.fetch) never pre-wraps: resolve Host→tenant once,
295
+ // run inside the tenant context, and warm before the handler.
279
296
  const { resolveTenantForHost } = require('./libs/drivers/identity');
280
297
  const instanceDid = await resolveTenantForHost(c.req.header('host'));
281
- return requestCtx.withTenant(instanceDid, () => next());
298
+ return requestCtx.withTenant(instanceDid, async () => {
299
+ await warmTenantIdentity(instanceDid);
300
+ return next();
301
+ });
282
302
  });
283
303
 
284
304
  for (const h of connectHandlerModules()) handlers.attach(Object.assign({ app: connectApp }, h));
@@ -455,8 +475,20 @@ async function provisionTenant(instanceDid: string): Promise<void> {
455
475
  const { PaymentMethod, PaymentCurrency } = require('./store/models');
456
476
 
457
477
  const CHAINS = [
458
- { chainId: 'main', livemode: true, symbol: 'ABT', label: 'ArcBlock Main', contract: 'z35nNRvYxBoHitx9yZ5ATS88psfShzPPBLxYD' },
459
- { chainId: 'beta', livemode: false, symbol: 'TBA', label: 'ArcBlock Beta', contract: 'z35n6UoHSi9MED4uaQy6ozFgKPaZj2UKrurBG' },
478
+ {
479
+ chainId: 'main',
480
+ livemode: true,
481
+ symbol: 'ABT',
482
+ label: 'ArcBlock Main',
483
+ contract: 'z35nNRvYxBoHitx9yZ5ATS88psfShzPPBLxYD',
484
+ },
485
+ {
486
+ chainId: 'beta',
487
+ livemode: false,
488
+ symbol: 'TBA',
489
+ label: 'ArcBlock Beta',
490
+ contract: 'z35n6UoHSi9MED4uaQy6ozFgKPaZj2UKrurBG',
491
+ },
460
492
  ];
461
493
 
462
494
  await requestContext.withTenant(instanceDid, async () => {
@@ -464,6 +496,11 @@ async function provisionTenant(instanceDid: string): Promise<void> {
464
496
  const existing = await PaymentMethod.findOne({ where: { type: 'arcblock' } });
465
497
  if (existing) return;
466
498
 
499
+ // Sequential by design: only 2 chains, and within each the currency needs the
500
+ // method id and the method's default_currency_id needs the currency id — no
501
+ // intra-chain parallelism is possible, and inter-chain ordering is irrelevant
502
+ // for two one-time seed inserts.
503
+ /* eslint-disable no-await-in-loop */
467
504
  for (const chain of CHAINS) {
468
505
  const logo = '/methods/arcblock.png';
469
506
  const method = await PaymentMethod.create({
@@ -507,6 +544,7 @@ async function provisionTenant(instanceDid: string): Promise<void> {
507
544
  });
508
545
  await method.update({ default_currency_id: currency.id });
509
546
  }
547
+ /* eslint-enable no-await-in-loop */
510
548
  logger.info('provisionTenant: seeded arcblock payment methods + currencies', { instanceDid });
511
549
  });
512
550
  }
@@ -19,7 +19,7 @@ import type { LiteralUnion } from 'type-fest';
19
19
  import { fromTokenToUnit } from '@ocap/util';
20
20
  import { TenantModel } from '../tenant-model';
21
21
  import { getInstanceDid } from '../../libs/context';
22
- import { createIdGenerator } from '../../libs/util';
22
+ import { createIdGenerator, getPublicAssetUrl } from '../../libs/util';
23
23
  import { RechargeConfig, VaultConfig } from './types';
24
24
 
25
25
  const nextId = createIdGenerator('pc', 12);
@@ -57,6 +57,12 @@ export class PaymentCurrency extends TenantModel<InferAttributes<PaymentCurrency
57
57
  declare recharge_config?: RechargeConfig;
58
58
  declare token_config?: Record<string, any> | null;
59
59
 
60
+ public override toJSON(): any {
61
+ const data = super.toJSON() as any;
62
+ if (data.logo) data.logo = getPublicAssetUrl(data.logo);
63
+ return data;
64
+ }
65
+
60
66
  public static readonly GENESIS_ATTRIBUTES = {
61
67
  id: {
62
68
  type: DataTypes.STRING(15),
@@ -16,7 +16,7 @@ import { encryptSecret, decryptSecret } from '../../libs/secrets';
16
16
 
17
17
  import { AppStoreClient } from '../../integrations/app-store/client';
18
18
  import { GooglePlayClient } from '../../integrations/google-play/client';
19
- import { STRIPE_API_VERSION, createIdGenerator } from '../../libs/util';
19
+ import { STRIPE_API_VERSION, createIdGenerator, getPublicAssetUrl } from '../../libs/util';
20
20
  import { sequelize } from '../sequelize';
21
21
  import type { PaymentMethodSettings } from './types';
22
22
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../../libs/constants';
@@ -73,6 +73,12 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
73
73
  declare created_at: CreationOptional<Date>;
74
74
  declare updated_at: CreationOptional<Date>;
75
75
 
76
+ public override toJSON(): any {
77
+ const data = super.toJSON() as any;
78
+ if (data.logo) data.logo = getPublicAssetUrl(data.logo);
79
+ return data;
80
+ }
81
+
76
82
  public static readonly GENESIS_ATTRIBUTES = {
77
83
  id: {
78
84
  type: DataTypes.STRING(30),
@@ -170,11 +176,21 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
170
176
 
171
177
  public static encryptSettings(settings: PaymentMethodSettings) {
172
178
  const tmp = cloneDeep(settings);
173
- if (tmp.stripe) {
179
+ // Per-field truthy guards: admin UI submits *all* channel stubs (empty
180
+ // strings for the ones the user is not configuring), so a parent-level
181
+ // `if (tmp.stripe)` would gladly enter the branch and call
182
+ // encryptSecret('') for every empty stub. Under the multi-tenant
183
+ // KeyringSecretsDriver, CryptoJS.AES.encrypt(undefined, …) throws
184
+ // "Cannot read properties of undefined (reading 'encrypt')" — the
185
+ // single-tenant DefaultSecretsDriver swallowed it silently. Field-level
186
+ // guards make the function robust to either caller shape.
187
+ if (tmp.stripe?.secret_key) {
174
188
  tmp.stripe.secret_key = encryptSecret(tmp.stripe.secret_key);
189
+ }
190
+ if (tmp.stripe?.webhook_signing_secret) {
175
191
  tmp.stripe.webhook_signing_secret = encryptSecret(tmp.stripe.webhook_signing_secret);
176
192
  }
177
- if (tmp.google_play) {
193
+ if (tmp.google_play?.service_account_json) {
178
194
  tmp.google_play.service_account_json = encryptSecret(tmp.google_play.service_account_json);
179
195
  }
180
196
  if (tmp.app_store?.private_key_pem) {
@@ -189,11 +205,16 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
189
205
 
190
206
  public static decryptSettings(settings: PaymentMethodSettings) {
191
207
  const tmp = cloneDeep(settings);
192
- if (tmp.stripe) {
208
+ // Same per-field guard as encryptSettings — empty stubs from the admin
209
+ // UI roundtrip would otherwise hit decryptSecret('') and explode under
210
+ // the keyring driver.
211
+ if (tmp.stripe?.secret_key) {
193
212
  tmp.stripe.secret_key = decryptSecret(tmp.stripe.secret_key);
213
+ }
214
+ if (tmp.stripe?.webhook_signing_secret) {
194
215
  tmp.stripe.webhook_signing_secret = decryptSecret(tmp.stripe.webhook_signing_secret);
195
216
  }
196
- if (tmp.google_play) {
217
+ if (tmp.google_play?.service_account_json) {
197
218
  tmp.google_play.service_account_json = decryptSecret(tmp.google_play.service_account_json);
198
219
  }
199
220
  if (tmp.app_store?.private_key_pem) {
@@ -250,7 +271,10 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
250
271
  // wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
251
272
  // (protobuf client) so encode / decode / broadcast all stay protobuf,
252
273
  // matching the wallet signatures and what the chain node expects to hash.
253
- const created = new OcapClient(host);
274
+ // Disable the client's implicit getContext() warmup. Its zero-delay timer
275
+ // can be classified as a permanently hung request by workerd. Callers that
276
+ // need chain context fetch it explicitly through Worker-safe I/O.
277
+ const created = new OcapClient(host, false);
254
278
  ocapClients.set(host, created);
255
279
  return created;
256
280
  }
@@ -105,7 +105,9 @@ export class Subscription extends TenantModel<InferAttributes<Subscription>, Inf
105
105
  declare schedule_id?: string;
106
106
 
107
107
  // If the subscription has ended, the date the subscription ended.
108
- declare ended_at?: number;
108
+ // Column name in DB is `end_at` (see init below). Historical phantom
109
+ // attribute `ended_at` silently dropped writes — never name-drift again.
110
+ declare end_at?: number;
109
111
  declare start_date: number;
110
112
  declare trial_end?: number;
111
113
  declare trial_start?: number;
@@ -278,7 +278,7 @@ describe('ingestVerifiedAppStorePurchase', () => {
278
278
  );
279
279
  });
280
280
 
281
- it('does NOT reactivate a canceled subscription when the new transaction is also expired', async () => {
281
+ it('marks a fully-lapsed subscription as incomplete_expired when the new transaction is also expired', async () => {
282
282
  const update = jest.fn();
283
283
  const existing = { id: 'sub_existing', status: 'canceled', customer_id: 'cus_1', payment_details: { app_store: {} }, update };
284
284
  mockSubscriptionFindOne.mockResolvedValue(existing);
@@ -292,7 +292,18 @@ describe('ingestVerifiedAppStorePurchase', () => {
292
292
  });
293
293
 
294
294
  expect(result.subscription).toBe(existing);
295
- expect(update).not.toHaveBeenCalled();
295
+ // Stored + replay both expired (Sandbox steady state after Apple's
296
+ // max-renewals cap) — converge the row to incomplete_expired so the
297
+ // worker's Transaction.updates replay loop stops and entitlement.check
298
+ // converges to inactive. Without this terminal write the verify route
299
+ // would silently `return existing` on every replay and the SDK would
300
+ // never see the lapsed state.
301
+ expect(update).toHaveBeenCalledWith(
302
+ expect.objectContaining({
303
+ status: 'incomplete_expired',
304
+ cancel_at_period_end: false,
305
+ })
306
+ );
296
307
  expect(mockSubscriptionCreate).not.toHaveBeenCalled();
297
308
  });
298
309
 
@@ -0,0 +1,80 @@
1
+ import { reconcileStripeCheckoutSessionWithDeps } from '../../../src/integrations/stripe/reconcile-checkout-core';
2
+
3
+ describe('reconcileStripeCheckoutSession', () => {
4
+ it('syncs an unfinished one-time Stripe payment and returns the updated checkout status', async () => {
5
+ const checkoutSession = {
6
+ id: 'cs_1',
7
+ status: 'open',
8
+ payment_status: 'unpaid',
9
+ payment_intent_id: 'pi_local',
10
+ mode: 'payment',
11
+ reload: jest.fn(function reload() {
12
+ this.status = 'complete';
13
+ this.payment_status = 'paid';
14
+ return Promise.resolve();
15
+ }),
16
+ };
17
+ const paymentIntent = { id: 'pi_local', status: 'processing' };
18
+ const syncPayment = jest.fn().mockResolvedValue(undefined);
19
+
20
+ const result = await reconcileStripeCheckoutSessionWithDeps('cs_1', {
21
+ findCheckoutSession: jest.fn().mockResolvedValue(checkoutSession),
22
+ findPaymentIntent: jest.fn().mockResolvedValue(paymentIntent),
23
+ getSubscriptionIds: jest.fn().mockReturnValue([]),
24
+ findSubscriptions: jest.fn().mockResolvedValue([]),
25
+ syncPayment,
26
+ syncSubscription: jest.fn(),
27
+ });
28
+
29
+ expect(syncPayment).toHaveBeenCalledWith(paymentIntent);
30
+ expect(checkoutSession.reload).toHaveBeenCalled();
31
+ expect(result).toEqual({ status: 'complete', paymentStatus: 'paid', reconciled: true });
32
+ });
33
+
34
+ it('syncs only incomplete subscriptions so repeated reconciliation stays idempotent', async () => {
35
+ const checkoutSession = {
36
+ id: 'cs_1',
37
+ status: 'open',
38
+ payment_status: 'unpaid',
39
+ mode: 'subscription',
40
+ reload: jest.fn(),
41
+ };
42
+ const incomplete = { id: 'sub_1', status: 'incomplete' };
43
+ const active = { id: 'sub_2', status: 'active' };
44
+ const syncSubscription = jest.fn().mockResolvedValue(undefined);
45
+
46
+ await reconcileStripeCheckoutSessionWithDeps('cs_1', {
47
+ findCheckoutSession: jest.fn().mockResolvedValue(checkoutSession),
48
+ findPaymentIntent: jest.fn(),
49
+ getSubscriptionIds: jest.fn().mockReturnValue(['sub_1', 'sub_2']),
50
+ findSubscriptions: jest.fn().mockResolvedValue([incomplete, active]),
51
+ syncPayment: jest.fn(),
52
+ syncSubscription,
53
+ });
54
+
55
+ expect(syncSubscription).toHaveBeenCalledTimes(1);
56
+ expect(syncSubscription).toHaveBeenCalledWith(incomplete);
57
+ });
58
+
59
+ it('does not call Stripe for an already completed checkout session', async () => {
60
+ const syncPayment = jest.fn();
61
+ const syncSubscription = jest.fn();
62
+
63
+ const result = await reconcileStripeCheckoutSessionWithDeps('cs_1', {
64
+ findCheckoutSession: jest.fn().mockResolvedValue({
65
+ id: 'cs_1',
66
+ status: 'complete',
67
+ payment_status: 'paid',
68
+ }),
69
+ findPaymentIntent: jest.fn(),
70
+ getSubscriptionIds: jest.fn(),
71
+ findSubscriptions: jest.fn(),
72
+ syncPayment,
73
+ syncSubscription,
74
+ });
75
+
76
+ expect(syncPayment).not.toHaveBeenCalled();
77
+ expect(syncSubscription).not.toHaveBeenCalled();
78
+ expect(result).toEqual({ status: 'complete', paymentStatus: 'paid', reconciled: false });
79
+ });
80
+ });
@@ -0,0 +1,58 @@
1
+ const fetchContext = jest.fn();
2
+ const encodeTx = jest.fn();
3
+
4
+ jest.mock('@ocap/client/encode', () => ({
5
+ fetchContext,
6
+ encodeTx,
7
+ }));
8
+
9
+ import { encodeArcblockTransfer } from '../../src/libs/arcblock-transfer';
10
+
11
+ describe('encodeArcblockTransfer', () => {
12
+ beforeEach(() => {
13
+ fetchContext.mockReset();
14
+ encodeTx.mockReset();
15
+ });
16
+
17
+ it('uses the worker-safe context fetcher instead of the OCAP client context timer', async () => {
18
+ fetchContext.mockResolvedValue({
19
+ chainId: 'beta',
20
+ txFee: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
21
+ });
22
+ const encoded = {
23
+ object: { signature: Buffer.from('signed') },
24
+ buffer: Buffer.from('encoded'),
25
+ };
26
+ encodeTx.mockReturnValue(encoded);
27
+
28
+ const client = {
29
+ getContext: jest.fn(() => {
30
+ throw new Error('must not call timer-backed getContext');
31
+ }),
32
+ encodeTransferV3Tx: jest.fn(() => {
33
+ throw new Error('must not call timer-backed client encoder');
34
+ }),
35
+ };
36
+ const tx = { chainId: '', itx: { typeUrl: 'fg:t:transfer_v3', value: { outputs: [] } } };
37
+ const wallet = { address: 'zUser', publicKey: 'zPk' };
38
+
39
+ await expect(
40
+ encodeArcblockTransfer({
41
+ chainHost: 'https://beta.abtnetwork.io/api/',
42
+ tx,
43
+ wallet,
44
+ })
45
+ ).resolves.toBe(encoded);
46
+
47
+ expect(fetchContext).toHaveBeenCalledWith('https://beta.abtnetwork.io/api/');
48
+ expect(encodeTx).toHaveBeenCalledWith({
49
+ type: 'TransferV3Tx',
50
+ tx,
51
+ wallet,
52
+ chainId: 'beta',
53
+ feeConfig: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
54
+ });
55
+ expect(client.getContext).not.toHaveBeenCalled();
56
+ expect(client.encodeTransferV3Tx).not.toHaveBeenCalled();
57
+ });
58
+ });