@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
@@ -0,0 +1,22 @@
1
+ import { type SemanticsContext } from '@volter/world-core';
2
+ /** The platform's test client_id: the World's one platform (acct_twin_self) is the application it names. */
3
+ export declare const PLATFORM_CLIENT_ID = "ca_twin_self";
4
+ export declare const SETTINGS_URL = "https://dashboard.stripe.com/settings/connect/onboarding-options/oauth";
5
+ /** The page's addresses: the docs' own, its test-mode link, and Connect's settings root. */
6
+ export declare const CONNECT_SETTINGS_PATH: RegExp;
7
+ export type ConnectOAuthSettings = {
8
+ oauth_enabled: boolean;
9
+ redirect_uris: string[];
10
+ };
11
+ /** What the platform saved, or the settings a new platform starts with (OAuth off, no redirect URI). */
12
+ export declare function connectOAuthSettings(ctx: SemanticsContext): ConnectOAuthSettings;
13
+ /** The redirect URIs a form's field names, or the first one refused and why. */
14
+ export declare function redirectUris(field: string): {
15
+ uris: string[];
16
+ } | {
17
+ refused: string;
18
+ };
19
+ export declare function stripeConnectSettingsFlow(scope: {
20
+ root?: string;
21
+ clock?: () => string;
22
+ }): (request: Request) => Promise<Response | undefined>;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // THE DASHBOARD'S CONNECT OAUTH SETTINGS — a workspace page (docs/contributing/architecture.md, "Screens") of the
3
+ // Dashboard (manifest screen `dashboard`), at dashboard.stripe.com/settings/connect/onboarding-options/oauth, where a
4
+ // platform starts an OAuth integration: "Enable onboarding accounts with OAuth", "Copy your client_id, a unique
5
+ // identifier for your platform that's generated by Stripe", and "Set your redirect_uri ... You must specify all redirect
6
+ // URLs in your platform settings. If you don't include the redirect_uri parameter in your request, Stripe defaults to
7
+ // using the first address you've configured" (docs.stripe.com/connect/oauth-standard-accounts). The test client_id
8
+ // "allows you to: Set your redirect_uri to a non-HTTPS URL; Set your redirect_uri to localhost"
9
+ // (docs.stripe.com/connect/testing#using-oauth). Stripe's API has no call that reads or changes these settings (GET
10
+ // /v1/account does not carry the client_id), so this page is the only way to them, and connect.stripe.com/oauth/authorize
11
+ // (./connect-oauth.tsx) reads what it saves. Authored from plain markup under Stripe's type; nothing of Stripe's page is
12
+ // copied.
13
+ //
14
+ // Where the documentation stops and the twin decides: the World has one platform, and its test client_id is
15
+ // `ca_twin_self` (PLATFORM_CLIENT_ID; the platform account is acct_twin_self), the same in every World, shown on the
16
+ // page in an element a runner reads (data-testid="connect-client-id"). OAuth starts disabled. The page is served at
17
+ // the docs' address, at /test/… (the docs' test-mode link) and at /settings/connect (the address Cal.com's setup
18
+ // guide gives). The redirect URIs are one form field, one per line (or comma-separated, as the reference describes
19
+ // them), saved whole: each an absolute http or https URL without a fragment; http and localhost are allowed, as the
20
+ // test client_id allows them. No sign-in guards the page, as none guards the Dashboard mirror. The form posts to the
21
+ // page's own address and the save redirects to it.
22
+ import { semanticsContext } from '@volter/world-core';
23
+ import { flowPage } from '@volter/world-ui';
24
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
25
+ import { manifest } from "../manifest.js";
26
+ import { formOf } from "./consent-skin.js";
27
+ /** The platform's test client_id: the World's one platform (acct_twin_self) is the application it names. */
28
+ export const PLATFORM_CLIENT_ID = 'ca_twin_self';
29
+ export const SETTINGS_URL = 'https://dashboard.stripe.com/settings/connect/onboarding-options/oauth';
30
+ /** The page's addresses: the docs' own, its test-mode link, and Connect's settings root. */
31
+ export const CONNECT_SETTINGS_PATH = /^\/(test\/)?settings\/connect(\/onboarding-options\/oauth)?\/?$/;
32
+ const SETTINGS = '_connect_oauth_settings';
33
+ const OPERATION = surface.operations.find((o) => o.id === 'PostAccountsAccount');
34
+ /** What the platform saved, or the settings a new platform starts with (OAuth off, no redirect URI). */
35
+ export function connectOAuthSettings(ctx) {
36
+ const row = ctx.rowsRaw(SETTINGS).find((r) => r.id === PLATFORM_CLIENT_ID);
37
+ return { oauth_enabled: row?.oauth_enabled === true, redirect_uris: Array.isArray(row?.redirect_uris) ? row.redirect_uris : [] };
38
+ }
39
+ /** The redirect URIs a form's field names, or the first one refused and why. */
40
+ export function redirectUris(field) {
41
+ const uris = [...new Set(field.split(/[\n,]/).map((s) => s.trim()).filter(Boolean))];
42
+ for (const uri of uris) {
43
+ let url;
44
+ try {
45
+ url = new URL(uri);
46
+ }
47
+ catch {
48
+ return { refused: `${uri} is not a valid URL.` };
49
+ }
50
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
51
+ return { refused: `${uri} must use http or https.` };
52
+ if (uri.includes('#'))
53
+ return { refused: `${uri} must not contain a fragment.` };
54
+ }
55
+ return { uris };
56
+ }
57
+ const CSS = `
58
+ * { box-sizing: border-box; }
59
+ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; }
60
+ .co { max-width: 680px; margin: 48px auto; padding: 0 16px; }
61
+ .co h1 { font-size: 28px; margin: 16px 0 4px; }
62
+ .co-lead { color: #697386; margin: 0 0 24px; }
63
+ .co-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; margin-bottom: 16px; }
64
+ .co-card h2 { font-size: 16px; margin: 0 0 12px; }
65
+ .co-card label { display: block; font-weight: 600; margin-bottom: 6px; }
66
+ .co-card small { display: block; color: #697386; margin-bottom: 8px; }
67
+ .co-card textarea { width: 100%; min-height: 96px; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: 13px ui-monospace, Menlo, monospace; }
68
+ .co-id { font: 14px ui-monospace, Menlo, monospace; background: #f6f8fa; border: 1px solid #e3e8ee; border-radius: 6px; padding: 6px 10px; display: inline-block; }
69
+ .co-toggle { display: flex; gap: 8px; align-items: center; font-weight: 600; }
70
+ .co-actions { display: flex; justify-content: flex-end; }
71
+ .co-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
72
+ .co-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
73
+ .co-error { background: #fff0f3; color: #df1b41; }
74
+ .co-saved { background: #e7f8ef; color: #0e6245; }
75
+ .co-uris { margin: 0; padding-left: 18px; }
76
+ `;
77
+ function page(s, opts = {}) {
78
+ const enabled = opts.enabled ?? s.oauth_enabled;
79
+ return flowPage({
80
+ title: 'OAuth – Connect settings – Stripe',
81
+ css: [CSS],
82
+ ...(opts.error ? { status: 400 } : {}),
83
+ body: (_jsxs("main", { className: "co", children: [_jsx("h1", { children: "OAuth" }), _jsx("p", { className: "co-lead", children: "Onboard Standard accounts by sending them to Stripe's OAuth flow. Test mode." }), opts.error ? _jsx("p", { className: "co-notice co-error", role: "alert", children: opts.error }) : null, opts.saved ? _jsx("p", { className: "co-notice co-saved", role: "status", children: "Your OAuth settings were saved." }) : null, _jsxs("section", { className: "co-card", children: [_jsx("h2", { children: "Test mode client ID" }), _jsx("small", { children: "The client_id your platform sends to connect.stripe.com/oauth/authorize." }), _jsx("code", { className: "co-id", "data-testid": "connect-client-id", children: PLATFORM_CLIENT_ID }), _jsx("p", { "data-testid": "connect-oauth-status", children: s.oauth_enabled ? 'OAuth is enabled.' : 'OAuth is disabled.' }), s.redirect_uris.length ? _jsx("ul", { className: "co-uris", children: s.redirect_uris.map((u) => _jsx("li", { "data-testid": "connect-redirect-uri", children: u }, u)) }) : _jsx("p", { children: "No redirect URIs." })] }), _jsxs("form", { className: "co-card", method: "post", children: [_jsxs("label", { className: "co-toggle", htmlFor: "oauth_enabled", children: [_jsx("input", { type: "checkbox", id: "oauth_enabled", name: "oauth_enabled", value: "on", defaultChecked: enabled }), " Enable OAuth for Standard accounts"] }), _jsx("label", { htmlFor: "redirect_uris", children: "Redirect URIs" }), _jsx("small", { children: "One per line. The first is used when a request names none. http and localhost are allowed in test mode." }), _jsx("textarea", { id: "redirect_uris", name: "redirect_uris", defaultValue: opts.value ?? s.redirect_uris.join('\n') }), _jsx("div", { className: "co-actions", children: _jsx("button", { type: "submit", children: "Save" }) })] })] })),
84
+ });
85
+ }
86
+ export function stripeConnectSettingsFlow(scope) {
87
+ return async (request) => {
88
+ const url = new URL(request.url);
89
+ if (!CONNECT_SETTINGS_PATH.test(url.pathname) || (request.method !== 'GET' && request.method !== 'POST'))
90
+ return undefined;
91
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
92
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
93
+ const held = connectOAuthSettings(ctx);
94
+ if (request.method === 'GET')
95
+ return page(held, { saved: url.searchParams.has('saved') });
96
+ const enabled = form.oauth_enabled === 'on' || form.oauth_enabled === 'true';
97
+ const parsed = redirectUris(form.redirect_uris ?? '');
98
+ if ('refused' in parsed)
99
+ return page(held, { value: form.redirect_uris ?? '', enabled, error: parsed.refused });
100
+ await ctx.record(SETTINGS, { oauth_enabled: enabled, redirect_uris: parsed.uris }, PLATFORM_CLIENT_ID);
101
+ return new Response(null, { status: 303, headers: { location: '?saved=1' } });
102
+ };
103
+ }
@@ -22,6 +22,7 @@ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
22
22
  import surface from '../generated/surface.gen.json' with { type: 'json' };
23
23
  import { manifest } from "../manifest.js";
24
24
  import { payerOf, payOpenInvoice } from "../semantics/renewals.js";
25
+ import { afterSuccessOf } from "../semantics/after-payment.js";
25
26
  import { created } from "../semantics/shared.js";
26
27
  import { publicBusinessName } from "../stripe-shared.js";
27
28
  import { declineFor, paymentMethodSubObject, PLATFORM_ACCOUNT_ID } from "../stripe-twin.js";
@@ -165,6 +166,7 @@ async function updatePaymentMethod(ctx, id, session, v) {
165
166
  type: 'card', customer, livemode: false,
166
167
  billing_details: { address: { country: v.billingCountry || null, postal_code: v.billingPostalCode || null, city: null, line1: null, line2: null, state: null }, email: null, name: v.billingName || null, phone: null },
167
168
  ...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null,
169
+ ...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}),
168
170
  });
169
171
  const holder = ctx.get('customer', customer) ?? {};
170
172
  await ctx.write('customer', customer, { invoice_settings: { ...(holder.invoice_settings ?? {}), default_payment_method: pm.id } }, 'customer.update');
@@ -1,5 +1,5 @@
1
1
  import type { SemanticsContext } from '@volter/world-core';
2
- type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review';
2
+ type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review' | 'available';
3
3
  /** The test cards whose success Stripe follows with its own act, by number and by their documented test names. */
4
4
  export declare const AFTER_SUCCESS_CARDS: Record<string, {
5
5
  brand: string;
@@ -1,5 +1,6 @@
1
1
  import { disputeEvidence } from "../stripe-twin.js";
2
2
  import { actingAccount, settleApplicationFee, settleDispute, settleTransfer } from "./ledger.js";
3
+ import { BYPASS_PENDING_CARDS } from "./test-cards.js";
3
4
  import { created } from "./shared.js";
4
5
  const DAY = 86_400;
5
6
  /** The test cards whose success Stripe follows with its own act, by number and by their documented test names. */
@@ -8,11 +9,16 @@ export const AFTER_SUCCESS_CARDS = {
8
9
  pm_card_createDisputeProductNotReceived: { brand: 'visa', number: '4000000000002685', outcome: 'dispute_not_received' },
9
10
  pm_card_createDisputeInquiry: { brand: 'visa', number: '4000000000001976', outcome: 'inquiry' },
10
11
  pm_card_riskLevelElevated: { brand: 'visa', number: '4000000000009235', outcome: 'review' },
12
+ // its funds go straight to the available balance (ledger.ts reads it; no act follows here)
13
+ ...Object.fromEntries(Object.entries(BYPASS_PENDING_CARDS).map(([name, c]) => [name, { ...c, outcome: 'available' }])),
11
14
  };
12
15
  const BY_NUMBER = Object.fromEntries(Object.values(AFTER_SUCCESS_CARDS).map((c) => [c.number, c.outcome]));
13
16
  /** What Stripe does after a card succeeds: from a raw number, a test name (pm_card_* or tok_*), or a stored
14
17
  * PaymentMethod that recorded it when it was made. */
15
18
  export function afterSuccessOf(ctx, ref) {
19
+ // a card given as a token (card[token], payment_method_data[card][token]) is the card the token names
20
+ if (ref && typeof ref === 'object' && typeof ref.token === 'string')
21
+ return afterSuccessOf(ctx, ref.token);
16
22
  if (ref && typeof ref === 'object')
17
23
  return BY_NUMBER[String(ref.number ?? '').replace(/\D/g, '')];
18
24
  if (typeof ref !== 'string' || !ref)
@@ -15,13 +15,21 @@ export function chargeBody(ctx, c) {
15
15
  // (capture=false: its status is succeeded, captured false), and charge.failed, "Occurs whenever a failed charge attempt
16
16
  // occurs", for a declined attempt. The twin makes no pending charge (charge.pending, "Occurs whenever a pending charge is
17
17
  // created"): its charges settle when made.
18
+ /** The create's parameters as the charge keeps them: without `card`, the request's card details ("A token, like the ones
19
+ * returned by Stripe.js", or a hash of the number, expiry and CVC). The Charge object has no `card` property (served
20
+ * spec, charge); the card it was made with is its payment_method_details. Kept, it answered the card's full number and
21
+ * CVC back on every read of the charge. */
22
+ function withoutCard(params) {
23
+ const { card: _card, ...rest } = params;
24
+ return rest;
25
+ }
18
26
  /** A Charges-API charge a declining test card refuses: a 402 card_error naming the failed charge Stripe records for the
19
27
  * attempt (docs.stripe.com/declines). */
20
28
  async function declinedCharge(ctx, decline) {
21
29
  const params = ctx.params;
22
30
  const failedId = ctx.mint('charge');
23
31
  const amount = Number(params.amount) || 0;
24
- await created(ctx, 'charge', { ...params, id: failedId }, {
32
+ await created(ctx, 'charge', { ...withoutCard(params), id: failedId }, {
25
33
  ...chargeDefaults(failedId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
26
34
  failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
27
35
  outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
@@ -56,7 +64,7 @@ const create = async (ctx) => {
56
64
  const card = String(params.source ?? params.payment_method ?? params.card?.number ?? '');
57
65
  const bt = captured ? await settleCharge(ctx, id, amount, String(params.currency ?? 'usd'), card) : null;
58
66
  const details = paymentMethodDetails(ctx, params.payment_method ?? params.source ?? params.card);
59
- await created(ctx, 'charge', { ...params, id }, { ...chargeDefaults(id, amount, captured, ctx.occurredAt), balance_transaction: bt, payment_method_details: details ?? null }, { operation: 'charge.succeeded' });
67
+ await created(ctx, 'charge', { ...withoutCard(params), id }, { ...chargeDefaults(id, amount, captured, ctx.occurredAt), balance_transaction: bt, payment_method_details: details ?? null }, { operation: 'charge.succeeded' });
60
68
  await afterCharge(ctx, id, { amount, currency: String(params.currency ?? 'usd'), application_fee_amount: params.application_fee_amount, destination: params.transfer_data?.destination }, params.payment_method ?? params.source ?? params.card);
61
69
  return ctx.reply(chargeBody(ctx, ctx.get('charge', id)));
62
70
  };
@@ -158,9 +158,20 @@ async function complete(ctx, id, existing, card) {
158
158
  const link = { status: 'complete' };
159
159
  const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
160
160
  const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
161
- // the entered card becomes a PaymentMethod, on the customer when there is one, carrying what a later charge to it
162
- // answers and what Stripe does after it succeeds (docs.stripe.com/testing#declined-payments, #disputes)
163
- 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;
161
+ // the entered card becomes a PaymentMethod carrying what a later charge to it answers and what Stripe does after it
162
+ // succeeds (docs.stripe.com/testing#declined-payments, #disputes)
163
+ 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;
164
+ // and Checkout attaches it to the customer when it saves it: a subscription's card ("If your Checkout Session uses
165
+ // subscription mode, Stripe saves the payment method by default", docs.stripe.com/payments/checkout/how-checkout-works),
166
+ // a setup session's (it exists to save one), and a payment's only under payment_intent_data.setup_future_usage ("to have
167
+ // Checkout automatically attach the payment method to the Customer you pass in", docs.stripe.com/api/checkout/sessions/
168
+ // create#create_checkout_session-customer). Attaching is its own write, so payment_method.attached is delivered ("Occurs
169
+ // whenever a new payment method is attached to a customer", docs.stripe.com/api/events/types) before what the card then
170
+ // pays for. Where the documentation stops and the twin decides: the order of the events, which Stripe does not
171
+ // guarantee (docs.stripe.com/webhooks#event-ordering). A gap: the customer's own opt-in to save a payment's card
172
+ // (saved_payment_method_options.payment_method_save) is not modelled; the page offers no such box.
173
+ const saves = existing.mode !== 'payment' || !!(ctx.row(CS, id)?._payment_intent_data ?? {}).setup_future_usage;
174
+ const pm = made && customer && saves ? await ctx.write('payment_method', String(made.id), { customer }, 'payment_method.attach') : made;
164
175
  if (existing.mode === 'payment') {
165
176
  const amount = Number(existing.amount_total) || 0;
166
177
  const piId = ctx.mint('payment_intent');
@@ -169,6 +180,8 @@ async function complete(ctx, id, existing, card) {
169
180
  ...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
170
181
  ...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: pid.transfer_data.destination } } : {}),
171
182
  ...(typeof pid.description === 'string' ? { description: pid.description } : {}),
183
+ // the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
184
+ ...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
172
185
  ...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
173
186
  };
174
187
  const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
@@ -225,7 +238,7 @@ async function complete(ctx, id, existing, card) {
225
238
  link.payment_status = 'paid';
226
239
  }
227
240
  else
228
- link.setup_intent = await completeSetup(ctx, customer);
241
+ link.setup_intent = await completeSetup(ctx, customer, pm);
229
242
  return link;
230
243
  }
231
244
  /** A line item of a one-time Price: a stored Price, or an inline `price_data`, without `recurring` ("Line items with
@@ -261,9 +274,9 @@ async function sessionPrices(ctx, lines, inline) {
261
274
  return out;
262
275
  }
263
276
  /** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
264
- async function completeSetup(ctx, customer) {
277
+ async function completeSetup(ctx, customer, pm) {
265
278
  const siId = ctx.mint('setup_intent');
266
- 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 });
279
+ 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 });
267
280
  return si.id;
268
281
  }
269
282
  /** The discount a completed subscription session gives its subscription. */
@@ -1,6 +1,7 @@
1
1
  import { ACCOUNT_TYPES, accountCapabilities, accountRequirements, accountSettings, asBool, PLATFORM_ACCOUNT_ID } from "../stripe-twin.js";
2
2
  import { currentlyDue, kindOf } from "../screens/onboarding.js";
3
3
  import { at, created, externalList, fail, list, newest, path, syncExternals } from "./shared.js";
4
+ import { OAUTH_CONNECTIONS } from "../stripe-shared.js";
4
5
  const accountMissing = (ctx, id, status = 404) => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
5
6
  /** The account, tombstones included where the hand-written routes looked past a deletion. */
6
7
  const accountRow = (ctx, id) => ctx.row('account', id, { withDeleted: true });
@@ -19,7 +20,18 @@ export const platformAccountDefault = () => ({
19
20
  external_accounts: externalList(PLATFORM_ACCOUNT_ID, []), tos_acceptance: { date: null, ip: null, user_agent: null }, business_profile: {},
20
21
  settings: accountSettings(undefined), livemode: false,
21
22
  });
22
- const platform = async (ctx) => ctx.reply(ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
23
+ // GET /v1/account answers the account the request acts as: with the Stripe-Account header (or a connected account's OAuth
24
+ // key, which acts as it: stripe-server.ts) that connected account, "Retrieves the details of an account"
25
+ // (docs.stripe.com/api/accounts/retrieve; docs.stripe.com/connect/authentication), else the platform's own
26
+ const platform = async (ctx) => {
27
+ const acting = ctx.call.request.headers.get('stripe-account');
28
+ const connected = acting && acting !== PLATFORM_ACCOUNT_ID ? ctx.get('account', acting) : undefined;
29
+ // a Stripe-Account naming no account is refused as Stripe refuses a Stripe-Account the key cannot use (account_invalid,
30
+ // docs.stripe.com/error-codes; stripe-server.ts revokedAccountRefused words it the same)
31
+ if (acting && acting !== PLATFORM_ACCOUNT_ID && !connected)
32
+ 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');
33
+ return ctx.reply(connected ?? ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
34
+ };
23
35
  /** A new Express account owes what its hosted onboarding will ask for (screens/onboarding.tsx; the US sets
24
36
  * docs.stripe.com/connect/required-verification-information lists), or, where the twin does not model the set, the
25
37
  * generic list. */
@@ -59,8 +71,13 @@ function controllerOf(type) {
59
71
  : type === 'custom' ? ['application', 'application_custom', 'application', 'none'] : ['stripe', 'account', 'stripe', 'full'];
60
72
  return { type: 'application', is_controller: true, losses: { payments: losses }, fees: { payer }, requirement_collection: collection, stripe_dashboard: { type: dashboard } };
61
73
  }
62
- // connected accounts only: the platform's own is not one
63
- const listAccounts = async (ctx) => list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID));
74
+ // connected accounts only: the platform's own is not one, and neither is an account whose OAuth connection was revoked,
75
+ // which "can't be accessed by your platform in the Dashboard or through the API" (docs.stripe.com/connect/oauth-reference;
76
+ // its connection row is OAUTH_CONNECTIONS, screens/connect-oauth.tsx)
77
+ const listAccounts = async (ctx) => {
78
+ const revoked = new Set(ctx.rowsRaw(OAUTH_CONNECTIONS).filter((c) => c.revoked === true).map((c) => c.id));
79
+ return list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID && !revoked.has(a.id)));
80
+ };
64
81
  /** What a Custom account still owes, from what the platform has given for it, as Stripe's requirements endpoint lists it
65
82
  * for a US account with no Stripe Dashboard and the full service agreement
66
83
  * (docs.stripe.com/_endpoint/get-requirements-for-setups, the data behind
@@ -307,7 +307,11 @@ const pay = async (ctx) => {
307
307
  return send(ctx, cardError(decline, intent ? { payment_intent: intent } : {}));
308
308
  const amountPaid = Number(inv.total ?? inv.amount_due) || 0;
309
309
  let body = await ctx.write(INV, id, {
310
+ // paid outside Stripe, the invoice counts it as such: `amount_paid_off_stripe`, "Amount, in cents (or local
311
+ // equivalent), that was paid on the invoice outside of Stripe" (served spec, invoice), the only place the served
312
+ // version shows it now that basil renders no `paid_out_of_band` (stripe-version.ts)
310
313
  status: 'paid', paid: true, paid_out_of_band: paidOutOfBand, amount_paid: amountPaid, amount_remaining: 0,
314
+ ...(paidOutOfBand ? { amount_paid_off_stripe: amountPaid } : {}),
311
315
  status_transitions: { ...transitions(inv), paid_at: ctx.now() },
312
316
  }, 'invoice.pay');
313
317
  if (!paidOutOfBand && typeof inv.payment_intent === 'string' && inv.payment_intent) {
@@ -382,10 +382,15 @@ const captureAuthorization = async (ctx) => {
382
382
  const captureAmount = ctx.params.capture_amount !== undefined ? Math.trunc(Number(ctx.params.capture_amount) || 0) : remaining;
383
383
  if (captureAmount <= 0 || captureAmount > remaining)
384
384
  return fail(ctx, 'Invalid capture_amount: must be a positive integer no greater than the uncaptured authorized amount.', 400, 'parameter_invalid_integer');
385
- // the capture releases what the approval held and debits the transaction
385
+ // the capture releases what the approval held and debits the transaction; a capture that closes the authorization
386
+ // releases ALL it still holds, the uncaptured rest included: the amount is held "until the authorization is either
387
+ // captured, voided, or expired without capture" (docs.stripe.com/issuing/purchases/authorizations), and a closed
388
+ // authorization can be captured no further (close_authorization "Defaults to true. Set to false to enable
389
+ // multi-capture flows", the served spec). A capture that keeps it open releases only what it captured.
386
390
  const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
391
+ const released = closeAuthorization ? remaining : captureAmount;
387
392
  const release = await created(ctx, 'balance_transaction', {}, {
388
- amount: captureAmount, currency, fee: 0, net: captureAmount, type: 'issuing_authorization_release',
393
+ amount: released, currency, fee: 0, net: released, type: 'issuing_authorization_release',
389
394
  status: 'available', balance_type: 'issuing', reporting_category: 'issuing_authorization_release',
390
395
  available_on: ctx.now(), fee_details: [], source: id,
391
396
  });
@@ -1,9 +1,13 @@
1
1
  import type { Semantics, SemanticsContext } from '@volter/world-core';
2
2
  import { type Row } from './shared.js';
3
+ /** Whether a charge's funds go straight to the available balance: a bypass card as a raw number, a test name (pm_card_*
4
+ * or tok_*), or a stored PaymentMethod made from one (which records it as what follows its success, after-payment.ts);
5
+ * or a US bank account, stored or one of Stripe's test bank accounts named as one (pm_usBankAccount_*). */
6
+ export declare function bypassesPending(ctx: SemanticsContext, card: string | undefined): boolean;
3
7
  /** The connected account a request acts for, or undefined for the platform. */
4
8
  export declare const actingAccount: (ctx: SemanticsContext) => string | undefined;
5
- /** A captured charge's credit: its amount less the fee, pending two days unless the card (a number, or a test
6
- * payment method or token named for it) settles at once. The
9
+ /** A captured charge's credit: its amount less the fee, pending two days unless the card bypasses the pending
10
+ * balance (bypassesPending), when it is due at once. The
7
11
  * caller mints the charge's id first and stores the returned id as the charge's balance_transaction. */
8
12
  export declare function settleCharge(ctx: SemanticsContext, chargeId: string, amount: number, currency: string, card?: string): Promise<string>;
9
13
  /** A refund's debit, at once, on the acting account's balance unless one is named (null names the platform). */
@@ -27,9 +31,10 @@ export declare function unpaidEntries(ctx: SemanticsContext, account: string | u
27
31
  export declare function settleAutomaticPayout(ctx: SemanticsContext, payoutId: string, amount: number, currency: string, account: string | undefined, at: number, entries: Row[]): Promise<string>;
28
32
  /** A payout's debit (negative) or a cancellation's or reversal's credit (positive), at once, on the payout's account. */
29
33
  export declare function settlePayout(ctx: SemanticsContext, payoutId: string, amount: number, currency: string, account?: string | null | undefined): Promise<string>;
30
- /** A transfer's two entries: out of the platform's balance at once, into the destination's when `availableOn` comes
31
- * (a plain transfer's funds are available already). Answers the platform's entry. */
32
- export declare function settleTransfer(ctx: SemanticsContext, transferId: string, amount: number, currency: string, destination: string, availableOn?: number, fromPending?: boolean): Promise<string>;
34
+ /** A transfer's two entries: out of the platform's balance at once, into the destination's when `fromCharge` (its
35
+ * charge's funds' availability) comes (a plain transfer, with no `fromCharge`, moves funds available already).
36
+ * Answers the platform's entry. */
37
+ export declare function settleTransfer(ctx: SemanticsContext, transferId: string, amount: number, currency: string, destination: string, fromCharge?: number, fromPending?: boolean): Promise<string>;
33
38
  /** A transfer reversal's two entries: back into the platform's balance, out of the destination's. */
34
39
  export declare function settleTransferReversal(ctx: SemanticsContext, reversalId: string, amount: number, currency: string, destination: string): Promise<string>;
35
40
  /** A direct charge's application fee: out of the connected account's balance, into the platform's. */
@@ -41,7 +46,7 @@ export declare function settleTopup(ctx: SemanticsContext, topupId: string, amou
41
46
  /** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
42
47
  * moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
43
48
  * sees it (asOf); the record is what lets Stripe's balance.available be sent once (stripe-server.ts, the drain).
44
- * Answers the entries that moved. */
49
+ * Funds that were available at once and not yet sent are taken too, and marked sent. Answers the entries taken. */
45
50
  export declare function settleDueEntries(ctx: SemanticsContext): Promise<Row[]>;
46
51
  /** An account's balance, by currency: what the clock has made available, what is still pending, and Issuing's own. */
47
52
  export declare function balanceOf(ctx: SemanticsContext, account?: string | undefined): {
@@ -1,36 +1,46 @@
1
- import { at, created, fail, inRange, kept, list, newest, where } from "./shared.js";
1
+ import { asOf, at, created, fail, inRange, kept, list, newest, where } from "./shared.js";
2
+ import { BYPASS_PENDING_CARDS } from "./test-cards.js";
2
3
  const BT = 'balance_transaction';
3
4
  const DAY = 86_400;
4
- const BYPASS_PENDING = '4000000000000077';
5
- const feeOf = (amount) => (amount > 0 ? Math.round(amount * 0.029) + 30 : 0);
6
- /** A transaction as the clock reads it: available once its available_on has passed, the clock's move,
7
- * asked of the machine as a write asks it. */
8
- function asOf(ctx, t) {
9
- const due = Number(t.available_on);
10
- if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now()))
11
- return t;
12
- ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
13
- return { ...t, status: 'available' };
5
+ /** A ledger entry's mark: available at once, its balance.available not yet sent (settleDueEntries). */
6
+ const UNSENT = '_availableUnsent';
7
+ const BYPASS_NUMBERS = new Set(Object.values(BYPASS_PENDING_CARDS).map((c) => c.number));
8
+ /** Whether a charge's funds go straight to the available balance: a bypass card as a raw number, a test name (pm_card_*
9
+ * or tok_*), or a stored PaymentMethod made from one (which records it as what follows its success, after-payment.ts);
10
+ * or a US bank account, stored or one of Stripe's test bank accounts named as one (pm_usBankAccount_*). */
11
+ export function bypassesPending(ctx, card) {
12
+ if (!card)
13
+ return false;
14
+ const stored = ctx.row('payment_method', card);
15
+ // the row carries what the method recorded; its type is the vendor object's (ctx.get), not the row's envelope
16
+ if (stored)
17
+ return stored._afterSuccess === 'available' || ctx.get('payment_method', card)?.type === 'us_bank_account';
18
+ if (/^pm_us_?bank_?account/i.test(card))
19
+ return true;
20
+ return BYPASS_NUMBERS.has(card.replace(/\D/g, '')) || card.replace(/^tok_/, 'pm_card_') in BYPASS_PENDING_CARDS;
14
21
  }
22
+ const feeOf = (amount) => (amount > 0 ? Math.round(amount * 0.029) + 30 : 0);
15
23
  /** The connected account a request acts for, or undefined for the platform. */
16
24
  export const actingAccount = (ctx) => ctx.call.request.headers.get('stripe-account') ?? undefined;
17
25
  /** A ledger entry on an account's balance: the acting account's unless one is named (null names the platform). */
18
26
  async function write(ctx, fields, account = actingAccount(ctx)) {
19
- const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...(account ? { _account: account } : {}) });
27
+ // a credit available at once waits for the drain's balance.available (settleDueEntries)
28
+ const unsent = (fields.status ?? 'available') === 'available' && Number(fields.net) > 0 ? { [UNSENT]: true } : {};
29
+ const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...unsent, ...(account ? { _account: account } : {}) });
20
30
  return String(bt.id);
21
31
  }
22
32
  /** Whether a stored ledger entry is on this account's balance (undefined: the platform's). */
23
33
  const onAccount = (t, account) => (typeof t._account === 'string' ? t._account : undefined) === account;
24
- /** A captured charge's credit: its amount less the fee, pending two days unless the card (a number, or a test
25
- * payment method or token named for it) settles at once. The
34
+ /** A captured charge's credit: its amount less the fee, pending two days unless the card bypasses the pending
35
+ * balance (bypassesPending), when it is due at once. The
26
36
  * caller mints the charge's id first and stores the returned id as the charge's balance_transaction. */
27
37
  export async function settleCharge(ctx, chargeId, amount, currency, card) {
28
38
  const now = Number(ctx.now());
29
39
  const fee = feeOf(amount);
30
- const settled = !!card && (card.replace(/\D/g, '') === BYPASS_PENDING || /bypassPending/i.test(card));
40
+ const atOnce = bypassesPending(ctx, card);
31
41
  const id = await write(ctx, {
32
42
  amount, currency, fee, net: amount - fee, type: 'charge', reporting_category: 'charge', source: chargeId,
33
- status: settled ? 'available' : 'pending', available_on: settled ? now : now + 2 * DAY,
43
+ ...(atOnce ? { status: 'available', available_on: now } : { status: 'pending', available_on: now + 2 * DAY }),
34
44
  fee_details: fee ? [{ amount: fee, application: null, currency, description: 'Stripe processing fees', type: 'stripe_fee' }] : [],
35
45
  });
36
46
  return id;
@@ -89,9 +99,11 @@ export async function settlePayout(ctx, payoutId, amount, currency, account = ac
89
99
  const type = amount < 0 ? 'payout' : 'payout_cancel';
90
100
  return write(ctx, { amount, currency, fee: 0, net: amount, type, reporting_category: type === 'payout' ? 'payout' : 'payout_reversal', source: payoutId, available_on: Number(ctx.now()) }, account);
91
101
  }
92
- /** A transfer's two entries: out of the platform's balance at once, into the destination's when `availableOn` comes
93
- * (a plain transfer's funds are available already). Answers the platform's entry. */
94
- export async function settleTransfer(ctx, transferId, amount, currency, destination, availableOn = Number(ctx.now()), fromPending = false) {
102
+ /** A transfer's two entries: out of the platform's balance at once, into the destination's when `fromCharge` (its
103
+ * charge's funds' availability) comes (a plain transfer, with no `fromCharge`, moves funds available already).
104
+ * Answers the platform's entry. */
105
+ export async function settleTransfer(ctx, transferId, amount, currency, destination, fromCharge, fromPending = false) {
106
+ const availableOn = fromCharge ?? Number(ctx.now());
95
107
  const settled = availableOn <= Number(ctx.now());
96
108
  // a transfer from a charge's pending funds (source_transaction) leaves the platform when they arrive, not before
97
109
  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);
@@ -119,13 +131,20 @@ export async function settleTopup(ctx, topupId, amount, currency, destination) {
119
131
  /** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
120
132
  * moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
121
133
  * sees it (asOf); the record is what lets Stripe's balance.available be sent once (stripe-server.ts, the drain).
122
- * Answers the entries that moved. */
134
+ * Funds that were available at once and not yet sent are taken too, and marked sent. Answers the entries taken. */
123
135
  export async function settleDueEntries(ctx) {
124
136
  const now = Number(ctx.now());
125
137
  const account = actingAccount(ctx);
126
138
  const moved = [];
127
139
  for (const t of ctx.rowsRaw(BT)) {
128
- if (!onAccount(t, account) || t.status !== 'pending' || !(Number(t.available_on) <= now))
140
+ if (!onAccount(t, account))
141
+ continue;
142
+ if (t.status === 'available' && t[UNSENT] === true) {
143
+ await ctx.write(BT, String(t.id), { [UNSENT]: false }, 'balance_transaction.available_sent');
144
+ moved.push(t);
145
+ continue;
146
+ }
147
+ if (t.status !== 'pending' || !(Number(t.available_on) <= now))
129
148
  continue;
130
149
  if (ctx.legal(BT, 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time'))
131
150
  continue;
@@ -126,6 +126,8 @@ export async function methodFromData(ctx, data) {
126
126
  type, customer: null, livemode: false, metadata: {},
127
127
  billing_details: { ...noBilling, ...(d.billing_details ?? {}) },
128
128
  ...paymentMethodSubObject(type, d),
129
+ // what follows its card's success (a dispute, funds straight to available), as a PaymentMethod created directly carries
130
+ ...(type === 'card' && afterSuccessOf(ctx, d.card) ? { _afterSuccess: afterSuccessOf(ctx, d.card) } : {}),
129
131
  });
130
132
  return String(made.id);
131
133
  }
@@ -43,8 +43,12 @@ export declare function inRange(t: unknown, v: unknown): boolean;
43
43
  * moment a later request caught it up. */
44
44
  export declare function at_(ctx: SemanticsContext): (t: number) => Promise<SemanticsContext>;
45
45
  export declare function where(ctx: SemanticsContext, items: Row[], spec: Record<string, (item: Row, value: unknown) => boolean>): Row[];
46
+ /** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
47
+ * asked of the machine as a write asks it (semantics/ledger.ts). */
48
+ export declare function asOf(ctx: SemanticsContext, t: Row): Row;
46
49
  /** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
47
- * holds, one segment at a time; an id the tree lacks stays an id. */
50
+ * holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
51
+ * (asOf), as its own retrieve does. */
48
52
  export declare function expandRow(ctx: SemanticsContext, resource: string, body: Row, paths: string[][]): Row;
49
53
  /** One resource with the request's `expand[]` applied. */
50
54
  export declare const expanded: (ctx: SemanticsContext, resource: string, body: Row) => Row;
@@ -109,8 +109,18 @@ function expandPaths(ctx) {
109
109
  const expand = ctx.params.expand;
110
110
  return Array.isArray(expand) ? expand.map((p) => String(p).split('.')) : [];
111
111
  }
112
+ /** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
113
+ * asked of the machine as a write asks it (semantics/ledger.ts). */
114
+ export function asOf(ctx, t) {
115
+ const due = Number(t.available_on);
116
+ if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now()))
117
+ return t;
118
+ ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
119
+ return { ...t, status: 'available' };
120
+ }
112
121
  /** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
113
- * holds, one segment at a time; an id the tree lacks stays an id. */
122
+ * holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
123
+ * (asOf), as its own retrieve does. */
114
124
  export function expandRow(ctx, resource, body, paths) {
115
125
  const embeds = manifest.resources[resource]?.embeds;
116
126
  if (!embeds || paths.length === 0)
@@ -129,9 +139,10 @@ export function expandRow(ctx, resource, body, paths) {
129
139
  if (!target)
130
140
  continue;
131
141
  const cur = out[head];
132
- const sub = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? cur : undefined;
133
- if (!sub)
142
+ const found = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? cur : undefined;
143
+ if (!found)
134
144
  continue;
145
+ const sub = target === 'balance_transaction' ? asOf(ctx, found) : found;
135
146
  const deeper = tails.filter((t) => t.length > 0);
136
147
  out = { ...out, [head]: deeper.length ? expandRow(ctx, target, sub, deeper) : sub };
137
148
  }
@@ -0,0 +1,4 @@
1
+ export declare const BYPASS_PENDING_CARDS: Record<string, {
2
+ brand: string;
3
+ number: string;
4
+ }>;
@@ -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 = {
5
+ pm_card_bypassPending: { brand: 'visa', number: '4000000000000077' },
6
+ pm_card_bypassPendingInternational: { brand: 'visa', number: '4000003720000278' },
7
+ };
@@ -55,7 +55,7 @@ const create = async (ctx) => {
55
55
  return refused;
56
56
  }
57
57
  const id = ctx.mint('transfer');
58
- const bt = await settleTransfer(ctx, id, amount, currency, destination, availableOn ?? Number(ctx.now()), sourceId !== undefined);
58
+ const bt = await settleTransfer(ctx, id, amount, currency, destination, availableOn, sourceId !== undefined);
59
59
  return ctx.reply(await created(ctx, 'transfer', { id, ...ctx.params, ...(group ? { transfer_group: group } : {}) }, {
60
60
  amount_reversed: 0, balance_transaction: bt, livemode: false, metadata: {},
61
61
  reversed: false, source_type: 'card', source_transaction: null,