@volter/twin-stripe 0.1.2 → 2.0.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/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
// Stripe twin REQUEST HANDLER — the canonical Stripe API surface for the twin and
|
|
2
|
+
// the QA stack's authoritative Stripe (it replaced the old hand-made in-process
|
|
3
|
+
// mock). Contract: handleStripeTwinRequest({method, path, body, mode}) -> {status,
|
|
4
|
+
// body}. The whole point: this is backed by the event/action-log kernel and its
|
|
5
|
+
// response SHAPES are conformant to real Stripe (see stripe-conformance), instead
|
|
6
|
+
// of being invented — so the SDK/API call shapes the app relies on actually match
|
|
7
|
+
// Stripe. HTTP wrapper: stripe-server.ts → createStripeTwinServer.
|
|
8
|
+
//
|
|
9
|
+
// State lives in the action log (R18): writes are local actions, reads are the
|
|
10
|
+
// projection. No real Stripe is ever called.
|
|
11
|
+
import { applyTwinWrite } from '@volter/world-core';
|
|
12
|
+
import { projectResources } from '@volter/world-core';
|
|
13
|
+
import { createStripeTwinFetch } from "./stripe-server.js";
|
|
14
|
+
import { emitStripeEvent, eventTypeFor } from "./stripe-events.js";
|
|
15
|
+
import { SERVED_VERSION } from "./stripe-version.js";
|
|
16
|
+
import { planOf } from "./semantics/plans.js";
|
|
17
|
+
import { platformAccountDefault } from "./semantics/connect.js";
|
|
18
|
+
const SERVICE = 'stripe';
|
|
19
|
+
// The Stripe API version a request that pins none is served in: the vendored spec's (stripe-version.ts renders
|
|
20
|
+
// every answer in it). A request may override it via the `Stripe-Version` header (apiVersion);
|
|
21
|
+
// the value is echoed onto every event the request produces, exactly like real Stripe,
|
|
22
|
+
// whose stored Event.api_version reflects the version in force when the event was created.
|
|
23
|
+
export const TWIN_API_VERSION = SERVED_VERSION;
|
|
24
|
+
// Stripe API versions are dates (YYYY-MM-DD) optionally suffixed with a release channel
|
|
25
|
+
// (e.g. 2024-06-20.acacia). A malformed Stripe-Version is rejected with a 400, like Stripe.
|
|
26
|
+
const API_VERSION_RE = /^\d{4}-\d{2}-\d{2}(\.[a-z_]+)?$/;
|
|
27
|
+
export function isValidApiVersion(v) {
|
|
28
|
+
return API_VERSION_RE.test(v);
|
|
29
|
+
}
|
|
30
|
+
// resource type → id prefix (Stripe's conventions).
|
|
31
|
+
const PREFIX = {
|
|
32
|
+
charge: 'ch', customer: 'cus', payment_intent: 'pi', setup_intent: 'seti', payment_method: 'pm',
|
|
33
|
+
subscription: 'sub', price: 'price', product: 'prod', invoice: 'in', invoiceitem: 'ii',
|
|
34
|
+
refund: 're', verification_session: 'vs', ephemeral_key: 'ephkey', file_link: 'link',
|
|
35
|
+
dispute: 'dp', payout: 'po', balance_transaction: 'txn', event: 'evt',
|
|
36
|
+
checkout_session: 'cs', billing_portal_session: 'bps', billing_portal_configuration: 'bpc',
|
|
37
|
+
account: 'acct', transfer: 'tr',
|
|
38
|
+
tax_rate: 'txr', tax_calculation: 'taxcalc', tax_registration: 'taxreg',
|
|
39
|
+
credit_note: 'cn', tax_id: 'txi', customer_balance_transaction: 'cbtxn',
|
|
40
|
+
coupon: 'coupon', promotion_code: 'promo', payment_link: 'plink', quote: 'qt',
|
|
41
|
+
webhook_endpoint: 'we',
|
|
42
|
+
subscription_schedule: 'sub_sched', billing_meter: 'mtr', billing_meter_event: 'mtr_evt',
|
|
43
|
+
usage_record: 'mbur', test_clock: 'clock',
|
|
44
|
+
token: 'tok', mandate: 'mandate', application_fee: 'fee', fee_refund: 'fr',
|
|
45
|
+
transfer_reversal: 'trr', account_link: 'acctlink', person: 'person',
|
|
46
|
+
external_account: 'ba', file: 'file',
|
|
47
|
+
subscription_item: 'si', tax_transaction: 'tax', radar_review: 'prv',
|
|
48
|
+
radar_value_list: 'rsl', radar_value_list_item: 'rsli', radar_rule: 'rule',
|
|
49
|
+
// Issuing family (vendor-faithful Stripe id prefixes).
|
|
50
|
+
issuing_cardholder: 'ich', issuing_card: 'ic', issuing_authorization: 'iauth',
|
|
51
|
+
issuing_transaction: 'ipi', issuing_dispute: 'idp',
|
|
52
|
+
// Terminal family.
|
|
53
|
+
terminal_location: 'tml', terminal_reader: 'tmr', terminal_configuration: 'tmc',
|
|
54
|
+
// Top-ups, identity reports, reporting runs.
|
|
55
|
+
topup: 'tu', verification_report: 'vr', report_run: 'frr',
|
|
56
|
+
// Customer cash balance ledger + legacy sources.
|
|
57
|
+
cash_balance_transaction: 'ccsbtxn', source: 'src',
|
|
58
|
+
// Billing: prepaid credit grants + usage alerts.
|
|
59
|
+
credit_grant: 'credgr', billing_alert: 'alert',
|
|
60
|
+
// Entitlements: account-level features + per-customer active entitlements.
|
|
61
|
+
entitlements_feature: 'feat', active_entitlement: 'ent',
|
|
62
|
+
// Connect: embedded account sessions + apps secret store.
|
|
63
|
+
account_session: 'accts', apps_secret: 'apmc',
|
|
64
|
+
// Issuing: network tokens + card personalization designs.
|
|
65
|
+
issuing_token: 'iss_tok', personalization_design: 'pd',
|
|
66
|
+
// Treasury: financial accounts + money-movement flows + ledger.
|
|
67
|
+
financial_account: 'fa',
|
|
68
|
+
outbound_payment: 'obp', outbound_transfer: 'obt', inbound_transfer: 'ibt',
|
|
69
|
+
received_credit: 'rc', received_debit: 'rd',
|
|
70
|
+
treasury_transaction: 'trxn', treasury_transaction_entry: 'trxne',
|
|
71
|
+
// Climate (carbon removal): orders. (products/suppliers are a fixed read-only catalog.)
|
|
72
|
+
climate_order: 'climorder',
|
|
73
|
+
// Financial Connections: sessions + linked accounts + account transactions.
|
|
74
|
+
fc_session: 'fcsess', fc_account: 'fca', fc_transaction: 'fctxn',
|
|
75
|
+
// Forwarding (PAN forwarding) requests + Crypto onramp sessions.
|
|
76
|
+
forwarding_request: 'fwdr', onramp_session: 'cos',
|
|
77
|
+
// Entitlements: a product↔feature grant.
|
|
78
|
+
product_feature: 'prodft',
|
|
79
|
+
// Tax settings singleton.
|
|
80
|
+
tax_settings: 'taxset',
|
|
81
|
+
};
|
|
82
|
+
function err(message, status = 404, code) {
|
|
83
|
+
return { status, body: { error: { type: status === 404 ? 'invalid_request_error' : 'invalid_request_error', message, ...(code ? { code } : {}) } } };
|
|
84
|
+
}
|
|
85
|
+
// Fidelity: real Stripe rejects a charge/payment_intent missing or with a non-positive
|
|
86
|
+
// amount, or missing currency, before any state change.
|
|
87
|
+
export function validateMoney(params) {
|
|
88
|
+
const amount = params.amount;
|
|
89
|
+
if (amount === undefined)
|
|
90
|
+
return err('Missing required param: amount.', 400, 'parameter_missing');
|
|
91
|
+
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0)
|
|
92
|
+
return err('Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
|
|
93
|
+
if (params.currency === undefined || params.currency === '')
|
|
94
|
+
return err('Missing required param: currency.', 400, 'parameter_missing');
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
// Raw test-PAN → outcome (null = success). Mirrors Stripe's documented test cards.
|
|
98
|
+
const TEST_CARD_DECLINES = {
|
|
99
|
+
'4242424242424242': null, // Visa — always succeeds
|
|
100
|
+
'4000000000000002': { code: 'card_declined', decline_code: 'generic_decline', message: 'Your card was declined.' },
|
|
101
|
+
'4000000000009995': { code: 'card_declined', decline_code: 'insufficient_funds', message: 'Your card has insufficient funds.' },
|
|
102
|
+
'4000000000000069': { code: 'expired_card', message: 'Your card has expired.' },
|
|
103
|
+
'4000000000000127': { code: 'incorrect_cvc', message: "Your card's security code is incorrect." },
|
|
104
|
+
'4000000000000119': { code: 'processing_error', message: 'An error occurred while processing your card. Try again in a little bit.' },
|
|
105
|
+
// attaching it to a customer succeeds; charging it is declined (docs.stripe.com/testing#declined-payments)
|
|
106
|
+
'4000000000000341': { code: 'card_declined', decline_code: 'generic_decline', message: 'Your card was declined.' },
|
|
107
|
+
};
|
|
108
|
+
// Stripe's well-known test payment-method / source tokens → outcome (null = success).
|
|
109
|
+
// These map to the same outcomes as the PANs above (pm_card_visa ≡ 4242…, etc.).
|
|
110
|
+
export const TEST_TOKEN_DECLINES = {
|
|
111
|
+
pm_card_visa: null,
|
|
112
|
+
tok_visa: null,
|
|
113
|
+
pm_card_mastercard: null,
|
|
114
|
+
tok_mastercard: null,
|
|
115
|
+
pm_card_amex: null,
|
|
116
|
+
tok_amex: null,
|
|
117
|
+
pm_card_discover: null,
|
|
118
|
+
tok_discover: null,
|
|
119
|
+
pm_card_chargeDeclined: TEST_CARD_DECLINES['4000000000000002'],
|
|
120
|
+
tok_chargeDeclined: TEST_CARD_DECLINES['4000000000000002'],
|
|
121
|
+
pm_card_chargeDeclinedInsufficientFunds: TEST_CARD_DECLINES['4000000000009995'],
|
|
122
|
+
tok_chargeDeclinedInsufficientFunds: TEST_CARD_DECLINES['4000000000009995'],
|
|
123
|
+
// the Visa-branded names docs.stripe.com/testing#declined-payments lists for the same two cards
|
|
124
|
+
pm_card_visa_chargeDeclined: TEST_CARD_DECLINES['4000000000000002'],
|
|
125
|
+
tok_visa_chargeDeclined: TEST_CARD_DECLINES['4000000000000002'],
|
|
126
|
+
pm_card_visa_chargeDeclinedInsufficientFunds: TEST_CARD_DECLINES['4000000000009995'],
|
|
127
|
+
tok_visa_chargeDeclinedInsufficientFunds: TEST_CARD_DECLINES['4000000000009995'],
|
|
128
|
+
pm_card_chargeDeclinedExpiredCard: TEST_CARD_DECLINES['4000000000000069'],
|
|
129
|
+
tok_chargeDeclinedExpiredCard: TEST_CARD_DECLINES['4000000000000069'],
|
|
130
|
+
pm_card_chargeDeclinedIncorrectCvc: TEST_CARD_DECLINES['4000000000000127'],
|
|
131
|
+
tok_chargeDeclinedIncorrectCvc: TEST_CARD_DECLINES['4000000000000127'],
|
|
132
|
+
pm_card_chargeDeclinedProcessingError: TEST_CARD_DECLINES['4000000000000119'],
|
|
133
|
+
tok_chargeDeclinedProcessingError: TEST_CARD_DECLINES['4000000000000119'],
|
|
134
|
+
pm_card_chargeCustomerFail: TEST_CARD_DECLINES['4000000000000341'],
|
|
135
|
+
tok_chargeCustomerFail: TEST_CARD_DECLINES['4000000000000341'],
|
|
136
|
+
};
|
|
137
|
+
// Pull a card identifier out of whatever the caller attached, in Stripe's accepted
|
|
138
|
+
// shapes: a raw PAN under card[number] / source[number], or a token/pm id under
|
|
139
|
+
// payment_method / source / card (string). Returns either a normalized PAN (digits
|
|
140
|
+
// only) or a token string, or undefined when nothing card-shaped is present.
|
|
141
|
+
function resolveCardRef(params) {
|
|
142
|
+
const fromObj = (o) => {
|
|
143
|
+
// the form reader (the kernel's readParams) coerces an all-digit card[number] to a JS number, so accept both.
|
|
144
|
+
const n = o && typeof o === 'object' ? o.number : undefined;
|
|
145
|
+
if (typeof n === 'string' || typeof n === 'number')
|
|
146
|
+
return String(n).replace(/\D/g, '');
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
const pan = fromObj(params.card) ?? fromObj(params.source);
|
|
150
|
+
if (pan)
|
|
151
|
+
return pan;
|
|
152
|
+
// a payment method or token by name, or payment_method_data[card][number] (a raw PAN in the modern nested shape)
|
|
153
|
+
const named = (v) => (typeof v === 'string' && v ? v : v && typeof v === 'object' ? fromObj(v.card) : undefined);
|
|
154
|
+
return ['payment_method', 'source', 'card', 'payment_method_data'].map((key) => named(params[key])).find((ref) => ref !== undefined);
|
|
155
|
+
}
|
|
156
|
+
export function declineFor(find, ...paramSets) {
|
|
157
|
+
for (const params of paramSets) {
|
|
158
|
+
const ref = resolveCardRef(params);
|
|
159
|
+
if (ref === undefined)
|
|
160
|
+
continue;
|
|
161
|
+
if (ref in TEST_CARD_DECLINES)
|
|
162
|
+
return TEST_CARD_DECLINES[ref];
|
|
163
|
+
if (ref in TEST_TOKEN_DECLINES)
|
|
164
|
+
return TEST_TOKEN_DECLINES[ref];
|
|
165
|
+
const pm = find('payment_method', ref);
|
|
166
|
+
if (pm && Object.prototype.hasOwnProperty.call(pm, '_declineOutcome')) {
|
|
167
|
+
return pm._declineOutcome;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
// Stripe's well-known 3DS / SCA test instruments: confirming with one of these does NOT
|
|
173
|
+
// immediately succeed — it returns the PaymentIntent in `requires_action` with a
|
|
174
|
+
// `next_action` of type use_stripe_sdk (the client must run the 3DS challenge), exactly
|
|
175
|
+
// like a card that requires authentication. A SECOND confirm (after the simulated
|
|
176
|
+
// challenge) completes it. Covers both the PM tokens and the raw 3DS test PANs.
|
|
177
|
+
const THREE_DS_PANS = new Set(['4000002500003155', '4000002760003184', '4000003800000446', '4000000000003220']);
|
|
178
|
+
const THREE_DS_TOKENS = new Set(['pm_card_authenticationRequired', 'pm_card_authenticationRequiredOnSetup', 'pm_card_threeDSecure2Required', 'tok_threeDSecure2Required']);
|
|
179
|
+
export function requiresAuthentication(...paramSets) {
|
|
180
|
+
for (const params of paramSets) {
|
|
181
|
+
const ref = resolveCardRef(params);
|
|
182
|
+
if (ref === undefined)
|
|
183
|
+
continue;
|
|
184
|
+
if (THREE_DS_PANS.has(ref) || THREE_DS_TOKENS.has(ref))
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// The canonical next_action Stripe attaches to a PaymentIntent that needs a 3DS challenge.
|
|
190
|
+
export function threeDsNextAction() {
|
|
191
|
+
return {
|
|
192
|
+
type: 'use_stripe_sdk',
|
|
193
|
+
use_stripe_sdk: { type: 'three_d_secure_redirect', stripe_js: 'https://js.stripe.com/v3' },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ---- MICRO-DEPOSIT VERIFICATION (ACH / SEPA delayed bank debit) ----
|
|
197
|
+
// Confirming a PaymentIntent/SetupIntent with a us_bank_account PM that requires
|
|
198
|
+
// micro-deposit verification leaves it in `requires_action` with a
|
|
199
|
+
// `verify_with_microdeposits` next_action. Verification finishes by submitting the two
|
|
200
|
+
// deposit amounts (Stripe's documented test descriptor verifies with amounts 32 + 45) OR
|
|
201
|
+
// a `descriptor_code` (the test value `SM11AA`). We model that exact pair so the verify
|
|
202
|
+
// can FAIL with the wrong amounts. The well-known token that triggers this flow:
|
|
203
|
+
const MICRODEPOSIT_TOKENS = new Set(['pm_usBankAccount_requiresVerification', 'pm_us_bank_account']);
|
|
204
|
+
const MICRODEPOSIT_AMOUNTS = [32, 45];
|
|
205
|
+
const MICRODEPOSIT_DESCRIPTOR = 'SM11AA';
|
|
206
|
+
export function requiresMicrodeposits(...paramSets) {
|
|
207
|
+
for (const params of paramSets) {
|
|
208
|
+
const ref = resolveCardRef(params);
|
|
209
|
+
if (ref !== undefined && MICRODEPOSIT_TOKENS.has(ref))
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
// The canonical next_action for a PI/SI awaiting micro-deposit verification.
|
|
215
|
+
export function microdepositsNextAction() {
|
|
216
|
+
return {
|
|
217
|
+
type: 'verify_with_microdeposits',
|
|
218
|
+
verify_with_microdeposits: {
|
|
219
|
+
arrival_date: 0, hosted_verification_url: 'https://payments.twin.local/microdeposit', microdeposit_type: 'amounts',
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** The two deposits' amounts as sent: the test values (docs.stripe.com/payments/ach-direct-debit/accept-a-payment). */
|
|
224
|
+
function amountsMatch(params) {
|
|
225
|
+
const got = params.amounts.map((a) => Math.trunc(Number(a) || 0));
|
|
226
|
+
return got.length === 2 && got[0] === MICRODEPOSIT_AMOUNTS[0] && got[1] === MICRODEPOSIT_AMOUNTS[1];
|
|
227
|
+
}
|
|
228
|
+
/** The deposit's descriptor code as sent: the test value. */
|
|
229
|
+
function descriptorMatches(params) {
|
|
230
|
+
return String(params.descriptor_code) === MICRODEPOSIT_DESCRIPTOR;
|
|
231
|
+
}
|
|
232
|
+
/** Stripe's refusal of deposits that do not match what was sent. */
|
|
233
|
+
function depositMismatch(byAmounts) {
|
|
234
|
+
return byAmounts
|
|
235
|
+
? err('The amounts provided do not match the amounts that were sent to the bank account.', 400, 'payment_intent_microdeposit_verification_amounts_mismatch')
|
|
236
|
+
: err('The descriptor code provided does not match the one that was sent to the bank account.', 400, 'payment_intent_microdeposit_verification_descriptor_code_mismatch');
|
|
237
|
+
}
|
|
238
|
+
// Validate the submitted micro-deposit verification params against the modeled test values.
|
|
239
|
+
// Returns a vendor 400 on missing/both/wrong inputs, or null when the verification matches.
|
|
240
|
+
export function verifyMicrodeposits(params, _existing) {
|
|
241
|
+
const hasAmounts = Array.isArray(params.amounts);
|
|
242
|
+
const hasDescriptor = typeof params.descriptor_code === 'string' && params.descriptor_code !== '';
|
|
243
|
+
if (hasAmounts === hasDescriptor)
|
|
244
|
+
return err('You must pass exactly one of `amounts` and `descriptor_code`.', 400, 'parameter_missing');
|
|
245
|
+
const matches = hasAmounts ? amountsMatch(params) : descriptorMatches(params);
|
|
246
|
+
return matches ? null : depositMismatch(hasAmounts);
|
|
247
|
+
}
|
|
248
|
+
// Build the real Stripe card-error envelope (HTTP 402). `param` is 'card' for card
|
|
249
|
+
// errors; charge/payment_intent ids are attached when known so SDK error objects carry
|
|
250
|
+
// them (Stripe does this for confirm/charge failures).
|
|
251
|
+
/** A card error's body: `charge` "For card errors, the ID of the failed charge", and `payment_intent` "The PaymentIntent
|
|
252
|
+
* object for errors returned on a request involving a PaymentIntent" (docs.stripe.com/api/errors). */
|
|
253
|
+
export function cardError(outcome, attach = {}) {
|
|
254
|
+
return {
|
|
255
|
+
status: 402,
|
|
256
|
+
body: {
|
|
257
|
+
error: {
|
|
258
|
+
type: 'card_error',
|
|
259
|
+
code: outcome.code,
|
|
260
|
+
...(outcome.decline_code ? { decline_code: outcome.decline_code } : {}),
|
|
261
|
+
message: outcome.message,
|
|
262
|
+
param: 'card',
|
|
263
|
+
...(attach.charge ? { charge: attach.charge } : {}),
|
|
264
|
+
...(attach.payment_intent ? { payment_intent: attach.payment_intent } : {}),
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function nowUnix(occurredAt) {
|
|
270
|
+
return Math.floor((occurredAt ? Date.parse(occurredAt) : 0) / 1000);
|
|
271
|
+
}
|
|
272
|
+
function rows(type, root) {
|
|
273
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
274
|
+
}
|
|
275
|
+
function nextId(type, root) {
|
|
276
|
+
const prefix = PREFIX[type] ?? type;
|
|
277
|
+
let max = 0;
|
|
278
|
+
for (const r of rows(type, root)) {
|
|
279
|
+
const m = new RegExp(`^${prefix}_twin_(\\d+)$`).exec(r.id);
|
|
280
|
+
if (m)
|
|
281
|
+
max = Math.max(max, Number(m[1]));
|
|
282
|
+
}
|
|
283
|
+
return `${prefix}_twin_${max + 1}`;
|
|
284
|
+
}
|
|
285
|
+
// Real Stripe embeds the resource's OWN id inside its client_secret (format
|
|
286
|
+
// `<id>_secret_<random>`) — Stripe.js's confirmPayment/confirmSetup parse the id back
|
|
287
|
+
// out of the client_secret (splitting on `_secret`) to build the same-origin confirm
|
|
288
|
+
// URL (`/v1/payment_intents/<id>/confirm`), rather than being told the id separately.
|
|
289
|
+
// A secret that doesn't carry the real id breaks that round trip: a browser Stripe.js
|
|
290
|
+
// call would try to confirm a resource whose id it invented from the string
|
|
291
|
+
// (`'pi_twin_secret'` → `'pi_twin'`, which was never actually created). Every PI/SI a
|
|
292
|
+
// caller might confirm CLIENT-SIDE (real Stripe.js — see the QA proxy's browserRouting
|
|
293
|
+
// in packages/twin/stripe/src/index.ts) must mint a secret this way.
|
|
294
|
+
export function mintClientSecret(id) {
|
|
295
|
+
return `${id}_secret_twin`;
|
|
296
|
+
}
|
|
297
|
+
// Stripe resource view: inject `object` + `id`, drop kernel meta.
|
|
298
|
+
//
|
|
299
|
+
// The kernel reserves the field name `type` as its resource-type discriminator, so a
|
|
300
|
+
// stored vendor field literally named `type` is dropped on projection (see actions.ts
|
|
301
|
+
// META). Several Stripe objects (payout.type, balance_transaction.type, event.type,
|
|
302
|
+
// dispute uses `reason` not `type`) DO have a vendor `type`, so the create path stashes
|
|
303
|
+
// it under the reserved key `_stripe_type` (a `_`-prefixed twin-internal field that the
|
|
304
|
+
// conformance gate exempts) and we restore it to `type` here. This keeps the vendor
|
|
305
|
+
// field faithful without touching the kernel discriminator. (price.type stays a declared
|
|
306
|
+
// deviation — its create path predates this and one_time/recurring is derivable.)
|
|
307
|
+
// A few Stripe objects have a DOTTED `object` value that differs from the twin's
|
|
308
|
+
// (identifier-safe) resource type — e.g. type 'checkout_session' emits object
|
|
309
|
+
// 'checkout.session'. Map them on emit so the `object` field is vendor-faithful.
|
|
310
|
+
// (stripe-conformance.ts emitted() mirrors this map.)
|
|
311
|
+
export const OBJECT_NAME = {
|
|
312
|
+
checkout_session: 'checkout.session',
|
|
313
|
+
billing_portal_session: 'billing_portal.session',
|
|
314
|
+
billing_portal_configuration: 'billing_portal.configuration',
|
|
315
|
+
// Stripe Tax: the TaxRate object is `tax_rate`, but a Tax calculation/registration
|
|
316
|
+
// emit the dotted object names `tax.calculation` / `tax.registration`.
|
|
317
|
+
tax_calculation: 'tax.calculation',
|
|
318
|
+
tax_registration: 'tax.registration',
|
|
319
|
+
// Billing Meters live under the `billing.meter` object; a test clock is `test_helpers.test_clock`.
|
|
320
|
+
billing_meter: 'billing.meter',
|
|
321
|
+
test_clock: 'test_helpers.test_clock',
|
|
322
|
+
// A connected account's external account is the `bank_account` object; an application-fee
|
|
323
|
+
// refund is `fee_refund`; a transfer reversal is `transfer_reversal` (1:1, no mapping).
|
|
324
|
+
external_account: 'bank_account',
|
|
325
|
+
// Stripe Tax transactions emit the dotted object name `tax.transaction`. Radar objects
|
|
326
|
+
// are `radar.review` (NOTE: the public review object is just `review`), `radar.value_list`,
|
|
327
|
+
// `radar.value_list_item`, `radar.rule`.
|
|
328
|
+
tax_transaction: 'tax.transaction',
|
|
329
|
+
radar_review: 'review',
|
|
330
|
+
radar_value_list: 'radar.value_list',
|
|
331
|
+
radar_value_list_item: 'radar.value_list_item',
|
|
332
|
+
radar_rule: 'radar.rule',
|
|
333
|
+
// Issuing objects carry dotted `object` names.
|
|
334
|
+
issuing_cardholder: 'issuing.cardholder',
|
|
335
|
+
issuing_card: 'issuing.card',
|
|
336
|
+
issuing_authorization: 'issuing.authorization',
|
|
337
|
+
issuing_transaction: 'issuing.transaction',
|
|
338
|
+
issuing_dispute: 'issuing.dispute',
|
|
339
|
+
// Terminal objects carry dotted `object` names.
|
|
340
|
+
terminal_location: 'terminal.location',
|
|
341
|
+
terminal_reader: 'terminal.reader',
|
|
342
|
+
terminal_configuration: 'terminal.configuration',
|
|
343
|
+
// Identity VerificationReport + Reporting ReportRun.
|
|
344
|
+
verification_report: 'identity.verification_report',
|
|
345
|
+
report_run: 'reporting.report_run',
|
|
346
|
+
// Customer cash-balance ledger entry.
|
|
347
|
+
cash_balance_transaction: 'customer_cash_balance_transaction',
|
|
348
|
+
// Billing: prepaid credit grants + usage alerts.
|
|
349
|
+
credit_grant: 'billing.credit_grant', billing_alert: 'billing.alert',
|
|
350
|
+
// Entitlements: account feature + per-customer active entitlement.
|
|
351
|
+
entitlements_feature: 'entitlements.feature', active_entitlement: 'entitlements.active_entitlement',
|
|
352
|
+
// Connect: embedded account session + apps secret.
|
|
353
|
+
account_session: 'account_session', apps_secret: 'apps.secret',
|
|
354
|
+
// Issuing: network token + personalization design.
|
|
355
|
+
issuing_token: 'issuing.token', personalization_design: 'issuing.personalization_design',
|
|
356
|
+
// Treasury: financial account + money-movement flows + ledger.
|
|
357
|
+
financial_account: 'treasury.financial_account',
|
|
358
|
+
outbound_payment: 'treasury.outbound_payment', outbound_transfer: 'treasury.outbound_transfer',
|
|
359
|
+
inbound_transfer: 'treasury.inbound_transfer',
|
|
360
|
+
received_credit: 'treasury.received_credit', received_debit: 'treasury.received_debit',
|
|
361
|
+
treasury_transaction: 'treasury.transaction', treasury_transaction_entry: 'treasury.transaction_entry',
|
|
362
|
+
// Climate.
|
|
363
|
+
climate_order: 'climate.order',
|
|
364
|
+
verification_session: 'identity.verification_session',
|
|
365
|
+
// Financial Connections.
|
|
366
|
+
fc_session: 'financial_connections.session', fc_account: 'financial_connections.account',
|
|
367
|
+
fc_transaction: 'financial_connections.transaction',
|
|
368
|
+
// Forwarding + Crypto onramp.
|
|
369
|
+
forwarding_request: 'forwarding.request', onramp_session: 'crypto.onramp_session',
|
|
370
|
+
};
|
|
371
|
+
// Exported for the pack's own modules that must render the SAME vendor view of a stored
|
|
372
|
+
// resource (stripe-emit.ts synthesizes `data.object` for emitted events from it) — one
|
|
373
|
+
// projection, no drift. Not a public API for consumers.
|
|
374
|
+
export function view(type, r) {
|
|
375
|
+
// `_subscription_data` is twin-internal (a Checkout Session's create-only
|
|
376
|
+
// subscription_data, held for the completion transition to copy onto the created
|
|
377
|
+
// subscription) — real Stripe never returns it on the Session, so strip it here.
|
|
378
|
+
const { type: _t, updatedAt: _u, _stripe_type, _subscription_data, ...rest } = r;
|
|
379
|
+
const out = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
|
|
380
|
+
if (_stripe_type !== undefined)
|
|
381
|
+
out.type = _stripe_type;
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
// Real Stripe list pagination. Honors limit (default 10, max 100, min 1),
|
|
385
|
+
// starting_after (cursor: exclude up to & including that id), ending_before
|
|
386
|
+
// (cursor: take the page ending just before that id); computes has_more against
|
|
387
|
+
// the FULL filtered set. Items must already be in Stripe's list order (newest
|
|
388
|
+
// first by `created`). Unknown params are ignored, like Stripe.
|
|
389
|
+
/** The page ending just before a cursor: the limit items immediately preceding it (docs.stripe.com/api/pagination). */
|
|
390
|
+
function pageBefore(items, endingBefore, limit) {
|
|
391
|
+
const idx = items.findIndex((r) => r.id === endingBefore);
|
|
392
|
+
const upTo = idx === -1 ? items.length : idx; // unknown cursor → from the start
|
|
393
|
+
const start = Math.max(0, upTo - limit);
|
|
394
|
+
return { page: items.slice(start, upTo), hasMore: start > 0 };
|
|
395
|
+
}
|
|
396
|
+
/** Where a page after a cursor starts (docs.stripe.com/api/pagination); an unknown cursor starts from the first. */
|
|
397
|
+
function afterCursor(items, startingAfter) {
|
|
398
|
+
const idx = items.findIndex((r) => r.id === startingAfter);
|
|
399
|
+
return idx === -1 ? 0 : idx + 1;
|
|
400
|
+
}
|
|
401
|
+
export function paginate(items, params) {
|
|
402
|
+
let limit = 10;
|
|
403
|
+
if (params.limit !== undefined) {
|
|
404
|
+
const n = Number(params.limit);
|
|
405
|
+
if (Number.isFinite(n))
|
|
406
|
+
limit = Math.min(100, Math.max(1, Math.trunc(n)));
|
|
407
|
+
}
|
|
408
|
+
const startingAfter = typeof params.starting_after === 'string' ? params.starting_after : undefined;
|
|
409
|
+
const endingBefore = typeof params.ending_before === 'string' ? params.ending_before : undefined;
|
|
410
|
+
if (endingBefore)
|
|
411
|
+
return pageBefore(items, endingBefore, limit);
|
|
412
|
+
const from = startingAfter ? afterCursor(items, startingAfter) : 0;
|
|
413
|
+
const page = items.slice(from, from + limit);
|
|
414
|
+
return { page, hasMore: from + limit < items.length };
|
|
415
|
+
}
|
|
416
|
+
async function writeResource(type, id, fields, op, root, occurredAt, apiVersion) {
|
|
417
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: id, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
|
|
418
|
+
const out = view(type, resource);
|
|
419
|
+
await afterStripeWrite(type, op, out, root, occurredAt, apiVersion);
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
/** What follows every stored Stripe write: the webhook for the state change and the stored Event
|
|
423
|
+
* the Events API lists. The derived pack's write hook (manifest.ts) is this same function. */
|
|
424
|
+
export async function afterStripeWrite(type, op, out, root, occurredAt, apiVersion, stripeAccount) {
|
|
425
|
+
// R17: fire the Stripe event/webhook for this state change (no-op if none registered). An event is a connected
|
|
426
|
+
// account's when its resource lives in one (a request made as it, the Stripe-Account header) or is a connected
|
|
427
|
+
// account itself (its account.updated): docs.stripe.com/connect/webhooks, "Connected accounts" scope.
|
|
428
|
+
// A write with no header is still a connected account's when the row it wrote is kept on that account's books (a
|
|
429
|
+
// payout or ledger entry's `_account`: an automatic payout time makes for it, semantics/balance.ts).
|
|
430
|
+
const account = stripeAccount ?? (type === 'account' && typeof out.id === 'string' && out.id !== PLATFORM_ACCOUNT_ID ? out.id : undefined) ?? ownerOf(type, out.id, root);
|
|
431
|
+
// a plan is stored as the recurring price it is; its own events carry the plan object (semantics/plans.ts)
|
|
432
|
+
const object = type === 'price' && op.startsWith('plan.') ? planOf(out) : out;
|
|
433
|
+
// events.full_types: the Stripe `event` envelope for this state change is also stored, so it appears in GET
|
|
434
|
+
// /v1/events (independent of webhook registration), exactly like real Stripe's stored Events API. Skip when the op
|
|
435
|
+
// has no mapped event type, and never for event/_idempotency writes themselves (no recursion / no meta-events). The
|
|
436
|
+
// event's id is minted once and is the delivered webhook's id and the stored event's alike: one event, one id, which
|
|
437
|
+
// a consumer deduplicates by and retrieves by.
|
|
438
|
+
const eventType = type !== 'event' && type !== IDEMPOTENCY_TYPE ? eventTypeFor(op) : null;
|
|
439
|
+
const id = eventType ? nextStripeEventId(root) : undefined;
|
|
440
|
+
// stored before it is delivered: a consumer that looks its webhook's event up finds it
|
|
441
|
+
if (eventType)
|
|
442
|
+
await persistStripeEvent(eventType, object, root, occurredAt, apiVersion, account, id);
|
|
443
|
+
await emitStripeEvent(op, object, { occurredAt: occurredAt ?? '1970-01-01T00:00:00.000Z', endpoints: webhookTargets(root), ...(account ? { account } : {}), ...(id ? { id } : {}) });
|
|
444
|
+
}
|
|
445
|
+
/** Event numbers taken in this process, per root, before their events are stored: two writes whose events overlap
|
|
446
|
+
* (a webhook handler calling back in, concurrent requests) never take the same number. */
|
|
447
|
+
const takenEventNumbers = new Map();
|
|
448
|
+
/** The id the World's next event takes: its place in the World's events, so it is deterministic and never repeats
|
|
449
|
+
* within a World (a Balance has no id of its own, so nothing about the object can tell two events apart). The number
|
|
450
|
+
* is taken synchronously — the count of stored events, or the last number taken, whichever is higher. */
|
|
451
|
+
export function nextStripeEventId(root) {
|
|
452
|
+
const key = root ?? '';
|
|
453
|
+
const n = Math.max(rows('event', root).length, takenEventNumbers.get(key) ?? 0) + 1;
|
|
454
|
+
takenEventNumbers.set(key, n);
|
|
455
|
+
return `evt_twin_${n}`;
|
|
456
|
+
}
|
|
457
|
+
/** The connected account whose books a stored row is kept on (its `_account`), if any. */
|
|
458
|
+
function ownerOf(type, id, root) {
|
|
459
|
+
if (typeof id !== 'string')
|
|
460
|
+
return undefined;
|
|
461
|
+
const owner = rows(type, root).find((r) => r.id === id)?._account;
|
|
462
|
+
return typeof owner === 'string' ? owner : undefined;
|
|
463
|
+
}
|
|
464
|
+
// Persist a Stripe `event` resource (object 'event') for a state change into the action
|
|
465
|
+
// log so the Events API (GET /v1/events) lists/retrieves it. Deterministic id derived
|
|
466
|
+
// from the per-root event count; data.object carries the resource snapshot. Written via
|
|
467
|
+
// applyTwinWrite directly (NOT writeResource) to avoid re-emitting an event for the event.
|
|
468
|
+
export async function persistStripeEvent(eventType, resource, root, occurredAt, apiVersion, account, id) {
|
|
469
|
+
const eventId = id ?? nextStripeEventId(root);
|
|
470
|
+
const fields = {
|
|
471
|
+
object: 'event', _stripe_type: eventType, created: nowUnix(occurredAt), livemode: false,
|
|
472
|
+
pending_webhooks: 0, api_version: apiVersion && isValidApiVersion(apiVersion) ? apiVersion : TWIN_API_VERSION,
|
|
473
|
+
request: { id: null, idempotency_key: null },
|
|
474
|
+
data: { object: resource },
|
|
475
|
+
// a connected account's event names it, and is listed to requests made as it (semantics/webhook-endpoints.ts)
|
|
476
|
+
...(account ? { account } : {}),
|
|
477
|
+
};
|
|
478
|
+
await applyTwinWrite(SERVICE, { operation: 'event.record', subjectType: 'event', subjectId: eventId, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'system' } }, root);
|
|
479
|
+
}
|
|
480
|
+
function getOne(type, id, root) {
|
|
481
|
+
const r = rows(type, root).find((x) => x.id === id);
|
|
482
|
+
return r ? view(type, r) : undefined;
|
|
483
|
+
}
|
|
484
|
+
// Stripe list order: newest first by `created` (or `date` for invoiceitem), ties
|
|
485
|
+
// broken by id descending so the order is stable for cursor pagination.
|
|
486
|
+
/** The World's live webhook endpoints, from its tree: delivery survives a restart and belongs to
|
|
487
|
+
* this World alone. A disabled or deleted endpoint receives nothing, like the vendor. */
|
|
488
|
+
function webhookTargets(root) {
|
|
489
|
+
// `connect` is a create parameter the endpoint object does not answer, so it is read off the stored row
|
|
490
|
+
return rows('webhook_endpoint', root)
|
|
491
|
+
.map((r) => ({ w: view('webhook_endpoint', r), connect: asBool(r.connect) }))
|
|
492
|
+
.filter(({ w }) => !w.deleted && w.status !== 'disabled' && typeof w.url === 'string')
|
|
493
|
+
.map(({ w, connect }) => ({ url: String(w.url), ...(typeof w.secret === 'string' ? { secret: w.secret } : {}), ...(Array.isArray(w.enabled_events) ? { enabledEvents: w.enabled_events.map(String) } : {}), connect }));
|
|
494
|
+
}
|
|
495
|
+
// Stripe's `active=true|false` filters arrive as strings/booleans; normalize.
|
|
496
|
+
export function asBool(v) {
|
|
497
|
+
return v === true || v === 'true' || v === 1 || v === '1';
|
|
498
|
+
}
|
|
499
|
+
// Tokenize on top-level AND/OR (we treat the whole query as either all-AND or all-OR; mixed
|
|
500
|
+
// precedence is uncommon in practice and Stripe groups with parens we don't model — kept simple).
|
|
501
|
+
function parseSearchQuery(query) {
|
|
502
|
+
const trimmed = query.trim();
|
|
503
|
+
if (!trimmed)
|
|
504
|
+
return null;
|
|
505
|
+
const disjunction = / OR /i.test(trimmed) && !/ AND /i.test(trimmed);
|
|
506
|
+
const parts = trimmed.split(disjunction ? / OR /i : / AND /i);
|
|
507
|
+
const clauses = [];
|
|
508
|
+
const re = /^\s*([A-Za-z0-9_]+(?:\[[^\]]+\])?)\s*(>=|<=|>|<|:)\s*(.+?)\s*$/;
|
|
509
|
+
for (const part of parts) {
|
|
510
|
+
const m = re.exec(part);
|
|
511
|
+
if (!m)
|
|
512
|
+
return null;
|
|
513
|
+
let value = m[3].trim();
|
|
514
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
515
|
+
value = value.slice(1, -1);
|
|
516
|
+
clauses.push({ field: m[1], op: m[2], value });
|
|
517
|
+
}
|
|
518
|
+
return { clauses, disjunction };
|
|
519
|
+
}
|
|
520
|
+
// Resolve a query field path to a value on a resource view: supports plain fields and the
|
|
521
|
+
// metadata["key"] accessor, its key quoted either way (docs.stripe.com/search writes both `metadata["key"]:"value"` and,
|
|
522
|
+
// in its "Charges metadata search" example, `metadata['key']:'value'`).
|
|
523
|
+
function searchFieldValue(item, field) {
|
|
524
|
+
const meta = /^metadata\[["']?([^"'\]]+)["']?\]$/.exec(field);
|
|
525
|
+
if (meta) {
|
|
526
|
+
const m = item.metadata;
|
|
527
|
+
return m && typeof m === 'object' ? m[meta[1]] : undefined;
|
|
528
|
+
}
|
|
529
|
+
return item[field];
|
|
530
|
+
}
|
|
531
|
+
/** A search's numeric comparison other than `>=` (docs.stripe.com/search#search-query-language). */
|
|
532
|
+
function otherComparison(op, a, n) {
|
|
533
|
+
switch (op) {
|
|
534
|
+
case '>': return a > n;
|
|
535
|
+
case '<': return a < n;
|
|
536
|
+
case '<=': return a <= n;
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
function evalSearchClause(item, c) {
|
|
541
|
+
const actual = searchFieldValue(item, c.field);
|
|
542
|
+
if (c.op === ':') {
|
|
543
|
+
if (c.value === 'null')
|
|
544
|
+
return actual === null || actual === undefined;
|
|
545
|
+
// numeric exact when both look numeric; else string-equality (case-sensitive, like Stripe tokens)
|
|
546
|
+
if (typeof actual === 'number' && /^-?\d+(\.\d+)?$/.test(c.value))
|
|
547
|
+
return actual === Number(c.value);
|
|
548
|
+
if (typeof actual === 'boolean')
|
|
549
|
+
return String(actual) === c.value;
|
|
550
|
+
return String(actual) === c.value;
|
|
551
|
+
}
|
|
552
|
+
const n = Number(c.value);
|
|
553
|
+
const a = Number(actual);
|
|
554
|
+
if (!Number.isFinite(n) || !Number.isFinite(a))
|
|
555
|
+
return false;
|
|
556
|
+
return c.op === '>=' ? a >= n : otherComparison(c.op, a, n);
|
|
557
|
+
}
|
|
558
|
+
// A Stripe search over rows in list order: the search-result envelope (object 'search_result',
|
|
559
|
+
// has_more, data, next_page), or a vendor 400 when `query` is missing or unparseable. The page
|
|
560
|
+
// size honors `limit`.
|
|
561
|
+
export function searchOver(items, params, path) {
|
|
562
|
+
const query = typeof params.query === 'string' ? params.query : '';
|
|
563
|
+
if (!query)
|
|
564
|
+
return err('Missing required param: query.', 400, 'parameter_missing');
|
|
565
|
+
const parsed = parseSearchQuery(query);
|
|
566
|
+
if (!parsed)
|
|
567
|
+
return err(`Invalid search query: '${query}'.`, 400, 'parameter_invalid');
|
|
568
|
+
const all = items.filter((item) => parsed.disjunction ? parsed.clauses.some((c) => evalSearchClause(item, c)) : parsed.clauses.every((c) => evalSearchClause(item, c)));
|
|
569
|
+
const { page, hasMore } = paginate(all, params);
|
|
570
|
+
return { status: 200, body: { object: 'search_result', url: path, has_more: hasMore, data: page, next_page: null, total_count: null } };
|
|
571
|
+
}
|
|
572
|
+
// Subscriptions `?price=` filters to subs that include that price. The twin stores
|
|
573
|
+
// items in whatever shape the caller sent (commonly items[].data[].price[.id]); be
|
|
574
|
+
// lenient about the nesting and also accept a flat stored `price` field.
|
|
575
|
+
export function subscriptionHasPrice(sub, priceId) {
|
|
576
|
+
if (sub.price === priceId)
|
|
577
|
+
return true;
|
|
578
|
+
const items = sub.items;
|
|
579
|
+
const data = Array.isArray(items?.data) ? items.data : Array.isArray(sub.items) ? sub.items : [];
|
|
580
|
+
return data.some((it) => it?.price === priceId || (!!it?.price && typeof it.price === 'object' && it.price.id === priceId));
|
|
581
|
+
}
|
|
582
|
+
// The `coupon` a subscription create/update is attaching, accepting both the top-level
|
|
583
|
+
// `coupon=` shorthand and Stripe's discounts[0][coupon] array form. Returns undefined when
|
|
584
|
+
// no coupon param was sent at all (so update can distinguish "not set" from "clear" = '').
|
|
585
|
+
export function subscriptionCouponParam(params) {
|
|
586
|
+
if ('coupon' in params)
|
|
587
|
+
return typeof params.coupon === 'string' ? params.coupon : '';
|
|
588
|
+
const ds = params.discounts;
|
|
589
|
+
return Array.isArray(ds) && ds[0] && typeof ds[0] === 'object' ? couponOfDiscounts(ds[0]) : undefined;
|
|
590
|
+
}
|
|
591
|
+
/** discounts[0][coupon], Stripe's array form of a subscription's coupon (docs.stripe.com/api/subscriptions/create#create_subscription-discounts). */
|
|
592
|
+
function couponOfDiscounts(first) {
|
|
593
|
+
return typeof first.coupon === 'string' ? first.coupon : undefined;
|
|
594
|
+
}
|
|
595
|
+
// Build Stripe's canonical `discount` object from a coupon (the shape a subscription /
|
|
596
|
+
// customer carries once a coupon is applied). id `di_`, references the source coupon +
|
|
597
|
+
// customer; end is null for a forever/repeating coupon (we don't compute repeat windows).
|
|
598
|
+
/** A coupon applied: the Discount object, naming what it applies to ("customer: The ID of the customer associated
|
|
599
|
+
* with this discount"; "subscription: The subscription that this coupon is applied to, if it is applied to a
|
|
600
|
+
* particular subscription", docs.stripe.com/api/discounts/object). */
|
|
601
|
+
export function buildDiscount(coupon, customer, at, on = {}) {
|
|
602
|
+
return {
|
|
603
|
+
id: `di_twin_${coupon.id}`, object: 'discount', coupon, customer: customer || null,
|
|
604
|
+
start: at, end: null, subscription: on.subscription ?? null, subscription_item: null,
|
|
605
|
+
invoice: null, invoice_item: null, promotion_code: null, checkout_session: null,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// The (price id, quantity) pairs a subscription bills, normalized across the shapes the
|
|
609
|
+
// twin stores items in (the create form array items:[{price,quantity}], or the canonical
|
|
610
|
+
// items:{data:[{price}]} list). Used to compute upcoming-invoice lines.
|
|
611
|
+
export function subscriptionItemPairs(sub) {
|
|
612
|
+
const items = sub.items;
|
|
613
|
+
const data = Array.isArray(items?.data) ? items.data : Array.isArray(sub.items) ? sub.items : [];
|
|
614
|
+
const out = [];
|
|
615
|
+
for (const it of data) {
|
|
616
|
+
const p = it?.price;
|
|
617
|
+
const id = typeof p === 'string' ? p : (p && typeof p === 'object' ? String(p.id ?? '') : '');
|
|
618
|
+
if (id)
|
|
619
|
+
out.push({ price: id, quantity: Math.max(1, Math.trunc(Number(it.quantity) || 1)) });
|
|
620
|
+
}
|
|
621
|
+
// a bare `price` field (the connector's flattened shape) counts as one item.
|
|
622
|
+
if (out.length === 0 && typeof sub.price === 'string' && sub.price)
|
|
623
|
+
out.push({ price: sub.price, quantity: 1 });
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
// The raw item entries a subscription/checkout-derived-subscription create sent,
|
|
627
|
+
// normalized to an ordered array. The form reader already turns the bracket form
|
|
628
|
+
// items[0][price]=… into an array of objects (see lineItemEntries() above for the
|
|
629
|
+
// identical `line_items` case); accept that, and a single-object shape defensively.
|
|
630
|
+
export function subscriptionItemEntries(value) {
|
|
631
|
+
return Array.isArray(value) ? value.filter((x) => x && typeof x === 'object') : [];
|
|
632
|
+
}
|
|
633
|
+
const BILLING_INTERVALS = new Set(['day', 'week', 'month', 'year']);
|
|
634
|
+
// Resolve the (interval, interval_count) a new subscription bills on, from the FIRST
|
|
635
|
+
// entry's price (Stripe requires every item on a subscription to share one
|
|
636
|
+
// billing_cycle_anchor, so the first item's cadence drives current_period_end).
|
|
637
|
+
// `entries` is whatever subscriptionItemEntries()/lineItemEntries() produced — each
|
|
638
|
+
// entry's `price` may be a price id string or an already-resolved Price object
|
|
639
|
+
// (the checkout-session line_items shape). Falls back to a defensive month/1 default
|
|
640
|
+
// (`resolved: false`) when no entry resolves to a stored recurring price — e.g. an
|
|
641
|
+
// inline price_data with no persisted Price, or (real Stripe would 400) no item at
|
|
642
|
+
// all. This should not happen for a well-formed subscription create; it exists so
|
|
643
|
+
// current_period_end is always populated rather than silently NaN/undefined.
|
|
644
|
+
export function resolveSubscriptionBillingInterval(entries, find) {
|
|
645
|
+
// the first item whose price recurs sets the billing interval
|
|
646
|
+
const recurring = entries.map((entry) => {
|
|
647
|
+
const p = entry.price;
|
|
648
|
+
const priceId = typeof p === 'string' ? p : (p && typeof p === 'object' ? String(p.id ?? '') : '');
|
|
649
|
+
return (priceId ? find('price', priceId)?.recurring : undefined);
|
|
650
|
+
}).find((r) => r && typeof r === 'object' && typeof r.interval === 'string' && BILLING_INTERVALS.has(r.interval));
|
|
651
|
+
return recurring
|
|
652
|
+
? { interval: recurring.interval, interval_count: Math.max(1, Math.trunc(Number(recurring.interval_count) || 1)), resolved: true }
|
|
653
|
+
: { interval: 'month', interval_count: 1, resolved: false };
|
|
654
|
+
}
|
|
655
|
+
// Add one billing period to a unix timestamp, real-Stripe-faithful: `month`/`year`
|
|
656
|
+
// add CALENDAR months clamped to the target month's last day (Jan 31 + 1 month ->
|
|
657
|
+
// Feb 28/29, not an overflow into March, matching how Stripe advances a billing_
|
|
658
|
+
// cycle_anchor); `day`/`week` are exact multiples of 86400/604800 seconds.
|
|
659
|
+
export function addBillingInterval(atUnix, interval, count) {
|
|
660
|
+
const n = Math.max(1, Math.trunc(count) || 1);
|
|
661
|
+
if (interval === 'day')
|
|
662
|
+
return atUnix + n * 24 * 3600;
|
|
663
|
+
if (interval === 'week')
|
|
664
|
+
return atUnix + n * 7 * 24 * 3600;
|
|
665
|
+
const months = interval === 'year' ? n * 12 : n;
|
|
666
|
+
const d = new Date(atUnix * 1000);
|
|
667
|
+
const anchorDay = d.getUTCDate();
|
|
668
|
+
const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
|
|
669
|
+
const daysInTargetMonth = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
|
|
670
|
+
first.setUTCDate(Math.min(anchorDay, daysInTargetMonth));
|
|
671
|
+
return Math.floor(first.getTime() / 1000);
|
|
672
|
+
}
|
|
673
|
+
// Build the canonical items.data list Stripe returns on a subscription: a real
|
|
674
|
+
// SubscriptionItem per entry, carrying the resolved Price object plus the
|
|
675
|
+
// deprecated-but-still-served `plan` mirror (some integrations still read
|
|
676
|
+
// item.plan.interval / .product / .id off a subscription item). `subId` is the
|
|
677
|
+
// subscription's own (already-assigned, see the `id` pre-generation in the POST
|
|
678
|
+
// handler) id, so each item's `subscription` back-reference round-trips. Fixes the
|
|
679
|
+
// prior always-empty `items.data` on subscription create — real Stripe's create
|
|
680
|
+
// response includes the actual items, mirroring what was requested.
|
|
681
|
+
/** What a subscription item carries of its price: the Price itself and its plan view (a subscription item answers both,
|
|
682
|
+
* docs.stripe.com/api/subscription_items/object), by the price's id or an already-resolved Price. */
|
|
683
|
+
export function subscriptionItemPrice(p, find) {
|
|
684
|
+
const priceId = typeof p === 'string' ? p : (p && typeof p === 'object' ? String(p.id ?? '') : '');
|
|
685
|
+
const price = priceId ? find('price', priceId) : undefined;
|
|
686
|
+
const recurring = price?.recurring;
|
|
687
|
+
const plan = price ? {
|
|
688
|
+
id: price.id, object: 'plan', active: price.active ?? true, amount: price.unit_amount ?? null,
|
|
689
|
+
amount_decimal: price.unit_amount_decimal ?? null, billing_scheme: price.billing_scheme ?? 'per_unit',
|
|
690
|
+
currency: price.currency ?? 'usd', interval: recurring?.interval ?? null,
|
|
691
|
+
interval_count: recurring?.interval_count ?? 1, livemode: false,
|
|
692
|
+
metadata: price.metadata ?? {}, nickname: price.nickname ?? null,
|
|
693
|
+
product: price.product ?? null, tiers_mode: price.tiers_mode ?? null, usage_type: 'licensed',
|
|
694
|
+
} : null;
|
|
695
|
+
return { price: price ?? (priceId || null), plan };
|
|
696
|
+
}
|
|
697
|
+
export function buildSubscriptionItemsList(entries, subId, at, find) {
|
|
698
|
+
const data = entries.map((entry, i) => {
|
|
699
|
+
const quantity = entry.quantity !== undefined ? Math.max(1, Math.trunc(Number(entry.quantity) || 1)) : 1;
|
|
700
|
+
const { price, plan } = subscriptionItemPrice(entry.price, find);
|
|
701
|
+
return {
|
|
702
|
+
id: `si_twin_${subId}_${i + 1}`, object: 'subscription_item',
|
|
703
|
+
price, plan, quantity, subscription: subId,
|
|
704
|
+
created: at, metadata: {}, discounts: [], billing_thresholds: null, tax_rates: [],
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
return { object: 'list', data, has_more: false, total_count: data.length, url: `/v1/subscription_items?subscription=${subId}` };
|
|
708
|
+
}
|
|
709
|
+
/** What a per-unit price bills for a quantity: its `unit_amount` times the quantity, or, for a price given only as a
|
|
710
|
+
* decimal (`unit_amount_decimal`, "represented as a decimal string with at most 12 decimal places", spec/openapi.json.gz),
|
|
711
|
+
* the decimal times the quantity rounded to a whole amount: "rounding occurs after multiplying the quantity by the
|
|
712
|
+
* decimal amount ... `0.05 * 30 = 1.5`, which rounds up to 2 cents" (docs.stripe.com/products-prices/manage-prices).
|
|
713
|
+
* Where the documentation stops and the twin decides: the rounding is to the nearest whole amount, halves up. */
|
|
714
|
+
export function priceAmount(price, quantity) {
|
|
715
|
+
if (!price)
|
|
716
|
+
return 0;
|
|
717
|
+
if (typeof price.unit_amount === 'number')
|
|
718
|
+
return price.unit_amount * quantity;
|
|
719
|
+
const decimal = Number(price.unit_amount_decimal);
|
|
720
|
+
return Number.isFinite(decimal) ? Math.round(decimal * quantity) : 0;
|
|
721
|
+
}
|
|
722
|
+
// Apply a coupon's discount to a subtotal (percent_off or amount_off), clamped at 0.
|
|
723
|
+
export function applyCouponDiscount(subtotal, coupon) {
|
|
724
|
+
return !coupon ? 0 : typeof coupon.percent_off === 'number' ? Math.round((subtotal * coupon.percent_off) / 100) : typeof coupon.amount_off === 'number' ? amountOffDiscount(subtotal, coupon.amount_off) : 0;
|
|
725
|
+
}
|
|
726
|
+
/** A fixed-amount coupon's discount, never more than the subtotal (docs.stripe.com/api/coupons/object#coupon_object-amount_off). */
|
|
727
|
+
function amountOffDiscount(subtotal, amountOff) {
|
|
728
|
+
return Math.min(subtotal, Math.trunc(amountOff));
|
|
729
|
+
}
|
|
730
|
+
// Resolve a subscription/checkout trial window from trial_period_days or trial_end. A
|
|
731
|
+
// trial_end of "now" / 0 / absent means no trial. Returns {start,end} unix or undefined.
|
|
732
|
+
export function resolveTrial(params, at) {
|
|
733
|
+
if (params.trial_period_days !== undefined) {
|
|
734
|
+
const days = Math.trunc(Number(params.trial_period_days) || 0);
|
|
735
|
+
if (days > 0)
|
|
736
|
+
return { start: at, end: at + days * 24 * 3600 };
|
|
737
|
+
}
|
|
738
|
+
if (params.trial_end !== undefined && params.trial_end !== 'now') {
|
|
739
|
+
const end = Math.trunc(Number(params.trial_end) || 0);
|
|
740
|
+
if (end > at)
|
|
741
|
+
return { start: at, end };
|
|
742
|
+
}
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
// Create a resource from form params, stamping object + id + created + the given
|
|
746
|
+
// defaults. Only real Stripe field names are emitted (conformance-checked).
|
|
747
|
+
// A vendor `type` field (a price's one_time or recurring, a payout's bank_account) always collides with the kernel's
|
|
748
|
+
// row type, so it is stashed under `_stripe_type` and restored by view().
|
|
749
|
+
export function stashVendorType(_type, fields) {
|
|
750
|
+
if (!('type' in fields))
|
|
751
|
+
return fields;
|
|
752
|
+
const { type: vt, ...rest } = fields;
|
|
753
|
+
return { ...rest, _stripe_type: vt };
|
|
754
|
+
}
|
|
755
|
+
async function create(type, params, defaults, req, opts = {}) {
|
|
756
|
+
// honor a caller-provided id (seeding uses the catalog's real prod_*/price_* ids);
|
|
757
|
+
// otherwise assign one. The id is identity, not a stored field.
|
|
758
|
+
const { id: providedId, ...rest } = params;
|
|
759
|
+
const id = typeof providedId === 'string' && providedId ? providedId : nextId(type, req.root);
|
|
760
|
+
// Most Stripe objects stamp `created`; a few (invoiceitem) use a differently-named
|
|
761
|
+
// timestamp instead (`date`) and have NO `created` field — honor that, don't fabricate.
|
|
762
|
+
const fields = stashVendorType(type, { object: OBJECT_NAME[type] ?? type, [opts.timeField ?? 'created']: nowUnix(req.occurredAt), ...defaults, ...rest });
|
|
763
|
+
return { status: 200, body: await writeResource(type, id, fields, `${type}.create`, req.root, req.occurredAt, req.apiVersion) };
|
|
764
|
+
}
|
|
765
|
+
// ── ISSUING helpers: spending controls, real-time-auth shapes ─────────────────────────
|
|
766
|
+
// Authorization.card is the FULL Card object in every vendor egress (API responses and
|
|
767
|
+
// webhook payloads) — it is not expandable (stripe@22.3.0 Issuing.Authorization.card:
|
|
768
|
+
// Stripe.Issuing.Card). The twin stores the id internally and embeds at the boundary;
|
|
769
|
+
// cardholder stays an id string (expandable, unexpanded default). Caught live: the
|
|
770
|
+
// issuing-bridge reads event.data.object.card.id and refused every twin presentment.
|
|
771
|
+
export function embedIssuingAuthorizationCard(body, root) {
|
|
772
|
+
if (typeof body.card === 'string') {
|
|
773
|
+
const cardRow = getOne('issuing_card', body.card, root);
|
|
774
|
+
if (cardRow !== undefined)
|
|
775
|
+
body.card = cardRow;
|
|
776
|
+
}
|
|
777
|
+
return body;
|
|
778
|
+
}
|
|
779
|
+
// The closed interval set for spending_controls[spending_limits][][interval]
|
|
780
|
+
// (stripe@22.3.0 Issuing/Cards.d.ts SpendingLimit.Interval — a documented closed enum).
|
|
781
|
+
const SPENDING_LIMIT_INTERVALS = new Set(['all_time', 'daily', 'monthly', 'per_authorization', 'weekly', 'yearly']);
|
|
782
|
+
// The stored SpendingControls shape (stripe@22.3.0 Card.SpendingControls): categories and
|
|
783
|
+
// countries are nullable arrays, spending_limits an array of {amount, categories, interval}.
|
|
784
|
+
export function emptySpendingControls() {
|
|
785
|
+
return {
|
|
786
|
+
allowed_categories: null, allowed_merchant_countries: null,
|
|
787
|
+
blocked_categories: null, blocked_merchant_countries: null,
|
|
788
|
+
spending_limits: [], spending_limits_currency: null,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** A malformed spending_controls dictionary's refusal, by what is wrong with it (normalizeSpendingControls). */
|
|
792
|
+
function controlsRefused(what, i = 0) {
|
|
793
|
+
switch (what) {
|
|
794
|
+
case 'dictionary': return { error: err('Invalid spending_controls: must be a dictionary.', 400, 'parameter_invalid_dictionary') };
|
|
795
|
+
case 'categories': return { error: err('spending_controls[allowed_categories] cannot be set with spending_controls[blocked_categories].', 400) };
|
|
796
|
+
case 'countries': return { error: err('spending_controls[allowed_merchant_countries] cannot be set with spending_controls[blocked_merchant_countries].', 400) };
|
|
797
|
+
case 'limit': return { error: err(`Invalid spending_controls[spending_limits][${i}]: must be a dictionary.`, 400, 'parameter_invalid_dictionary') };
|
|
798
|
+
case 'amount': return { error: err(`Invalid integer: spending_controls[spending_limits][${i}][amount] must be a positive integer.`, 400, 'parameter_invalid_integer') };
|
|
799
|
+
case 'interval': return { error: err(`Invalid spending_controls[spending_limits][${i}][interval]: must be one of 'all_time', 'daily', 'monthly', 'per_authorization', 'weekly', or 'yearly'.`, 400, 'parameter_invalid_string_enum') };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// Validate + normalize a caller-provided spending_controls dictionary (card create/update).
|
|
803
|
+
// Vendor rules enforced: interval must be in the closed enum; a spending limit amount is a
|
|
804
|
+
// positive integer; allowed_categories "Cannot be set with blocked_categories" (and the
|
|
805
|
+
// merchant-country pair likewise) — both restrictions verbatim from the SDK's field docs.
|
|
806
|
+
export function normalizeSpendingControls(raw, currency) {
|
|
807
|
+
if (raw === undefined)
|
|
808
|
+
return {};
|
|
809
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
810
|
+
return controlsRefused('dictionary');
|
|
811
|
+
const sc = raw;
|
|
812
|
+
const strArr = (v) => (Array.isArray(v) ? v.map(String) : undefined);
|
|
813
|
+
const allowedCats = strArr(sc.allowed_categories);
|
|
814
|
+
const blockedCats = strArr(sc.blocked_categories);
|
|
815
|
+
if (allowedCats?.length && blockedCats?.length)
|
|
816
|
+
return controlsRefused('categories');
|
|
817
|
+
const allowedCountries = strArr(sc.allowed_merchant_countries);
|
|
818
|
+
const blockedCountries = strArr(sc.blocked_merchant_countries);
|
|
819
|
+
if (allowedCountries?.length && blockedCountries?.length)
|
|
820
|
+
return controlsRefused('countries');
|
|
821
|
+
const limits = [];
|
|
822
|
+
const rawLimits = Array.isArray(sc.spending_limits) ? sc.spending_limits : [];
|
|
823
|
+
for (let i = 0; i < rawLimits.length; i++) {
|
|
824
|
+
const l = rawLimits[i];
|
|
825
|
+
if (!l || typeof l !== 'object' || Array.isArray(l))
|
|
826
|
+
return controlsRefused('limit', i);
|
|
827
|
+
const { amount, interval, categories } = l;
|
|
828
|
+
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0)
|
|
829
|
+
return controlsRefused('amount', i);
|
|
830
|
+
if (typeof interval !== 'string' || !SPENDING_LIMIT_INTERVALS.has(interval))
|
|
831
|
+
return controlsRefused('interval', i);
|
|
832
|
+
const cats = strArr(categories);
|
|
833
|
+
limits.push({ amount, categories: cats?.length ? cats : null, interval });
|
|
834
|
+
}
|
|
835
|
+
const arrOrNull = (v) => (v === undefined ? undefined : v.length ? v : null);
|
|
836
|
+
const controls = {
|
|
837
|
+
...emptySpendingControls(),
|
|
838
|
+
spending_limits: limits,
|
|
839
|
+
...(limits.length ? { spending_limits_currency: typeof sc.spending_limits_currency === 'string' ? sc.spending_limits_currency : currency } : {}),
|
|
840
|
+
};
|
|
841
|
+
for (const [key, v] of [
|
|
842
|
+
['allowed_categories', arrOrNull(allowedCats)], ['blocked_categories', arrOrNull(blockedCats)],
|
|
843
|
+
['allowed_merchant_countries', arrOrNull(allowedCountries)], ['blocked_merchant_countries', arrOrNull(blockedCountries)],
|
|
844
|
+
]) {
|
|
845
|
+
if (v !== undefined)
|
|
846
|
+
controls[key] = v;
|
|
847
|
+
}
|
|
848
|
+
return { controls };
|
|
849
|
+
}
|
|
850
|
+
// Start of the current spending-limit window, seconds since epoch (UTC calendar windows;
|
|
851
|
+
// weekly starts Monday 00:00 UTC — the week-start day is not vendor-documented).
|
|
852
|
+
function issuingWindowStartSec(interval, nowSec) {
|
|
853
|
+
if (interval === 'all_time')
|
|
854
|
+
return 0;
|
|
855
|
+
const d = new Date(nowSec * 1000);
|
|
856
|
+
if (interval === 'daily')
|
|
857
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / 1000;
|
|
858
|
+
if (interval === 'weekly') {
|
|
859
|
+
const monOffset = (d.getUTCDay() + 6) % 7; // Monday = 0
|
|
860
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - monOffset) / 1000;
|
|
861
|
+
}
|
|
862
|
+
if (interval === 'monthly')
|
|
863
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;
|
|
864
|
+
if (interval === 'yearly')
|
|
865
|
+
return Date.UTC(d.getUTCFullYear(), 0, 1) / 1000;
|
|
866
|
+
return 0;
|
|
867
|
+
}
|
|
868
|
+
// Evaluate one spending_controls dictionary against a presented authorization. Returns a
|
|
869
|
+
// human-readable reason_message when a control declines the authorization, else null.
|
|
870
|
+
// (The request_history.reason for any violation is 'spending_controls' — the vendor enum's
|
|
871
|
+
// single value for controls declines.)
|
|
872
|
+
export function spendingControlsViolation(controls, opts) {
|
|
873
|
+
if (!controls || typeof controls !== 'object')
|
|
874
|
+
return null;
|
|
875
|
+
const sc = controls;
|
|
876
|
+
const list = (v) => (Array.isArray(v) && v.length ? v.map(String) : null);
|
|
877
|
+
const allowedCats = list(sc.allowed_categories);
|
|
878
|
+
if (allowedCats && !allowedCats.includes(opts.category))
|
|
879
|
+
return `merchant category ${opts.category} is not in allowed_categories`;
|
|
880
|
+
const blockedCats = list(sc.blocked_categories);
|
|
881
|
+
if (blockedCats?.includes(opts.category))
|
|
882
|
+
return `merchant category ${opts.category} is in blocked_categories`;
|
|
883
|
+
const allowedCountries = list(sc.allowed_merchant_countries);
|
|
884
|
+
if (allowedCountries && (!opts.country || !allowedCountries.includes(opts.country)))
|
|
885
|
+
return `merchant country ${opts.country ?? '(none)'} is not in allowed_merchant_countries`;
|
|
886
|
+
const blockedCountries = list(sc.blocked_merchant_countries);
|
|
887
|
+
if (blockedCountries && opts.country && blockedCountries.includes(opts.country))
|
|
888
|
+
return `merchant country ${opts.country} is in blocked_merchant_countries`;
|
|
889
|
+
for (const rawLimit of Array.isArray(sc.spending_limits) ? sc.spending_limits : []) {
|
|
890
|
+
if (!rawLimit || typeof rawLimit !== 'object')
|
|
891
|
+
continue;
|
|
892
|
+
const l = rawLimit;
|
|
893
|
+
const limit = Number(l.amount) || 0;
|
|
894
|
+
if (limit <= 0)
|
|
895
|
+
continue;
|
|
896
|
+
const limCats = list(l.categories);
|
|
897
|
+
if (limCats && !limCats.includes(opts.category))
|
|
898
|
+
continue;
|
|
899
|
+
const interval = String(l.interval ?? '');
|
|
900
|
+
if (interval === 'per_authorization') {
|
|
901
|
+
if (opts.amount > limit)
|
|
902
|
+
return `amount exceeds the per_authorization spending limit of ${limit}`;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const start = issuingWindowStartSec(interval, opts.nowSec);
|
|
906
|
+
const spent = opts.priorApproved
|
|
907
|
+
.filter((a) => a.created >= start && (!limCats || limCats.includes(a.category)))
|
|
908
|
+
.reduce((s, a) => s + a.amount, 0);
|
|
909
|
+
if (spent + opts.amount > limit)
|
|
910
|
+
return `amount exceeds the ${interval} spending limit of ${limit}`;
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
// Stripe's dispute.evidence is a flat bag of (mostly null) string fields; the twin
|
|
915
|
+
// emits the canonical empty shape so the object is shaped right without fabricating
|
|
916
|
+
// content. Callers can overwrite individual fields via POST /v1/disputes/:id.
|
|
917
|
+
export function disputeEvidence() {
|
|
918
|
+
return {
|
|
919
|
+
access_activity_log: null, billing_address: null, cancellation_policy: null,
|
|
920
|
+
cancellation_policy_disclosure: null, cancellation_rebuttal: null, customer_communication: null,
|
|
921
|
+
customer_email_address: null, customer_name: null, customer_purchase_ip: null, customer_signature: null,
|
|
922
|
+
duplicate_charge_documentation: null, duplicate_charge_explanation: null, duplicate_charge_id: null,
|
|
923
|
+
product_description: null, receipt: null, refund_policy: null, refund_policy_disclosure: null,
|
|
924
|
+
refund_refusal_explanation: null, service_date: null, service_documentation: null,
|
|
925
|
+
shipping_address: null, shipping_carrier: null, shipping_date: null, shipping_documentation: null,
|
|
926
|
+
shipping_tracking_number: null, uncategorized_file: null, uncategorized_text: null,
|
|
927
|
+
// required by the served spec; empty for a dispute not eligible for enhanced evidence (Visa CE 3.0 eligibility
|
|
928
|
+
// comes from its own test card, pm_card_createCe3EligibleDispute: docs.stripe.com/disputes/api/visa-ce3)
|
|
929
|
+
enhanced_evidence: {},
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
const subParams = (params, k) => (params[k] && typeof params[k] === 'object' ? params[k] : {});
|
|
933
|
+
/** A card's own sub-object, from the number and expiry given. */
|
|
934
|
+
function cardSubObject(params) {
|
|
935
|
+
const c = subParams(params, 'card');
|
|
936
|
+
// the form reader (the kernel's readParams) coerces an all-digit card[number] to a JS number (same as any
|
|
937
|
+
// other numeric-looking form field) — accept both shapes so a raw test PAN still
|
|
938
|
+
// produces its real last4 instead of silently falling back to the '4242' default.
|
|
939
|
+
const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
|
|
940
|
+
: typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
|
|
941
|
+
return { card: {
|
|
942
|
+
brand: 'visa', last4: number ? number.slice(-4) : '4242',
|
|
943
|
+
exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
|
|
944
|
+
funding: 'credit', country: 'US',
|
|
945
|
+
checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
|
|
946
|
+
networks: { available: ['visa'], preferred: null },
|
|
947
|
+
three_d_secure_usage: { supported: true }, wallet: null,
|
|
948
|
+
} };
|
|
949
|
+
}
|
|
950
|
+
/** A SEPA Direct Debit account's sub-object, from its IBAN. */
|
|
951
|
+
function sepaDebitSubObject(params) {
|
|
952
|
+
const s = subParams(params, 'sepa_debit');
|
|
953
|
+
const iban = typeof s.iban === 'string' ? s.iban.replace(/\s/g, '') : '';
|
|
954
|
+
return { sepa_debit: {
|
|
955
|
+
bank_code: '37040044', branch_code: '', country: iban ? iban.slice(0, 2).toUpperCase() : 'DE',
|
|
956
|
+
fingerprint: 'twin_sepa_fp', last4: iban ? iban.slice(-4) : '3000',
|
|
957
|
+
generated_from: { charge: null, setup_attempt: null }, mandate: null,
|
|
958
|
+
} };
|
|
959
|
+
}
|
|
960
|
+
/** A US bank account's sub-object, from its numbers. */
|
|
961
|
+
function usBankAccountSubObject(params) {
|
|
962
|
+
const a = subParams(params, 'us_bank_account');
|
|
963
|
+
const num = typeof a.account_number === 'string' ? a.account_number.replace(/\D/g, '') : '';
|
|
964
|
+
return { us_bank_account: {
|
|
965
|
+
account_holder_type: a.account_holder_type ?? 'individual',
|
|
966
|
+
account_type: a.account_type ?? 'checking',
|
|
967
|
+
bank_name: 'STRIPE TEST BANK', financial_connections_account: null,
|
|
968
|
+
fingerprint: 'twin_ach_fp', last4: num ? num.slice(-4) : '6789',
|
|
969
|
+
networks: { preferred: 'ach', supported: ['ach'] },
|
|
970
|
+
routing_number: typeof a.routing_number === 'string' ? a.routing_number : '110000000',
|
|
971
|
+
status_details: {},
|
|
972
|
+
} };
|
|
973
|
+
}
|
|
974
|
+
/** A Link or Cash App Pay method's sub-object, which carries nothing the twin knows. */
|
|
975
|
+
function walletSubObject(pmType) {
|
|
976
|
+
return pmType === 'link' ? { link: { email: null, persistent_token: null } } : { cashapp: { buyer_id: null, cashtag: null } };
|
|
977
|
+
}
|
|
978
|
+
/** Wallet / other rails the twin doesn't deep-model: an empty type bag, like Stripe's for a minimally-specified
|
|
979
|
+
* PaymentMethod (the `type` field is the truth). */
|
|
980
|
+
function otherSubObject(pmType) {
|
|
981
|
+
return { [pmType]: {} };
|
|
982
|
+
}
|
|
983
|
+
const SUB_OBJECTS = {
|
|
984
|
+
card: cardSubObject, sepa_debit: sepaDebitSubObject, us_bank_account: usBankAccountSubObject,
|
|
985
|
+
link: (_p, t) => walletSubObject(t), cashapp: (_p, t) => walletSubObject(t),
|
|
986
|
+
};
|
|
987
|
+
export function paymentMethodSubObject(pmType, params) {
|
|
988
|
+
return SUB_OBJECTS[pmType] ? SUB_OBJECTS[pmType](params, pmType) : otherSubObject(pmType);
|
|
989
|
+
}
|
|
990
|
+
// ---- CONNECT: connected accounts (vendor-faithful) ----
|
|
991
|
+
// A connected account (Stripe Connect) represents a business onboarded onto the
|
|
992
|
+
// platform. We model the OBJECT (id `acct_`, object 'account', type express|standard|
|
|
993
|
+
// custom, country, email, charges_enabled/payouts_enabled/details_submitted booleans,
|
|
994
|
+
// capabilities map, and the `requirements` hash) plus transfers to those accounts and
|
|
995
|
+
// payout-to-account links. The hosted Connect onboarding flow is Stripe's own page — we
|
|
996
|
+
// model the Account API object + its login_links, the API surface apps depend on. A freshly-created account is NOT yet active: charges/payouts disabled,
|
|
997
|
+
// details_submitted false, and `requirements` lists the outstanding onboarding fields,
|
|
998
|
+
// exactly like real Stripe before onboarding completes.
|
|
999
|
+
export const ACCOUNT_TYPES = new Set(['express', 'standard', 'custom']);
|
|
1000
|
+
// The fixed id of the platform's OWN account (GET/POST /v1/account, singular).
|
|
1001
|
+
export const PLATFORM_ACCOUNT_ID = 'acct_twin_self';
|
|
1002
|
+
// Stripe's canonical "freshly created, not yet onboarded" requirements hash: a set of
|
|
1003
|
+
// currently-due fields, empty arrays for the other buckets, and a null deadline. This
|
|
1004
|
+
// matches the shape real Stripe returns for a brand-new account before onboarding.
|
|
1005
|
+
export function accountRequirements() {
|
|
1006
|
+
return {
|
|
1007
|
+
alternatives: [],
|
|
1008
|
+
current_deadline: null,
|
|
1009
|
+
currently_due: ['business_profile.mcc', 'business_profile.url', 'external_account', 'tos_acceptance.date', 'tos_acceptance.ip'],
|
|
1010
|
+
disabled_reason: 'requirements.past_due',
|
|
1011
|
+
errors: [],
|
|
1012
|
+
eventually_due: ['business_profile.mcc', 'business_profile.url', 'external_account', 'tos_acceptance.date', 'tos_acceptance.ip'],
|
|
1013
|
+
past_due: [],
|
|
1014
|
+
pending_verification: [],
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// Normalize the requested `capabilities` create param (Stripe sends
|
|
1018
|
+
// capabilities[card_payments][requested]=true) into the account.capabilities map,
|
|
1019
|
+
// whose values are the capability STATUS enum (active|inactive|pending). A requested
|
|
1020
|
+
// capability on a brand-new account starts `inactive` (not yet granted), like Stripe.
|
|
1021
|
+
export function accountCapabilities(params) {
|
|
1022
|
+
const requested = params.capabilities;
|
|
1023
|
+
const out = {};
|
|
1024
|
+
if (requested && typeof requested === 'object') {
|
|
1025
|
+
for (const key of Object.keys(requested))
|
|
1026
|
+
out[key] = 'inactive';
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
// Normalize an account's `settings`, focusing on the payout SCHEDULE (the automatic-payout
|
|
1031
|
+
// cadence: settings.payouts.schedule.{interval, weekly_anchor, monthly_anchor, delay_days}).
|
|
1032
|
+
// Stripe's default is a daily automatic schedule with a default delay; an explicit schedule
|
|
1033
|
+
// merges over the existing one. Other settings sub-objects are emitted with canonical empty
|
|
1034
|
+
// shapes so the object is shaped right without fabricating values.
|
|
1035
|
+
export function accountSettings(input, existing) {
|
|
1036
|
+
const base = existing ?? {};
|
|
1037
|
+
const prevPayouts = (base.payouts && typeof base.payouts === 'object' ? base.payouts : {});
|
|
1038
|
+
const prevSchedule = (prevPayouts.schedule && typeof prevPayouts.schedule === 'object' ? prevPayouts.schedule : {});
|
|
1039
|
+
const inObj = (input && typeof input === 'object' ? input : {});
|
|
1040
|
+
const inPayouts = (inObj.payouts && typeof inObj.payouts === 'object' ? inObj.payouts : {});
|
|
1041
|
+
const inSchedule = (inPayouts.schedule && typeof inPayouts.schedule === 'object' ? inPayouts.schedule : {});
|
|
1042
|
+
const interval = typeof inSchedule.interval === 'string' ? inSchedule.interval : (typeof prevSchedule.interval === 'string' ? prevSchedule.interval : 'daily');
|
|
1043
|
+
const schedule = {
|
|
1044
|
+
interval,
|
|
1045
|
+
delay_days: inSchedule.delay_days !== undefined ? Math.trunc(Number(inSchedule.delay_days) || 0) : (prevSchedule.delay_days ?? 2),
|
|
1046
|
+
weekly_anchor: interval === 'weekly' ? (inSchedule.weekly_anchor ?? prevSchedule.weekly_anchor ?? 'monday') : null,
|
|
1047
|
+
monthly_anchor: interval === 'monthly' ? (inSchedule.monthly_anchor !== undefined ? Math.trunc(Number(inSchedule.monthly_anchor)) : (prevSchedule.monthly_anchor ?? 1)) : null,
|
|
1048
|
+
};
|
|
1049
|
+
// the sections a new account's settings answer with, as the Account object's example shows a fresh account's
|
|
1050
|
+
// (docs.stripe.com/api/accounts/object); what the account has set is kept
|
|
1051
|
+
const fresh = {
|
|
1052
|
+
bacs_debit_payments: { display_name: null, service_user_number: null },
|
|
1053
|
+
branding: { icon: null, logo: null, primary_color: null, secondary_color: null },
|
|
1054
|
+
card_issuing: { tos_acceptance: { date: null, ip: null } },
|
|
1055
|
+
card_payments: { decline_on: { avs_failure: false, cvc_failure: false }, statement_descriptor_prefix: null, statement_descriptor_prefix_kanji: null, statement_descriptor_prefix_kana: null },
|
|
1056
|
+
dashboard: { display_name: null, timezone: 'Etc/UTC' },
|
|
1057
|
+
invoices: { default_account_tax_ids: null, hosted_payment_method_save: null },
|
|
1058
|
+
payments: { statement_descriptor: null, statement_descriptor_kana: null, statement_descriptor_kanji: null },
|
|
1059
|
+
sepa_debit_payments: {},
|
|
1060
|
+
};
|
|
1061
|
+
const sections = Object.fromEntries(Object.entries(fresh).map(([k, v]) => [k, base[k] && typeof base[k] === 'object' ? { ...v, ...base[k] } : v]));
|
|
1062
|
+
return {
|
|
1063
|
+
...base,
|
|
1064
|
+
...sections,
|
|
1065
|
+
payouts: { ...prevPayouts, schedule, statement_descriptor: inPayouts.statement_descriptor ?? prevPayouts.statement_descriptor ?? null, debit_negative_balances: inPayouts.debit_negative_balances ?? prevPayouts.debit_negative_balances ?? true },
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
// Resolve the unit amount + currency for one line_items entry, preferring an existing
|
|
1069
|
+
// price (looked up in the action log) then inline price_data. Unknown/missing price →
|
|
1070
|
+
// 0 amount with the session currency (faithful: Stripe would 400, but we keep the
|
|
1071
|
+
// referenced-price path strict and leave amount 0 only when nothing is resolvable).
|
|
1072
|
+
export function resolveLineItem(entry, index, fallbackCurrency, find) {
|
|
1073
|
+
const quantity = entry.quantity === undefined ? 1 : Math.max(0, Math.trunc(Number(entry.quantity) || 0));
|
|
1074
|
+
let billed;
|
|
1075
|
+
let currency = fallbackCurrency;
|
|
1076
|
+
let priceField = null;
|
|
1077
|
+
let priceMissing;
|
|
1078
|
+
// a line item's description "Defaults to product name" (spec/openapi.json.gz, the `item` schema)
|
|
1079
|
+
let description = null;
|
|
1080
|
+
const priceRef = typeof entry.price === 'string' ? entry.price : undefined;
|
|
1081
|
+
const priceData = entry.price_data && typeof entry.price_data === 'object' ? entry.price_data : undefined;
|
|
1082
|
+
if (priceRef) {
|
|
1083
|
+
const price = find('price', priceRef);
|
|
1084
|
+
if (!price) {
|
|
1085
|
+
priceMissing = priceRef;
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
billed = price;
|
|
1089
|
+
currency = typeof price.currency === 'string' ? price.currency : currency;
|
|
1090
|
+
priceField = price;
|
|
1091
|
+
const product = typeof price.product === 'string' ? find('product', price.product) : undefined;
|
|
1092
|
+
description = typeof product?.name === 'string' ? product.name : null;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
else if (priceData) {
|
|
1096
|
+
billed = { unit_amount: priceData.unit_amount_decimal !== undefined ? undefined : Number(priceData.unit_amount) || 0, unit_amount_decimal: priceData.unit_amount_decimal };
|
|
1097
|
+
currency = typeof priceData.currency === 'string' ? priceData.currency : currency;
|
|
1098
|
+
priceField = null; // inline price_data is not persisted as a Price object
|
|
1099
|
+
const productData = priceData.product_data;
|
|
1100
|
+
const product = typeof priceData.product === 'string' ? find('product', priceData.product) : undefined;
|
|
1101
|
+
description = typeof productData?.name === 'string' ? productData.name : typeof product?.name === 'string' ? product.name : null;
|
|
1102
|
+
}
|
|
1103
|
+
const amount_subtotal = priceAmount(billed, quantity);
|
|
1104
|
+
const item = {
|
|
1105
|
+
object: 'item', id: `li_twin_${index + 1}`, amount_discount: 0, amount_subtotal,
|
|
1106
|
+
amount_tax: 0, amount_total: amount_subtotal, currency, description,
|
|
1107
|
+
price: priceField, quantity,
|
|
1108
|
+
};
|
|
1109
|
+
return { item, priceMissing };
|
|
1110
|
+
}
|
|
1111
|
+
// Normalize the bracket-form line_items param to an ordered array. The form reader
|
|
1112
|
+
// turns line_items[0][price]=… into an array of objects already; accept that, and a
|
|
1113
|
+
// single-object shape defensively.
|
|
1114
|
+
export function lineItemEntries(params) {
|
|
1115
|
+
const li = params.line_items;
|
|
1116
|
+
return Array.isArray(li) ? li.filter((x) => x && typeof x === 'object') : [];
|
|
1117
|
+
}
|
|
1118
|
+
// ---- IDEMPOTENCY KEYS (vendor-faithful) ----
|
|
1119
|
+
// Real Stripe honors the `Idempotency-Key` header on POST: the first request with a
|
|
1120
|
+
// key executes and the (status + body) response is stored against the key; a replay
|
|
1121
|
+
// with the SAME key returns the SAME stored response WITHOUT re-applying the write.
|
|
1122
|
+
// We persist the stored response as an `_idempotency` resource in the action log
|
|
1123
|
+
// (per-root, so a fork has its own keyspace). `_idempotency` is a twin-internal type
|
|
1124
|
+
// (never served as a Stripe object), so it does not affect spec conformance.
|
|
1125
|
+
//
|
|
1126
|
+
// Note: real Stripe only persists idempotency for responses it considers "completed"
|
|
1127
|
+
// (2xx and most 4xx, including card 402s) and scopes the key to the account; here we
|
|
1128
|
+
// store every executed POST response (success and error alike), keyed per-root.
|
|
1129
|
+
const IDEMPOTENCY_TYPE = '_idempotency';
|
|
1130
|
+
// ── THE MONEY MODEL: a charge and the refunds taken out of it ──────────────────────────
|
|
1131
|
+
//
|
|
1132
|
+
// The rule this section exists to hold: A FACT RECORDED ON ONE MONEY OBJECT IS VISIBLE FROM
|
|
1133
|
+
// THE OBJECT IT CONCERNS. A twin that mints a `succeeded` Refund and leaves the Charge saying
|
|
1134
|
+
// `amount_refunded: 0` hands a consumer reconciling a ledger the wrong answer with NO error to
|
|
1135
|
+
// tell them it is wrong — worse than refusing, because it looks like it worked.
|
|
1136
|
+
//
|
|
1137
|
+
// Every Charge carries the same money spine: the captured/refunded scalars, and a `refunds`
|
|
1138
|
+
// sub-list (real Stripe's Charge always has one, empty or not).
|
|
1139
|
+
function emptyRefundsList(chargeId) {
|
|
1140
|
+
return { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/charges/${chargeId}/refunds` };
|
|
1141
|
+
}
|
|
1142
|
+
export function chargeDefaults(chargeId, amount, captured, occurredAt) {
|
|
1143
|
+
return {
|
|
1144
|
+
status: 'succeeded', paid: true, captured, refunded: false, disputed: false,
|
|
1145
|
+
amount_captured: captured ? amount : 0, amount_refunded: 0, metadata: {},
|
|
1146
|
+
refunds: emptyRefundsList(chargeId),
|
|
1147
|
+
...(captured ? {} : { capture_before: nowUnix(occurredAt) + 7 * 24 * 3600 }),
|
|
1148
|
+
billing_details: { address: null, email: null, name: null, phone: null }, livemode: false,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Public entry: honors the Idempotency-Key (replay → stored response, no re-write)
|
|
1153
|
+
* then delegates to the router. Only POSTs are idempotent (matches Stripe; GET/DELETE
|
|
1154
|
+
* pass straight through). A read-only twin never stores (it cannot write).
|
|
1155
|
+
*/
|
|
1156
|
+
/** Stripe's API as one call: the request goes through the pack's own dispatch (the derived dispatch over
|
|
1157
|
+
* its semantics, its derived core and the hand-written routes below), so a caller that holds no
|
|
1158
|
+
* HTTP server reaches exactly what an SDK does. `occurredAt` pins the World instant. */
|
|
1159
|
+
export async function handleStripeTwinRequest(req) {
|
|
1160
|
+
const twin = createStripeTwinFetch({ ...(req.root !== undefined ? { root: req.root } : {}), readOnly: req.readOnly ?? false, ...(req.occurredAt ? { clock: () => req.occurredAt } : {}) });
|
|
1161
|
+
const method = req.method.toUpperCase();
|
|
1162
|
+
const headers = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
1163
|
+
if (req.idempotencyKey)
|
|
1164
|
+
headers['idempotency-key'] = req.idempotencyKey;
|
|
1165
|
+
if (req.apiVersion !== undefined)
|
|
1166
|
+
headers['stripe-version'] = req.apiVersion;
|
|
1167
|
+
if (req.stripeAccount)
|
|
1168
|
+
headers['stripe-account'] = req.stripeAccount;
|
|
1169
|
+
// a GET carries its parameters in the query; a caller that handed them as a body keeps them
|
|
1170
|
+
let path = req.path.startsWith('/') ? req.path : `/${req.path}`;
|
|
1171
|
+
if ((method === 'GET' || method === 'HEAD') && req.body)
|
|
1172
|
+
path += (path.includes('?') ? '&' : '?') + req.body;
|
|
1173
|
+
const response = await twin(new Request(`https://api.stripe.com${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
|
|
1174
|
+
const text = await response.text();
|
|
1175
|
+
return { status: response.status, body: text ? JSON.parse(text) : null };
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The twin's doors: what stands in for an act Stripe's API does not have, each under `/_twin/`. The
|
|
1179
|
+
* platform's own account settings (its payout schedule) change on Stripe's Dashboard, which has no API:
|
|
1180
|
+
* POST /_twin/account stands in for that page. Answers nothing for any other path.
|
|
1181
|
+
*/
|
|
1182
|
+
export async function handleStripeDoor(req, params) {
|
|
1183
|
+
const method = req.method.toUpperCase();
|
|
1184
|
+
const path = (req.path.split('?')[0] ?? '/').replace(/\/+$/, '') || '/';
|
|
1185
|
+
const seg = path.replace(/^\/+/, '').split('/');
|
|
1186
|
+
if (seg[0] !== '_twin')
|
|
1187
|
+
return undefined;
|
|
1188
|
+
if (req.readOnly)
|
|
1189
|
+
return err('twin is read-only; omit readOnly to accept writes', 405);
|
|
1190
|
+
if (path === '/_twin/account' && method === 'POST') {
|
|
1191
|
+
const ex = getOne('account', PLATFORM_ACCOUNT_ID, req.root);
|
|
1192
|
+
const { type: _t, settings: rawSettings, business_profile: rawProfile, ...rest } = params;
|
|
1193
|
+
// the platform's account is Stripe's default until the page first changes it (semantics/connect.ts, the same
|
|
1194
|
+
// default GET /v1/account answers and the Public details page stores)
|
|
1195
|
+
const fields = {
|
|
1196
|
+
...(ex ? {} : (({ id: _id, ...base }) => base)(platformAccountDefault())),
|
|
1197
|
+
...rest,
|
|
1198
|
+
settings: accountSettings(rawSettings, ex?.settings),
|
|
1199
|
+
// a hash is merged into what the account holds, as an update merges it (the name the Public details page saved stays)
|
|
1200
|
+
...(rawProfile && typeof rawProfile === 'object' ? { business_profile: { ...(ex?.business_profile ?? {}), ...rawProfile } } : {}),
|
|
1201
|
+
};
|
|
1202
|
+
return { status: 200, body: await writeResource('account', PLATFORM_ACCOUNT_ID, stashVendorType('account', fields), 'account.update', req.root, req.occurredAt) };
|
|
1203
|
+
}
|
|
1204
|
+
// Issuing network tokens are minted by the card network when a cardholder adds the card to a
|
|
1205
|
+
// phone's wallet (docs.stripe.com/issuing/cards/digital-wallets): there is no API create, and the
|
|
1206
|
+
// act happens in the phone's wallet app, not on a Stripe page. POST /_twin/issuing/cards/:card/wallets
|
|
1207
|
+
// stands in for that act ({wallet_provider, network}); the API reads the token and moves its status.
|
|
1208
|
+
// Where the documentation stops and the twin decides: the network approves at once (the token is active).
|
|
1209
|
+
if (seg[0] === '_twin' && seg[1] === 'issuing' && seg[2] === 'cards' && seg[3] && seg[4] === 'wallets' && seg.length === 5 && method === 'POST') {
|
|
1210
|
+
const card = decodeURIComponent(seg[3]);
|
|
1211
|
+
const c = getOne('issuing_card', card, req.root);
|
|
1212
|
+
if (!c)
|
|
1213
|
+
return err(`No such card: '${card}'`, 400, 'resource_missing');
|
|
1214
|
+
const wallet = typeof params.wallet_provider === 'string' && ['apple_pay', 'google_pay', 'samsung_pay'].includes(params.wallet_provider)
|
|
1215
|
+
? params.wallet_provider : 'apple_pay';
|
|
1216
|
+
const network = typeof params.network === 'string' && ['visa', 'mastercard'].includes(params.network) ? params.network : 'visa';
|
|
1217
|
+
const last4 = typeof c.last4 === 'string' ? c.last4 : '0000';
|
|
1218
|
+
return create('issuing_token', { card, cardholder: typeof c.cardholder === 'string' ? c.cardholder : (c.cardholder?.id ?? null) }, {
|
|
1219
|
+
status: 'active', wallet_provider: wallet, network, last4, livemode: false,
|
|
1220
|
+
device_fingerprint: null, network_data: null, network_updated_at: nowUnix(req.occurredAt),
|
|
1221
|
+
}, req);
|
|
1222
|
+
}
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
// collection path segment → resource type, for the generic list of a collection Stripe does not
|
|
1226
|
+
// list at that path (the twin's test clocks, which Stripe lists under /v1/test_helpers).
|