@volter/twin-stripe 0.1.1 → 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,181 @@
|
|
|
1
|
+
import { at, created, fail, inRange, kept, list, newest, where } from "./shared.js";
|
|
2
|
+
const BT = 'balance_transaction';
|
|
3
|
+
const DAY = 86_400;
|
|
4
|
+
const BYPASS_PENDING = '4000000000000077';
|
|
5
|
+
const feeOf = (amount) => (amount > 0 ? Math.round(amount * 0.029) + 30 : 0);
|
|
6
|
+
/** A transaction as the clock reads it: available once its available_on has passed, the clock's move,
|
|
7
|
+
* asked of the machine as a write asks it. */
|
|
8
|
+
function asOf(ctx, t) {
|
|
9
|
+
const due = Number(t.available_on);
|
|
10
|
+
if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now()))
|
|
11
|
+
return t;
|
|
12
|
+
ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
|
|
13
|
+
return { ...t, status: 'available' };
|
|
14
|
+
}
|
|
15
|
+
/** The connected account a request acts for, or undefined for the platform. */
|
|
16
|
+
export const actingAccount = (ctx) => ctx.call.request.headers.get('stripe-account') ?? undefined;
|
|
17
|
+
/** A ledger entry on an account's balance: the acting account's unless one is named (null names the platform). */
|
|
18
|
+
async function write(ctx, fields, account = actingAccount(ctx)) {
|
|
19
|
+
const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...(account ? { _account: account } : {}) });
|
|
20
|
+
return String(bt.id);
|
|
21
|
+
}
|
|
22
|
+
/** Whether a stored ledger entry is on this account's balance (undefined: the platform's). */
|
|
23
|
+
const onAccount = (t, account) => (typeof t._account === 'string' ? t._account : undefined) === account;
|
|
24
|
+
/** A captured charge's credit: its amount less the fee, pending two days unless the card (a number, or a test
|
|
25
|
+
* payment method or token named for it) settles at once. The
|
|
26
|
+
* caller mints the charge's id first and stores the returned id as the charge's balance_transaction. */
|
|
27
|
+
export async function settleCharge(ctx, chargeId, amount, currency, card) {
|
|
28
|
+
const now = Number(ctx.now());
|
|
29
|
+
const fee = feeOf(amount);
|
|
30
|
+
const settled = !!card && (card.replace(/\D/g, '') === BYPASS_PENDING || /bypassPending/i.test(card));
|
|
31
|
+
const id = await write(ctx, {
|
|
32
|
+
amount, currency, fee, net: amount - fee, type: 'charge', reporting_category: 'charge', source: chargeId,
|
|
33
|
+
status: settled ? 'available' : 'pending', available_on: settled ? now : now + 2 * DAY,
|
|
34
|
+
fee_details: fee ? [{ amount: fee, application: null, currency, description: 'Stripe processing fees', type: 'stripe_fee' }] : [],
|
|
35
|
+
});
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
/** A refund's debit, at once, on the acting account's balance unless one is named (null names the platform). */
|
|
39
|
+
export async function settleRefund(ctx, refundId, amount, currency, account = actingAccount(ctx)) {
|
|
40
|
+
return write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'refund', reporting_category: 'refund', source: refundId, available_on: Number(ctx.now()) }, account);
|
|
41
|
+
}
|
|
42
|
+
/** What an account had available in a currency at a moment: every entry of its payments balance whose funds had come
|
|
43
|
+
* due by then. */
|
|
44
|
+
export function availableAt(ctx, account, currency, at) {
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (const t of ctx.rowsRaw(BT)) {
|
|
47
|
+
if (!onAccount(t, account) || t.balance_type === 'issuing' || String(t.currency ?? 'usd') !== currency)
|
|
48
|
+
continue;
|
|
49
|
+
const due = Number(t.available_on ?? t.created);
|
|
50
|
+
if (Number.isFinite(due) && due <= at)
|
|
51
|
+
sum += Number(t.net ?? 0);
|
|
52
|
+
}
|
|
53
|
+
return sum;
|
|
54
|
+
}
|
|
55
|
+
/** The moments after `from` and up to `until` when funds of an account came due, in order: when a held refund can
|
|
56
|
+
* next be covered. */
|
|
57
|
+
export function fundsDueBetween(ctx, account, currency, from, until) {
|
|
58
|
+
const times = ctx.rowsRaw(BT).filter((t) => onAccount(t, account) && String(t.currency ?? 'usd') === currency).map((t) => Number(t.available_on ?? t.created)).filter((d) => Number.isFinite(d) && d > from && d <= until);
|
|
59
|
+
return [...new Set(times)].sort((a, b) => a - b);
|
|
60
|
+
}
|
|
61
|
+
/** A dispute's debit, at once: the disputed amount and the dispute fee (the twin's 1500 cents, Stripe's US $15). */
|
|
62
|
+
export async function settleDispute(ctx, disputeId, amount, currency) {
|
|
63
|
+
const fee = 1500;
|
|
64
|
+
return write(ctx, {
|
|
65
|
+
amount: -amount, currency, fee, net: -amount - fee, type: 'adjustment', reporting_category: 'dispute', source: disputeId, available_on: Number(ctx.now()),
|
|
66
|
+
fee_details: [{ amount: fee, application: null, currency, description: 'Dispute fee', type: 'stripe_fee' }],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** A won dispute's credit, at once: the disputed amount returned (docs.stripe.com/disputes/how-disputes-work). Where the
|
|
70
|
+
* documentation stops and the twin decides: the dispute fee is not returned. */
|
|
71
|
+
export async function settleDisputeWon(ctx, disputeId, amount, currency) {
|
|
72
|
+
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'adjustment', reporting_category: 'dispute_reversal', source: disputeId, available_on: Number(ctx.now()) });
|
|
73
|
+
}
|
|
74
|
+
/** An account's ledger entries an automatic payout has not yet paid out: every entry on its payments balance that no
|
|
75
|
+
* automatic payout took (the automatic payouts' own debits excluded), as stored. */
|
|
76
|
+
export function unpaidEntries(ctx, account) {
|
|
77
|
+
const automatic = new Set(ctx.rowsRaw('payout').filter((p) => p.automatic === true).map((p) => p.id));
|
|
78
|
+
return ctx.rowsRaw(BT).filter((t) => onAccount(t, account) && t.balance_type !== 'issuing' && !t._payout && !automatic.has(t.source));
|
|
79
|
+
}
|
|
80
|
+
/** An automatic payout's debit at `at`, and the entries it pays out marked with it, so the ledger lists them under
|
|
81
|
+
* the payout (docs.stripe.com/api/balance_transactions/list#balance_transaction_list-payout). */
|
|
82
|
+
export async function settleAutomaticPayout(ctx, payoutId, amount, currency, account, at, entries) {
|
|
83
|
+
for (const t of entries)
|
|
84
|
+
await ctx.write(BT, String(t.id), { _payout: payoutId }, 'balance_transaction.paid_out');
|
|
85
|
+
return write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payout', reporting_category: 'payout', source: payoutId, available_on: at, _payout: payoutId }, account ?? null);
|
|
86
|
+
}
|
|
87
|
+
/** A payout's debit (negative) or a cancellation's or reversal's credit (positive), at once, on the payout's account. */
|
|
88
|
+
export async function settlePayout(ctx, payoutId, amount, currency, account = actingAccount(ctx)) {
|
|
89
|
+
const type = amount < 0 ? 'payout' : 'payout_cancel';
|
|
90
|
+
return write(ctx, { amount, currency, fee: 0, net: amount, type, reporting_category: type === 'payout' ? 'payout' : 'payout_reversal', source: payoutId, available_on: Number(ctx.now()) }, account);
|
|
91
|
+
}
|
|
92
|
+
/** A transfer's two entries: out of the platform's balance at once, into the destination's when `availableOn` comes
|
|
93
|
+
* (a plain transfer's funds are available already). Answers the platform's entry. */
|
|
94
|
+
export async function settleTransfer(ctx, transferId, amount, currency, destination, availableOn = Number(ctx.now()), fromPending = false) {
|
|
95
|
+
const settled = availableOn <= Number(ctx.now());
|
|
96
|
+
// a transfer from a charge's pending funds (source_transaction) leaves the platform when they arrive, not before
|
|
97
|
+
const platform = await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'transfer', reporting_category: 'transfer', source: transferId, ...(fromPending ? { status: settled ? 'available' : 'pending', available_on: availableOn } : { available_on: Number(ctx.now()) }) }, null);
|
|
98
|
+
await write(ctx, { amount, currency, fee: 0, net: amount, type: 'payment', reporting_category: 'charge', source: transferId, status: settled ? 'available' : 'pending', available_on: availableOn }, destination);
|
|
99
|
+
return platform;
|
|
100
|
+
}
|
|
101
|
+
/** A transfer reversal's two entries: back into the platform's balance, out of the destination's. */
|
|
102
|
+
export async function settleTransferReversal(ctx, reversalId, amount, currency, destination) {
|
|
103
|
+
const platform = await write(ctx, { amount, currency, fee: 0, net: amount, type: 'transfer_refund', reporting_category: 'transfer_reversal', source: reversalId, available_on: Number(ctx.now()) }, null);
|
|
104
|
+
await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payment_refund', reporting_category: 'refund', source: reversalId, available_on: Number(ctx.now()) }, destination);
|
|
105
|
+
return platform;
|
|
106
|
+
}
|
|
107
|
+
/** A direct charge's application fee: out of the connected account's balance, into the platform's. */
|
|
108
|
+
export async function settleApplicationFee(ctx, feeId, amount, currency, account) {
|
|
109
|
+
await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(ctx.now()) }, account);
|
|
110
|
+
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(ctx.now()) }, null);
|
|
111
|
+
}
|
|
112
|
+
/** A top-up's credit, to the payments balance or, with `destination_balance=issuing`, to Issuing's
|
|
113
|
+
* (docs.stripe.com/issuing/funding/balance). Where the documentation stops and the twin decides: a test-mode top-up
|
|
114
|
+
* is available at once. */
|
|
115
|
+
export async function settleTopup(ctx, topupId, amount, currency, destination) {
|
|
116
|
+
const issuing = destination === 'issuing';
|
|
117
|
+
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: topupId, available_on: Number(ctx.now()), balance_type: issuing ? 'issuing' : 'payments' });
|
|
118
|
+
}
|
|
119
|
+
/** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
|
|
120
|
+
* moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
|
|
121
|
+
* sees it (asOf); the record is what lets Stripe's balance.available be sent once (stripe-server.ts, the drain).
|
|
122
|
+
* Answers the entries that moved. */
|
|
123
|
+
export async function settleDueEntries(ctx) {
|
|
124
|
+
const now = Number(ctx.now());
|
|
125
|
+
const account = actingAccount(ctx);
|
|
126
|
+
const moved = [];
|
|
127
|
+
for (const t of ctx.rowsRaw(BT)) {
|
|
128
|
+
if (!onAccount(t, account) || t.status !== 'pending' || !(Number(t.available_on) <= now))
|
|
129
|
+
continue;
|
|
130
|
+
if (ctx.legal(BT, 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time'))
|
|
131
|
+
continue;
|
|
132
|
+
await ctx.write(BT, String(t.id), { status: 'available' }, 'balance_transaction.available');
|
|
133
|
+
moved.push(t);
|
|
134
|
+
}
|
|
135
|
+
return moved;
|
|
136
|
+
}
|
|
137
|
+
/** An account's balance, by currency: what the clock has made available, what is still pending, and Issuing's own. */
|
|
138
|
+
export function balanceOf(ctx, account = actingAccount(ctx)) {
|
|
139
|
+
const available = new Map();
|
|
140
|
+
const pending = new Map();
|
|
141
|
+
const issuing = new Map();
|
|
142
|
+
for (const raw of ctx.rowsRaw(BT)) {
|
|
143
|
+
if (!onAccount(raw, account))
|
|
144
|
+
continue;
|
|
145
|
+
const t = asOf(ctx, raw);
|
|
146
|
+
const cur = String(t.currency ?? 'usd');
|
|
147
|
+
const net = Number(t.net ?? 0);
|
|
148
|
+
const bucket = t.balance_type === 'issuing' ? issuing : t.status === 'available' ? available : pending;
|
|
149
|
+
bucket.set(cur, (bucket.get(cur) ?? 0) + net);
|
|
150
|
+
}
|
|
151
|
+
return { available, pending, issuing };
|
|
152
|
+
}
|
|
153
|
+
/** balance_insufficient when a payout or transfer would take more than the account has available in its currency. */
|
|
154
|
+
export function refusePayout(ctx, amount, currency, account = actingAccount(ctx)) {
|
|
155
|
+
const have = balanceOf(ctx, account).available.get(currency) ?? 0;
|
|
156
|
+
if (amount <= have)
|
|
157
|
+
return undefined;
|
|
158
|
+
return fail(ctx, "The transfer or payout couldn't be completed because the associated account doesn't have a sufficient balance available.", 400, 'balance_insufficient');
|
|
159
|
+
}
|
|
160
|
+
/** The ids of the ledger entries on the acting account's balance. */
|
|
161
|
+
const mine = (ctx) => new Set(ctx.rowsRaw(BT).filter((t) => onAccount(t, actingAccount(ctx))).map((t) => t.id));
|
|
162
|
+
const retrieve = async (ctx) => {
|
|
163
|
+
const t = ctx.get(BT, at(ctx, 'id'));
|
|
164
|
+
return t && mine(ctx).has(t.id) ? ctx.reply(asOf(ctx, t)) : ctx.notFound(BT, at(ctx, 'id'));
|
|
165
|
+
};
|
|
166
|
+
const listAll = async (ctx) => {
|
|
167
|
+
const own = mine(ctx);
|
|
168
|
+
return list(ctx, BT, where(ctx, newest(ctx, BT).filter((t) => own.has(t.id)).map((t) => asOf(ctx, t)), {
|
|
169
|
+
type: (t, v) => t.type === v,
|
|
170
|
+
currency: (t, v) => t.currency === v,
|
|
171
|
+
source: (t, v) => t.source === v,
|
|
172
|
+
// an automatic payout lists the entries it paid out, its own debit among them
|
|
173
|
+
payout: (t, v) => t.source === v || kept(ctx, BT, t, '_payout') === v,
|
|
174
|
+
// a range of creation times (docs.stripe.com/api/balance_transactions/list#balance_transaction_list-created)
|
|
175
|
+
created: (t, v) => inRange(t.created, v),
|
|
176
|
+
}));
|
|
177
|
+
};
|
|
178
|
+
export const ledger = {
|
|
179
|
+
GetBalanceTransactions: listAll,
|
|
180
|
+
GetBalanceTransactionsId: retrieve,
|
|
181
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from './shared.js';
|
|
3
|
+
export declare function mintCharge(ctx: SemanticsContext, piId: string, pi: Row, amount: number): Promise<string>;
|
|
4
|
+
/** A manual-capture intent's authorization: "Separate payment authorization and capture to create a charge now, but
|
|
5
|
+
* capture funds later", and on capture "A partial capture automatically releases the remaining amount"
|
|
6
|
+
* (docs.stripe.com/payments/place-a-hold-on-a-payment-method). The charge succeeded and is not captured, holding the
|
|
7
|
+
* amount until capture_before, with nothing on the balance yet. Written as
|
|
8
|
+
* charge.succeeded, as a charge made with capture=false is (charges.ts); its capture writes charge.captured
|
|
9
|
+
* (captureAuthorization). Where the documentation stops and the twin decides: the events page names no event for an
|
|
10
|
+
* authorization of its own, so the authorized charge sends charge.succeeded, its status being succeeded. */
|
|
11
|
+
export declare function authorizeCharge(ctx: SemanticsContext, piId: string, pi: Row, amount: number): Promise<string>;
|
|
12
|
+
/** Whether the method an intent is confirmed with is a bank debit: a us_bank_account PaymentMethod, or one of Stripe's
|
|
13
|
+
* test bank accounts named as one (pm_usBankAccount_*, pm_us_bank_account). */
|
|
14
|
+
export declare function isBankDebit(ctx: SemanticsContext, method: unknown): boolean;
|
|
15
|
+
/** Time's settling of bank debits, caught up to the World's clock: each processing debit whose moment has come succeeds
|
|
16
|
+
* then, its Charge made (naming the mandate it ran under) and the invoice it collects for paid. */
|
|
17
|
+
export declare function settleBankDebits(ctx: SemanticsContext): Promise<void>;
|
|
18
|
+
export declare const paymentIntents: Record<string, Semantics>;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { asBool, cardError, chargeDefaults, declineFor, microdepositsNextAction, mintClientSecret, requiresAuthentication, requiresMicrodeposits, threeDsNextAction, validateMoney, verifyMicrodeposits, } from "../stripe-twin.js";
|
|
2
|
+
import { afterCharge, mintMandate } from "./after-payment.js";
|
|
3
|
+
import { cancelAuthorization, captureAuthorization } from "./charges.js";
|
|
4
|
+
import { settleCharge } from "./ledger.js";
|
|
5
|
+
import { materializeTestMethod, methodFromData, microdepositsAsked, paymentMethodDetails } from "./payment-methods.js";
|
|
6
|
+
import { at_, confirmOnly, created, fail, finder, search } from "./shared.js";
|
|
7
|
+
const PI = 'payment_intent';
|
|
8
|
+
const send = (ctx, r) => ctx.reply(r.body, r.status);
|
|
9
|
+
// ── THE MONEY MODEL, continued: a succeeded intent has a Charge ──
|
|
10
|
+
//
|
|
11
|
+
// Real Stripe always materializes a Charge when a PaymentIntent succeeds and points the intent's
|
|
12
|
+
// `latest_charge` at it: the charge is what a refund lands on, so an intent that succeeds without
|
|
13
|
+
// one cannot answer "refund this payment" at all. Idempotent against a repeated confirm: an intent
|
|
14
|
+
// that already names a successful charge keeps it (a second one would double the money collected); a
|
|
15
|
+
// declined attempt's failed charge is history, and the payment that succeeds makes its own. The id is
|
|
16
|
+
// returned so the caller folds `latest_charge` into the same intent write (one write, one event). The
|
|
17
|
+
// charge is written as the event Stripe sends for it, `charge.succeeded` ("Occurs whenever a charge is
|
|
18
|
+
// successful", docs.stripe.com/api/events/types); Stripe has no `charge.created`.
|
|
19
|
+
/** What every charge an intent makes carries of it: its transfer_group, which "identifies the resulting payment as part
|
|
20
|
+
* of a group" (docs.stripe.com/api/payment_intents/object), its description and its metadata. */
|
|
21
|
+
function intentCarries(pi) {
|
|
22
|
+
return {
|
|
23
|
+
...(typeof pi.transfer_group === 'string' ? { transfer_group: pi.transfer_group } : {}),
|
|
24
|
+
...(typeof pi.description === 'string' ? { description: pi.description } : {}),
|
|
25
|
+
// "When a PaymentIntent creates a Charge, the metadata copies to the Charge in a one-time snapshot"
|
|
26
|
+
// (docs.stripe.com/metadata, "Copy metadata to another object")
|
|
27
|
+
...(pi.metadata && typeof pi.metadata === 'object' ? { metadata: { ...pi.metadata } } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function mintCharge(ctx, piId, pi, amount) {
|
|
31
|
+
const already = ctx.get(PI, piId)?.latest_charge;
|
|
32
|
+
if (typeof already === 'string' && ctx.get('charge', already)?.status === 'succeeded')
|
|
33
|
+
return already;
|
|
34
|
+
const chargeId = ctx.mint('charge');
|
|
35
|
+
const bt = await settleCharge(ctx, chargeId, amount, String(pi.currency ?? 'usd'), typeof pi.payment_method === 'string' ? pi.payment_method : undefined);
|
|
36
|
+
await created(ctx, 'charge', {
|
|
37
|
+
id: chargeId, amount, currency: pi.currency ?? 'usd', payment_intent: piId,
|
|
38
|
+
...(typeof pi.customer === 'string' ? { customer: pi.customer } : {}),
|
|
39
|
+
...(typeof pi.payment_method === 'string' ? { payment_method: pi.payment_method } : {}),
|
|
40
|
+
...(typeof pi.invoice === 'string' ? { invoice: pi.invoice } : {}),
|
|
41
|
+
...intentCarries(pi),
|
|
42
|
+
}, { ...chargeDefaults(chargeId, amount, true, ctx.occurredAt), balance_transaction: bt, payment_method_details: paymentMethodDetails(ctx, pi.payment_method) ?? null }, { operation: 'charge.succeeded' });
|
|
43
|
+
await afterCharge(ctx, chargeId, { amount, currency: String(pi.currency ?? 'usd'), payment_intent: piId, application_fee_amount: pi.application_fee_amount, destination: pi.transfer_data?.destination }, pi.payment_method);
|
|
44
|
+
return chargeId;
|
|
45
|
+
}
|
|
46
|
+
/** A manual-capture intent's authorization: "Separate payment authorization and capture to create a charge now, but
|
|
47
|
+
* capture funds later", and on capture "A partial capture automatically releases the remaining amount"
|
|
48
|
+
* (docs.stripe.com/payments/place-a-hold-on-a-payment-method). The charge succeeded and is not captured, holding the
|
|
49
|
+
* amount until capture_before, with nothing on the balance yet. Written as
|
|
50
|
+
* charge.succeeded, as a charge made with capture=false is (charges.ts); its capture writes charge.captured
|
|
51
|
+
* (captureAuthorization). Where the documentation stops and the twin decides: the events page names no event for an
|
|
52
|
+
* authorization of its own, so the authorized charge sends charge.succeeded, its status being succeeded. */
|
|
53
|
+
export async function authorizeCharge(ctx, piId, pi, amount) {
|
|
54
|
+
const chargeId = ctx.mint('charge');
|
|
55
|
+
await created(ctx, 'charge', {
|
|
56
|
+
id: chargeId, amount, currency: pi.currency ?? 'usd', payment_intent: piId,
|
|
57
|
+
...(typeof pi.customer === 'string' ? { customer: pi.customer } : {}),
|
|
58
|
+
...(typeof pi.payment_method === 'string' ? { payment_method: pi.payment_method } : {}),
|
|
59
|
+
...(typeof pi.invoice === 'string' ? { invoice: pi.invoice } : {}),
|
|
60
|
+
...intentCarries(pi),
|
|
61
|
+
}, { ...chargeDefaults(chargeId, amount, false, ctx.occurredAt), balance_transaction: null, payment_method_details: paymentMethodDetails(ctx, pi.payment_method) ?? null }, { operation: 'charge.succeeded' });
|
|
62
|
+
return chargeId;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* An intent an invoice collects through succeeded (the default_incomplete subscription flow: create
|
|
66
|
+
* now, confirm client-side): its invoice is paid and, when it is a subscription's first, the
|
|
67
|
+
* subscription leaves incomplete for active. Both are moves the invoice and subscription machines
|
|
68
|
+
* declare under the confirming operation. No-op when the intent names no invoice, or the invoice is
|
|
69
|
+
* already paid (a duplicate confirm) or void (voiding ends what it collects).
|
|
70
|
+
*/
|
|
71
|
+
async function payInvoiceOf(ctx, pi, operationId, actor) {
|
|
72
|
+
const invoiceId = typeof pi.invoice === 'string' ? pi.invoice : undefined;
|
|
73
|
+
const invoice = invoiceId ? ctx.get('invoice', invoiceId) : undefined;
|
|
74
|
+
if (!invoiceId || !invoice || invoice.status === 'paid' || invoice.status === 'void')
|
|
75
|
+
return;
|
|
76
|
+
if (ctx.legal('invoice', 'status', operationId, invoice.status, 'paid', invoiceId, actor))
|
|
77
|
+
return;
|
|
78
|
+
const amount = Number(invoice.total ?? invoice.amount_due) || 0;
|
|
79
|
+
// `invoice.pay` is the event the standalone pay action sends: invoice.paid
|
|
80
|
+
await ctx.write('invoice', invoiceId, {
|
|
81
|
+
status: 'paid', paid: true, amount_paid: amount, amount_remaining: 0,
|
|
82
|
+
status_transitions: { ...(invoice.status_transitions ?? {}), paid_at: ctx.now() },
|
|
83
|
+
}, 'invoice.pay');
|
|
84
|
+
const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
|
|
85
|
+
const sub = subId ? ctx.get('subscription', subId) : undefined;
|
|
86
|
+
if (subId && sub && sub.status === 'incomplete' && !ctx.legal('subscription', 'status', operationId, sub.status, 'active', subId, actor)) {
|
|
87
|
+
await ctx.write('subscription', subId, { status: 'active' }, 'subscription.update');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The intent the path names, and the machine's refusal when this operation may not move it. */
|
|
91
|
+
function load(ctx, operationId) {
|
|
92
|
+
const pi = ctx.id ? ctx.get(PI, ctx.id) : undefined;
|
|
93
|
+
if (!pi)
|
|
94
|
+
return { answer: ctx.notFound(PI, String(ctx.id)) };
|
|
95
|
+
const refusal = ctx.legal(PI, 'status', operationId, pi.status);
|
|
96
|
+
return refusal ? { answer: ctx.refuse(refusal) } : { pi };
|
|
97
|
+
}
|
|
98
|
+
const create = async (ctx) => {
|
|
99
|
+
const params = ctx.params;
|
|
100
|
+
const bad = validateMoney(params);
|
|
101
|
+
if (bad)
|
|
102
|
+
return send(ctx, bad);
|
|
103
|
+
// what describes a confirmation needs one: error_on_requires_action, mandate, mandate_data, off_session and return_url
|
|
104
|
+
// are each, in the served spec, a parameter that "can only be used with `confirm=true`" (shared.ts confirmOnly)
|
|
105
|
+
const unconfirmed = confirmOnly(ctx, ['error_on_requires_action', 'mandate', 'mandate_data', 'off_session', 'return_url'], asBool(params.confirm));
|
|
106
|
+
if (unconfirmed)
|
|
107
|
+
return unconfirmed;
|
|
108
|
+
// automatic_payment_methods: when enabled, Stripe selects eligible methods and answers the
|
|
109
|
+
// normalized { enabled, allow_redirects } object
|
|
110
|
+
const apmIn = params.automatic_payment_methods;
|
|
111
|
+
const apmEnabled = apmIn && typeof apmIn === 'object' ? asBool(apmIn.enabled) : false;
|
|
112
|
+
const automatic_payment_methods = apmEnabled ? { enabled: true, allow_redirects: apmIn.allow_redirects ?? 'always' } : null;
|
|
113
|
+
// the methods it may be paid with: allowed_payment_method_types in the served version, payment_method_types for a
|
|
114
|
+
// caller pinned to an earlier one (docs.stripe.com/api/payment_intents/create)
|
|
115
|
+
const given = Array.isArray(params.allowed_payment_method_types) ? params.allowed_payment_method_types : params.payment_method_types;
|
|
116
|
+
const payment_method_types = Array.isArray(given) ? given.map(String) : ['card'];
|
|
117
|
+
// `confirm` is an instruction, not a field: `confirm=true` attempts to confirm the intent at once
|
|
118
|
+
// (https://docs.stripe.com/api/payment_intents/create#create_payment_intent-confirm)
|
|
119
|
+
const { automatic_payment_methods: _a, payment_method_types: _p, allowed_payment_method_types: _ap, id: provided, expand: _e, confirm: confirmNow, payment_method_data: data, mandate_data: _mandate, ...rest } = params;
|
|
120
|
+
// a create's payment_method_data makes its method, as a confirm's does (payment-methods.ts methodFromData)
|
|
121
|
+
const made = await methodFromData(ctx, data);
|
|
122
|
+
if (made)
|
|
123
|
+
rest.payment_method = made;
|
|
124
|
+
// the id is minted first so the client secret can carry it: Stripe.js parses the id back out
|
|
125
|
+
const id = typeof provided === 'string' && provided ? provided : ctx.mint(PI);
|
|
126
|
+
// it waits for a payment method until it has one, then for its confirmation (manifest.ts)
|
|
127
|
+
const hasMethod = typeof rest.payment_method === 'string' && rest.payment_method !== '';
|
|
128
|
+
if (hasMethod)
|
|
129
|
+
ctx.legal(PI, 'status', 'PostPaymentIntents', 'requires_payment_method', 'requires_confirmation', id);
|
|
130
|
+
const body = await ctx.write(PI, id, {
|
|
131
|
+
object: 'payment_intent',
|
|
132
|
+
created: ctx.now(),
|
|
133
|
+
status: hasMethod ? 'requires_confirmation' : 'requires_payment_method',
|
|
134
|
+
client_secret: mintClientSecret(id),
|
|
135
|
+
livemode: false,
|
|
136
|
+
capture_method: 'automatic',
|
|
137
|
+
amount_capturable: 0,
|
|
138
|
+
amount_received: 0,
|
|
139
|
+
next_action: null,
|
|
140
|
+
automatic_payment_methods,
|
|
141
|
+
payment_method_types,
|
|
142
|
+
...(Array.isArray(params.allowed_payment_method_types) ? { allowed_payment_method_types: payment_method_types } : {}),
|
|
143
|
+
payment_method_options: params.payment_method_options ?? {},
|
|
144
|
+
...rest,
|
|
145
|
+
}, 'payment_intent.create');
|
|
146
|
+
if (asBool(confirmNow))
|
|
147
|
+
return confirmIntent(ctx, body, {});
|
|
148
|
+
return ctx.reply(ctx.expand(PI, body));
|
|
149
|
+
};
|
|
150
|
+
const confirm = async (ctx) => {
|
|
151
|
+
const loaded = load(ctx, 'PostPaymentIntentsIntentConfirm');
|
|
152
|
+
if ('answer' in loaded)
|
|
153
|
+
return loaded.answer;
|
|
154
|
+
const { expand: _e, payment_method_data: data, mandate_data: _mandate, ...params } = ctx.params;
|
|
155
|
+
// a confirm's payment_method_data makes the method it pays with; neither it nor mandate_data is a field of the intent
|
|
156
|
+
const made = await methodFromData(ctx, data);
|
|
157
|
+
return confirmIntent(ctx, loaded.pi, made ? { ...params, payment_method: made } : params);
|
|
158
|
+
};
|
|
159
|
+
/** Confirm an intent the confirm machine allows to move: the confirm action, and a create with `confirm=true`. */
|
|
160
|
+
async function confirmIntent(ctx, existing, params) {
|
|
161
|
+
const id = String(existing.id);
|
|
162
|
+
// a payment names a mandate only an active one authorizes: an inactive mandate "was rejected, revoked, or previously
|
|
163
|
+
// used, and may not be used to initiate future payments" (docs.stripe.com/api/mandates/object), refused as
|
|
164
|
+
// payment_intent_mandate_invalid, "The provided mandate is invalid and can't be used for the payment intent"
|
|
165
|
+
// (docs.stripe.com/error-codes)
|
|
166
|
+
const named = typeof params.mandate === 'string' ? params.mandate : typeof existing.mandate === 'string' ? existing.mandate : undefined;
|
|
167
|
+
if (named && ctx.get('mandate', named)?.status !== 'active')
|
|
168
|
+
return fail(ctx, 'The provided mandate is invalid and can\'t be used for the payment intent.', 400, 'payment_intent_mandate_invalid');
|
|
169
|
+
// a declining test card leaves the intent needing a payment method, with the error on it and a 402
|
|
170
|
+
const decline = declineFor(finder(ctx), params, existing);
|
|
171
|
+
if (decline) {
|
|
172
|
+
// Stripe records the attempt: a failed charge the intent's latest_charge names, the declined card on
|
|
173
|
+
// last_payment_error (a test name made a real PaymentMethod), and no payment_method left on the intent
|
|
174
|
+
// (docs.stripe.com/payments/paymentintents/lifecycle, docs.stripe.com/declines)
|
|
175
|
+
const ref = params.payment_method ?? existing.payment_method;
|
|
176
|
+
let pmId = typeof ref === 'string' ? ref : undefined;
|
|
177
|
+
if (pmId && !ctx.get('payment_method', pmId) && pmId.startsWith('pm_card_'))
|
|
178
|
+
pmId = String((await materializeTestMethod(ctx, pmId, typeof existing.customer === 'string' ? existing.customer : null)).id);
|
|
179
|
+
const pm = pmId ? ctx.get('payment_method', pmId) : undefined;
|
|
180
|
+
const amount = Number(params.amount ?? existing.amount) || 0;
|
|
181
|
+
const chargeId = ctx.mint('charge');
|
|
182
|
+
await created(ctx, 'charge', {
|
|
183
|
+
id: chargeId, amount, currency: existing.currency ?? 'usd', payment_intent: id,
|
|
184
|
+
...(typeof existing.customer === 'string' ? { customer: existing.customer } : {}),
|
|
185
|
+
...(pmId ? { payment_method: pmId } : {}),
|
|
186
|
+
...intentCarries({ ...existing, ...params }),
|
|
187
|
+
}, {
|
|
188
|
+
...chargeDefaults(chargeId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
|
|
189
|
+
failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
|
|
190
|
+
outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
|
|
191
|
+
payment_method_details: pmId ? paymentMethodDetails(ctx, pmId) ?? null : null,
|
|
192
|
+
}, { operation: 'charge.failed' }); // "Occurs whenever a failed charge attempt occurs." (docs.stripe.com/api/events/types)
|
|
193
|
+
const { payment_method: _pm, ...rest } = params;
|
|
194
|
+
const last_payment_error = { type: 'card_error', code: decline.code, ...(decline.decline_code ? { decline_code: decline.decline_code } : {}), message: decline.message, param: 'card', charge: chargeId, payment_method: pm ?? null };
|
|
195
|
+
const failed = await ctx.write(PI, id, { ...rest, payment_method: null, latest_charge: chargeId, status: 'requires_payment_method', last_payment_error }, 'payment_intent.payment_failed');
|
|
196
|
+
return send(ctx, cardError(decline, { payment_intent: failed, charge: chargeId }));
|
|
197
|
+
}
|
|
198
|
+
// a bank debit that needs micro-deposit verification waits in requires_action
|
|
199
|
+
const method = typeof params.payment_method === 'string' ? params.payment_method : typeof existing.payment_method === 'string' ? existing.payment_method : undefined;
|
|
200
|
+
// a bank transfer is paid from the customer's cash balance, or waits for the transfer (fundFromCashBalance)
|
|
201
|
+
if (method && ctx.get('payment_method', method)?.type === 'customer_balance')
|
|
202
|
+
return ctx.reply(ctx.expand(PI, await fundFromCashBalance(ctx, { ...existing, ...params }, params)));
|
|
203
|
+
if (requiresMicrodeposits(params, existing) || microdepositsAsked(ctx, params, existing, method)) {
|
|
204
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_action', next_action: microdepositsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
205
|
+
}
|
|
206
|
+
// 3DS: the first confirm asks for authentication; a second one completes it
|
|
207
|
+
if (existing.status !== 'requires_action' && requiresAuthentication(params, existing)) {
|
|
208
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_action', next_action: threeDsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
209
|
+
}
|
|
210
|
+
// manual capture authorizes and waits for /capture: the authorization is a charge, succeeded and not captured, the
|
|
211
|
+
// intent's latest_charge
|
|
212
|
+
if ((params.capture_method ?? existing.capture_method) === 'manual') {
|
|
213
|
+
const amount = Number(existing.amount ?? params.amount ?? 0);
|
|
214
|
+
const charge = await authorizeCharge(ctx, id, { ...existing, ...params }, amount);
|
|
215
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_capture', amount_capturable: amount, amount_received: 0, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.amount_capturable_updated')));
|
|
216
|
+
}
|
|
217
|
+
// a bank debit is submitted and settles later (beginBankDebit)
|
|
218
|
+
if (isBankDebit(ctx, method))
|
|
219
|
+
return ctx.reply(ctx.expand(PI, await beginBankDebit(ctx, id, params)));
|
|
220
|
+
const amount = Number(existing.amount ?? params.amount ?? 0);
|
|
221
|
+
// a succeeded intent has a Charge, and latest_charge names it: what a refund of it lands on
|
|
222
|
+
const charge = await mintCharge(ctx, id, { ...existing, ...params }, amount);
|
|
223
|
+
const body = await ctx.write(PI, id, { ...params, status: 'succeeded', amount_received: amount, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.confirm');
|
|
224
|
+
await payInvoiceOf(ctx, body, 'PostPaymentIntentsIntentConfirm');
|
|
225
|
+
return ctx.reply(ctx.expand(PI, body));
|
|
226
|
+
}
|
|
227
|
+
const capture = async (ctx) => {
|
|
228
|
+
const loaded = load(ctx, 'PostPaymentIntentsIntentCapture');
|
|
229
|
+
if ('answer' in loaded)
|
|
230
|
+
return loaded.answer;
|
|
231
|
+
const pi = loaded.pi;
|
|
232
|
+
const authorized = Number(pi.amount_capturable ?? pi.amount ?? 0);
|
|
233
|
+
// amount_to_capture lowers the captured amount (a partial capture); it never raises it
|
|
234
|
+
const asked = ctx.params.amount_to_capture !== undefined ? Math.max(0, Math.trunc(Number(ctx.params.amount_to_capture) || 0)) : authorized;
|
|
235
|
+
const captured = Math.min(asked, authorized);
|
|
236
|
+
// the authorization confirm made is captured; an intent authorized before it was kept is charged as before
|
|
237
|
+
const auth = typeof pi.latest_charge === 'string' ? ctx.get('charge', pi.latest_charge) : undefined;
|
|
238
|
+
const held = auth && auth.status === 'succeeded' && auth.captured === false ? auth : undefined;
|
|
239
|
+
if (held) {
|
|
240
|
+
const refused = ctx.legal('charge', 'captured', 'PostPaymentIntentsIntentCapture', 'false', 'true', String(held.id));
|
|
241
|
+
if (refused)
|
|
242
|
+
return ctx.refuse(refused);
|
|
243
|
+
await captureAuthorization(ctx, held, captured, 'PostPaymentIntentsIntentCapture');
|
|
244
|
+
await afterCharge(ctx, String(held.id), { amount: captured, currency: String(pi.currency ?? 'usd'), payment_intent: String(pi.id), application_fee_amount: pi.application_fee_amount, destination: pi.transfer_data?.destination }, pi.payment_method);
|
|
245
|
+
}
|
|
246
|
+
const charge = held ? String(held.id) : await mintCharge(ctx, String(pi.id), pi, captured);
|
|
247
|
+
const body = await ctx.write(PI, String(pi.id), { status: 'succeeded', amount_received: captured, amount_capturable: 0, latest_charge: charge }, 'payment_intent.succeeded');
|
|
248
|
+
await payInvoiceOf(ctx, body, 'PostPaymentIntentsIntentCapture');
|
|
249
|
+
return ctx.reply(ctx.expand(PI, body));
|
|
250
|
+
};
|
|
251
|
+
/** An increment that does not raise the authorization. */
|
|
252
|
+
function incrementTooSmall(ctx) {
|
|
253
|
+
return ctx.refuse({ status: 400, code: 'parameter_invalid_integer', message: 'The new amount must be greater than the current amount of the PaymentIntent.' });
|
|
254
|
+
}
|
|
255
|
+
const incrementAuthorization = async (ctx) => {
|
|
256
|
+
const loaded = load(ctx, 'PostPaymentIntentsIntentIncrementAuthorization');
|
|
257
|
+
if ('answer' in loaded)
|
|
258
|
+
return loaded.answer;
|
|
259
|
+
const pi = loaded.pi;
|
|
260
|
+
if (ctx.params.amount === undefined)
|
|
261
|
+
return ctx.refuse({ status: 400, code: 'parameter_missing', message: 'Missing required param: amount.' });
|
|
262
|
+
const amount = Math.trunc(Number(ctx.params.amount) || 0);
|
|
263
|
+
if (!Number.isInteger(amount) || amount <= Number(pi.amount ?? 0))
|
|
264
|
+
return incrementTooSmall(ctx);
|
|
265
|
+
// the authorization the charge holds grows with it: "If the incremental authorization fails ... no other fields on
|
|
266
|
+
// the PaymentIntent or Charge update" (docs.stripe.com/api/payment_intents/increment_authorization), so on success the
|
|
267
|
+
// charge's amount is the new authorized amount. Where the documentation stops and the twin decides: the charge's
|
|
268
|
+
// update sends no event of its own (the events page names none for it).
|
|
269
|
+
const held = typeof pi.latest_charge === 'string' ? ctx.get('charge', pi.latest_charge) : undefined;
|
|
270
|
+
if (held && held.captured === false)
|
|
271
|
+
await ctx.write('charge', String(held.id), { amount }, 'charge.authorization_incremented');
|
|
272
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, String(pi.id), { amount, amount_capturable: amount }, 'payment_intent.amount_capturable_updated')));
|
|
273
|
+
};
|
|
274
|
+
const cancel = async (ctx) => {
|
|
275
|
+
const loaded = load(ctx, 'PostPaymentIntentsIntentCancel');
|
|
276
|
+
if ('answer' in loaded)
|
|
277
|
+
return loaded.answer;
|
|
278
|
+
const cancellation_reason = typeof ctx.params.cancellation_reason === 'string' ? ctx.params.cancellation_reason : 'requested_by_customer';
|
|
279
|
+
// "For PaymentIntents with a `status` of `requires_capture`, the remaining `amount_capturable` is automatically
|
|
280
|
+
// refunded" (docs.stripe.com/api/payment_intents/cancel): the authorization is released (charges.ts)
|
|
281
|
+
const held = loaded.pi.status === 'requires_capture' && typeof loaded.pi.latest_charge === 'string' ? ctx.get('charge', loaded.pi.latest_charge) : undefined;
|
|
282
|
+
if (held && held.captured === false && held.refunded !== true)
|
|
283
|
+
await cancelAuthorization(ctx, held);
|
|
284
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, String(loaded.pi.id), { status: 'canceled', cancellation_reason, amount_capturable: 0 }, 'payment_intent.canceled')));
|
|
285
|
+
};
|
|
286
|
+
const verifyMicrodepositsHandler = async (ctx) => {
|
|
287
|
+
const pi = ctx.id ? ctx.get(PI, ctx.id) : undefined;
|
|
288
|
+
if (!pi)
|
|
289
|
+
return ctx.notFound(PI, String(ctx.id));
|
|
290
|
+
// the deposit amounts are checked before the intent's state, as Stripe does
|
|
291
|
+
const wrong = verifyMicrodeposits(ctx.params, pi);
|
|
292
|
+
if (wrong)
|
|
293
|
+
return send(ctx, wrong);
|
|
294
|
+
const refusal = ctx.legal(PI, 'status', 'PostPaymentIntentsIntentVerifyMicrodeposits', pi.status);
|
|
295
|
+
if (refusal)
|
|
296
|
+
return ctx.refuse(refusal);
|
|
297
|
+
const amount = Number(pi.amount) || 0;
|
|
298
|
+
// a debit saved for reuse ("If you want to reuse the payment method in the future, provide the setup_future_usage
|
|
299
|
+
// parameter with the value of off_session", the ACH page) is authorized for many payments (multi_use, "Represents
|
|
300
|
+
// permission given for multiple payments"); else for this one (single_use, "a one-time permission given for a single
|
|
301
|
+
// payment", docs.stripe.com/api/mandates/object)
|
|
302
|
+
const reuse = pi.setup_future_usage === 'off_session' || pi.setup_future_usage === 'on_session';
|
|
303
|
+
const mandate = reuse ? await mintMandate(ctx, pi.payment_method, 'multi_use') : await mintMandate(ctx, pi.payment_method, 'single_use', amount, String(pi.currency ?? 'usd'));
|
|
304
|
+
// "When the bank account is successfully verified, Stripe returns the PaymentIntent object with a status of
|
|
305
|
+
// `processing`" (docs.stripe.com/payments/ach-direct-debit/accept-a-payment?payment-ui=direct-api)
|
|
306
|
+
return ctx.reply(ctx.expand(PI, await beginBankDebit(ctx, String(pi.id), { mandate })));
|
|
307
|
+
};
|
|
308
|
+
// ── a bank debit is submitted, and settles ──
|
|
309
|
+
//
|
|
310
|
+
// ACH Direct Debit "is a delayed notification payment method ... The PaymentIntent you create initially has a status
|
|
311
|
+
// of `processing`. After the payment has succeeded, the PaymentIntent status is updated from `processing` to
|
|
312
|
+
// `succeeded`"; verification by micro-deposits returns "a status of `processing`, and sends a payment_intent.processing
|
|
313
|
+
// webhook event"; and, the twin being a test-mode account: "Test transactions settle instantly and are added to your
|
|
314
|
+
// available test balance. This behavior differs from live mode, where transactions can take multiple days to settle"
|
|
315
|
+
// (docs.stripe.com/payments/ach-direct-debit/accept-a-payment?payment-ui=direct-api). A confirm answers processing; the
|
|
316
|
+
// debit succeeds at that same instant of the World's clock, caught up before the next request is answered (as
|
|
317
|
+
// renewals and payouts are). The page's test accounts give each debit its outcome: pm_usBankAccount_success
|
|
318
|
+
// (000123456789) "The payment succeeds"; pm_usBankAccount_processing (000000000009) "The payment stays in processing
|
|
319
|
+
// indefinitely". Where the documentation stops and the twin decides: its Charge is made when it succeeds (the page
|
|
320
|
+
// names none while it processes); the failing test accounts (closed, no account, insufficient funds, debit not
|
|
321
|
+
// authorized, invalid currency, dispute, weekly limit, Radar block) are not modelled and succeed.
|
|
322
|
+
/** Whether the method an intent is confirmed with is a bank debit: a us_bank_account PaymentMethod, or one of Stripe's
|
|
323
|
+
* test bank accounts named as one (pm_usBankAccount_*, pm_us_bank_account). */
|
|
324
|
+
export function isBankDebit(ctx, method) {
|
|
325
|
+
if (typeof method !== 'string')
|
|
326
|
+
return false;
|
|
327
|
+
return ctx.get('payment_method', method)?.type === 'us_bank_account' || /^pm_us_?bank_?account/i.test(method);
|
|
328
|
+
}
|
|
329
|
+
/** Whether a bank debit stays processing: the page's pm_usBankAccount_processing, or its account 000000000009. */
|
|
330
|
+
function staysProcessing(ctx, method) {
|
|
331
|
+
if (method === 'pm_usBankAccount_processing')
|
|
332
|
+
return true;
|
|
333
|
+
const bank = typeof method === 'string' ? ctx.get('payment_method', method)?.us_bank_account : undefined;
|
|
334
|
+
return bank?.last4 === '0009';
|
|
335
|
+
}
|
|
336
|
+
/** A bank debit submitted: the intent processes, nothing received yet, sent as payment_intent.processing; it is due to
|
|
337
|
+
* settle at this instant, unless its test account stays processing. */
|
|
338
|
+
async function beginBankDebit(ctx, id, fields) {
|
|
339
|
+
const method = fields.payment_method ?? ctx.get(PI, id)?.payment_method;
|
|
340
|
+
return ctx.write(PI, id, { ...fields, status: 'processing', amount_received: 0, next_action: null, last_payment_error: null, _settles_at: staysProcessing(ctx, method) ? null : Number(ctx.now()) }, 'payment_intent.processing');
|
|
341
|
+
}
|
|
342
|
+
/** Time's settling of bank debits, caught up to the World's clock: each processing debit whose moment has come succeeds
|
|
343
|
+
* then, its Charge made (naming the mandate it ran under) and the invoice it collects for paid. */
|
|
344
|
+
export async function settleBankDebits(ctx) {
|
|
345
|
+
const now = Number(ctx.now());
|
|
346
|
+
const due = ctx.rowsRaw(PI).filter((p) => p.status === 'processing' && typeof p._settles_at === 'number' && p._settles_at <= now).sort((a, b) => Number(a._settles_at) - Number(b._settles_at));
|
|
347
|
+
for (const pi of due) {
|
|
348
|
+
const id = String(pi.id);
|
|
349
|
+
const c = await at_(ctx)(Number(pi._settles_at));
|
|
350
|
+
c.legal(PI, 'status', ctx.call.operation.id, 'processing', 'succeeded', id, 'vendor');
|
|
351
|
+
const amount = Number(pi.amount) || 0;
|
|
352
|
+
const charge = await mintCharge(c, id, pi, amount);
|
|
353
|
+
// the charge names the mandate its bank debit ran under (payment_method_details.us_bank_account.mandate)
|
|
354
|
+
const details = (c.get('charge', charge)?.payment_method_details ?? null);
|
|
355
|
+
const kind = typeof details?.type === 'string' ? details.type : undefined;
|
|
356
|
+
if (details && kind && typeof pi.mandate === 'string')
|
|
357
|
+
await c.write('charge', charge, { payment_method_details: { ...details, [kind]: { ...(details[kind] ?? {}), mandate: pi.mandate } } }, 'charge.mandate');
|
|
358
|
+
const body = await c.write(PI, id, { status: 'succeeded', amount_received: amount, latest_charge: charge, _settles_at: null }, 'payment_intent.succeeded');
|
|
359
|
+
// a single-use mandate is spent by its payment: "previously used, and may not be used to initiate future payments"
|
|
360
|
+
const used = typeof pi.mandate === 'string' ? c.get('mandate', pi.mandate) : undefined;
|
|
361
|
+
if (used && used.type === 'single_use' && used.status === 'active') {
|
|
362
|
+
c.legal('mandate', 'status', ctx.call.operation.id, 'active', 'inactive', String(used.id), 'vendor');
|
|
363
|
+
await c.write('mandate', String(used.id), { status: 'inactive' }, 'mandate.updated');
|
|
364
|
+
}
|
|
365
|
+
await payInvoiceOf(c, body, ctx.call.operation.id, 'vendor');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** A customer_balance intent funded from the customer's cash balance: paid when the balance covers it ("If the customer
|
|
369
|
+
* already has a balance high enough to cover the payment amount, the PaymentIntent immediately succeeds"), else
|
|
370
|
+
* waiting for a transfer: "If the customer balance isn’t high enough to cover the request amount, the PaymentIntent
|
|
371
|
+
* shows a requires_action status ... next_action ... display_bank_transfer_instructions" with the amount_remaining
|
|
372
|
+
* (docs.stripe.com/payments/bank-transfers/accept-a-payment). Answers the intent as written. */
|
|
373
|
+
async function fundFromCashBalance(ctx, pi, fields = {}) {
|
|
374
|
+
const customer = typeof pi.customer === 'string' ? pi.customer : '';
|
|
375
|
+
const currency = String(pi.currency ?? 'usd');
|
|
376
|
+
// what the customer's funded cash balance holds in this currency, from its ledger
|
|
377
|
+
const available = customer ? ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0) : 0;
|
|
378
|
+
const amount = Number(pi.amount) || 0;
|
|
379
|
+
if (available >= amount) {
|
|
380
|
+
// the balance pays the intent: its ledger records what was applied
|
|
381
|
+
await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'applied_to_payment', net_amount: -amount, ending_balance: available - amount, applied_to_payment: { payment_intent: String(pi.id) }, livemode: false });
|
|
382
|
+
const charge = await mintCharge(ctx, String(pi.id), { ...pi, ...fields }, amount);
|
|
383
|
+
return ctx.write(PI, String(pi.id), { ...fields, status: 'succeeded', next_action: null, amount_received: amount, last_payment_error: null, latest_charge: charge }, 'payment_intent.succeeded');
|
|
384
|
+
}
|
|
385
|
+
// not enough yet: the intent waits for the rest, with the instructions to send it
|
|
386
|
+
const next_action = { type: 'display_bank_transfer_instructions', display_bank_transfer_instructions: { amount_remaining: amount - available, currency, type: 'us_bank_transfer' } };
|
|
387
|
+
return ctx.write(PI, String(pi.id), { ...fields, status: 'requires_action', next_action }, 'payment_intent.requires_action');
|
|
388
|
+
}
|
|
389
|
+
const applyCustomerBalance = async (ctx) => {
|
|
390
|
+
const loaded = load(ctx, 'PostPaymentIntentsIntentApplyCustomerBalance');
|
|
391
|
+
if ('answer' in loaded)
|
|
392
|
+
return loaded.answer;
|
|
393
|
+
return ctx.reply(ctx.expand(PI, await fundFromCashBalance(ctx, loaded.pi)));
|
|
394
|
+
};
|
|
395
|
+
export const paymentIntents = {
|
|
396
|
+
PostPaymentIntents: create,
|
|
397
|
+
GetPaymentIntentsSearch: async (ctx) => search(ctx, PI),
|
|
398
|
+
PostPaymentIntentsIntentConfirm: confirm,
|
|
399
|
+
PostPaymentIntentsIntentCapture: capture,
|
|
400
|
+
PostPaymentIntentsIntentIncrementAuthorization: incrementAuthorization,
|
|
401
|
+
PostPaymentIntentsIntentCancel: cancel,
|
|
402
|
+
PostPaymentIntentsIntentVerifyMicrodeposits: verifyMicrodepositsHandler,
|
|
403
|
+
PostPaymentIntentsIntentApplyCustomerBalance: applyCustomerBalance,
|
|
404
|
+
};
|