@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
|
@@ -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
|
|
162
|
-
//
|
|
163
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
6
|
-
*
|
|
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 `
|
|
31
|
-
* (a plain transfer
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
*
|
|
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
|
|
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
|
-
|
|
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 `
|
|
93
|
-
* (a plain transfer
|
|
94
|
-
|
|
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
|
|
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)
|
|
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
|
|
133
|
-
if (!
|
|
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,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
|
|
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,
|