payment-kit 1.27.2 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +10 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +32 -16
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +16 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +14 -2
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +1 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/models/customer.ts +23 -1
- package/api/src/store/models/payment-method.ts +4 -0
- package/api/src/store/models/price.ts +23 -14
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/run-build.js +390 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +585 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1553 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +69 -0
- package/cloudflare/wrangler.staging.json +66 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +26 -22
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/safe-did-address.tsx +75 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/customers/customers/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -6,6 +6,7 @@ import { encodeTransferItx } from '../../integrations/ethereum/token';
|
|
|
6
6
|
import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
|
|
7
7
|
import { CallbackArgs, ethWallet } from '../../libs/auth';
|
|
8
8
|
import logger from '../../libs/logger';
|
|
9
|
+
import { parseChainError } from '../../libs/chain-error';
|
|
9
10
|
import { getGasPayerExtra } from '../../libs/payment';
|
|
10
11
|
import { createPaymentOutput } from '../../libs/session';
|
|
11
12
|
import { getTxMetadata } from '../../libs/util';
|
|
@@ -130,6 +131,9 @@ export default {
|
|
|
130
131
|
const client = paymentMethod.getOcapClient();
|
|
131
132
|
const claim = claims.find((x) => x.type === 'prepareTx');
|
|
132
133
|
|
|
134
|
+
// Warm up chain context cache to avoid setTimeout(resolve,0) hang in workerd
|
|
135
|
+
await client.getContext();
|
|
136
|
+
|
|
133
137
|
const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
|
|
134
138
|
if (claim.delegator && claim.from) {
|
|
135
139
|
tx.delegator = claim.delegator;
|
|
@@ -227,10 +231,15 @@ export default {
|
|
|
227
231
|
|
|
228
232
|
return { hash: txHash };
|
|
229
233
|
} catch (err) {
|
|
230
|
-
|
|
231
|
-
logger.error('Failed to finalize paymentIntent on arcblock', {
|
|
234
|
+
const parsed = parseChainError(err);
|
|
235
|
+
logger.error('Failed to finalize paymentIntent on arcblock', {
|
|
236
|
+
paymentIntentId: paymentIntent.id,
|
|
237
|
+
code: parsed.code,
|
|
238
|
+
details: parsed.details,
|
|
239
|
+
raw: err,
|
|
240
|
+
});
|
|
232
241
|
await paymentIntent.update({ status: 'requires_capture' });
|
|
233
|
-
throw
|
|
242
|
+
throw parsed;
|
|
234
243
|
}
|
|
235
244
|
}
|
|
236
245
|
|
|
@@ -101,6 +101,8 @@ export default {
|
|
|
101
101
|
billingThreshold: Math.max(minStakeAmount, billingThreshold),
|
|
102
102
|
items,
|
|
103
103
|
slippageConfig: subscription?.slippage_config || undefined,
|
|
104
|
+
// Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
|
|
105
|
+
delegateState: delegation.state,
|
|
104
106
|
}),
|
|
105
107
|
});
|
|
106
108
|
}
|
|
@@ -217,7 +219,7 @@ export default {
|
|
|
217
219
|
|
|
218
220
|
await checkoutSession.update({ status: 'complete', payment_status: 'paid' });
|
|
219
221
|
if (invoice) {
|
|
220
|
-
invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
|
|
222
|
+
await invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
|
|
221
223
|
}
|
|
222
224
|
await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
|
|
223
225
|
logger.info('CheckoutSession updated on setup done', {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/indent */
|
|
2
2
|
/* eslint-disable prettier/prettier */
|
|
3
3
|
import { toTypeInfo } from '@arcblock/did';
|
|
4
|
-
import { toDelegateAddress } from '@arcblock/did-util';
|
|
5
|
-
import type { Transaction } from '@ocap/client';
|
|
4
|
+
import { toDelegateAddress } from '@arcblock/did-util/cbor';
|
|
5
|
+
import type { DelegateState as DelegateStateType, Transaction } from '@ocap/client';
|
|
6
6
|
import { BN, fromTokenToUnit, toBase58 } from '@ocap/util';
|
|
7
7
|
import { fromPublicKey } from '@ocap/wallet';
|
|
8
8
|
import type { Request } from 'express';
|
|
9
|
-
import
|
|
9
|
+
import isEmpty from 'lodash/isEmpty';
|
|
10
10
|
import dayjs from '../../libs/dayjs';
|
|
11
11
|
import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/arcblock/stake';
|
|
12
12
|
import { encodeApproveItx } from '../../integrations/ethereum/token';
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../../libs/subscription';
|
|
32
32
|
import { getCustomerStakeAddress, OCAP_PAYMENT_TX_TYPE } from '../../libs/util';
|
|
33
33
|
|
|
34
|
+
// eslint-disable-next-line import/no-cycle
|
|
34
35
|
import { invoiceQueue } from '../../queues/invoice';
|
|
35
36
|
import { AutoRechargeConfig, type TLineItemExpanded } from '../../store/models';
|
|
36
37
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
@@ -453,8 +454,6 @@ export async function ensureInvoiceForCheckout({
|
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
456
|
|
|
456
|
-
const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
|
|
457
|
-
|
|
458
457
|
const metadata = {
|
|
459
458
|
...(checkoutSession.invoice_creation?.invoice_data?.metadata || {}),
|
|
460
459
|
};
|
|
@@ -467,7 +466,13 @@ export async function ensureInvoiceForCheckout({
|
|
|
467
466
|
const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
|
|
468
467
|
const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
|
|
469
468
|
const now = dayjs().unix();
|
|
470
|
-
|
|
469
|
+
|
|
470
|
+
// Parallel: fetch currency + expand line items (independent queries)
|
|
471
|
+
const [currency, expandedItems] = await Promise.all([
|
|
472
|
+
PaymentCurrency.findByPk(checkoutSession.currency_id),
|
|
473
|
+
lineItems ? Promise.resolve(lineItems) : Price.expand(checkoutSession.line_items, { product: true, upsell: true }),
|
|
474
|
+
]);
|
|
475
|
+
let invoiceItems = expandedItems;
|
|
471
476
|
|
|
472
477
|
// For items with quote_id, fetch full quote info and attach to line item metadata
|
|
473
478
|
// This ensures invoice line items have complete quote info for display
|
|
@@ -610,33 +615,21 @@ export async function ensureInvoiceForCheckout({
|
|
|
610
615
|
discountCount: discountInfo.appliedDiscounts.length,
|
|
611
616
|
});
|
|
612
617
|
|
|
613
|
-
//
|
|
614
|
-
if (!isGroupInvoice) {
|
|
615
|
-
await checkoutSession.update({ invoice_id: invoice.id });
|
|
616
|
-
}
|
|
617
|
-
if (paymentIntent) {
|
|
618
|
-
await paymentIntent.update({ invoice_id: invoice.id });
|
|
619
|
-
}
|
|
620
|
-
if (subscription) {
|
|
621
|
-
await subscription.update({ latest_invoice_id: invoice.id });
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Update quotes with invoice_id for audit trail
|
|
625
|
-
// Find quote_ids from invoice items and update the quotes
|
|
618
|
+
// Update invoice_id references in parallel — these are independent writes
|
|
626
619
|
const quoteIdsFromItems = items
|
|
627
620
|
.map((item) => item.metadata?.quote_id)
|
|
628
621
|
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
629
622
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
623
|
+
await Promise.all([
|
|
624
|
+
!isGroupInvoice ? checkoutSession.update({ invoice_id: invoice.id }) : Promise.resolve(),
|
|
625
|
+
paymentIntent ? paymentIntent.update({ invoice_id: invoice.id }) : Promise.resolve(),
|
|
626
|
+
subscription ? subscription.update({ latest_invoice_id: invoice.id }) : Promise.resolve(),
|
|
627
|
+
quoteIdsFromItems.length > 0
|
|
628
|
+
? PriceQuote.update({ invoice_id: invoice.id }, { where: { id: quoteIdsFromItems } }).then(() => {
|
|
629
|
+
logger.info('Updated quotes with invoice_id', { invoiceId: invoice.id, quoteIds: quoteIdsFromItems });
|
|
630
|
+
})
|
|
631
|
+
: Promise.resolve(),
|
|
632
|
+
]);
|
|
640
633
|
|
|
641
634
|
return { invoice, items };
|
|
642
635
|
}
|
|
@@ -861,6 +854,7 @@ export async function getDelegationTxClaim({
|
|
|
861
854
|
billingThreshold = 0,
|
|
862
855
|
requiredStake = true,
|
|
863
856
|
slippageConfig,
|
|
857
|
+
delegateState,
|
|
864
858
|
}: {
|
|
865
859
|
userDid: string;
|
|
866
860
|
userPk: string;
|
|
@@ -880,6 +874,10 @@ export async function getDelegationTxClaim({
|
|
|
880
874
|
min_acceptable_rate?: string;
|
|
881
875
|
base_currency?: string;
|
|
882
876
|
};
|
|
877
|
+
// Optional DelegateState passthrough — avoids a duplicate chain RPC in getTokenLimitsForDelegation
|
|
878
|
+
// when the caller already fetched it (e.g. via isDelegationSufficientForPayment). Same-request
|
|
879
|
+
// usage only — do not cache across requests (see delegationCache note in libs/payment.ts).
|
|
880
|
+
delegateState?: DelegateStateType | null;
|
|
883
881
|
}) {
|
|
884
882
|
// Calculate authorization amount
|
|
885
883
|
// When slippage_config with min_acceptable_rate is provided, use it for precise calculation
|
|
@@ -902,16 +900,23 @@ export async function getDelegationTxClaim({
|
|
|
902
900
|
}
|
|
903
901
|
|
|
904
902
|
const address = toDelegateAddress(userDid, wallet.address);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
903
|
+
// These two helpers each issue 1–2 chain RPCs and don't depend on each other,
|
|
904
|
+
// so fan them out — on CF Workers cold isolates the serial path easily pushed the
|
|
905
|
+
// request over the runtime CPU/real-time budget and tripped a "hung promise" 500
|
|
906
|
+
// (see #1351). Parallelising halves the wall-clock for the delegation tx claim.
|
|
907
|
+
const [tokenLimits, tokenRequirementsResult] = await Promise.all([
|
|
908
|
+
getTokenLimitsForDelegation(items, paymentMethod, paymentCurrency, address, amount, delegateState),
|
|
909
|
+
getTokenRequirements({
|
|
910
|
+
items,
|
|
911
|
+
mode,
|
|
912
|
+
trialing,
|
|
913
|
+
billingThreshold,
|
|
914
|
+
paymentMethod,
|
|
915
|
+
paymentCurrency,
|
|
916
|
+
requiredStake,
|
|
917
|
+
}),
|
|
918
|
+
]);
|
|
919
|
+
let tokenRequirements = tokenRequirementsResult;
|
|
915
920
|
|
|
916
921
|
if (mode === 'delegation') {
|
|
917
922
|
tokenRequirements = [];
|
|
@@ -1049,6 +1054,9 @@ export async function getStakeTxClaim({
|
|
|
1049
1054
|
if (paymentMethod.type === 'arcblock') {
|
|
1050
1055
|
// create staking data
|
|
1051
1056
|
const client = paymentMethod.getOcapClient();
|
|
1057
|
+
// CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
|
|
1058
|
+
// Same pattern as pay.ts; needed because did-connect-js tryWithTimeout bounds this callback at 10s.
|
|
1059
|
+
await client.getContext();
|
|
1052
1060
|
|
|
1053
1061
|
const stakeId = hasGrouping ? `stake-group-${subscription.id}` : subscription.id;
|
|
1054
1062
|
|
|
@@ -1155,6 +1163,8 @@ export async function getOverdraftProtectionStakeTxClaim({
|
|
|
1155
1163
|
if (paymentMethod.type === 'arcblock') {
|
|
1156
1164
|
// create staking data
|
|
1157
1165
|
const client = paymentMethod.getOcapClient();
|
|
1166
|
+
// CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
|
|
1167
|
+
await client.getContext();
|
|
1158
1168
|
const nonce = `overdraft-protection-${subscription.id}`;
|
|
1159
1169
|
const address = await getCustomerStakeAddress(userDid, nonce);
|
|
1160
1170
|
const { state } = await client.getStakeState({ address });
|
|
@@ -1579,6 +1589,9 @@ export async function executeOcapTransactions(
|
|
|
1579
1589
|
nonce?: string
|
|
1580
1590
|
) {
|
|
1581
1591
|
const client = paymentMethod.getOcapClient();
|
|
1592
|
+
// CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
|
|
1593
|
+
// Each subsequent executeSingleTransaction (Delegate + Stake broadcast) needs ready context.
|
|
1594
|
+
await client.getContext();
|
|
1582
1595
|
logger.info('start executeOcapTransactions', { userDid, claims });
|
|
1583
1596
|
|
|
1584
1597
|
const delegation = claims.find((x) => x.type === 'signature' && x.meta?.purpose === 'delegation');
|
|
@@ -133,6 +133,8 @@ export default {
|
|
|
133
133
|
items,
|
|
134
134
|
requiredStake,
|
|
135
135
|
slippageConfig: primarySubscription?.slippage_config || undefined,
|
|
136
|
+
// Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
|
|
137
|
+
delegateState: delegation.state,
|
|
136
138
|
}),
|
|
137
139
|
});
|
|
138
140
|
}
|
|
@@ -243,7 +245,8 @@ export default {
|
|
|
243
245
|
|
|
244
246
|
for (const invoice of invoices) {
|
|
245
247
|
if (invoice) {
|
|
246
|
-
|
|
248
|
+
// eslint-disable-next-line no-await-in-loop
|
|
249
|
+
await invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
|
|
247
250
|
}
|
|
248
251
|
}
|
|
249
252
|
|
|
@@ -27,6 +27,7 @@ import { blocklet } from '../libs/auth';
|
|
|
27
27
|
import { formatMetadata } from '../libs/util';
|
|
28
28
|
import { getPriceUintAmountByCurrency } from '../libs/price';
|
|
29
29
|
import { checkTokenBalance } from '../libs/payment';
|
|
30
|
+
import { measurePhase } from '../libs/timing';
|
|
30
31
|
import { getExchangeRateService } from '../libs/exchange-rate/service';
|
|
31
32
|
import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
|
|
32
33
|
import { isRateBelowMinAcceptableRate } from '../libs/slippage';
|
|
@@ -290,16 +291,19 @@ router.get('/verify-availability', authMine, async (req, res) => {
|
|
|
290
291
|
const { customer_id: customerId, currency_id: currencyId } = value;
|
|
291
292
|
let pendingAmount = value.pending_amount;
|
|
292
293
|
|
|
293
|
-
|
|
294
|
+
// Parallelize Customer + PaymentCurrency lookups (independent, saves ~85ms RTT)
|
|
295
|
+
const [customer, currency] = await Promise.all([
|
|
296
|
+
Customer.findByPkOrDid(customerId),
|
|
297
|
+
PaymentCurrency.findByPk(currencyId),
|
|
298
|
+
]);
|
|
294
299
|
if (!customer) {
|
|
295
300
|
return res.status(404).json({ error: `Customer ${customerId} not found` });
|
|
296
301
|
}
|
|
297
|
-
|
|
298
|
-
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
299
302
|
if (!currency) {
|
|
300
303
|
return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
|
|
301
304
|
}
|
|
302
305
|
|
|
306
|
+
// AutoRechargeConfig needs customer.id which we now have
|
|
303
307
|
const config = (await AutoRechargeConfig.findOne({
|
|
304
308
|
where: {
|
|
305
309
|
customer_id: customer.id,
|
|
@@ -511,13 +515,15 @@ router.get('/verify-availability', authMine, async (req, res) => {
|
|
|
511
515
|
// This ensures user can pay off pending amount and still have balance for continued usage
|
|
512
516
|
// minimumRequiredBalance = requiredPaymentAmount (to pay off pending) + totalAmount (for one more recharge)
|
|
513
517
|
const minimumRequiredBalance = requiredPaymentAmount.add(totalAmount);
|
|
514
|
-
balanceResult = await
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
518
|
+
balanceResult = await measurePhase('chain', () =>
|
|
519
|
+
checkTokenBalance({
|
|
520
|
+
paymentMethod: config.paymentMethod!,
|
|
521
|
+
paymentCurrency: config.rechargeCurrency!,
|
|
522
|
+
userDid: payer,
|
|
523
|
+
amount: minimumRequiredBalance.toString(),
|
|
524
|
+
skipUserCheck: true,
|
|
525
|
+
})
|
|
526
|
+
);
|
|
521
527
|
|
|
522
528
|
if (!balanceResult.sufficient) {
|
|
523
529
|
return res.json({
|
|
@@ -531,13 +537,15 @@ router.get('/verify-availability', authMine, async (req, res) => {
|
|
|
531
537
|
}
|
|
532
538
|
} else {
|
|
533
539
|
// No pending amount: check if balance can cover at least one recharge
|
|
534
|
-
balanceResult = await
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
540
|
+
balanceResult = await measurePhase('chain', () =>
|
|
541
|
+
checkTokenBalance({
|
|
542
|
+
paymentMethod: config.paymentMethod!,
|
|
543
|
+
paymentCurrency: config.rechargeCurrency!,
|
|
544
|
+
userDid: payer,
|
|
545
|
+
amount: totalAmount.toString(),
|
|
546
|
+
skipUserCheck: true,
|
|
547
|
+
})
|
|
548
|
+
);
|
|
541
549
|
|
|
542
550
|
if (!balanceResult.sufficient) {
|
|
543
551
|
return res.json({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Joi from 'joi';
|
|
2
2
|
import { Router } from 'express';
|
|
3
3
|
|
|
4
4
|
import { BN } from '@ocap/util';
|
|
@@ -24,7 +24,7 @@ const donationSchema = Joi.object<DonationSettings>({
|
|
|
24
24
|
beneficiaries: Joi.array()
|
|
25
25
|
.items(
|
|
26
26
|
Joi.object({
|
|
27
|
-
address: Joi.
|
|
27
|
+
address: Joi.string().required(),
|
|
28
28
|
share: Joi.number().positive().required(),
|
|
29
29
|
memo: Joi.string().max(64).optional(),
|
|
30
30
|
})
|
|
@@ -414,12 +414,22 @@ router.get('/overdue-summary', auth, async (req, res) => {
|
|
|
414
414
|
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
415
415
|
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
417
|
+
// Filter out results whose currency no longer exists (e.g. deleted currency)
|
|
418
|
+
const list = results
|
|
419
|
+
.map((r) => {
|
|
420
|
+
const currency = currencyMap.get(r.currency_id);
|
|
421
|
+
if (!currency) {
|
|
422
|
+
logger.warn('overdue-summary: currency not found, skipping row', { currency_id: r.currency_id });
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
currency,
|
|
427
|
+
total_pending: r.total_pending,
|
|
428
|
+
customer_count: r.customer_count,
|
|
429
|
+
event_count: r.event_count,
|
|
430
|
+
};
|
|
431
|
+
})
|
|
432
|
+
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
423
433
|
|
|
424
434
|
return res.json({ list });
|
|
425
435
|
} catch (err) {
|
|
@@ -6,7 +6,7 @@ import pick from 'lodash/pick';
|
|
|
6
6
|
import { InferAttributes, Op, WhereOptions } from 'sequelize';
|
|
7
7
|
import cloneDeep from 'lodash/cloneDeep';
|
|
8
8
|
import merge from 'lodash/merge';
|
|
9
|
-
import
|
|
9
|
+
import Joi from 'joi';
|
|
10
10
|
import { ensureWebhookRegistered, cleanupStripeWebhook, validateStripeKeys } from '../integrations/stripe/setup';
|
|
11
11
|
import logger from '../libs/logger';
|
|
12
12
|
import { authenticate } from '../libs/security';
|
|
@@ -3,7 +3,7 @@ import pick from 'lodash/pick';
|
|
|
3
3
|
import { Op, type WhereOptions } from 'sequelize';
|
|
4
4
|
|
|
5
5
|
import Joi from 'joi';
|
|
6
|
-
import
|
|
6
|
+
import merge from 'lodash/merge';
|
|
7
7
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
8
8
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
9
9
|
import { authenticate } from '../libs/security';
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
} from 'sequelize';
|
|
14
14
|
|
|
15
15
|
import merge from 'lodash/merge';
|
|
16
|
+
import { getLock } from '../../libs/lock';
|
|
16
17
|
import { createEvent } from '../../libs/audit';
|
|
17
18
|
import CustomError from '../../libs/error';
|
|
18
|
-
import { getLock } from '../../libs/lock';
|
|
19
19
|
import { createCodeGenerator, createIdGenerator } from '../../libs/util';
|
|
20
20
|
import type { CustomerAddress, CustomerPreferences, CustomerShipping } from './types';
|
|
21
21
|
|
|
@@ -156,6 +156,28 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
|
|
|
156
156
|
};
|
|
157
157
|
|
|
158
158
|
public async getInvoiceNumber() {
|
|
159
|
+
// Optimized path for CF Workers: atomic UPDATE...RETURNING in 1 D1 round trip.
|
|
160
|
+
// Original lock-based approach: lock.acquire → reload → increment(UPDATE + reload) → release
|
|
161
|
+
// = 2 D1 round trips for lock + 3 D1 round trips for data = ~1000ms at ~350ms/RT
|
|
162
|
+
// Optimized: single UPDATE...RETURNING = 1 D1 round trip = ~350ms
|
|
163
|
+
//
|
|
164
|
+
// SQLite serializes writes at the DB level, so the application-level lock is
|
|
165
|
+
// redundant for both D1 and Blocklet Server's SQLite.
|
|
166
|
+
const d1 = (globalThis as any).__CF_ENV__?.DB;
|
|
167
|
+
if (d1) {
|
|
168
|
+
try {
|
|
169
|
+
const sql =
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/quotes
|
|
171
|
+
'UPDATE "customers" SET "next_invoice_sequence" = COALESCE("next_invoice_sequence", 1) + 1 WHERE "id" = ? RETURNING "next_invoice_sequence" - 1 as "prev_sequence"';
|
|
172
|
+
const result = await d1.prepare(sql).bind(this.id).first();
|
|
173
|
+
const sequence = (result as any)?.prev_sequence ?? 1;
|
|
174
|
+
return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn('[getInvoiceNumber] D1 RETURNING failed, falling back:', (err as any)?.message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Blocklet Server path (or D1 fallback): use lock-based approach
|
|
159
181
|
const lock = getLock(`${this.id}-invoice-number`);
|
|
160
182
|
await lock.acquire();
|
|
161
183
|
await this.reload();
|
|
@@ -207,6 +207,10 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
|
|
|
207
207
|
const host = this.settings.arcblock?.api_host;
|
|
208
208
|
const cached = ocapClients.has(host);
|
|
209
209
|
if (!cached) {
|
|
210
|
+
// 1.30.9: the default `@ocap/client` entry went CBOR-only, but current ABT
|
|
211
|
+
// wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
|
|
212
|
+
// (protobuf client) so encode / decode / broadcast all stay protobuf,
|
|
213
|
+
// matching the wallet signatures and what the chain node expects to hash.
|
|
210
214
|
const created = new OcapClient(host);
|
|
211
215
|
ocapClients.set(host, created);
|
|
212
216
|
return created;
|
|
@@ -438,23 +438,32 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
|
|
|
438
438
|
}
|
|
439
439
|
});
|
|
440
440
|
|
|
441
|
-
// expand upsell
|
|
441
|
+
// expand upsell — batch load to avoid N+1 queries
|
|
442
442
|
if (upsell) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
443
|
+
const upsellToIds = [...new Set(prices.map((x) => x.upsell?.upsells_to_id).filter(Boolean))] as string[];
|
|
444
|
+
const meterIds = [...new Set(prices.map((x) => x.recurring?.meter_id).filter(Boolean))] as string[];
|
|
445
|
+
|
|
446
|
+
const [upsellPrices, meters] = await Promise.all([
|
|
447
|
+
upsellToIds.length > 0 ? Price.findAll({ where: { id: upsellToIds } }) : [],
|
|
448
|
+
meterIds.length > 0 ? Meter.findAll({ where: { id: meterIds } }) : [],
|
|
449
|
+
]);
|
|
450
|
+
|
|
451
|
+
const upsellPriceMap = new Map(upsellPrices.map((p) => [p.id, p]));
|
|
452
|
+
const meterMap = new Map(meters.map((m) => [m.id, m]));
|
|
453
|
+
|
|
454
|
+
prices.forEach((x) => {
|
|
455
|
+
if (x.upsell?.upsells_to_id) {
|
|
456
|
+
const to = upsellPriceMap.get(x.upsell.upsells_to_id);
|
|
457
|
+
if (to) {
|
|
453
458
|
// @ts-ignore
|
|
454
|
-
x.
|
|
459
|
+
x.upsell.upsells_to = to;
|
|
455
460
|
}
|
|
456
|
-
}
|
|
457
|
-
|
|
461
|
+
}
|
|
462
|
+
if (x.recurring?.meter_id) {
|
|
463
|
+
// @ts-ignore
|
|
464
|
+
x.meter = meterMap.get(x.recurring.meter_id) ?? null;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
458
467
|
}
|
|
459
468
|
|
|
460
469
|
// @ts-ignore
|
|
@@ -79,7 +79,7 @@ describe('wallet-migration', () => {
|
|
|
79
79
|
|
|
80
80
|
it('should return stored address if available and valid', async () => {
|
|
81
81
|
const mockClient = createMockClient({
|
|
82
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
82
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
83
83
|
if (address === 'stored_delegation_address') {
|
|
84
84
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
85
85
|
}
|
|
@@ -105,7 +105,7 @@ describe('wallet-migration', () => {
|
|
|
105
105
|
it('should fallback if stored address has no valid state', async () => {
|
|
106
106
|
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
107
107
|
const mockClient = createMockClient({
|
|
108
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
108
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
109
109
|
// stored address has no state, but migratedFrom address has
|
|
110
110
|
if (address === oldAddress) {
|
|
111
111
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
@@ -131,7 +131,7 @@ describe('wallet-migration', () => {
|
|
|
131
131
|
it('should return current address if delegation exists on current wallet', async () => {
|
|
132
132
|
const currentAddress = toDelegateAddress(delegator, currentWalletAddress);
|
|
133
133
|
const mockClient = createMockClient({
|
|
134
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
134
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
135
135
|
if (address === currentAddress) {
|
|
136
136
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
137
137
|
}
|
|
@@ -154,7 +154,7 @@ describe('wallet-migration', () => {
|
|
|
154
154
|
it('should fallback to migratedFrom addresses', async () => {
|
|
155
155
|
const oldAddress = toDelegateAddress(delegator, oldAppDid);
|
|
156
156
|
const mockClient = createMockClient({
|
|
157
|
-
getDelegateState: jest.fn().mockImplementation(({ address }) => {
|
|
157
|
+
getDelegateState: jest.fn().mockImplementation(async ({ address }) => {
|
|
158
158
|
if (address === oldAddress) {
|
|
159
159
|
return { state: { ops: [{ key: 'fg:x:transfer' }] } };
|
|
160
160
|
}
|
|
@@ -248,13 +248,16 @@ describe('credit-consume: handleBatchCreditConsumption', () => {
|
|
|
248
248
|
expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
|
|
249
249
|
|
|
250
250
|
// Event 2 should fail — only 20 remaining but needs 80
|
|
251
|
-
//
|
|
251
|
+
// Partial consumption is saved and event leaves the retry chain immediately
|
|
252
|
+
// (Insufficient balance is non-retryable per Plan 3, 2026-04-25 — retry
|
|
253
|
+
// would only re-trip the same shortage until external credit top-up).
|
|
252
254
|
expect(event2.update).toHaveBeenCalledWith(
|
|
253
255
|
expect.objectContaining({
|
|
254
256
|
credit_pending: expect.any(String),
|
|
255
257
|
})
|
|
256
258
|
);
|
|
257
|
-
expect(event2.
|
|
259
|
+
expect(event2.markAsRequiresAction).toHaveBeenCalled();
|
|
260
|
+
expect(event2.markAsRequiresCapture).not.toHaveBeenCalled();
|
|
258
261
|
|
|
259
262
|
expect(getLock().release).toHaveBeenCalled();
|
|
260
263
|
});
|
|
@@ -386,7 +386,11 @@ describe('credit-consume: handleCreditConsumption', () => {
|
|
|
386
386
|
// ==========================================
|
|
387
387
|
// Scenario 22/23: Retry scheduling + max retries
|
|
388
388
|
// ==========================================
|
|
389
|
-
it('scenario 22:
|
|
389
|
+
it('scenario 22: insufficient balance is non-retryable (goes straight to requires_action)', async () => {
|
|
390
|
+
// Insufficient credit balance only resolves via external top-up; retrying
|
|
391
|
+
// before that is guaranteed to fail again. Per Plan 3 (2026-04-25), this
|
|
392
|
+
// is classified as non-retryable so the event leaves the retry chain
|
|
393
|
+
// immediately rather than spawning N more delayed queue messages.
|
|
390
394
|
const event = makeMeterEvent({
|
|
391
395
|
getValue: jest.fn().mockReturnValue('500'),
|
|
392
396
|
attempt_count: 0,
|
|
@@ -397,10 +401,10 @@ describe('credit-consume: handleCreditConsumption', () => {
|
|
|
397
401
|
|
|
398
402
|
setupBasicMocks(event, meter, customer, [grant]);
|
|
399
403
|
|
|
400
|
-
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();
|
|
404
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow(/Insufficient credit balance/);
|
|
401
405
|
|
|
402
|
-
|
|
403
|
-
expect(event.markAsRequiresCapture).toHaveBeenCalled();
|
|
406
|
+
expect(event.markAsRequiresAction).toHaveBeenCalled();
|
|
407
|
+
expect(event.markAsRequiresCapture).not.toHaveBeenCalled();
|
|
404
408
|
});
|
|
405
409
|
|
|
406
410
|
it('scenario 23: marks as requires_action after max retries', async () => {
|
|
@@ -92,6 +92,7 @@ describe('GET /api/credit-grants/verify-availability', () => {
|
|
|
92
92
|
it('should return 404 if customer not found', async () => {
|
|
93
93
|
if (!routeHandler) return;
|
|
94
94
|
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue(null as any);
|
|
95
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'currency_123' } as any);
|
|
95
96
|
|
|
96
97
|
await routeHandler(mockReq as Request, mockRes as Response);
|
|
97
98
|
|