payment-kit 1.29.9 → 1.29.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
- package/api/src/integrations/arcblock/stake.ts +4 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +1 -1
- package/api/src/integrations/google-play/handlers/voided.ts +1 -1
- package/api/src/integrations/stripe/handlers/payment-intent.ts +4 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +1 -1
- package/api/src/integrations/stripe/reconcile-checkout-core.ts +60 -0
- package/api/src/integrations/stripe/reconcile-checkout.ts +40 -0
- package/api/src/libs/entitlement.ts +12 -0
- package/api/src/middlewares/hono/security.ts +47 -2
- package/api/src/routes/hono/checkout-sessions.ts +71 -32
- package/api/src/store/models/subscription.ts +3 -1
- package/api/tests/integrations/app-store/handlers.spec.ts +13 -2
- package/api/tests/integrations/stripe/reconcile-checkout.spec.ts +80 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -7
- package/src/libs/did.ts +27 -0
- package/src/pages/customer/credit-grant/detail.tsx +2 -1
- package/src/pages/customer/credit-transaction/detail.tsx +2 -1
- package/src/pages/customer/invoice/detail.tsx +2 -1
- package/src/pages/customer/recharge/subscription.tsx +2 -1
- package/src/pages/customer/subscription/detail.tsx +2 -1
- package/tests/libs/did.spec.ts +43 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
661
|
+
end_at: now,
|
|
611
662
|
cancelation_details: {
|
|
612
663
|
...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
|
|
613
664
|
reason: 'cancellation_requested',
|
|
@@ -34,6 +34,10 @@ export async function ensureStakedForGas() {
|
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// ensure context for stake (mirror integrations/arcblock/token.ts): client.stake
|
|
38
|
+
// -> getTokenStateMap reads client.context, which only getContext() populates.
|
|
39
|
+
await client.getContext();
|
|
40
|
+
|
|
37
41
|
const result = await client.getForgeState({});
|
|
38
42
|
const { token, txConfig } = result.state;
|
|
39
43
|
const holding = (account?.tokens || []).find((x: any) => x.address === token.address);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
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
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.11",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
50
50
|
"@arcblock/did": "^1.30.24",
|
|
51
|
-
"@arcblock/did-connect-js": "^4.1.
|
|
51
|
+
"@arcblock/did-connect-js": "^4.1.3",
|
|
52
52
|
"@arcblock/did-connect-react": "^3.5.4",
|
|
53
53
|
"@arcblock/did-connect-storage-nedb": "^1.8.0",
|
|
54
54
|
"@arcblock/did-util": "^1.30.24",
|
|
@@ -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.
|
|
64
|
-
"@blocklet/payment-react": "1.29.
|
|
65
|
-
"@blocklet/payment-vendor": "1.29.
|
|
63
|
+
"@blocklet/payment-broker-client": "1.29.11",
|
|
64
|
+
"@blocklet/payment-react": "1.29.11",
|
|
65
|
+
"@blocklet/payment-vendor": "1.29.11",
|
|
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.
|
|
136
|
+
"@blocklet/payment-types": "1.29.11",
|
|
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": "
|
|
183
|
+
"gitHead": "775c2d6b9624e70d12a3872f46f7b2deb52591ba"
|
|
184
184
|
}
|
package/src/libs/did.ts
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|