@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.
Files changed (67) hide show
  1. package/README.md +33 -1
  2. package/dist/src/index.js +6 -4
  3. package/dist/src/manifest.js +8 -3
  4. package/dist/src/screens/checkout.js +20 -6
  5. package/dist/src/screens/connect-oauth.d.ts +27 -0
  6. package/dist/src/screens/connect-oauth.js +414 -0
  7. package/dist/src/screens/connect-settings.d.ts +22 -0
  8. package/dist/src/screens/connect-settings.js +103 -0
  9. package/dist/src/screens/portal.js +2 -0
  10. package/dist/src/semantics/after-payment.d.ts +1 -1
  11. package/dist/src/semantics/after-payment.js +6 -0
  12. package/dist/src/semantics/charges.js +10 -2
  13. package/dist/src/semantics/checkout.js +19 -6
  14. package/dist/src/semantics/connect.js +20 -3
  15. package/dist/src/semantics/invoices.js +4 -0
  16. package/dist/src/semantics/issuing.js +7 -2
  17. package/dist/src/semantics/ledger.d.ts +11 -6
  18. package/dist/src/semantics/ledger.js +40 -21
  19. package/dist/src/semantics/payment-methods.js +2 -0
  20. package/dist/src/semantics/shared.d.ts +5 -1
  21. package/dist/src/semantics/shared.js +14 -3
  22. package/dist/src/semantics/test-cards.d.ts +4 -0
  23. package/dist/src/semantics/test-cards.js +7 -0
  24. package/dist/src/semantics/transfers.js +1 -1
  25. package/dist/src/stripe-capabilities.js +829 -186
  26. package/dist/src/stripe-conformance.d.ts +2 -0
  27. package/dist/src/stripe-conformance.js +11 -2
  28. package/dist/src/stripe-emit.js +2 -2
  29. package/dist/src/stripe-events.js +14 -10
  30. package/dist/src/stripe-mirror-ui.js +3 -3
  31. package/dist/src/stripe-server.js +97 -24
  32. package/dist/src/stripe-shared.d.ts +3 -0
  33. package/dist/src/stripe-shared.js +3 -0
  34. package/dist/src/stripe-twin.js +7 -1
  35. package/dist/src/stripe-version.d.ts +2 -0
  36. package/dist/src/stripe-version.js +2 -0
  37. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  38. package/dist/test-fixtures/stripe-schemas.json +85 -12
  39. package/package.json +4 -4
  40. package/src/index.ts +6 -4
  41. package/src/manifest.ts +8 -3
  42. package/src/screens/checkout.tsx +21 -6
  43. package/src/screens/connect-oauth.tsx +400 -0
  44. package/src/screens/connect-settings.tsx +121 -0
  45. package/src/screens/portal.tsx +2 -0
  46. package/src/semantics/after-payment.ts +6 -1
  47. package/src/semantics/charges.ts +11 -2
  48. package/src/semantics/checkout.ts +19 -6
  49. package/src/semantics/connect.ts +19 -3
  50. package/src/semantics/invoices.ts +4 -0
  51. package/src/semantics/issuing.ts +7 -2
  52. package/src/semantics/ledger.ts +60 -23
  53. package/src/semantics/payment-methods.ts +2 -0
  54. package/src/semantics/shared.ts +14 -3
  55. package/src/semantics/test-cards.ts +7 -0
  56. package/src/semantics/transfers.ts +1 -1
  57. package/src/stripe-capabilities.ts +826 -182
  58. package/src/stripe-conformance.ts +13 -2
  59. package/src/stripe-emit.ts +2 -2
  60. package/src/stripe-events.ts +14 -10
  61. package/src/stripe-mirror-ui.ts +3 -3
  62. package/src/stripe-server.ts +85 -24
  63. package/src/stripe-shared.ts +3 -0
  64. package/src/stripe-twin.ts +6 -1
  65. package/src/stripe-version.ts +3 -0
  66. package/test-fixtures/stripe-known-deviations.json +6 -1
  67. 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, on the customer when there is one, carrying what a later charge to it
159
- // answers and what Stripe does after it succeeds (docs.stripe.com/testing#declined-payments, #disputes)
160
- const pm = card && existing.mode !== 'setup' ? await created(ctx, 'payment_method', {}, { type: 'card', customer: 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;
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
 
@@ -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
- const platform: Semantics = async (ctx) => ctx.reply(ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
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
- const listAccounts: Semantics = async (ctx) => list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID));
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) {
@@ -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: captureAmount, currency, fee: 0, net: captureAmount, type: 'issuing_authorization_release',
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
  });
@@ -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); the card 4000000000000077 settles at once, as Stripe's test card of that
10
- // number does (docs.stripe.com/testing#available-balance).
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
- const BYPASS_PENDING = '4000000000000077';
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
- const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...(account ? { _account: account } : {}) });
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 (a number, or a test
48
- * payment method or token named for it) settles at once. The
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 settled = !!card && (card.replace(/\D/g, '') === BYPASS_PENDING || /bypassPending/i.test(card));
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
- status: settled ? 'available' : 'pending', available_on: settled ? now : now + 2 * DAY,
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 `availableOn` comes
122
- * (a plain transfer's funds are available already). Answers the platform's entry. */
123
- export async function settleTransfer(ctx: SemanticsContext, transferId: string, amount: number, currency: string, destination: string, availableOn = Number(ctx.now()), fromPending = false): Promise<string> {
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 that moved. */
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) || t.status !== 'pending' || !(Number(t.available_on) <= now)) continue;
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
  }
@@ -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 sub = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? (cur as Row) : undefined;
146
- if (!sub) continue;
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 ?? Number(ctx.now()), sourceId !== undefined);
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: {},