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.
- package/api/src/host-node/serve-static-arc.ts +1 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
- package/api/src/integrations/app-store/native-asn1.ts +1 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
- 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/integrations/stripe/setup.ts +3 -3
- package/api/src/libs/arcblock-transfer.ts +27 -0
- package/api/src/libs/context.ts +10 -2
- package/api/src/libs/did-connect/tenant-identity.ts +19 -1
- package/api/src/libs/entitlement.ts +12 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/notification/index.ts +4 -3
- package/api/src/libs/util.ts +31 -2
- package/api/src/middlewares/hono/csrf.ts +6 -0
- package/api/src/middlewares/hono/security.ts +47 -2
- package/api/src/middlewares/hono/session.ts +22 -0
- package/api/src/routes/connect/pay.ts +21 -9
- package/api/src/routes/hono/checkout-sessions.ts +71 -32
- package/api/src/routes/hono/entitlements.ts +8 -1
- package/api/src/routes/hono/payment-methods.ts +6 -2
- package/api/src/service.ts +42 -4
- package/api/src/store/models/payment-currency.ts +7 -1
- package/api/src/store/models/payment-method.ts +30 -6
- 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/api/tests/libs/arcblock-transfer.spec.ts +58 -0
- package/api/tests/libs/util.spec.ts +51 -0
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +35 -4
- package/cloudflare/did-connect-runtime.ts +43 -5
- package/cloudflare/esbuild-cf-config.cjs +12 -7
- package/cloudflare/tests/cf-adapter.spec.ts +10 -1
- package/cloudflare/worker.ts +41 -1
- package/package.json +9 -9
- package/src/components/layout/user.tsx +7 -1
- 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
- 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
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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) {
|
package/api/src/service.ts
CHANGED
|
@@ -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
|
-
|
|
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, () =>
|
|
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
|
-
{
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|