payment-kit 1.29.9 → 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.
@@ -110,7 +110,19 @@ export async function ingestVerifiedAppStorePurchase({
110
110
 
111
111
  const newExpiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
112
112
  const newTxnValid = !!newExpiresAt && newExpiresAt * 1000 > Date.now();
113
- const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string);
113
+ // A subscription is "lapsed" when one of two things is true:
114
+ // (a) The stored status is already terminal (canceled / past_due / …).
115
+ // (b) Status is still 'active' but stored current_period_end is in the
116
+ // past. This is the Sandbox steady state — Apple doesn't send the
117
+ // RTDN that would flip the status on expiry, so a freshly bought
118
+ // 5-minute Sandbox sub goes silently stale. Without case (b), a
119
+ // re-Subscribe tap hits the idempotency `return existing` branch
120
+ // below and the row never gets the new expires_at — the user
121
+ // stays in the "Free" state because entitlement filters
122
+ // current_period_end > now.
123
+ const storedExpiresAt = existing.current_period_end ?? 0;
124
+ const storedExpired = !!storedExpiresAt && storedExpiresAt * 1000 < Date.now();
125
+ const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string) || storedExpired;
114
126
  // Sandbox re-subscribe / renewal arriving via client verify reuses the SAME
115
127
  // originalTransactionId. If the stored sub had lapsed (expired→canceled) but
116
128
  // the new transaction is valid, reactivate it instead of returning the stale
@@ -121,7 +133,7 @@ export async function ingestVerifiedAppStorePurchase({
121
133
  const updatePatch: any = {
122
134
  status: 'active',
123
135
  current_period_end: newExpiresAt,
124
- ended_at: null,
136
+ end_at: null,
125
137
  cancel_at_period_end: false,
126
138
  payment_details: {
127
139
  ...(existing.payment_details || {}),
@@ -165,6 +177,45 @@ export async function ingestVerifiedAppStorePurchase({
165
177
  return { subscription: existing, isFirstSubscribe: false, transaction };
166
178
  }
167
179
 
180
+ // Lapsed + new transaction ALSO expired (Apple Sandbox steady state
181
+ // once a sub has cycled past its max auto-renewals: Apple keeps
182
+ // replaying the final expired transaction through Transaction.updates
183
+ // forever — both stored and replay are stale). Without an explicit
184
+ // terminal write here, the row stays status='active' with a past
185
+ // current_period_end, entitlement.check filters it out (correct), but
186
+ // the verify route keeps returning success: true on every replay so
187
+ // PaymentKit.handle() finishes the transaction successfully — yet
188
+ // Apple Sandbox redelivers it on the next tick anyway, producing the
189
+ // hundreds-per-minute verify spam seen in cf-aside logs. Marking
190
+ // incomplete_expired + ended_at converges the local truth, and a
191
+ // subsequent fresh purchase will land in the reactivate branch above
192
+ // (lapsed + newTxnValid) and revive the row cleanly.
193
+ if (lapsed && !newTxnValid) {
194
+ await existing.update({
195
+ status: 'incomplete_expired',
196
+ end_at: Math.floor(Date.now() / 1000),
197
+ cancel_at_period_end: false,
198
+ payment_details: {
199
+ ...(existing.payment_details || {}),
200
+ app_store: {
201
+ ...(existing.payment_details?.app_store || {}),
202
+ transaction_id: transaction.transactionId,
203
+ expires_at: newExpiresAt || (existing.payment_details?.app_store as any)?.expires_at,
204
+ },
205
+ },
206
+ } as any);
207
+ logger.info(
208
+ 'app_store verify: subscription fully lapsed (stored + replay both expired) — marked incomplete_expired',
209
+ {
210
+ subscriptionId: existing.id,
211
+ originalTransactionId: transaction.originalTransactionId,
212
+ storedExpiresAt,
213
+ newExpiresAt,
214
+ }
215
+ );
216
+ return { subscription: existing, isFirstSubscribe: false, transaction };
217
+ }
218
+
168
219
  // SKU drift sync — when the StoreKit-side product_id differs from
169
220
  // what we have stored (Apple crossgrade / downgrade landed in
170
221
  // Sandbox immediately; or the production DID_RENEW webhook hadn't
@@ -579,7 +630,7 @@ async function handleAppStoreRenewed(
579
630
 
580
631
  async function markAppStoreExpired(subscription: Subscription): Promise<void> {
581
632
  if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
582
- await subscription.update({ status: 'canceled', ended_at: Math.floor(Date.now() / 1000) });
633
+ await subscription.update({ status: 'canceled', end_at: Math.floor(Date.now() / 1000) });
583
634
  createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(reportAuditFailure);
584
635
  }
585
636
 
@@ -607,7 +658,7 @@ async function handleAppStoreRevoked(
607
658
  const now = Math.floor(Date.now() / 1000);
608
659
  await subscription.update({
609
660
  status: 'canceled',
610
- ended_at: now,
661
+ end_at: now,
611
662
  cancelation_details: {
612
663
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
613
664
  reason: 'cancellation_requested',
@@ -541,7 +541,7 @@ async function handleRevoked(subscription: Subscription): Promise<void> {
541
541
  const now = Math.floor(Date.now() / 1000);
542
542
  await subscription.update({
543
543
  status: 'canceled',
544
- ended_at: now,
544
+ end_at: now,
545
545
  cancelation_details: {
546
546
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
547
547
  reason: 'cancellation_requested',
@@ -80,7 +80,7 @@ export async function handleGooglePlayVoidedPurchase({
80
80
  if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
81
81
  await subscription.update({
82
82
  status: 'canceled',
83
- ended_at: Math.floor(Date.now() / 1000),
83
+ end_at: Math.floor(Date.now() / 1000),
84
84
  cancelation_details: {
85
85
  ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
86
86
  reason: 'cancellation_requested',
@@ -41,7 +41,9 @@ export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, e
41
41
  }
42
42
 
43
43
  export async function syncStripePayment(paymentIntent: PaymentIntent) {
44
- if (!paymentIntent?.metadata?.stripe_id) {
44
+ const stripePaymentIntentId =
45
+ paymentIntent?.payment_details?.stripe?.payment_intent_id || paymentIntent?.metadata?.stripe_id;
46
+ if (!stripePaymentIntentId) {
45
47
  return;
46
48
  }
47
49
 
@@ -52,7 +54,7 @@ export async function syncStripePayment(paymentIntent: PaymentIntent) {
52
54
 
53
55
  const triggerRenew = paymentIntent.status !== 'succeeded';
54
56
  const client = await method.getStripeClient();
55
- const stripeIntent = await client.paymentIntents.retrieve(paymentIntent.metadata.stripe_id);
57
+ const stripeIntent = await client.paymentIntents.retrieve(stripePaymentIntentId);
56
58
  if (stripeIntent) {
57
59
  // @ts-ignore
58
60
  await paymentIntent.update({
@@ -148,7 +148,7 @@ export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe)
148
148
  if (['incomplete', 'incomplete_expired'].includes(subscription.status)) {
149
149
  logger.warn('subscription not ended on stripe event', { id: subscription.id });
150
150
  } else {
151
- await subscription.update({ status: 'canceled', ended_at: event.data.object.ended_at });
151
+ await subscription.update({ status: 'canceled', end_at: event.data.object.ended_at });
152
152
  logger.info('subscription ended on stripe event', { id: subscription.id });
153
153
  }
154
154
  }
@@ -0,0 +1,60 @@
1
+ export type ReconcileResult = {
2
+ status: string;
3
+ paymentStatus: string;
4
+ reconciled: boolean;
5
+ };
6
+
7
+ export type ReconcileDeps = {
8
+ findCheckoutSession: (id: string) => Promise<any>;
9
+ findPaymentIntent: (id: string) => Promise<any>;
10
+ getSubscriptionIds: (checkoutSession: any) => string[];
11
+ findSubscriptions: (ids: string[]) => Promise<any[]>;
12
+ syncPayment: (paymentIntent: any) => Promise<void>;
13
+ syncSubscription: (subscription: any) => Promise<void>;
14
+ };
15
+
16
+ export async function reconcileStripeCheckoutSessionWithDeps(
17
+ sessionId: string,
18
+ deps: ReconcileDeps
19
+ ): Promise<ReconcileResult> {
20
+ const checkoutSession = await deps.findCheckoutSession(sessionId);
21
+ if (!checkoutSession) {
22
+ throw new Error('Checkout session not found');
23
+ }
24
+ if (checkoutSession.status === 'complete') {
25
+ return {
26
+ status: checkoutSession.status,
27
+ paymentStatus: checkoutSession.payment_status,
28
+ reconciled: false,
29
+ };
30
+ }
31
+
32
+ let reconciled = false;
33
+ if (checkoutSession.payment_intent_id) {
34
+ const paymentIntent = await deps.findPaymentIntent(checkoutSession.payment_intent_id);
35
+ if (paymentIntent && paymentIntent.status !== 'succeeded') {
36
+ await deps.syncPayment(paymentIntent);
37
+ reconciled = true;
38
+ }
39
+ }
40
+
41
+ const subscriptionIds = deps.getSubscriptionIds(checkoutSession);
42
+ if (subscriptionIds.length > 0) {
43
+ const subscriptions = await deps.findSubscriptions(subscriptionIds);
44
+ for (const subscription of subscriptions) {
45
+ if (subscription.status === 'incomplete') {
46
+ // Sequential reconciliation avoids racing checkout success counters.
47
+ // eslint-disable-next-line no-await-in-loop
48
+ await deps.syncSubscription(subscription);
49
+ reconciled = true;
50
+ }
51
+ }
52
+ }
53
+
54
+ await checkoutSession.reload();
55
+ return {
56
+ status: checkoutSession.status,
57
+ paymentStatus: checkoutSession.payment_status,
58
+ reconciled,
59
+ };
60
+ }
@@ -0,0 +1,40 @@
1
+ import { getCheckoutSessionSubscriptionIds } from '../../libs/session';
2
+ import { CheckoutSession, PaymentIntent, PaymentMethod, Subscription } from '../../store/models';
3
+ import { syncStripePayment } from './handlers/payment-intent';
4
+ import { handleStripeSubscriptionSucceed } from './handlers/subscription';
5
+ import {
6
+ reconcileStripeCheckoutSessionWithDeps,
7
+ type ReconcileDeps,
8
+ type ReconcileResult,
9
+ } from './reconcile-checkout-core';
10
+
11
+ async function syncSubscription(subscription: Subscription): Promise<void> {
12
+ if (!subscription.payment_details?.stripe?.subscription_id) return;
13
+ const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
14
+ if (!method || method.type !== 'stripe') return;
15
+
16
+ const remote = await method
17
+ .getStripeClient()
18
+ .subscriptions.retrieve(subscription.payment_details.stripe.subscription_id, {
19
+ expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
20
+ });
21
+ if (['active', 'trialing'].includes(remote.status)) {
22
+ await handleStripeSubscriptionSucceed(subscription, remote.status);
23
+ }
24
+ }
25
+
26
+ const defaultDeps: ReconcileDeps = {
27
+ findCheckoutSession: (id) => CheckoutSession.findByPk(id),
28
+ findPaymentIntent: (id) => PaymentIntent.findByPk(id),
29
+ getSubscriptionIds: getCheckoutSessionSubscriptionIds,
30
+ findSubscriptions: (ids) => Subscription.findAll({ where: { id: ids } }),
31
+ syncPayment: syncStripePayment,
32
+ syncSubscription,
33
+ };
34
+
35
+ export function reconcileStripeCheckoutSession(
36
+ sessionId: string,
37
+ deps: ReconcileDeps = defaultDeps
38
+ ): Promise<ReconcileResult> {
39
+ return reconcileStripeCheckoutSessionWithDeps(sessionId, deps);
40
+ }
@@ -49,6 +49,15 @@ export type EntitlementCheckResult = {
49
49
  subscription_id: string | null;
50
50
  source: 'subscription' | 'one_time' | null;
51
51
  credit_remaining?: string;
52
+ /**
53
+ * For subscription source: the customer has asked the platform (Apple,
54
+ * Google, Stripe) to STOP auto-renewing. The entitlement is still
55
+ * active until `expires_at` but won't renew. UIs should mark the card
56
+ * as "canceled, ends on X" and offer a re-enable action that deep-
57
+ * links to the platform's subscription management. Always omitted for
58
+ * one-time / credit-grant sources.
59
+ */
60
+ cancel_at_period_end?: boolean;
52
61
  };
53
62
 
54
63
  const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
@@ -231,6 +240,7 @@ export async function checkEntitlement({
231
240
  expires_at: best.current_period_end ?? null,
232
241
  subscription_id: best.id,
233
242
  source: 'subscription',
243
+ cancel_at_period_end: !!best.cancel_at_period_end,
234
244
  };
235
245
  }
236
246
 
@@ -258,6 +268,7 @@ export type EntitlementListItem = {
258
268
  subscription_id: string | null;
259
269
  source: 'subscription' | 'one_time';
260
270
  credit_remaining?: string;
271
+ cancel_at_period_end?: boolean;
261
272
  };
262
273
 
263
274
  /**
@@ -367,6 +378,7 @@ export async function listEntitlements({
367
378
  expires_at: best.current_period_end ?? null,
368
379
  subscription_id: best.id,
369
380
  source: 'subscription',
381
+ cancel_at_period_end: !!best.cancel_at_period_end,
370
382
  };
371
383
  return item;
372
384
  })
@@ -24,6 +24,43 @@ import { isDevelopmentEnv, enableDevFakeAuth } from '../../libs/env';
24
24
  import { wallet } from '../../libs/auth';
25
25
  import { Customer } from '../../store/models/customer';
26
26
 
27
+ // Bare ↔ prefixed DID coexist in this codebase:
28
+ // - Bearer-token branch stores `did:abt:<base58>` (token-path normalisation).
29
+ // - BS-injected `x-user-did` headers carry whichever shape the host emits.
30
+ // - Customer.did rows on multi-tenant deploys are mixed (older rows bare,
31
+ // CF-seeded rows prefixed).
32
+ // Comparing `user.did === customer.did` with strict equality silently
33
+ // false-negatives for half of these combinations, so every cross-DID match
34
+ // goes through this helper. `findCustomerByEitherDid` runs a single
35
+ // disjunction query so a row in either shape resolves.
36
+ export function stripDidPrefix(did: string | undefined | null): string {
37
+ if (!did) return '';
38
+ return did.startsWith('did:abt:') ? did.slice('did:abt:'.length) : did;
39
+ }
40
+
41
+ /** True when both DIDs resolve to the same identity ignoring the `did:abt:`
42
+ * prefix. Use this for any cross-DID equality check on multi-tenant deploys
43
+ * (auth/record-access guards). Mirrors `blocklets/core/src/libs/did.ts`
44
+ * isSameDid — keep behaviour in sync. */
45
+ export function isSameDid(a: string | undefined | null, b: string | undefined | null): boolean {
46
+ const x = stripDidPrefix(a);
47
+ const y = stripDidPrefix(b);
48
+ return !!x && x === y;
49
+ }
50
+
51
+ async function findCustomerByEitherDid(did: string): Promise<Customer | null> {
52
+ if (!did) return null;
53
+ const bare = stripDidPrefix(did);
54
+ if (!bare) return null;
55
+ const prefixed = `did:abt:${bare}`;
56
+ // Sequelize Op.or via plain object form; either shape resolves to the same
57
+ // logical user. Kept minimal to avoid an extra Op.in import path here.
58
+ // Bound to a local so the function body satisfies require-await without
59
+ // tripping no-return-await (returning the awaited binding, not the await).
60
+ const found = await Customer.findOne({ where: { did: [bare, prefixed] as any } });
61
+ return found;
62
+ }
63
+
27
64
  type PermissionSpec<T extends Model> = {
28
65
  component?: boolean;
29
66
  roles?: string[];
@@ -173,7 +210,12 @@ export function authenticate<T extends Model>({
173
210
  }
174
211
 
175
212
  if (mine) {
176
- const customer = await Customer.findOne({ where: { did: user.did } });
213
+ // Match Customer regardless of which DID shape the row was stored as
214
+ // (bare base58 from older single-tenant writes, did:abt: prefixed from
215
+ // CF multi-tenant flows). A strict findOne({did: user.did}) silently
216
+ // returned null when shapes mismatched, falling through to the 403
217
+ // even for the real owner.
218
+ const customer = await findCustomerByEitherDid(user.did);
177
219
  if (customer) {
178
220
  c.set('customer', customer);
179
221
  // hono query is immutable — inject the VERIFIED id into context so a
@@ -190,7 +232,10 @@ export function authenticate<T extends Model>({
190
232
  const doc: T | null =
191
233
  findById && typeof findById === 'function' ? await findById(id) : await (model as any).findByPk(id);
192
234
  if (doc && doc[field as keyof T]) {
193
- const customer = await Customer.findOne({ where: { did: user.did } });
235
+ // Same shape-tolerant lookup as the mine branch record-ownership
236
+ // checks were silently failing for users whose customer row lives in
237
+ // the other DID shape.
238
+ const customer = await findCustomerByEitherDid(user.did);
194
239
  c.set('doc', doc);
195
240
  c.set('customer', customer);
196
241
  if (customer && customer.id === doc[field as keyof T]) {
@@ -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
  }
@@ -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
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.29.9
17
+ version: 1.29.10
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.29.9",
3
+ "version": "1.29.10",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -60,9 +60,9 @@
60
60
  "@blocklet/error": "^0.3.5",
61
61
  "@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
62
62
  "@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
63
- "@blocklet/payment-broker-client": "1.29.9",
64
- "@blocklet/payment-react": "1.29.9",
65
- "@blocklet/payment-vendor": "1.29.9",
63
+ "@blocklet/payment-broker-client": "1.29.10",
64
+ "@blocklet/payment-react": "1.29.10",
65
+ "@blocklet/payment-vendor": "1.29.10",
66
66
  "@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
67
67
  "@blocklet/ui-react": "^3.5.4",
68
68
  "@blocklet/uploader": "^0.15.4",
@@ -133,7 +133,7 @@
133
133
  "devDependencies": {
134
134
  "@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
135
135
  "@arcblock/eslint-config-ts": "^0.3.3",
136
- "@blocklet/payment-types": "1.29.9",
136
+ "@blocklet/payment-types": "1.29.10",
137
137
  "@types/connect": "^3.4.38",
138
138
  "@types/debug": "^4.1.12",
139
139
  "@types/dotenv-flow": "^3.3.3",
@@ -180,5 +180,5 @@
180
180
  "parser": "typescript"
181
181
  }
182
182
  },
183
- "gitHead": "249bd9bf3c6cc54f77c260edb4b4d3c0be60f126"
183
+ "gitHead": "18c831f5dbdfb271a96640befb06f589adbeeab0"
184
184
  }
@@ -0,0 +1,27 @@
1
+ // DID comparison helpers shared by the customer portal pages.
2
+ //
3
+ // A single ArcBlock identity shows up in two string shapes across the stack:
4
+ // - bare base58 address (e.g. `z1xxx`)
5
+ // - canonical URI form (e.g. `did:abt:z1xxx`)
6
+ //
7
+ // On CF Workers the host AUTH_SERVICE hands the frontend a bare address while
8
+ // the backend canonicalizes login-token DIDs to the `did:abt:` form before it
9
+ // stores `customer.did` (see api/src/middlewares/hono/security.ts and
10
+ // api/src/routes/hono/entitlements.ts `canonicalDid`/`isSelf`). A raw `!==`
11
+ // between `session.user.did` and `customer.did` therefore flags the very owner
12
+ // of the record as "another customer". Normalize both sides before comparing.
13
+
14
+ const DID_PREFIX = 'did:abt:';
15
+
16
+ export function canonicalizeDid(did: string | undefined | null): string {
17
+ if (!did) return '';
18
+ return did.startsWith(DID_PREFIX) ? did.slice(DID_PREFIX.length) : did;
19
+ }
20
+
21
+ // True when both DIDs resolve to the same identity, ignoring the `did:abt:`
22
+ // prefix difference. Empty/missing inputs are never considered a match.
23
+ export function isSameDid(a: string | undefined | null, b: string | undefined | null): boolean {
24
+ const x = canonicalizeDid(a);
25
+ const y = canonicalizeDid(b);
26
+ return !!x && x === y;
27
+ }
@@ -24,6 +24,7 @@ import CreditGrantItemList from '../../../components/customer/credit-grant-item-
24
24
  import InfoRow from '../../../components/info-row';
25
25
  import InfoRowGroup from '../../../components/info-row-group';
26
26
  import { useArcsphere } from '../../../hooks/browser';
27
+ import { isSameDid } from '../../../libs/did';
27
28
 
28
29
  const fetchData = (id: string | undefined): Promise<TCreditGrantExpanded> => {
29
30
  return api.get(`/api/credit-grants/${id}`).then((res: any) => res.data);
@@ -41,7 +42,7 @@ export default function CustomerCreditGrantDetail() {
41
42
  navigate('/customer', { replace: true });
42
43
  }, [navigate]);
43
44
 
44
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
45
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
45
46
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
46
47
  }
47
48
  if (error) {
@@ -21,6 +21,7 @@ import SectionHeader from '../../../components/section/header';
21
21
  import InfoRow from '../../../components/info-row';
22
22
  import InfoRowGroup from '../../../components/info-row-group';
23
23
  import { useArcsphere } from '../../../hooks/browser';
24
+ import { isSameDid } from '../../../libs/did';
24
25
 
25
26
  const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
26
27
  return api.get(`/api/credit-transactions/${id}`).then((res: any) => res.data);
@@ -38,7 +39,7 @@ export default function CustomerCreditTransactionDetail() {
38
39
  navigate('/customer', { replace: true });
39
40
  }, [navigate]);
40
41
 
41
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
42
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
42
43
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
43
44
  }
44
45
 
@@ -34,6 +34,7 @@ import InfoRow from '../../../components/info-row';
34
34
  import { Download } from '../../../components/invoice-pdf/pdf';
35
35
  import InvoiceTable from '../../../components/invoice/table';
36
36
  import { goBackOrFallback } from '../../../libs/util';
37
+ import { isSameDid } from '../../../libs/did';
37
38
  import CustomerRefundList from '../refund/list';
38
39
  import InfoMetric from '../../../components/info-metric';
39
40
  import InfoRowGroup from '../../../components/info-row-group';
@@ -121,7 +122,7 @@ export default function CustomerInvoiceDetail() {
121
122
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
123
  }, [error]);
123
124
 
124
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
125
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
125
126
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
126
127
  }
127
128
 
@@ -36,6 +36,7 @@ import InfoRow from '../../../components/info-row';
36
36
  import Currency from '../../../components/currency';
37
37
  import SubscriptionMetrics from '../../../components/subscription/metrics';
38
38
  import { goBackOrFallback, MAX_SAFE_AMOUNT } from '../../../libs/util';
39
+ import { isSameDid } from '../../../libs/did';
39
40
  import CustomerLink from '../../../components/customer/link';
40
41
  import { useSessionContext } from '../../../contexts/session';
41
42
  import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
@@ -227,7 +228,7 @@ export default function RechargePage() {
227
228
  return <Alert severity="info">{t('common.dataNotFound')}</Alert>;
228
229
  }
229
230
 
230
- if (subscription?.customer?.did && session?.user?.did && subscription.customer.did !== session.user.did) {
231
+ if (subscription?.customer?.did && session?.user?.did && !isSameDid(subscription.customer.did, session.user.did)) {
231
232
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
232
233
  }
233
234
  const currentBalance = formatBNStr(payerValue?.token || '0', paymentCurrency?.decimal, 6, false);
@@ -67,6 +67,7 @@ import {
67
67
  } from '../../../hooks/subscription';
68
68
  import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
69
69
  import { canChangePaymentMethod } from '../../../libs/util';
70
+ import { isSameDid } from '../../../libs/did';
70
71
  import PaymentMethodInfo from '../../../components/subscription/payment-method-info';
71
72
  import { useArcsphere } from '../../../hooks/browser';
72
73
 
@@ -260,7 +261,7 @@ export default function CustomerSubscriptionDetail() {
260
261
  }
261
262
  };
262
263
 
263
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
264
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
264
265
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
265
266
  }
266
267
  if (error) {
@@ -0,0 +1,43 @@
1
+ import { canonicalizeDid, isSameDid } from '../../src/libs/did';
2
+
3
+ describe('canonicalizeDid', () => {
4
+ it('strips the did:abt: prefix', () => {
5
+ expect(canonicalizeDid('did:abt:z1xyz')).toBe('z1xyz');
6
+ });
7
+
8
+ it('leaves bare addresses untouched', () => {
9
+ expect(canonicalizeDid('z1xyz')).toBe('z1xyz');
10
+ });
11
+
12
+ it('returns empty string for nullish input', () => {
13
+ expect(canonicalizeDid(undefined)).toBe('');
14
+ expect(canonicalizeDid(null)).toBe('');
15
+ expect(canonicalizeDid('')).toBe('');
16
+ });
17
+ });
18
+
19
+ describe('isSameDid', () => {
20
+ // The regression: CF stores customer.did canonical while the host session
21
+ // reports a bare address. The old `data.customer.did !== session.user.did`
22
+ // check wrongly denied the owner. isSameDid must treat these as equal.
23
+ it('matches a canonical DID against the same bare address', () => {
24
+ expect(isSameDid('did:abt:z1xyz', 'z1xyz')).toBe(true);
25
+ expect(isSameDid('z1xyz', 'did:abt:z1xyz')).toBe(true);
26
+ });
27
+
28
+ it('matches identical shapes', () => {
29
+ expect(isSameDid('z1xyz', 'z1xyz')).toBe(true);
30
+ expect(isSameDid('did:abt:z1xyz', 'did:abt:z1xyz')).toBe(true);
31
+ });
32
+
33
+ it('rejects genuinely different identities', () => {
34
+ expect(isSameDid('did:abt:z1xyz', 'z1abc')).toBe(false);
35
+ expect(isSameDid('z1xyz', 'z1abc')).toBe(false);
36
+ });
37
+
38
+ it('never matches when either side is missing', () => {
39
+ expect(isSameDid('', 'z1xyz')).toBe(false);
40
+ expect(isSameDid('z1xyz', undefined)).toBe(false);
41
+ expect(isSameDid(null, null)).toBe(false);
42
+ });
43
+ });