@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,174 @@
|
|
|
1
|
+
// Transfer and application-fee semantics: money the platform moves to a connected account and
|
|
2
|
+
// claws back (reversals), and the platform's fee on a connected charge and its refunds. A
|
|
3
|
+
// reversal or fee refund never exceeds what is left, and lands on its parent's totals and list.
|
|
4
|
+
// A transfer goes only to an account whose transfers capability is active, comes out of the
|
|
5
|
+
// platform's available balance, and lands in the destination's (docs.stripe.com/connect/separate-
|
|
6
|
+
// charges-and-transfers); a reversal moves it back.
|
|
7
|
+
// A transfer tied to a charge (`source_transaction`, "Transfer availability" in the same guide) "returns success
|
|
8
|
+
// regardless of your available balance if the related charge hasn't settled yet", but "the funds don't become
|
|
9
|
+
// available in the destination account until the funds from the associated charge are available"; a charge whose funds
|
|
10
|
+
// are already available by the World's clock is no exception, and the transfer is held to the available balance as any
|
|
11
|
+
// other. "The amount of the transfer must not exceed the amount of the source charge", several transfers may share one
|
|
12
|
+
// charge while "the sum of the transfers doesn't exceed the source charge", and the charge's balance currency must
|
|
13
|
+
// match. "If the source charge has a `transfer_group` value, Stripe assigns the same value to the transfer's
|
|
14
|
+
// `transfer_group`. If it doesn't, then Stripe generates a string in the format `group_` plus the associated
|
|
15
|
+
// PaymentIntent ID ... It assigns that string as the `transfer_group` for both the charge and the transfer."
|
|
16
|
+
// Where the documentation stops and the twin decides: the sum from one charge counts each transfer less what was
|
|
17
|
+
// reversed of it; a transfer_group the request names is kept only when the charge has none and no PaymentIntent to
|
|
18
|
+
// name one after; an uncaptured charge (an authorization, docs silent) is refused as a source_transaction until it is
|
|
19
|
+
// captured; the refusals' wording is the twin's.
|
|
20
|
+
// Transfer list, retrieve and update and application-fee list and retrieve are the derived core's.
|
|
21
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
22
|
+
import { validateMoney } from '../stripe-twin.ts';
|
|
23
|
+
import { refusePayout, settleTransfer, settleTransferReversal } from './ledger.ts';
|
|
24
|
+
import { at, created, fail, list, newest, send, type Row } from './shared.ts';
|
|
25
|
+
|
|
26
|
+
const transferMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such transfer: '${id}'`, 404, 'resource_missing');
|
|
27
|
+
const emptyList = (url: string): Row => ({ object: 'list', data: [], has_more: false, total_count: 0, url });
|
|
28
|
+
|
|
29
|
+
/** A transfer to an account whose transfers capability is not active. */
|
|
30
|
+
function transfersInactive(ctx: SemanticsContext): Response {
|
|
31
|
+
return ctx.refuse({ status: 400, code: 'insufficient_capabilities_for_transfer', param: 'destination', message: 'Your destination account needs to have at least one of the following capabilities enabled: transfers, crypto_transfers, legacy_payments' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const create: Semantics = async (ctx) => {
|
|
35
|
+
const bad = validateMoney(ctx.params);
|
|
36
|
+
if (bad) return send(ctx, bad);
|
|
37
|
+
const destination = typeof ctx.params.destination === 'string' ? ctx.params.destination : '';
|
|
38
|
+
if (!destination) return fail(ctx, 'Missing required param: destination.', 400, 'parameter_missing');
|
|
39
|
+
const account = ctx.get('account', destination);
|
|
40
|
+
if (!account) return fail(ctx, `No such destination: '${destination}'`, 400, 'resource_missing');
|
|
41
|
+
if (((account.capabilities as Row | undefined)?.transfers) !== 'active') return transfersInactive(ctx);
|
|
42
|
+
const amount = Math.trunc(Number(ctx.params.amount) || 0);
|
|
43
|
+
const currency = String(ctx.params.currency ?? 'usd');
|
|
44
|
+
const sourceId = typeof ctx.params.source_transaction === 'string' ? ctx.params.source_transaction : undefined;
|
|
45
|
+
let availableOn: number | undefined;
|
|
46
|
+
let group: string | undefined;
|
|
47
|
+
if (sourceId) {
|
|
48
|
+
const charge = ctx.get('charge', sourceId);
|
|
49
|
+
if (!charge) return fail(ctx, `No such charge: '${sourceId}'`, 400, 'resource_missing');
|
|
50
|
+
if (charge.status !== 'succeeded') return fail(ctx, `The source_transaction ${sourceId} has not succeeded.`, 400, 'invalid_request_error');
|
|
51
|
+
// an authorization holds no funds to transfer
|
|
52
|
+
if (charge.captured === false) return fail(ctx, `The source_transaction ${sourceId} has not been captured; capture it before transferring its funds.`, 400);
|
|
53
|
+
if (String(charge.currency ?? 'usd') !== currency) return fail(ctx, `The currency of source_transaction ${sourceId} (${String(charge.currency)}) does not match the transfer's (${currency}).`, 400, 'invalid_request_error');
|
|
54
|
+
const already = ctx.rowsRaw('transfer').filter((t) => t.source_transaction === sourceId).reduce((sum, t) => sum + (Number(t.amount) || 0) - (Number(t.amount_reversed) || 0), 0);
|
|
55
|
+
if (already + amount > Number(charge.amount ?? 0)) return fail(ctx, `The transfers from source_transaction ${sourceId} would exceed its amount (${String(charge.amount)}).`, 400, 'invalid_request_error');
|
|
56
|
+
const bt = typeof charge.balance_transaction === 'string' ? ctx.get('balance_transaction', charge.balance_transaction) : undefined;
|
|
57
|
+
// a charge that has settled waives nothing: the transfer comes out of what is available
|
|
58
|
+
const settledRefusal = bt && Number(bt.available_on) <= Number(ctx.now()) ? settledSourceRefused(ctx, amount, currency) : undefined;
|
|
59
|
+
if (settledRefusal) return settledRefusal;
|
|
60
|
+
availableOn = Math.max(Number(ctx.now()), Number(bt?.available_on) || 0);
|
|
61
|
+
group = typeof charge.transfer_group === 'string' ? charge.transfer_group : typeof charge.payment_intent === 'string' ? `group_${charge.payment_intent}` : undefined;
|
|
62
|
+
// the generated group is the charge's too
|
|
63
|
+
if (group && charge.transfer_group !== group) await ctx.write('charge', sourceId, { transfer_group: group }, 'charge.transfer_group_assigned');
|
|
64
|
+
} else {
|
|
65
|
+
const refused = refusePayout(ctx, amount, currency, undefined);
|
|
66
|
+
if (refused) return refused;
|
|
67
|
+
}
|
|
68
|
+
const id = ctx.mint('transfer');
|
|
69
|
+
const bt = await settleTransfer(ctx, id, amount, currency, destination, availableOn ?? Number(ctx.now()), sourceId !== undefined);
|
|
70
|
+
return ctx.reply(
|
|
71
|
+
await created(ctx, 'transfer', { id, ...ctx.params, ...(group ? { transfer_group: group } : {}) }, {
|
|
72
|
+
amount_reversed: 0, balance_transaction: bt, livemode: false, metadata: {},
|
|
73
|
+
reversed: false, source_type: 'card', source_transaction: null,
|
|
74
|
+
reversals: emptyList(`/v1/transfers/${id}/reversals`),
|
|
75
|
+
destination_payment: `py_${id.replace(/^tr_/, '')}`,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A transfer from a charge whose funds have settled: "With a source_transaction, the transfer request returns success
|
|
81
|
+
* regardless of your available balance if the related charge hasn't settled yet" (docs.stripe.com/connect/separate-
|
|
82
|
+
* charges-and-transfers), so a settled one comes out of the available balance. */
|
|
83
|
+
function settledSourceRefused(ctx: SemanticsContext, amount: number, currency: string): Response | undefined {
|
|
84
|
+
return refusePayout(ctx, amount, currency, undefined);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An application fee refunded to its whole through its own refunds endpoint: the fee machine's move, or its refusal. */
|
|
88
|
+
function feeFullyRefunded(ctx: SemanticsContext, resource: string, flag: string, id: string): Response | undefined {
|
|
89
|
+
const refused = ctx.legal(resource, flag, ctx.call.operation.id, 'false', 'true', id);
|
|
90
|
+
return refused ? ctx.refuse(refused) : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Take `amount` (the remainder by default) back out of a parent's total, as a child row appended to its list. */
|
|
94
|
+
async function giveBack(ctx: SemanticsContext, o: {
|
|
95
|
+
parent: Row; parentResource: string; child: string; childFields: (amount: number) => Row; childDefaults: Row;
|
|
96
|
+
doneField: string; flag: string; listUrl: string; op: string; tooLarge: (remaining: number) => string;
|
|
97
|
+
/** what the money movement itself writes, once the child exists */
|
|
98
|
+
settle?: (child: Row, amount: number) => Promise<void>;
|
|
99
|
+
}): Promise<Response> {
|
|
100
|
+
const id = String(o.parent.id);
|
|
101
|
+
const total = Number(o.parent.amount ?? 0);
|
|
102
|
+
const already = Number(o.parent[o.doneField] ?? 0);
|
|
103
|
+
const remaining = total - already;
|
|
104
|
+
const amount = ctx.params.amount !== undefined ? Math.trunc(Number(ctx.params.amount) || 0) : remaining;
|
|
105
|
+
if (amount <= 0 || amount > remaining) return fail(ctx, o.tooLarge(remaining), 400, 'amount_too_large');
|
|
106
|
+
const done = already + amount;
|
|
107
|
+
// a fee refunded in full is `refunded`: the application fee machine's move (a transfer's `reversed` is no state)
|
|
108
|
+
const feeRefusal = o.parentResource === 'application_fee' && done >= total && o.parent[o.flag] !== true ? feeFullyRefunded(ctx, o.parentResource, o.flag, id) : undefined;
|
|
109
|
+
if (feeRefusal) return feeRefusal;
|
|
110
|
+
// the `metadata` a reversal or a fee refund is created with is its own ("Set of key-value pairs that you can attach to
|
|
111
|
+
// an object", docs.stripe.com/api/transfer_reversals/create, docs.stripe.com/api/fee_refunds/create)
|
|
112
|
+
const metadata = ctx.params.metadata && typeof ctx.params.metadata === 'object' ? { metadata: ctx.params.metadata } : {};
|
|
113
|
+
const child = await created(ctx, o.child, { ...o.childFields(amount), ...metadata }, o.childDefaults);
|
|
114
|
+
if (o.settle) await o.settle(child, amount);
|
|
115
|
+
const existing = (o.parent[o.flag === 'reversed' ? 'reversals' : 'refunds'] as Row) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
|
|
116
|
+
const data = [...((existing.data as unknown[]) ?? []), child];
|
|
117
|
+
await ctx.write(o.parentResource, id, {
|
|
118
|
+
[o.doneField]: done, [o.flag]: done >= total,
|
|
119
|
+
[o.flag === 'reversed' ? 'reversals' : 'refunds']: { ...existing, data, total_count: data.length, url: o.listUrl },
|
|
120
|
+
}, o.op);
|
|
121
|
+
return ctx.reply(child);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reverse: Semantics = async (ctx) => {
|
|
125
|
+
const id = at(ctx, 'id');
|
|
126
|
+
const tr = ctx.get('transfer', id);
|
|
127
|
+
if (!tr) return transferMissing(ctx, id);
|
|
128
|
+
return giveBack(ctx, {
|
|
129
|
+
parent: tr, parentResource: 'transfer', child: 'transfer_reversal',
|
|
130
|
+
childFields: (amount) => ({ amount, currency: tr.currency ?? 'usd', transfer: id }),
|
|
131
|
+
childDefaults: { balance_transaction: null, destination_payment_refund: null, source_refund: null, metadata: {} },
|
|
132
|
+
doneField: 'amount_reversed', flag: 'reversed', listUrl: `/v1/transfers/${id}/reversals`, op: 'transfer.reversed',
|
|
133
|
+
tooLarge: (remaining) => `Transfer ${id} can only be reversed up to ${remaining}.`,
|
|
134
|
+
settle: async (child, amount) => {
|
|
135
|
+
const bt = await settleTransferReversal(ctx, String(child.id), amount, String(tr.currency ?? 'usd'), String(tr.destination));
|
|
136
|
+
await ctx.write('transfer_reversal', String(child.id), { balance_transaction: bt }, 'transfer_reversal.updated');
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const reversals: Semantics = async (ctx) => {
|
|
142
|
+
const id = at(ctx, 'id');
|
|
143
|
+
if (!ctx.get('transfer', id)) return transferMissing(ctx, id);
|
|
144
|
+
return list(ctx, 'transfer_reversal', newest(ctx, 'transfer_reversal').filter((r) => r.transfer === id));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const feeMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such application fee: '${id}'`, 404, 'resource_missing');
|
|
148
|
+
|
|
149
|
+
const refundFee: Semantics = async (ctx) => {
|
|
150
|
+
const id = at(ctx, 'id');
|
|
151
|
+
const fee = ctx.get('application_fee', id);
|
|
152
|
+
if (!fee) return feeMissing(ctx, id);
|
|
153
|
+
return giveBack(ctx, {
|
|
154
|
+
parent: fee, parentResource: 'application_fee', child: 'fee_refund',
|
|
155
|
+
childFields: (amount) => ({ amount, currency: fee.currency ?? 'usd', fee: id }),
|
|
156
|
+
childDefaults: { balance_transaction: null, metadata: {} },
|
|
157
|
+
doneField: 'amount_refunded', flag: 'refunded', listUrl: `/v1/application_fees/${id}/refunds`, op: 'application_fee.refunded',
|
|
158
|
+
tooLarge: (remaining) => `Application fee ${id} can only be refunded up to ${remaining}.`,
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const feeRefunds: Semantics = async (ctx) => {
|
|
163
|
+
const id = at(ctx, 'id');
|
|
164
|
+
if (!ctx.get('application_fee', id)) return feeMissing(ctx, id);
|
|
165
|
+
return list(ctx, 'fee_refund', newest(ctx, 'fee_refund').filter((r) => r.fee === id));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const transfers: Record<string, Semantics> = {
|
|
169
|
+
PostTransfers: create,
|
|
170
|
+
PostTransfersIdReversals: reverse,
|
|
171
|
+
GetTransfersIdReversals: reversals,
|
|
172
|
+
PostApplicationFeesIdRefunds: refundFee,
|
|
173
|
+
GetApplicationFeesIdRefunds: feeRefunds,
|
|
174
|
+
};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Treasury semantics: financial accounts and the flows that move their money (outbound payments
|
|
2
|
+
// and transfers, inbound transfers), each posting a double entry to the account's ledger (a
|
|
3
|
+
// transaction and its entry). An outbound flow stays processing (cancelable) until the network
|
|
4
|
+
// settles it; an inbound transfer settles at once, as in test mode. The machines in
|
|
5
|
+
// ../manifest.ts say which flows cancel. Lists and retrieves with no required scope are the
|
|
6
|
+
// derived core's.
|
|
7
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
8
|
+
import { PLATFORM_ACCOUNT_ID, validateMoney } from '../stripe-twin.ts';
|
|
9
|
+
import { at, created, fail, list, newest, send, where, type Row } from './shared.ts';
|
|
10
|
+
|
|
11
|
+
const FA = 'treasury.financial_account';
|
|
12
|
+
const TX = 'treasury.transaction';
|
|
13
|
+
const ENTRY = 'treasury.transaction_entry';
|
|
14
|
+
const ENTRY_TYPES = new Set(['credit_reversal', 'debit_reversal', 'inbound_transfer', 'inbound_transfer_return', 'issuing_authorization_hold', 'issuing_authorization_release', 'outbound_payment', 'outbound_transfer', 'received_credit', 'received_debit']);
|
|
15
|
+
|
|
16
|
+
const faMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such financial account: '${id}'`, status, 'resource_missing');
|
|
17
|
+
const metadataOf = (ctx: SemanticsContext): unknown => (ctx.params.metadata && typeof ctx.params.metadata === 'object' ? ctx.params.metadata : {});
|
|
18
|
+
|
|
19
|
+
/** A financial account's features as the served spec's treasury.financial_account_features gives them: a toggle
|
|
20
|
+
* (card_issuing, deposit_insurance, intra_stripe_flows) or a feature per network (financial_addresses.aba,
|
|
21
|
+
* inbound_transfers.ach, outbound_payments.ach and .us_domestic_wire, outbound_transfers likewise), each
|
|
22
|
+
* { requested, status, status_details }. What a request asks for is laid over what the account had. Where the
|
|
23
|
+
* documentation stops and the twin decides: a requested feature is active at once (test mode), and one never requested
|
|
24
|
+
* is left out (each is optional in the served spec). Answers the features and the active features' paths. */
|
|
25
|
+
const NETWORKS: Record<string, string[]> = { financial_addresses: ['aba'], inbound_transfers: ['ach'], outbound_payments: ['ach', 'us_domestic_wire'], outbound_transfers: ['ach', 'us_domestic_wire'] };
|
|
26
|
+
const TOGGLES = ['card_issuing', 'deposit_insurance', 'intra_stripe_flows'];
|
|
27
|
+
function featuresOf(requested: Row, had: Row = {}): { features: Row; active: string[] } {
|
|
28
|
+
const on = (v: unknown): boolean => v === true || v === 'true';
|
|
29
|
+
const setting = (asked: boolean) => ({ requested: asked, status: asked ? 'active' : 'restricted', status_details: [] });
|
|
30
|
+
const features: Row = { ...had };
|
|
31
|
+
for (const t of TOGGLES) {
|
|
32
|
+
const r = requested[t] as Row | undefined;
|
|
33
|
+
if (r && typeof r === 'object' && r.requested !== undefined) features[t] = setting(on(r.requested));
|
|
34
|
+
}
|
|
35
|
+
for (const [f, nets] of Object.entries(NETWORKS)) {
|
|
36
|
+
const r = requested[f] as Row | undefined;
|
|
37
|
+
if (!r || typeof r !== 'object') continue;
|
|
38
|
+
const current = (features[f] as Row | undefined) ?? {};
|
|
39
|
+
const next: Row = { ...current };
|
|
40
|
+
for (const n of nets) {
|
|
41
|
+
const nr = r[n] as Row | undefined;
|
|
42
|
+
if (nr && typeof nr === 'object' && nr.requested !== undefined) next[n] = setting(on(nr.requested));
|
|
43
|
+
}
|
|
44
|
+
features[f] = next;
|
|
45
|
+
}
|
|
46
|
+
const active: string[] = [];
|
|
47
|
+
for (const t of TOGGLES) if ((features[t] as Row | undefined)?.status === 'active') active.push(t);
|
|
48
|
+
for (const [f, nets] of Object.entries(NETWORKS)) for (const n of nets) if (((features[f] as Row | undefined)?.[n] as Row | undefined)?.status === 'active') active.push(`${f}.${n}`);
|
|
49
|
+
return { features, active };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The account's bank address once financial_addresses.aba is active: "The FinancialAccount gains a FinancialAddress
|
|
53
|
+
* when the `financial_addresses.aba` feature is active", and "A FinancialAddress is not added until the
|
|
54
|
+
* financial_addresses.aba feature has been activated" (docs.stripe.com/treasury/account-management/financial-accounts),
|
|
55
|
+
* whose test example answers "bank_name": "Stripe Test Bank", "routing_number": "000000001" and the networks "ach",
|
|
56
|
+
* "us_domestic_wire", "rtp"; "The full account_number is only returned if the request expands it" (the same page).
|
|
57
|
+
* Where the documentation stops and the twin decides: the account number is ten digits drawn from the account's id,
|
|
58
|
+
* answered by its last four (the full number is null: expanding it is not modelled), and the holder's name is the
|
|
59
|
+
* platform account's business name, its dashboard name or, with neither, its id. */
|
|
60
|
+
function abaAddress(ctx: SemanticsContext, faId: string): Row {
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const ch of faId) n = (n * 31 + ch.charCodeAt(0)) % 10_000_000_000;
|
|
63
|
+
const number = String(n).padStart(10, '0');
|
|
64
|
+
const platform = ctx.get('account', PLATFORM_ACCOUNT_ID) as Row | undefined;
|
|
65
|
+
const name = ((platform?.business_profile as Row | undefined)?.name ?? ((platform?.settings as Row | undefined)?.dashboard as Row | undefined)?.display_name ?? PLATFORM_ACCOUNT_ID) as string;
|
|
66
|
+
return { type: 'aba', supported_networks: ['ach', 'us_domestic_wire', 'rtp'], aba: { account_holder_name: name, account_number: null, account_number_last4: number.slice(-4), bank_name: 'Stripe Test Bank', routing_number: '000000001' } };
|
|
67
|
+
}
|
|
68
|
+
const addressesOf = (ctx: SemanticsContext, faId: string, active: string[]): Row[] => (active.includes('financial_addresses.aba') ? [abaAddress(ctx, faId)] : []);
|
|
69
|
+
|
|
70
|
+
// an account opens with a zero balance in each supported currency and the requested features active
|
|
71
|
+
const open: Semantics = async (ctx) => {
|
|
72
|
+
const p = ctx.params;
|
|
73
|
+
const currencies = Array.isArray(p.supported_currencies) ? p.supported_currencies.map(String) : typeof p.supported_currencies === 'string' ? [p.supported_currencies] : [];
|
|
74
|
+
if (currencies.length === 0) return fail(ctx, 'Missing required param: supported_currencies.', 400, 'parameter_missing');
|
|
75
|
+
const requested = p.features && typeof p.features === 'object' ? (p.features as Row) : {};
|
|
76
|
+
const { features, active } = featuresOf(requested);
|
|
77
|
+
const zero = Object.fromEntries(currencies.map((c) => [c, 0]));
|
|
78
|
+
const id = ctx.mint(FA);
|
|
79
|
+
return ctx.reply(
|
|
80
|
+
await created(ctx, FA, { id }, {
|
|
81
|
+
livemode: false, status: 'open', country: (p.country as string) ?? 'US',
|
|
82
|
+
supported_currencies: currencies, active_features: active, pending_features: [], restricted_features: [],
|
|
83
|
+
features, balance: { cash: zero, inbound_pending: zero, outbound_pending: zero },
|
|
84
|
+
financial_addresses: addressesOf(ctx, id, active), platform_restrictions: null,
|
|
85
|
+
status_details: { closed: null },
|
|
86
|
+
metadata: metadataOf(ctx),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// reading the features answers them; updating them lays the request over them (featuresOf)
|
|
92
|
+
const features: Semantics = async (ctx) => {
|
|
93
|
+
const id = at(ctx, 'financial_account');
|
|
94
|
+
const f = ctx.get(FA, id);
|
|
95
|
+
if (!f) return faMissing(ctx, id);
|
|
96
|
+
if (ctx.call.request.method === 'GET') return ctx.reply({ object: 'treasury.financial_account_features', ...(f.features as object) });
|
|
97
|
+
const next = featuresOf(ctx.params, (f.features as Row | undefined) ?? {});
|
|
98
|
+
const updated = await ctx.write(FA, id, { features: next.features, active_features: next.active, financial_addresses: addressesOf(ctx, id, next.active) }, 'financial_account.features_updated');
|
|
99
|
+
return ctx.reply({ object: 'treasury.financial_account_features', ...(updated.features as object) });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// only metadata and platform restrictions change
|
|
103
|
+
const updateAccount: Semantics = async (ctx) => {
|
|
104
|
+
const id = at(ctx, 'financial_account');
|
|
105
|
+
if (!ctx.get(FA, id)) return faMissing(ctx, id);
|
|
106
|
+
const patch: Row = {};
|
|
107
|
+
if (ctx.params.metadata !== undefined) patch.metadata = ctx.params.metadata;
|
|
108
|
+
if (ctx.params.platform_restrictions !== undefined) patch.platform_restrictions = ctx.params.platform_restrictions;
|
|
109
|
+
return ctx.reply(await ctx.write(FA, id, patch, 'financial_account.updated'));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Post a flow's double entry: a ledger transaction with its net balance impact, and its one entry. */
|
|
113
|
+
async function post(ctx: SemanticsContext, o: {
|
|
114
|
+
financial_account: string; amount: number; currency: string; flow: string; flow_type: string;
|
|
115
|
+
status: 'open' | 'posted'; balance_impact: Row; description: string; flowDetailKey?: string;
|
|
116
|
+
}): Promise<string> {
|
|
117
|
+
const now = ctx.now();
|
|
118
|
+
const txnId = ctx.mint(TX);
|
|
119
|
+
await ctx.write(TX, txnId, {
|
|
120
|
+
object: 'treasury.transaction', created: now, livemode: false,
|
|
121
|
+
amount: o.amount, currency: o.currency, financial_account: o.financial_account,
|
|
122
|
+
flow: o.flow, flow_type: o.flow_type, flow_details: null,
|
|
123
|
+
status: o.status, balance_impact: o.balance_impact, description: o.description,
|
|
124
|
+
entries: { object: 'list', has_more: false, url: `/v1/treasury/transaction_entries?transaction=${txnId}`, data: [] },
|
|
125
|
+
status_transitions: { posted_at: o.status === 'posted' ? now : null, void_at: null },
|
|
126
|
+
}, 'treasury_transaction.created');
|
|
127
|
+
await ctx.write(ENTRY, ctx.mint(ENTRY), {
|
|
128
|
+
object: 'treasury.transaction_entry', created: now, livemode: false,
|
|
129
|
+
amount: o.amount, currency: o.currency, financial_account: o.financial_account,
|
|
130
|
+
flow: o.flow, flow_type: o.flow_type,
|
|
131
|
+
flow_details: o.flowDetailKey ? { type: o.flowDetailKey, [o.flowDetailKey]: o.flow } : null,
|
|
132
|
+
transaction: txnId, effective_at: now,
|
|
133
|
+
balance_impact: o.balance_impact,
|
|
134
|
+
// what the entry records: its flow's kind where Stripe names one (docs.stripe.com/api/treasury/transaction_entries/object);
|
|
135
|
+
// a vendor `type`, kept apart from the kernel's row type
|
|
136
|
+
_stripe_type: ENTRY_TYPES.has(String(o.flow_type)) ? o.flow_type : 'other',
|
|
137
|
+
}, 'treasury_transaction_entry.created');
|
|
138
|
+
// the account's balance moves by the entry's impact: cash "Funds the user can spend right now", inbound_pending
|
|
139
|
+
// "Funds not spendable yet", outbound_pending "held for pending outbound flows" (the served spec's balance)
|
|
140
|
+
const account = ctx.get(FA, o.financial_account);
|
|
141
|
+
if (account) {
|
|
142
|
+
const bal = (account.balance as Record<string, Record<string, number>> | undefined) ?? { cash: {}, inbound_pending: {}, outbound_pending: {} };
|
|
143
|
+
const moved = Object.fromEntries((['cash', 'inbound_pending', 'outbound_pending'] as const).map((k) => [k, { ...(bal[k] ?? {}), [o.currency]: ((bal[k] ?? {})[o.currency] ?? 0) + (Number(o.balance_impact[k]) || 0) }]));
|
|
144
|
+
await ctx.write(FA, o.financial_account, { balance: moved }, 'financial_account.balance_updated');
|
|
145
|
+
}
|
|
146
|
+
return txnId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The request's funding account, or the refusal. */
|
|
150
|
+
function fundingAccount(ctx: SemanticsContext): string | Response {
|
|
151
|
+
const bad = validateMoney(ctx.params);
|
|
152
|
+
if (bad) return send(ctx, bad);
|
|
153
|
+
const fa = typeof ctx.params.financial_account === 'string' ? ctx.params.financial_account : '';
|
|
154
|
+
if (!fa) return fail(ctx, 'Missing required param: financial_account.', 400, 'parameter_missing');
|
|
155
|
+
return ctx.get(FA, fa) ? fa : faMissing(ctx, fa, 400);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const noTransitions = { canceled_at: null, failed_at: null, posted_at: null, returned_at: null };
|
|
159
|
+
|
|
160
|
+
/** Where an outbound flow sends its money, as the served spec's payment method details give it: the bank account's
|
|
161
|
+
* holder type, last four digits and routing number from what the request sent (a saved method's, or the test bank's
|
|
162
|
+
* when none is given), and its billing details (required): the name and email sent, the address lines null where not
|
|
163
|
+
* given. Where the documentation stops and the twin decides: a saved method's details are the test bank's. */
|
|
164
|
+
function destinationDetails(ctx: SemanticsContext, dest: string | null): Row {
|
|
165
|
+
const data = ctx.params.destination_payment_method_data && typeof ctx.params.destination_payment_method_data === 'object' ? (ctx.params.destination_payment_method_data as Row) : {};
|
|
166
|
+
const bank = data.us_bank_account && typeof data.us_bank_account === 'object' ? (data.us_bank_account as Row) : {};
|
|
167
|
+
const billing = data.billing_details && typeof data.billing_details === 'object' ? (data.billing_details as Row) : {};
|
|
168
|
+
const address = billing.address && typeof billing.address === 'object' ? (billing.address as Row) : {};
|
|
169
|
+
const number = typeof bank.account_number === 'string' ? bank.account_number.replace(/\D/g, '') : '';
|
|
170
|
+
const billing_details = { name: billing.name ?? null, email: billing.email ?? null, address: Object.fromEntries(['city', 'country', 'line1', 'line2', 'postal_code', 'state'].map((k) => [k, address[k] ?? null])) };
|
|
171
|
+
if (dest && data.type === undefined && dest.startsWith('fa_')) return { type: 'financial_account', financial_account: { id: dest, network: 'stripe' }, billing_details };
|
|
172
|
+
return {
|
|
173
|
+
type: 'us_bank_account', billing_details,
|
|
174
|
+
us_bank_account: { last4: number ? number.slice(-4) : '6789', network: 'ach', routing_number: typeof bank.routing_number === 'string' ? bank.routing_number : '110000000', account_holder_type: typeof bank.account_holder_type === 'string' ? bank.account_holder_type : 'individual', account_type: typeof bank.account_type === 'string' ? bank.account_type : 'checking', bank_name: 'STRIPE TEST BANK', mandate: null },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An outbound payment that names no destination: "You must provide either destination_payment_method or
|
|
179
|
+
* destination_payment_method_data" (the served spec's outbound payment create). */
|
|
180
|
+
function noDestination(ctx: SemanticsContext): Response {
|
|
181
|
+
return fail(ctx, 'You must provide either `destination_payment_method` or `destination_payment_method_data`.', 400, 'parameter_missing');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const outboundPayment: Semantics = async (ctx) => {
|
|
185
|
+
const fa = fundingAccount(ctx);
|
|
186
|
+
if (fa instanceof Response) return fa;
|
|
187
|
+
const dest = typeof ctx.params.destination_payment_method === 'string' ? ctx.params.destination_payment_method : null;
|
|
188
|
+
if (!dest && !(ctx.params.destination_payment_method_data && typeof ctx.params.destination_payment_method_data === 'object')) return noDestination(ctx);
|
|
189
|
+
const amount = ctx.params.amount as number;
|
|
190
|
+
const currency = String(ctx.params.currency);
|
|
191
|
+
const id = ctx.mint('treasury.outbound_payment');
|
|
192
|
+
const txn = await post(ctx, { financial_account: fa, amount: -amount, currency, flow: id, flow_type: 'outbound_payment', status: 'open', balance_impact: { cash: -amount, inbound_pending: 0, outbound_pending: amount }, description: 'OutboundPayment' });
|
|
193
|
+
return ctx.reply(
|
|
194
|
+
await created(ctx, 'treasury.outbound_payment', { id }, {
|
|
195
|
+
amount, currency, financial_account: fa, livemode: false, status: 'processing', cancelable: true,
|
|
196
|
+
destination_payment_method: dest,
|
|
197
|
+
destination_payment_method_details: destinationDetails(ctx, dest),
|
|
198
|
+
destination_payment_method_data: null, end_user_details: null, expected_arrival_date: Number(ctx.now()) + 2 * 86400,
|
|
199
|
+
hosted_regulatory_receipt_url: null, returned_details: null, statement_descriptor: (ctx.params.statement_descriptor as string) ?? 'payment',
|
|
200
|
+
description: typeof ctx.params.description === 'string' ? ctx.params.description : null, transaction: txn, status_transitions: noTransitions, metadata: metadataOf(ctx),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const outboundTransfer: Semantics = async (ctx) => {
|
|
206
|
+
const fa = fundingAccount(ctx);
|
|
207
|
+
if (fa instanceof Response) return fa;
|
|
208
|
+
const dest = typeof ctx.params.destination_payment_method === 'string' ? ctx.params.destination_payment_method : null;
|
|
209
|
+
const amount = ctx.params.amount as number;
|
|
210
|
+
const currency = String(ctx.params.currency);
|
|
211
|
+
const id = ctx.mint('treasury.outbound_transfer');
|
|
212
|
+
const txn = await post(ctx, { financial_account: fa, amount: -amount, currency, flow: id, flow_type: 'outbound_transfer', status: 'open', balance_impact: { cash: -amount, inbound_pending: 0, outbound_pending: amount }, description: 'OutboundTransfer' });
|
|
213
|
+
return ctx.reply(
|
|
214
|
+
await created(ctx, 'treasury.outbound_transfer', { id }, {
|
|
215
|
+
amount, currency, financial_account: fa, livemode: false, status: 'processing', cancelable: true,
|
|
216
|
+
destination_payment_method: dest, destination_payment_method_details: destinationDetails(ctx, dest),
|
|
217
|
+
expected_arrival_date: Number(ctx.now()) + 2 * 86400, hosted_regulatory_receipt_url: null,
|
|
218
|
+
returned_details: null, statement_descriptor: (ctx.params.statement_descriptor as string) ?? 'transfer',
|
|
219
|
+
description: typeof ctx.params.description === 'string' ? ctx.params.description : null, transaction: txn, status_transitions: noTransitions, metadata: metadataOf(ctx),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** The bank account an inbound transfer may pull from: "You must first set up the account-attached payment method for
|
|
225
|
+
* inbound flows and verify the bank account using a SetupIntent", and invalid ones ("of unsupported types, containing
|
|
226
|
+
* an unverified bank account, or not set up for inbound flows") "throw the same errors as in live mode"
|
|
227
|
+
* (docs.stripe.com/treasury/moving-money/financial-accounts/into/inbound-transfers). A payment method is set up for
|
|
228
|
+
* inbound flows by a SetupIntent whose flow_directions holds `inbound`, and verified when that SetupIntent succeeded
|
|
229
|
+
* ("The bank account has been instantly verified or verification isn't necessary", its `succeeded` status,
|
|
230
|
+
* docs.stripe.com/treasury/connect/legacy/v1/moving-money/working-with-bankaccount-objects).
|
|
231
|
+
* Where the documentation stops and the twin decides: the page quotes no error, so the twin answers the documented code
|
|
232
|
+
* for the class, payment_method_unexpected_state ("The provided payment method's state was incompatible with the
|
|
233
|
+
* operation you were trying to perform", docs.stripe.com/error-codes), in its own words; an existing verified
|
|
234
|
+
* BankAccount (`ba_`), which the page also allows, is not checked. Answers the payment method, or the refusal. */
|
|
235
|
+
function inboundOrigin(ctx: SemanticsContext, origin: string): Row | Response | undefined {
|
|
236
|
+
if (origin.startsWith('ba_')) return undefined;
|
|
237
|
+
const pm = ctx.get('payment_method', origin);
|
|
238
|
+
if (!pm) return fail(ctx, `No such PaymentMethod: '${origin}'`, 400, 'resource_missing');
|
|
239
|
+
if (pm.type !== 'us_bank_account') return fail(ctx, `The PaymentMethod '${origin}' is of type ${String(pm.type)}; an InboundTransfer pulls from a us_bank_account.`, 400, 'payment_method_unexpected_state');
|
|
240
|
+
const setUp = ctx.rows('setup_intent').some((si) => si.payment_method === origin && si.status === 'succeeded' && Array.isArray(si.flow_directions) && si.flow_directions.map(String).includes('inbound'));
|
|
241
|
+
if (!setUp) return fail(ctx, `The PaymentMethod '${origin}' has not been set up for inbound flows and verified with a SetupIntent.`, 400, 'payment_method_unexpected_state');
|
|
242
|
+
return pm;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const inboundTransfer: Semantics = async (ctx) => {
|
|
246
|
+
const fa = fundingAccount(ctx);
|
|
247
|
+
if (fa instanceof Response) return fa;
|
|
248
|
+
const origin = typeof ctx.params.origin_payment_method === 'string' ? ctx.params.origin_payment_method : '';
|
|
249
|
+
if (!origin) return fail(ctx, 'Missing required param: origin_payment_method.', 400, 'parameter_missing');
|
|
250
|
+
const pm = inboundOrigin(ctx, origin);
|
|
251
|
+
if (pm instanceof Response) return pm;
|
|
252
|
+
const bank = (pm?.us_bank_account ?? {}) as Row;
|
|
253
|
+
const billing = (pm?.billing_details ?? {}) as Row;
|
|
254
|
+
// the debit mandate the account's verification made (docs.stripe.com/payments/setup-intents#mandates)
|
|
255
|
+
const mandate = ctx.rows('setup_intent').find((si) => si.payment_method === origin && si.status === 'succeeded' && typeof si.mandate === 'string')?.mandate;
|
|
256
|
+
const amount = ctx.params.amount as number;
|
|
257
|
+
const currency = String(ctx.params.currency);
|
|
258
|
+
const id = ctx.mint('treasury.inbound_transfer');
|
|
259
|
+
// it starts processing, its funds pending inbound, until confirmed (succeedInbound)
|
|
260
|
+
const txn = await post(ctx, { financial_account: fa, amount, currency, flow: id, flow_type: 'inbound_transfer', status: 'open', balance_impact: { cash: 0, inbound_pending: amount, outbound_pending: 0 }, description: 'InboundTransfer', flowDetailKey: 'inbound_transfer' });
|
|
261
|
+
return ctx.reply(
|
|
262
|
+
await created(ctx, 'treasury.inbound_transfer', { id }, {
|
|
263
|
+
amount, currency, financial_account: fa, origin_payment_method: origin, livemode: false,
|
|
264
|
+
status: 'processing', cancelable: true, returned: null,
|
|
265
|
+
// the pulled account as its payment method holds it
|
|
266
|
+
origin_payment_method_details: {
|
|
267
|
+
type: 'us_bank_account',
|
|
268
|
+
us_bank_account: { last4: bank.last4 ?? '6789', routing_number: bank.routing_number ?? '110000000', bank_name: bank.bank_name ?? 'STRIPE TEST BANK', account_holder_type: bank.account_holder_type ?? null, account_type: bank.account_type ?? null, fingerprint: bank.fingerprint ?? null, ...(mandate ? { mandate } : {}), network: 'ach' },
|
|
269
|
+
billing_details: { address: billing.address ?? {}, email: billing.email ?? null, name: billing.name ?? null },
|
|
270
|
+
},
|
|
271
|
+
failure_details: null, hosted_regulatory_receipt_url: null,
|
|
272
|
+
linked_flows: { received_debit: null }, statement_descriptor: (ctx.params.statement_descriptor as string) ?? 'transfer',
|
|
273
|
+
description: typeof ctx.params.description === 'string' ? ctx.params.description : null, transaction: txn, status_transitions: { succeeded_at: null, failed_at: null, canceled_at: null },
|
|
274
|
+
metadata: metadataOf(ctx),
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// test mode's stand-ins for money another party sends to the account or pulls from it
|
|
280
|
+
// (docs.stripe.com/treasury/moving-money/receiving-funds, docs.stripe.com/api/treasury/received_credits/test_mode_create)
|
|
281
|
+
function received(kind: 'received_credit' | 'received_debit'): Semantics {
|
|
282
|
+
return async (ctx) => {
|
|
283
|
+
const fa = fundingAccount(ctx);
|
|
284
|
+
if (fa instanceof Response) return fa;
|
|
285
|
+
const network = typeof ctx.params.network === 'string' ? ctx.params.network : '';
|
|
286
|
+
if (!['ach', 'us_domestic_wire', 'stripe'].includes(network)) return fail(ctx, 'Missing required param: network.', 400, 'parameter_missing');
|
|
287
|
+
const amount = ctx.params.amount as number;
|
|
288
|
+
const currency = String(ctx.params.currency);
|
|
289
|
+
const id = ctx.mint(`treasury.${kind}`);
|
|
290
|
+
const signed = kind === 'received_credit' ? amount : -amount;
|
|
291
|
+
const txn = await post(ctx, { financial_account: fa, amount: signed, currency, flow: id, flow_type: kind, status: 'posted', balance_impact: { cash: signed, inbound_pending: 0, outbound_pending: 0 }, description: kind === 'received_credit' ? 'ReceivedCredit' : 'ReceivedDebit', flowDetailKey: kind });
|
|
292
|
+
return ctx.reply(
|
|
293
|
+
await created(ctx, `treasury.${kind}`, { id }, {
|
|
294
|
+
amount, currency, financial_account: fa, network, livemode: false, status: 'succeeded', failure_code: null,
|
|
295
|
+
description: typeof ctx.params.description === 'string' ? ctx.params.description : kind === 'received_credit' ? 'Received credit' : 'Received debit',
|
|
296
|
+
hosted_regulatory_receipt_url: null, transaction: txn, reversal_details: null,
|
|
297
|
+
initiating_payment_method_details: { type: 'us_bank_account', billing_details: { address: {}, email: null, name: null }, us_bank_account: { bank_name: 'STRIPE TEST BANK', last4: '6789', routing_number: '110000000' } },
|
|
298
|
+
linked_flows: kind === 'received_credit' ? { credit_reversal: null, issuing_authorization: null, issuing_transaction: null, source_flow: null, source_flow_type: null } : { debit_reversal: null, inbound_transfer: null, issuing_authorization: null, issuing_transaction: null, payout: null },
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Test mode's confirmation of a processing inbound transfer: it succeeds, its transaction posts and the pending funds
|
|
305
|
+
* become cash ("The status changes to succeeded once the funds have been "confirmed" and a transaction is created and
|
|
306
|
+
* posted", docs.stripe.com/api/treasury/inbound_transfers). */
|
|
307
|
+
const succeedInbound: Semantics = async (ctx) => {
|
|
308
|
+
const id = at(ctx, 'id');
|
|
309
|
+
const flow = ctx.get('treasury.inbound_transfer', id);
|
|
310
|
+
if (!flow) return fail(ctx, `No such inbound transfer: '${id}'`, 404, 'resource_missing');
|
|
311
|
+
const refused = ctx.legal('treasury.inbound_transfer', 'status', 'PostTestHelpersTreasuryInboundTransfersIdSucceed', flow.status, 'succeeded', id);
|
|
312
|
+
if (refused) return ctx.refuse(refused);
|
|
313
|
+
const txn = typeof flow.transaction === 'string' ? ctx.get(TX, flow.transaction) : undefined;
|
|
314
|
+
const amount = Number(flow.amount) || 0;
|
|
315
|
+
const cur = String(flow.currency ?? 'usd');
|
|
316
|
+
if (txn && txn.status === 'open') {
|
|
317
|
+
await ctx.write(TX, String(txn.id), { status: 'posted', balance_impact: { cash: amount, inbound_pending: 0, outbound_pending: 0 }, status_transitions: { ...(txn.status_transitions as object), posted_at: ctx.now() } }, 'treasury_transaction.posted');
|
|
318
|
+
const account = ctx.get(FA, String(txn.financial_account));
|
|
319
|
+
if (account) {
|
|
320
|
+
const bal = (account.balance as Record<string, Record<string, number>> | undefined) ?? { cash: {}, inbound_pending: {}, outbound_pending: {} };
|
|
321
|
+
await ctx.write(FA, String(account.id), { balance: { ...bal, cash: { ...(bal.cash ?? {}), [cur]: ((bal.cash ?? {})[cur] ?? 0) + amount }, inbound_pending: { ...(bal.inbound_pending ?? {}), [cur]: ((bal.inbound_pending ?? {})[cur] ?? 0) - amount } } }, 'financial_account.balance_updated');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return ctx.reply(await ctx.write('treasury.inbound_transfer', id, { status: 'succeeded', cancelable: false, status_transitions: { ...(flow.status_transitions as object), succeeded_at: ctx.now() } }, 'inbound_transfer.succeeded'));
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Cancel a flow still processing; an outbound one must also still be cancelable. */
|
|
328
|
+
function cancel(resource: string, param: string, name: string, operationId: string, op: string, checkCancelable: boolean): Semantics {
|
|
329
|
+
return async (ctx) => {
|
|
330
|
+
const id = at(ctx, param);
|
|
331
|
+
const flow = ctx.get(resource, id);
|
|
332
|
+
if (!flow) return fail(ctx, `No such ${name}: '${id}'`, 404, 'resource_missing');
|
|
333
|
+
// a processing flow that is no longer cancelable is refused as one no longer processing
|
|
334
|
+
const refused = ctx.legal(resource, 'status', operationId, checkCancelable && flow.cancelable !== true ? 'not_cancelable' : flow.status, undefined, id);
|
|
335
|
+
if (refused) return ctx.refuse(refused);
|
|
336
|
+
// a canceled flow's open transaction is voided and what it held returns to the account (the reverse of its
|
|
337
|
+
// balance impact). Where the documentation stops and the twin decides: a flow already posted is only marked canceled
|
|
338
|
+
const txn = typeof flow.transaction === 'string' ? ctx.get(TX, flow.transaction) : undefined;
|
|
339
|
+
if (txn && txn.status === 'open') {
|
|
340
|
+
await ctx.write(TX, String(txn.id), { status: 'void', status_transitions: { ...(txn.status_transitions as object), void_at: ctx.now() } }, 'treasury_transaction.voided');
|
|
341
|
+
const account = ctx.get(FA, String(txn.financial_account));
|
|
342
|
+
const impact = (txn.balance_impact as Record<string, number> | undefined) ?? {};
|
|
343
|
+
const cur = String(txn.currency ?? 'usd');
|
|
344
|
+
if (account) {
|
|
345
|
+
const bal = (account.balance as Record<string, Record<string, number>> | undefined) ?? { cash: {}, inbound_pending: {}, outbound_pending: {} };
|
|
346
|
+
const back = Object.fromEntries((['cash', 'inbound_pending', 'outbound_pending'] as const).map((k) => [k, { ...(bal[k] ?? {}), [cur]: ((bal[k] ?? {})[cur] ?? 0) - (Number(impact[k]) || 0) }]));
|
|
347
|
+
await ctx.write(FA, String(account.id), { balance: back }, 'financial_account.balance_updated');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return ctx.reply(await ctx.write(resource, id, { status: 'canceled', cancelable: false, status_transitions: { ...(flow.status_transitions as object), canceled_at: ctx.now() } }, op));
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// the ledger is always read for one account
|
|
355
|
+
function ledger(resource: string, spec: Record<string, (r: Row, v: unknown) => boolean>): Semantics {
|
|
356
|
+
return async (ctx) => {
|
|
357
|
+
if (typeof ctx.params.financial_account !== 'string' || !ctx.params.financial_account) return fail(ctx, 'Missing required param: financial_account.', 400, 'parameter_missing');
|
|
358
|
+
return list(ctx, resource, where(ctx, newest(ctx, resource), spec));
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const treasury: Record<string, Semantics> = {
|
|
363
|
+
PostTreasuryFinancialAccounts: open,
|
|
364
|
+
GetTreasuryFinancialAccountsFinancialAccountFeatures: features,
|
|
365
|
+
PostTreasuryFinancialAccountsFinancialAccountFeatures: features,
|
|
366
|
+
PostTreasuryFinancialAccountsFinancialAccount: updateAccount,
|
|
367
|
+
PostTreasuryOutboundPayments: outboundPayment,
|
|
368
|
+
PostTreasuryOutboundPaymentsIdCancel: cancel('treasury.outbound_payment', 'id', 'outbound payment', 'PostTreasuryOutboundPaymentsIdCancel', 'outbound_payment.canceled', true),
|
|
369
|
+
PostTreasuryOutboundTransfers: outboundTransfer,
|
|
370
|
+
PostTreasuryOutboundTransfersOutboundTransferCancel: cancel('treasury.outbound_transfer', 'outbound_transfer', 'outbound transfer', 'PostTreasuryOutboundTransfersOutboundTransferCancel', 'outbound_transfer.canceled', true),
|
|
371
|
+
PostTreasuryInboundTransfers: inboundTransfer,
|
|
372
|
+
PostTestHelpersTreasuryInboundTransfersIdSucceed: succeedInbound,
|
|
373
|
+
PostTestHelpersTreasuryReceivedCredits: received('received_credit'),
|
|
374
|
+
PostTestHelpersTreasuryReceivedDebits: received('received_debit'),
|
|
375
|
+
PostTreasuryInboundTransfersInboundTransferCancel: cancel('treasury.inbound_transfer', 'inbound_transfer', 'inbound transfer', 'PostTreasuryInboundTransfersInboundTransferCancel', 'inbound_transfer.canceled', false),
|
|
376
|
+
GetTreasuryTransactions: ledger(TX, {
|
|
377
|
+
financial_account: (r, v) => r.financial_account === v, status: (r, v) => r.status === v,
|
|
378
|
+
flow: (r, v) => r.flow === v, flow_type: (r, v) => r.flow_type === v,
|
|
379
|
+
}),
|
|
380
|
+
GetTreasuryTransactionEntries: ledger(ENTRY, {
|
|
381
|
+
financial_account: (r, v) => r.financial_account === v, flow: (r, v) => r.flow === v, transaction: (r, v) => r.transaction === v,
|
|
382
|
+
}),
|
|
383
|
+
};
|