@volter/twin-stripe 2.0.0 → 2.0.2
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 +33 -1
- package/dist/src/index.js +6 -4
- package/dist/src/manifest.js +8 -3
- package/dist/src/screens/checkout.js +20 -6
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -0
- package/dist/src/screens/portal.js +2 -0
- package/dist/src/semantics/after-payment.d.ts +1 -1
- package/dist/src/semantics/after-payment.js +6 -0
- package/dist/src/semantics/charges.js +10 -2
- package/dist/src/semantics/checkout.js +19 -6
- package/dist/src/semantics/connect.js +20 -3
- package/dist/src/semantics/invoices.js +4 -0
- package/dist/src/semantics/issuing.js +7 -2
- package/dist/src/semantics/ledger.d.ts +11 -6
- package/dist/src/semantics/ledger.js +40 -21
- package/dist/src/semantics/payment-methods.js +2 -0
- package/dist/src/semantics/shared.d.ts +5 -1
- package/dist/src/semantics/shared.js +14 -3
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/transfers.js +1 -1
- package/dist/src/stripe-capabilities.js +829 -186
- package/dist/src/stripe-conformance.d.ts +2 -0
- package/dist/src/stripe-conformance.js +11 -2
- package/dist/src/stripe-emit.js +2 -2
- package/dist/src/stripe-events.js +14 -10
- package/dist/src/stripe-mirror-ui.js +3 -3
- package/dist/src/stripe-server.js +97 -24
- package/dist/src/stripe-shared.d.ts +3 -0
- package/dist/src/stripe-shared.js +3 -0
- package/dist/src/stripe-twin.js +7 -1
- package/dist/src/stripe-version.d.ts +2 -0
- package/dist/src/stripe-version.js +2 -0
- package/dist/test-fixtures/stripe-known-deviations.json +6 -1
- package/dist/test-fixtures/stripe-schemas.json +85 -12
- package/package.json +4 -4
- package/src/index.ts +6 -4
- package/src/manifest.ts +8 -3
- package/src/screens/checkout.tsx +21 -6
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/portal.tsx +2 -0
- package/src/semantics/after-payment.ts +6 -1
- package/src/semantics/charges.ts +11 -2
- package/src/semantics/checkout.ts +19 -6
- package/src/semantics/connect.ts +19 -3
- package/src/semantics/invoices.ts +4 -0
- package/src/semantics/issuing.ts +7 -2
- package/src/semantics/ledger.ts +60 -23
- package/src/semantics/payment-methods.ts +2 -0
- package/src/semantics/shared.ts +14 -3
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/transfers.ts +1 -1
- package/src/stripe-capabilities.ts +826 -182
- package/src/stripe-conformance.ts +13 -2
- package/src/stripe-emit.ts +2 -2
- package/src/stripe-events.ts +14 -10
- package/src/stripe-mirror-ui.ts +3 -3
- package/src/stripe-server.ts +85 -24
- package/src/stripe-shared.ts +3 -0
- package/src/stripe-twin.ts +6 -1
- package/src/stripe-version.ts +3 -0
- package/test-fixtures/stripe-known-deviations.json +6 -1
- package/test-fixtures/stripe-schemas.json +85 -12
|
@@ -155,9 +155,20 @@ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?:
|
|
|
155
155
|
const link: Row = { status: 'complete' };
|
|
156
156
|
const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
|
|
157
157
|
const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
|
|
158
|
-
// the entered card becomes a PaymentMethod
|
|
159
|
-
//
|
|
160
|
-
const
|
|
158
|
+
// the entered card becomes a PaymentMethod carrying what a later charge to it answers and what Stripe does after it
|
|
159
|
+
// succeeds (docs.stripe.com/testing#declined-payments, #disputes)
|
|
160
|
+
const made = card ? await created(ctx, 'payment_method', {}, { type: 'card', customer: null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null, ...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}) }) : undefined;
|
|
161
|
+
// and Checkout attaches it to the customer when it saves it: a subscription's card ("If your Checkout Session uses
|
|
162
|
+
// subscription mode, Stripe saves the payment method by default", docs.stripe.com/payments/checkout/how-checkout-works),
|
|
163
|
+
// a setup session's (it exists to save one), and a payment's only under payment_intent_data.setup_future_usage ("to have
|
|
164
|
+
// Checkout automatically attach the payment method to the Customer you pass in", docs.stripe.com/api/checkout/sessions/
|
|
165
|
+
// create#create_checkout_session-customer). Attaching is its own write, so payment_method.attached is delivered ("Occurs
|
|
166
|
+
// whenever a new payment method is attached to a customer", docs.stripe.com/api/events/types) before what the card then
|
|
167
|
+
// pays for. Where the documentation stops and the twin decides: the order of the events, which Stripe does not
|
|
168
|
+
// guarantee (docs.stripe.com/webhooks#event-ordering). A gap: the customer's own opt-in to save a payment's card
|
|
169
|
+
// (saved_payment_method_options.payment_method_save) is not modelled; the page offers no such box.
|
|
170
|
+
const saves = existing.mode !== 'payment' || !!((ctx.row(CS, id)?._payment_intent_data ?? {}) as Row).setup_future_usage;
|
|
171
|
+
const pm = made && customer && saves ? await ctx.write('payment_method', String(made.id), { customer }, 'payment_method.attach') : made;
|
|
161
172
|
if (existing.mode === 'payment') {
|
|
162
173
|
const amount = Number(existing.amount_total) || 0;
|
|
163
174
|
const piId = ctx.mint('payment_intent');
|
|
@@ -166,6 +177,8 @@ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?:
|
|
|
166
177
|
...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
|
|
167
178
|
...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: (pid.transfer_data as Row).destination } } : {}),
|
|
168
179
|
...(typeof pid.description === 'string' ? { description: pid.description } : {}),
|
|
180
|
+
// the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
|
|
181
|
+
...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
|
|
169
182
|
...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
|
|
170
183
|
};
|
|
171
184
|
const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
|
|
@@ -219,7 +232,7 @@ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?:
|
|
|
219
232
|
link.invoice = invoice;
|
|
220
233
|
}
|
|
221
234
|
link.payment_status = 'paid';
|
|
222
|
-
} else link.setup_intent = await completeSetup(ctx, customer);
|
|
235
|
+
} else link.setup_intent = await completeSetup(ctx, customer, pm);
|
|
223
236
|
return link;
|
|
224
237
|
}
|
|
225
238
|
|
|
@@ -254,9 +267,9 @@ async function sessionPrices(ctx: SemanticsContext, lines: Row[], inline: Array<
|
|
|
254
267
|
}
|
|
255
268
|
|
|
256
269
|
/** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
|
|
257
|
-
async function completeSetup(ctx: SemanticsContext, customer: string | undefined): Promise<unknown> {
|
|
270
|
+
async function completeSetup(ctx: SemanticsContext, customer: string | undefined, pm: Row | undefined): Promise<unknown> {
|
|
258
271
|
const siId = ctx.mint('setup_intent');
|
|
259
|
-
const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], livemode: false });
|
|
272
|
+
const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], livemode: false });
|
|
260
273
|
return si.id;
|
|
261
274
|
}
|
|
262
275
|
|
package/src/semantics/connect.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
|
7
7
|
import { ACCOUNT_TYPES, accountCapabilities, accountRequirements, accountSettings, asBool, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
8
8
|
import { currentlyDue, kindOf } from '../screens/onboarding.tsx';
|
|
9
9
|
import { at, created, externalList, fail, list, newest, path, syncExternals, type Row } from './shared.ts';
|
|
10
|
+
import { OAUTH_CONNECTIONS } from '../stripe-shared.ts';
|
|
10
11
|
|
|
11
12
|
const accountMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
|
|
12
13
|
/** The account, tombstones included where the hand-written routes looked past a deletion. */
|
|
@@ -27,7 +28,17 @@ export const platformAccountDefault = (): Row => ({
|
|
|
27
28
|
external_accounts: externalList(PLATFORM_ACCOUNT_ID, []), tos_acceptance: { date: null, ip: null, user_agent: null }, business_profile: {},
|
|
28
29
|
settings: accountSettings(undefined), livemode: false,
|
|
29
30
|
});
|
|
30
|
-
|
|
31
|
+
// GET /v1/account answers the account the request acts as: with the Stripe-Account header (or a connected account's OAuth
|
|
32
|
+
// key, which acts as it: stripe-server.ts) that connected account, "Retrieves the details of an account"
|
|
33
|
+
// (docs.stripe.com/api/accounts/retrieve; docs.stripe.com/connect/authentication), else the platform's own
|
|
34
|
+
const platform: Semantics = async (ctx) => {
|
|
35
|
+
const acting = ctx.call.request.headers.get('stripe-account');
|
|
36
|
+
const connected = acting && acting !== PLATFORM_ACCOUNT_ID ? ctx.get('account', acting) : undefined;
|
|
37
|
+
// a Stripe-Account naming no account is refused as Stripe refuses a Stripe-Account the key cannot use (account_invalid,
|
|
38
|
+
// docs.stripe.com/error-codes; stripe-server.ts revokedAccountRefused words it the same)
|
|
39
|
+
if (acting && acting !== PLATFORM_ACCOUNT_ID && !connected) return fail(ctx, `The provided key does not have access to account '${acting}' (or that account does not exist). Application access may have been revoked.`, 403, 'account_invalid');
|
|
40
|
+
return ctx.reply(connected ?? ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
|
|
41
|
+
};
|
|
31
42
|
|
|
32
43
|
/** A new Express account owes what its hosted onboarding will ask for (screens/onboarding.tsx; the US sets
|
|
33
44
|
* docs.stripe.com/connect/required-verification-information lists), or, where the twin does not model the set, the
|
|
@@ -70,8 +81,13 @@ function controllerOf(type: string): Row {
|
|
|
70
81
|
return { type: 'application', is_controller: true, losses: { payments: losses }, fees: { payer }, requirement_collection: collection, stripe_dashboard: { type: dashboard } };
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
// connected accounts only: the platform's own is not one
|
|
74
|
-
|
|
84
|
+
// connected accounts only: the platform's own is not one, and neither is an account whose OAuth connection was revoked,
|
|
85
|
+
// which "can't be accessed by your platform in the Dashboard or through the API" (docs.stripe.com/connect/oauth-reference;
|
|
86
|
+
// its connection row is OAUTH_CONNECTIONS, screens/connect-oauth.tsx)
|
|
87
|
+
const listAccounts: Semantics = async (ctx) => {
|
|
88
|
+
const revoked = new Set(ctx.rowsRaw(OAUTH_CONNECTIONS).filter((c) => c.revoked === true).map((c) => c.id));
|
|
89
|
+
return list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID && !revoked.has(a.id)));
|
|
90
|
+
};
|
|
75
91
|
|
|
76
92
|
/** What a Custom account still owes, from what the platform has given for it, as Stripe's requirements endpoint lists it
|
|
77
93
|
* for a US account with no Stripe Dashboard and the full service agreement
|
|
@@ -314,7 +314,11 @@ const pay: Semantics = async (ctx) => {
|
|
|
314
314
|
if (decline) return send(ctx, cardError(decline, intent ? { payment_intent: intent } : {}));
|
|
315
315
|
const amountPaid = Number(inv.total ?? inv.amount_due) || 0;
|
|
316
316
|
let body = await ctx.write(INV, id, {
|
|
317
|
+
// paid outside Stripe, the invoice counts it as such: `amount_paid_off_stripe`, "Amount, in cents (or local
|
|
318
|
+
// equivalent), that was paid on the invoice outside of Stripe" (served spec, invoice), the only place the served
|
|
319
|
+
// version shows it now that basil renders no `paid_out_of_band` (stripe-version.ts)
|
|
317
320
|
status: 'paid', paid: true, paid_out_of_band: paidOutOfBand, amount_paid: amountPaid, amount_remaining: 0,
|
|
321
|
+
...(paidOutOfBand ? { amount_paid_off_stripe: amountPaid } : {}),
|
|
318
322
|
status_transitions: { ...transitions(inv), paid_at: ctx.now() },
|
|
319
323
|
}, 'invoice.pay');
|
|
320
324
|
if (!paidOutOfBand && typeof inv.payment_intent === 'string' && inv.payment_intent) {
|
package/src/semantics/issuing.ts
CHANGED
|
@@ -393,10 +393,15 @@ const captureAuthorization: Semantics = async (ctx) => {
|
|
|
393
393
|
const remaining = (Number(auth.amount) || 0) - capturedSoFar;
|
|
394
394
|
const captureAmount = ctx.params.capture_amount !== undefined ? Math.trunc(Number(ctx.params.capture_amount) || 0) : remaining;
|
|
395
395
|
if (captureAmount <= 0 || captureAmount > remaining) return fail(ctx, 'Invalid capture_amount: must be a positive integer no greater than the uncaptured authorized amount.', 400, 'parameter_invalid_integer');
|
|
396
|
-
// the capture releases what the approval held and debits the transaction
|
|
396
|
+
// the capture releases what the approval held and debits the transaction; a capture that closes the authorization
|
|
397
|
+
// releases ALL it still holds, the uncaptured rest included: the amount is held "until the authorization is either
|
|
398
|
+
// captured, voided, or expired without capture" (docs.stripe.com/issuing/purchases/authorizations), and a closed
|
|
399
|
+
// authorization can be captured no further (close_authorization "Defaults to true. Set to false to enable
|
|
400
|
+
// multi-capture flows", the served spec). A capture that keeps it open releases only what it captured.
|
|
397
401
|
const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
|
|
402
|
+
const released = closeAuthorization ? remaining : captureAmount;
|
|
398
403
|
const release = await created(ctx, 'balance_transaction', {}, {
|
|
399
|
-
amount:
|
|
404
|
+
amount: released, currency, fee: 0, net: released, type: 'issuing_authorization_release',
|
|
400
405
|
status: 'available', balance_type: 'issuing', reporting_category: 'issuing_authorization_release',
|
|
401
406
|
available_on: ctx.now(), fee_details: [], source: id,
|
|
402
407
|
});
|
package/src/semantics/ledger.ts
CHANGED
|
@@ -6,8 +6,29 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Where the documentation stops and the twin decides: the fee is Stripe's standard US card pricing, 2.9% + 30¢
|
|
8
8
|
// (stripe.com/pricing), for every charge; funds become available two days after capture (Stripe's standard US
|
|
9
|
-
// payout schedule counts business days)
|
|
10
|
-
//
|
|
9
|
+
// payout schedule counts business days).
|
|
10
|
+
//
|
|
11
|
+
// Test mode (the twin serves livemode: false), as docs.stripe.com/testing documents it:
|
|
12
|
+
// - cards keep that delay but two: "Other test cards send funds from a successful payment to your pending balance",
|
|
13
|
+
// while 4000000000000077 and 4000003720000278 (and their test names pm_card_bypassPending,
|
|
14
|
+
// pm_card_bypassPendingInternational, tok_bypassPending, tok_bypassPendingInternational) "succeed. Funds are added
|
|
15
|
+
// directly to your available balance, bypassing your pending balance" (#available-balance; test-cards.ts). A
|
|
16
|
+
// PaymentMethod made from one keeps that (payment-methods.ts, after-payment.ts), so a saved card charged later by
|
|
17
|
+
// id bypasses too;
|
|
18
|
+
// - a US bank account debit: "Test transactions settle instantly and are added to your available test balance. This
|
|
19
|
+
// behavior differs from live mode" (ACH Direct Debit, "Test settlement behavior");
|
|
20
|
+
// - a source_transaction transfer "takes on the pending status of the associated charge"
|
|
21
|
+
// (docs.stripe.com/connect/separate-charges-and-transfers), so it is available at once when its charge is;
|
|
22
|
+
// - "Test payouts simulate a live payout but aren't processed with the bank" (docs.stripe.com/payouts#test-payouts):
|
|
23
|
+
// the payout clock (semantics/balance.ts) is live's.
|
|
24
|
+
// No other test-mode speed-up is documented, so 4242 4242 4242 4242 funds sit pending the two days.
|
|
25
|
+
//
|
|
26
|
+
// Where the documentation stops and the twin decides: every credit written available at once (a bypass or bank-debit
|
|
27
|
+
// charge, a transfer, a transfer from such a charge, an application fee, a top-up, an Issuing top-up included, a reversal) is marked not yet
|
|
28
|
+
// sent, and the drain sends its account balance.available for it ("Occurs whenever your Stripe balance has been
|
|
29
|
+
// updated (e.g., when a charge is available to be paid out). ... This event is not fired for negative
|
|
30
|
+
// transactions", docs.stripe.com/api/events/types), as it does for funds that came due after the delay. The Balance
|
|
31
|
+
// it carries is the account's at the drain: a debit or an automatic payout landing first shows in it.
|
|
11
32
|
//
|
|
12
33
|
// Each connected account keeps its own balance (docs.stripe.com/connect/account-balances): a transaction belongs to
|
|
13
34
|
// the account the request acts for (the Stripe-Account header), else to the platform. A transfer takes its amount out
|
|
@@ -15,45 +36,53 @@
|
|
|
15
36
|
// less the application fee, available when the charge's funds are; a direct charge's application fee moves from the
|
|
16
37
|
// connected account to the platform.
|
|
17
38
|
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
18
|
-
import { at, created, fail, inRange, kept, list, newest, where, type Row } from './shared.ts';
|
|
39
|
+
import { asOf, at, created, fail, inRange, kept, list, newest, where, type Row } from './shared.ts';
|
|
40
|
+
import { BYPASS_PENDING_CARDS } from './test-cards.ts';
|
|
19
41
|
|
|
20
42
|
const BT = 'balance_transaction';
|
|
21
43
|
const DAY = 86_400;
|
|
22
|
-
|
|
44
|
+
/** A ledger entry's mark: available at once, its balance.available not yet sent (settleDueEntries). */
|
|
45
|
+
const UNSENT = '_availableUnsent';
|
|
46
|
+
const BYPASS_NUMBERS = new Set(Object.values(BYPASS_PENDING_CARDS).map((c) => c.number));
|
|
47
|
+
|
|
48
|
+
/** Whether a charge's funds go straight to the available balance: a bypass card as a raw number, a test name (pm_card_*
|
|
49
|
+
* or tok_*), or a stored PaymentMethod made from one (which records it as what follows its success, after-payment.ts);
|
|
50
|
+
* or a US bank account, stored or one of Stripe's test bank accounts named as one (pm_usBankAccount_*). */
|
|
51
|
+
export function bypassesPending(ctx: SemanticsContext, card: string | undefined): boolean {
|
|
52
|
+
if (!card) return false;
|
|
53
|
+
const stored = ctx.row('payment_method', card);
|
|
54
|
+
// the row carries what the method recorded; its type is the vendor object's (ctx.get), not the row's envelope
|
|
55
|
+
if (stored) return stored._afterSuccess === 'available' || ctx.get('payment_method', card)?.type === 'us_bank_account';
|
|
56
|
+
if (/^pm_us_?bank_?account/i.test(card)) return true;
|
|
57
|
+
return BYPASS_NUMBERS.has(card.replace(/\D/g, '')) || card.replace(/^tok_/, 'pm_card_') in BYPASS_PENDING_CARDS;
|
|
58
|
+
}
|
|
23
59
|
|
|
24
60
|
const feeOf = (amount: number): number => (amount > 0 ? Math.round(amount * 0.029) + 30 : 0);
|
|
25
61
|
|
|
26
|
-
/** A transaction as the clock reads it: available once its available_on has passed, the clock's move,
|
|
27
|
-
* asked of the machine as a write asks it. */
|
|
28
|
-
function asOf(ctx: SemanticsContext, t: Row): Row {
|
|
29
|
-
const due = Number(t.available_on);
|
|
30
|
-
if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now())) return t;
|
|
31
|
-
ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
|
|
32
|
-
return { ...t, status: 'available' };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
62
|
/** The connected account a request acts for, or undefined for the platform. */
|
|
36
63
|
export const actingAccount = (ctx: SemanticsContext): string | undefined => ctx.call.request.headers.get('stripe-account') ?? undefined;
|
|
37
64
|
|
|
38
65
|
/** A ledger entry on an account's balance: the acting account's unless one is named (null names the platform). */
|
|
39
66
|
async function write(ctx: SemanticsContext, fields: Row, account: string | null | undefined = actingAccount(ctx)): Promise<string> {
|
|
40
|
-
|
|
67
|
+
// a credit available at once waits for the drain's balance.available (settleDueEntries)
|
|
68
|
+
const unsent = (fields.status ?? 'available') === 'available' && Number(fields.net) > 0 ? { [UNSENT]: true } : {};
|
|
69
|
+
const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...unsent, ...(account ? { _account: account } : {}) });
|
|
41
70
|
return String(bt.id);
|
|
42
71
|
}
|
|
43
72
|
|
|
44
73
|
/** Whether a stored ledger entry is on this account's balance (undefined: the platform's). */
|
|
45
74
|
const onAccount = (t: Row, account: string | undefined): boolean => (typeof t._account === 'string' ? t._account : undefined) === account;
|
|
46
75
|
|
|
47
|
-
/** A captured charge's credit: its amount less the fee, pending two days unless the card
|
|
48
|
-
*
|
|
76
|
+
/** A captured charge's credit: its amount less the fee, pending two days unless the card bypasses the pending
|
|
77
|
+
* balance (bypassesPending), when it is due at once. The
|
|
49
78
|
* caller mints the charge's id first and stores the returned id as the charge's balance_transaction. */
|
|
50
79
|
export async function settleCharge(ctx: SemanticsContext, chargeId: string, amount: number, currency: string, card?: string): Promise<string> {
|
|
51
80
|
const now = Number(ctx.now());
|
|
52
81
|
const fee = feeOf(amount);
|
|
53
|
-
const
|
|
82
|
+
const atOnce = bypassesPending(ctx, card);
|
|
54
83
|
const id = await write(ctx, {
|
|
55
84
|
amount, currency, fee, net: amount - fee, type: 'charge', reporting_category: 'charge', source: chargeId,
|
|
56
|
-
|
|
85
|
+
...(atOnce ? { status: 'available', available_on: now } : { status: 'pending', available_on: now + 2 * DAY }),
|
|
57
86
|
fee_details: fee ? [{ amount: fee, application: null, currency, description: 'Stripe processing fees', type: 'stripe_fee' }] : [],
|
|
58
87
|
});
|
|
59
88
|
return id;
|
|
@@ -118,9 +147,11 @@ export async function settlePayout(ctx: SemanticsContext, payoutId: string, amou
|
|
|
118
147
|
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);
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
/** A transfer's two entries: out of the platform's balance at once, into the destination's when `
|
|
122
|
-
* (a plain transfer
|
|
123
|
-
|
|
150
|
+
/** A transfer's two entries: out of the platform's balance at once, into the destination's when `fromCharge` (its
|
|
151
|
+
* charge's funds' availability) comes (a plain transfer, with no `fromCharge`, moves funds available already).
|
|
152
|
+
* Answers the platform's entry. */
|
|
153
|
+
export async function settleTransfer(ctx: SemanticsContext, transferId: string, amount: number, currency: string, destination: string, fromCharge?: number, fromPending = false): Promise<string> {
|
|
154
|
+
const availableOn = fromCharge ?? Number(ctx.now());
|
|
124
155
|
const settled = availableOn <= Number(ctx.now());
|
|
125
156
|
// a transfer from a charge's pending funds (source_transaction) leaves the platform when they arrive, not before
|
|
126
157
|
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);
|
|
@@ -152,13 +183,19 @@ export async function settleTopup(ctx: SemanticsContext, topupId: string, amount
|
|
|
152
183
|
/** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
|
|
153
184
|
* moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
|
|
154
185
|
* sees it (asOf); the record is what lets Stripe's balance.available be sent once (stripe-server.ts, the drain).
|
|
155
|
-
* Answers the entries
|
|
186
|
+
* Funds that were available at once and not yet sent are taken too, and marked sent. Answers the entries taken. */
|
|
156
187
|
export async function settleDueEntries(ctx: SemanticsContext): Promise<Row[]> {
|
|
157
188
|
const now = Number(ctx.now());
|
|
158
189
|
const account = actingAccount(ctx);
|
|
159
190
|
const moved: Row[] = [];
|
|
160
191
|
for (const t of ctx.rowsRaw(BT)) {
|
|
161
|
-
if (!onAccount(t, account)
|
|
192
|
+
if (!onAccount(t, account)) continue;
|
|
193
|
+
if (t.status === 'available' && t[UNSENT] === true) {
|
|
194
|
+
await ctx.write(BT, String(t.id), { [UNSENT]: false }, 'balance_transaction.available_sent');
|
|
195
|
+
moved.push(t);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (t.status !== 'pending' || !(Number(t.available_on) <= now)) continue;
|
|
162
199
|
if (ctx.legal(BT, 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time')) continue;
|
|
163
200
|
await ctx.write(BT, String(t.id), { status: 'available' }, 'balance_transaction.available');
|
|
164
201
|
moved.push(t);
|
|
@@ -130,6 +130,8 @@ export async function methodFromData(ctx: SemanticsContext, data: unknown): Prom
|
|
|
130
130
|
type, customer: null, livemode: false, metadata: {},
|
|
131
131
|
billing_details: { ...noBilling, ...((d.billing_details as Row | undefined) ?? {}) },
|
|
132
132
|
...paymentMethodSubObject(type, d),
|
|
133
|
+
// what follows its card's success (a dispute, funds straight to available), as a PaymentMethod created directly carries
|
|
134
|
+
...(type === 'card' && afterSuccessOf(ctx, d.card) ? { _afterSuccess: afterSuccessOf(ctx, d.card) } : {}),
|
|
133
135
|
});
|
|
134
136
|
return String(made.id);
|
|
135
137
|
}
|
package/src/semantics/shared.ts
CHANGED
|
@@ -125,8 +125,18 @@ function expandPaths(ctx: SemanticsContext): string[][] {
|
|
|
125
125
|
return Array.isArray(expand) ? expand.map((p) => String(p).split('.')) : [];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
|
|
129
|
+
* asked of the machine as a write asks it (semantics/ledger.ts). */
|
|
130
|
+
export function asOf(ctx: SemanticsContext, t: Row): Row {
|
|
131
|
+
const due = Number(t.available_on);
|
|
132
|
+
if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now())) return t;
|
|
133
|
+
ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
|
|
134
|
+
return { ...t, status: 'available' };
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
/** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
|
|
129
|
-
* holds, one segment at a time; an id the tree lacks stays an id.
|
|
138
|
+
* holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
|
|
139
|
+
* (asOf), as its own retrieve does. */
|
|
130
140
|
export function expandRow(ctx: SemanticsContext, resource: string, body: Row, paths: string[][]): Row {
|
|
131
141
|
const embeds = manifest.resources[resource]?.embeds;
|
|
132
142
|
if (!embeds || paths.length === 0) return body;
|
|
@@ -142,8 +152,9 @@ export function expandRow(ctx: SemanticsContext, resource: string, body: Row, pa
|
|
|
142
152
|
const target = embeds[head];
|
|
143
153
|
if (!target) continue;
|
|
144
154
|
const cur = out[head];
|
|
145
|
-
const
|
|
146
|
-
if (!
|
|
155
|
+
const found = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? (cur as Row) : undefined;
|
|
156
|
+
if (!found) continue;
|
|
157
|
+
const sub = target === 'balance_transaction' ? asOf(ctx, found) : found;
|
|
147
158
|
const deeper = tails.filter((t) => t.length > 0);
|
|
148
159
|
out = { ...out, [head]: deeper.length ? expandRow(ctx, target, sub, deeper) : sub };
|
|
149
160
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Stripe's test cards whose funds bypass the pending balance, by their test names and numbers
|
|
2
|
+
// (docs.stripe.com/testing#available-balance: "Funds are added directly to your available balance, bypassing your
|
|
3
|
+
// pending balance"). A leaf: ledger.ts reads it to settle a charge, after-payment.ts to record it on a PaymentMethod.
|
|
4
|
+
export const BYPASS_PENDING_CARDS: Record<string, { brand: string; number: string }> = {
|
|
5
|
+
pm_card_bypassPending: { brand: 'visa', number: '4000000000000077' },
|
|
6
|
+
pm_card_bypassPendingInternational: { brand: 'visa', number: '4000003720000278' },
|
|
7
|
+
};
|
|
@@ -66,7 +66,7 @@ const create: Semantics = async (ctx) => {
|
|
|
66
66
|
if (refused) return refused;
|
|
67
67
|
}
|
|
68
68
|
const id = ctx.mint('transfer');
|
|
69
|
-
const bt = await settleTransfer(ctx, id, amount, currency, destination, availableOn
|
|
69
|
+
const bt = await settleTransfer(ctx, id, amount, currency, destination, availableOn, sourceId !== undefined);
|
|
70
70
|
return ctx.reply(
|
|
71
71
|
await created(ctx, 'transfer', { id, ...ctx.params, ...(group ? { transfer_group: group } : {}) }, {
|
|
72
72
|
amount_reversed: 0, balance_transaction: bt, livemode: false, metadata: {},
|