@volter/twin-stripe 0.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from '../semantics/shared.js';
|
|
3
|
+
export declare const money: (minor: unknown, currency: unknown) => string;
|
|
4
|
+
export declare const COUNTRIES: {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}[];
|
|
8
|
+
/** The expiry as the customer typed it. Stripe's field formats itself as it is typed: the digits `1230` read `12 / 30`,
|
|
9
|
+
* and a first digit above 1 is a month on its own (`5` reads `05 / `), so a person types the digits straight through
|
|
10
|
+
* (Checkout's own field, seen on checkout.stripe.com; docs.stripe.com/payments/checkout names no format to type). The
|
|
11
|
+
* page's script (@volter/world-ui Payment, `format: 'card-expiry'`) shows it formatted; this reads what is posted either
|
|
12
|
+
* way: `MM / YY`, `MM/YYYY`, or the bare digits `MMYY`, `MYY` (`530`) and `MMYYYY`; a month past 12 is read, and refused
|
|
13
|
+
* as invalid by cardAnswer. */
|
|
14
|
+
export declare function parseExpiry(raw: string): {
|
|
15
|
+
month: number;
|
|
16
|
+
year: number;
|
|
17
|
+
} | undefined;
|
|
18
|
+
/** The card's answer: undefined when it pays, else what Checkout says under the button. */
|
|
19
|
+
export declare function cardAnswer(v: Record<string, string>, nowSeconds: number, chargesNow: boolean): string | undefined;
|
|
20
|
+
/** checkout.stripe.com's pages, or undefined for any other request. */
|
|
21
|
+
/** The name the page shows: the session's own `branding_settings.display_name` where the create set one, else the
|
|
22
|
+
* public business name (stripe-shared.ts, publicBusinessName) of the account whose brand Checkout uses: "Checkout uses
|
|
23
|
+
* the brand settings of the connected account for destination charges with on_behalf_of and for platforms performing
|
|
24
|
+
* direct charges" (a session made with Stripe-Account), and otherwise the platform's; "If you omit a field in
|
|
25
|
+
* branding_settings, Checkout applies the default value from the Dashboard"
|
|
26
|
+
* (docs.stripe.com/payments/checkout/customization/appearance). */
|
|
27
|
+
export declare function merchantOf(ctx: SemanticsContext, stored: Row): string;
|
|
28
|
+
export declare function stripeCheckoutFlow(scope: {
|
|
29
|
+
root?: string;
|
|
30
|
+
clock?: () => string;
|
|
31
|
+
}): (request: Request) => Promise<Response | undefined>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// STRIPE CHECKOUT — a hosted flow (docs/contributing/architecture.md, "Screens"): an application creates a
|
|
3
|
+
// Checkout Session and sends its customer to the session's `url` at checkout.stripe.com/c/pay/{id}; the
|
|
4
|
+
// customer pays there, the session completes (what the payment made is linked on it and
|
|
5
|
+
// checkout.session.completed is delivered), and Checkout redirects to success_url with
|
|
6
|
+
// {CHECKOUT_SESSION_ID} filled in, or the customer goes back to cancel_url
|
|
7
|
+
// (docs.stripe.com/payments/checkout/how-checkout-works). Paying is a move no API call makes: the external
|
|
8
|
+
// actor's move on the session's machine, taken here through the same handler context the API's handlers use.
|
|
9
|
+
// The page is authored from @volter/world-ui's payment piece under Stripe's skin, nothing of Stripe's page
|
|
10
|
+
// copied; its fields carry the ids tests of Checkout fill (email, cardNumber, cardExpiry, cardCvc,
|
|
11
|
+
// billingName, billingCountry, billingPostalCode) and its button the test id Stripe gives it.
|
|
12
|
+
//
|
|
13
|
+
// Where the documentation stops and the twin decides: test cards answer as docs.stripe.com/testing lists
|
|
14
|
+
// (4242… pays; 4000000000000002 declines; …9995 insufficient funds; …0069 expired; …0127 incorrect CVC;
|
|
15
|
+
// …0341 is saved but declines when charged, so it declines on a page that charges now and is saved on one that
|
|
16
|
+
// only sets the card up: setup mode, or a subscription's trial);
|
|
17
|
+
// any other number that passes the Luhn check pays, and a card that asks for authentication pays without
|
|
18
|
+
// the challenge. A subscription session with no customer makes one from the email, as Stripe does.
|
|
19
|
+
import { semanticsContext } from '@volter/world-core';
|
|
20
|
+
import { flowPage, Payment, PAYMENT_CSS } from '@volter/world-ui';
|
|
21
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
22
|
+
import { manifest } from "../manifest.js";
|
|
23
|
+
import { completeSession, isOneTime } from "../semantics/checkout.js";
|
|
24
|
+
import { newCustomer } from "../semantics/customers.js";
|
|
25
|
+
import { created } from "../semantics/shared.js";
|
|
26
|
+
import { publicBusinessName } from "../stripe-shared.js";
|
|
27
|
+
import { applyCouponDiscount, PLATFORM_ACCOUNT_ID, resolveTrial } from "../stripe-twin.js";
|
|
28
|
+
import { formOf } from "./consent-skin.js";
|
|
29
|
+
const CS = 'checkout.session';
|
|
30
|
+
const COMPLETE_OPERATION = surface.operations.find((o) => o.id === 'PostCheckoutSessionsSession');
|
|
31
|
+
const DECLINES = {
|
|
32
|
+
'4000000000000002': 'Your card was declined.',
|
|
33
|
+
'4000000000009995': 'Your card has insufficient funds.',
|
|
34
|
+
'4000000000000069': 'Your card has expired.',
|
|
35
|
+
'4000000000000127': "Your card's security code is incorrect.",
|
|
36
|
+
};
|
|
37
|
+
const luhn = (digits) => {
|
|
38
|
+
let sum = 0;
|
|
39
|
+
for (let i = 0; i < digits.length; i++) {
|
|
40
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
41
|
+
if (i % 2 === 1) {
|
|
42
|
+
d *= 2;
|
|
43
|
+
if (d > 9)
|
|
44
|
+
d -= 9;
|
|
45
|
+
}
|
|
46
|
+
sum += d;
|
|
47
|
+
}
|
|
48
|
+
return digits.length >= 12 && sum % 10 === 0;
|
|
49
|
+
};
|
|
50
|
+
export const money = (minor, currency) => {
|
|
51
|
+
const c = typeof currency === 'string' ? currency.toUpperCase() : 'USD';
|
|
52
|
+
const zeroDecimal = ['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'UGX', 'XAF', 'XOF'].includes(c);
|
|
53
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: c }).format((Number(minor) || 0) / (zeroDecimal ? 1 : 100));
|
|
54
|
+
};
|
|
55
|
+
// Stripe's skin: its type, its blue submit, the summary on a tinted half.
|
|
56
|
+
const SKIN = `
|
|
57
|
+
body { color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; }
|
|
58
|
+
.pay-summary { background: #f7f7f9; }
|
|
59
|
+
.pay-back, .pay-line small, .pay-total-lead span { color: #6b7280; }
|
|
60
|
+
.pay-field input, .pay-field select { border-color: #e0e0e0; background: #ffffff; }
|
|
61
|
+
.pay-error { color: #df1b41; }
|
|
62
|
+
.pay-submit { background: #0074d4; color: #ffffff; }
|
|
63
|
+
`;
|
|
64
|
+
export const COUNTRIES = [['US', 'United States'], ['GB', 'United Kingdom'], ['CA', 'Canada'], ['DE', 'Germany'], ['FR', 'France'], ['AU', 'Australia'], ['JP', 'Japan']].map(([value, label]) => ({ value: value, label: label }));
|
|
65
|
+
/** A subscription session's trial, when its subscription_data gives one: `trial_period_days`, or `trial_end`
|
|
66
|
+
* (docs.stripe.com/payments/checkout/free-trials), as resolveTrial makes the subscription's at completion. */
|
|
67
|
+
function trialOf(session, now) {
|
|
68
|
+
const data = session._subscription_data;
|
|
69
|
+
return session.mode === 'subscription' && data && typeof data === 'object' ? resolveTrial(data, now) : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** A session line's inline price_data, when it was given one (semantics/checkout.ts keeps them). */
|
|
72
|
+
const inlineOf = (session, i) => session._price_data?.[i];
|
|
73
|
+
const recurringOf = (item) => (item.price && typeof item.price === 'object' ? item.price.recurring ?? undefined : undefined);
|
|
74
|
+
/** How often a price bills, as Checkout says it after the amount: "per month". Where the documentation stops and the twin
|
|
75
|
+
* decides: a price billing every several intervals reads "every 3 months". */
|
|
76
|
+
function cadenceOf(session) {
|
|
77
|
+
const lines = (session.line_items?.data) ?? [];
|
|
78
|
+
const recurring = lines.map((it, i) => recurringOf(it) ?? inlineOf(session, i)?.recurring).find(Boolean);
|
|
79
|
+
const interval = typeof recurring?.interval === 'string' ? recurring.interval : 'month';
|
|
80
|
+
const count = Math.max(1, Math.trunc(Number(recurring?.interval_count ?? 1)) || 1);
|
|
81
|
+
return count === 1 ? `per ${interval}` : `every ${count} ${interval}s`;
|
|
82
|
+
}
|
|
83
|
+
/** What a trial's Checkout says, as checkout.stripe.com shows it for a 14-day trial of a $12.99 monthly price: the lead
|
|
84
|
+
* "14 days free" over "Then $12.99 per month", the button "Start trial", and under it "After your trial ends, you will
|
|
85
|
+
* be charged $12.99 per month starting July 7, 2025. You can always cancel before then." (a live Checkout page's
|
|
86
|
+
* screenshot, github.com/stripe/stripe-react-native/issues/1975; Stripe's docs describe no wording). Where that stops
|
|
87
|
+
* and the twin decides: a one-day trial reads "1 day free", a trial set by `trial_end` counts its days rounded up, and
|
|
88
|
+
* the amount is the session's total, what the first invoice after the trial charges. */
|
|
89
|
+
function trialSummary(session, trial) {
|
|
90
|
+
const days = Math.ceil((trial.end - trial.start) / 86400);
|
|
91
|
+
const then = `${money(afterTrial(session), session.currency)} ${cadenceOf(session)}`;
|
|
92
|
+
const date = new Date(trial.end * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
93
|
+
return {
|
|
94
|
+
lead: { label: '', amount: `${days} ${days === 1 ? 'day' : 'days'} free`, detail: `Then ${then}` },
|
|
95
|
+
note: `After your trial ends, you will be charged ${then} starting ${date}. You can always cancel before then.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** What each period after the trial charges, as the subscription's invoices will: its recurring line items (a one-time
|
|
99
|
+
* item is on the first invoice only, semantics/checkout.ts isOneTime), under the session's coupon unless the coupon is `once`, which the trial's own $0
|
|
100
|
+
* invoice takes (semantics/renewals.ts draftSubscriptionInvoice). */
|
|
101
|
+
function afterTrial(session) {
|
|
102
|
+
const items = ((session.line_items?.data) ?? []).filter((it, i) => !isOneTime(it, inlineOf(session, i)));
|
|
103
|
+
const subtotal = items.reduce((sum, it) => sum + (Number(it.amount_subtotal) || 0), 0);
|
|
104
|
+
const coupon = session._coupon;
|
|
105
|
+
return subtotal - (coupon && coupon.duration !== 'once' ? applyCouponDiscount(subtotal, coupon) : 0);
|
|
106
|
+
}
|
|
107
|
+
function page(session, id, now, values = {}, error) {
|
|
108
|
+
const lines = ((session.line_items?.data) ?? []).map((item) => ({
|
|
109
|
+
name: String(item.description ?? 'Item'),
|
|
110
|
+
amount: money(item.amount_total, item.currency ?? session.currency),
|
|
111
|
+
...(Number(item.quantity) > 1 ? { detail: `Qty ${String(item.quantity)}` } : {}),
|
|
112
|
+
}));
|
|
113
|
+
const mode = String(session.mode);
|
|
114
|
+
const merchant = String(session._merchant);
|
|
115
|
+
const trial = trialOf(session, now);
|
|
116
|
+
const submit = mode === 'setup' ? 'Save card' : mode === 'subscription' ? (trial ? 'Start trial' : 'Subscribe') : 'Pay';
|
|
117
|
+
const trialText = trial ? trialSummary(session, trial) : undefined;
|
|
118
|
+
const contact = typeof session.customer_email === 'string'
|
|
119
|
+
? [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', value: session.customer_email }]
|
|
120
|
+
: [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', placeholder: 'email@example.com', value: values.email ?? '' }];
|
|
121
|
+
const card = [
|
|
122
|
+
{ id: 'cardNumber', label: 'Card number', autoComplete: 'cc-number', placeholder: '1234 1234 1234 1234', value: values.cardNumber ?? '' },
|
|
123
|
+
{ id: 'cardExpiry', label: 'Expiration', autoComplete: 'cc-exp', placeholder: 'MM / YY', value: values.cardExpiry ?? '', format: 'card-expiry' },
|
|
124
|
+
{ id: 'cardCvc', label: 'CVC', autoComplete: 'cc-csc', placeholder: 'CVC', value: values.cardCvc ?? '' },
|
|
125
|
+
{ id: 'billingName', label: 'Cardholder name', autoComplete: 'cc-name', placeholder: 'Full name on card', value: values.billingName ?? '' },
|
|
126
|
+
{ id: 'billingCountry', label: 'Country or region', options: COUNTRIES, value: values.billingCountry ?? 'US' },
|
|
127
|
+
{ id: 'billingPostalCode', label: 'ZIP', autoComplete: 'postal-code', placeholder: 'ZIP', value: values.billingPostalCode ?? '' },
|
|
128
|
+
];
|
|
129
|
+
return flowPage({
|
|
130
|
+
title: 'Checkout',
|
|
131
|
+
css: [PAYMENT_CSS, SKIN],
|
|
132
|
+
status: error ? 402 : 200,
|
|
133
|
+
body: (_jsx(Payment, { merchant: merchant, lines: lines, ...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {}), action: `/c/pay/${id}`, sections: [{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }], submit: { label: submit, testId: 'hosted-payment-submit-button' }, ...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {}), ...(error ? { error } : {}) })),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const gone = () => flowPage({ title: 'Checkout', status: 404, css: [PAYMENT_CSS, SKIN], body: _jsx("main", { className: "pay-form", children: _jsx("h1", { children: "This Checkout Session is no longer active." }) }) });
|
|
137
|
+
/** Cards that are saved but decline when charged. */
|
|
138
|
+
const DECLINES_WHEN_CHARGED = { '4000000000000341': 'Your card was declined.' };
|
|
139
|
+
/** The expiry as the customer typed it. Stripe's field formats itself as it is typed: the digits `1230` read `12 / 30`,
|
|
140
|
+
* and a first digit above 1 is a month on its own (`5` reads `05 / `), so a person types the digits straight through
|
|
141
|
+
* (Checkout's own field, seen on checkout.stripe.com; docs.stripe.com/payments/checkout names no format to type). The
|
|
142
|
+
* page's script (@volter/world-ui Payment, `format: 'card-expiry'`) shows it formatted; this reads what is posted either
|
|
143
|
+
* way: `MM / YY`, `MM/YYYY`, or the bare digits `MMYY`, `MYY` (`530`) and `MMYYYY`; a month past 12 is read, and refused
|
|
144
|
+
* as invalid by cardAnswer. */
|
|
145
|
+
export function parseExpiry(raw) {
|
|
146
|
+
const split = /^\s*(\d{1,2})\s*\/\s*(\d{2}|\d{4})\s*$/.exec(raw);
|
|
147
|
+
const digits = raw.replace(/\s/g, '');
|
|
148
|
+
const bare = split ? undefined : /^(\d{2})(\d{2}|\d{4})$/.exec(digits) ?? /^([2-9])(\d{2})$/.exec(digits);
|
|
149
|
+
const m = split ?? bare;
|
|
150
|
+
if (!m)
|
|
151
|
+
return undefined;
|
|
152
|
+
return { month: Number(m[1]), year: m[2].length === 2 ? 2000 + Number(m[2]) : Number(m[2]) };
|
|
153
|
+
}
|
|
154
|
+
/** The card's answer: undefined when it pays, else what Checkout says under the button. */
|
|
155
|
+
export function cardAnswer(v, nowSeconds, chargesNow) {
|
|
156
|
+
const digits = (v.cardNumber ?? '').replace(/\D/g, '');
|
|
157
|
+
if (!digits)
|
|
158
|
+
return 'Your card number is incomplete.';
|
|
159
|
+
if (DECLINES[digits])
|
|
160
|
+
return DECLINES[digits];
|
|
161
|
+
if (chargesNow && DECLINES_WHEN_CHARGED[digits])
|
|
162
|
+
return DECLINES_WHEN_CHARGED[digits];
|
|
163
|
+
if (!luhn(digits))
|
|
164
|
+
return 'Your card number is invalid.';
|
|
165
|
+
const exp = parseExpiry(v.cardExpiry ?? '');
|
|
166
|
+
if (!exp)
|
|
167
|
+
return "Your card's expiration date is incomplete.";
|
|
168
|
+
const { month, year } = exp;
|
|
169
|
+
if (month < 1 || month > 12)
|
|
170
|
+
return "Your card's expiration date is invalid.";
|
|
171
|
+
const now = new Date(nowSeconds * 1000);
|
|
172
|
+
if (year < now.getUTCFullYear() || (year === now.getUTCFullYear() && month < now.getUTCMonth() + 1))
|
|
173
|
+
return "Your card's expiration year is in the past.";
|
|
174
|
+
if (!/^\d{3,4}$/.test(v.cardCvc ?? ''))
|
|
175
|
+
return "Your card's security code is incomplete.";
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
async function pay(ctx, id, session, v) {
|
|
179
|
+
const email = typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim();
|
|
180
|
+
if (session.mode !== 'setup' || !session.customer) {
|
|
181
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
182
|
+
return page(session, id, Number(ctx.now()), v, 'Your email address is incomplete.');
|
|
183
|
+
}
|
|
184
|
+
// a trial charges nothing now unless a one-time price is on the first invoice
|
|
185
|
+
const oneTimeDue = ((session.line_items?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
|
|
186
|
+
const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, Number(ctx.now())) || oneTimeDue));
|
|
187
|
+
const refused = cardAnswer(v, Number(ctx.now()), chargesNow);
|
|
188
|
+
if (refused)
|
|
189
|
+
return page(session, id, Number(ctx.now()), v, refused);
|
|
190
|
+
let existing = session;
|
|
191
|
+
// Checkout always makes a customer for a subscription; a payment makes one only when the session asks
|
|
192
|
+
if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
|
|
193
|
+
const cid = ctx.mint('customer');
|
|
194
|
+
const customer = await created(ctx, 'customer', { id: cid, email, ...(v.billingName ? { name: v.billingName } : {}) }, { livemode: false, ...newCustomer(cid) });
|
|
195
|
+
existing = { ...session, customer: customer.id };
|
|
196
|
+
}
|
|
197
|
+
const details = {
|
|
198
|
+
customer_details: { email, name: v.billingName || null, address: { country: v.billingCountry || null, postal_code: v.billingPostalCode || null, city: null, line1: null, line2: null, state: null }, phone: null, tax_exempt: 'none', tax_ids: [] },
|
|
199
|
+
...(existing.customer !== session.customer ? { customer: existing.customer } : {}),
|
|
200
|
+
};
|
|
201
|
+
const exp = parseExpiry(v.cardExpiry ?? '');
|
|
202
|
+
const done = await completeSession(ctx, id, existing, details, { number: (v.cardNumber ?? '').replace(/\D/g, ''), exp_month: exp.month, exp_year: exp.year });
|
|
203
|
+
if (done instanceof Response)
|
|
204
|
+
return gone();
|
|
205
|
+
const to = String(session.success_url).replaceAll('{CHECKOUT_SESSION_ID}', id);
|
|
206
|
+
return new Response(null, { status: 303, headers: { location: to } });
|
|
207
|
+
}
|
|
208
|
+
/** checkout.stripe.com's pages, or undefined for any other request. */
|
|
209
|
+
/** The name the page shows: the session's own `branding_settings.display_name` where the create set one, else the
|
|
210
|
+
* public business name (stripe-shared.ts, publicBusinessName) of the account whose brand Checkout uses: "Checkout uses
|
|
211
|
+
* the brand settings of the connected account for destination charges with on_behalf_of and for platforms performing
|
|
212
|
+
* direct charges" (a session made with Stripe-Account), and otherwise the platform's; "If you omit a field in
|
|
213
|
+
* branding_settings, Checkout applies the default value from the Dashboard"
|
|
214
|
+
* (docs.stripe.com/payments/checkout/customization/appearance). */
|
|
215
|
+
export function merchantOf(ctx, stored) {
|
|
216
|
+
const brand = stored._branding_settings?.display_name;
|
|
217
|
+
const behalf = stored._payment_intent_data?.on_behalf_of ?? stored._subscription_data?.on_behalf_of;
|
|
218
|
+
const account = typeof behalf === 'string' ? behalf : typeof stored._brand_account === 'string' ? stored._brand_account : PLATFORM_ACCOUNT_ID;
|
|
219
|
+
return typeof brand === 'string' && brand.trim() ? brand : publicBusinessName(ctx.get('account', account));
|
|
220
|
+
}
|
|
221
|
+
export function stripeCheckoutFlow(scope) {
|
|
222
|
+
return async (request) => {
|
|
223
|
+
const m = /^\/c\/pay\/(cs_[A-Za-z0-9_]+)\/?$/.exec(new URL(request.url).pathname);
|
|
224
|
+
if (!m || (request.method !== 'GET' && request.method !== 'POST'))
|
|
225
|
+
return undefined;
|
|
226
|
+
const id = m[1];
|
|
227
|
+
// the form is read before the context, which reads the request's body itself
|
|
228
|
+
const values = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
229
|
+
const ctx = await semanticsContext(manifest, request, COMPLETE_OPERATION, scope);
|
|
230
|
+
const session = ctx.get(CS, id);
|
|
231
|
+
if (!session)
|
|
232
|
+
return gone();
|
|
233
|
+
// an ended session's page is gone; paying on one is the customer's move the machine refuses
|
|
234
|
+
if (session.status !== 'open' && (request.method === 'GET' || ctx.legal(CS, 'status', COMPLETE_OPERATION.id, String(session.status), 'complete', id, 'external')))
|
|
235
|
+
return gone();
|
|
236
|
+
const stored = ctx.row(CS, id) ?? {};
|
|
237
|
+
const coupon = stored._discount?.coupon;
|
|
238
|
+
const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined };
|
|
239
|
+
return request.method === 'GET' ? page(full, id, Number(ctx.now())) : pay(ctx, id, full, values);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const STRIPE_CONSENT_SKIN = "\nbody { background: #f6f9fc; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Ubuntu, sans-serif; }\n.consent-badge { background: #ffffff; border: 1px solid #e3e8ee; color: #1a1f36; }\n.consent-vendor { background: #635bff; border-color: #635bff; }\n.consent-link { border-color: #c1c9d2; }\n.consent-box { background: #ffffff; border-color: #e3e8ee; }\n.consent-who small, .consent-permission summary small, .consent-note { color: #697386; }\n.consent-actions { border-color: #e3e8ee; }\n.consent-deny { background: #ffffff; border-color: #c1c9d2; color: #1a1f36; }\n.consent-allow { background: #635bff; border-color: #635bff; color: #ffffff; }\n";
|
|
2
|
+
/** The form fields a page's POST carries, as a browser posts them. */
|
|
3
|
+
export declare function formOf(request: Request): Promise<Record<string, string>>;
|
|
4
|
+
export declare const seeOther: (location: string) => Response;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Stripe's skin over @volter/world-ui's consent piece, for the hosted flows where a person agrees to something
|
|
2
|
+
// (connected-account onboarding, identity verification, linking a bank): Stripe's type and its purple action.
|
|
3
|
+
export const STRIPE_CONSENT_SKIN = `
|
|
4
|
+
body { background: #f6f9fc; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; }
|
|
5
|
+
.consent-badge { background: #ffffff; border: 1px solid #e3e8ee; color: #1a1f36; }
|
|
6
|
+
.consent-vendor { background: #635bff; border-color: #635bff; }
|
|
7
|
+
.consent-link { border-color: #c1c9d2; }
|
|
8
|
+
.consent-box { background: #ffffff; border-color: #e3e8ee; }
|
|
9
|
+
.consent-who small, .consent-permission summary small, .consent-note { color: #697386; }
|
|
10
|
+
.consent-actions { border-color: #e3e8ee; }
|
|
11
|
+
.consent-deny { background: #ffffff; border-color: #c1c9d2; color: #1a1f36; }
|
|
12
|
+
.consent-allow { background: #635bff; border-color: #635bff; color: #ffffff; }
|
|
13
|
+
`;
|
|
14
|
+
/** The form fields a page's POST carries, as a browser posts them. */
|
|
15
|
+
export async function formOf(request) {
|
|
16
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
17
|
+
}
|
|
18
|
+
export const seeOther = (location) => new Response(null, { status: 302, headers: { location } });
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// STRIPE FINANCIAL CONNECTIONS — a hosted flow (docs/contributing/architecture.md, "Screens"): an application
|
|
3
|
+
// creates a Financial Connections Session for an account holder and the permissions it wants, and hands its
|
|
4
|
+
// client_secret to Stripe.js, whose authentication flow lets the holder pick their bank and the accounts to share;
|
|
5
|
+
// the linked accounts are then on the session and readable through the Accounts API, with balances and
|
|
6
|
+
// transactions when the permissions asked for them (docs.stripe.com/financial-connections/fundamentals,
|
|
7
|
+
// docs.stripe.com/financial-connections/other-data-powered-products). In test mode the bank is Stripe's test
|
|
8
|
+
// institution (docs.stripe.com/financial-connections/testing). Linking is the holder's move, not an API call.
|
|
9
|
+
// Authored from @volter/world-ui's consent piece under Stripe's skin; nothing of Stripe's page is copied.
|
|
10
|
+
//
|
|
11
|
+
// The flow is opened with the session's client_secret, never its id: the session's own documentation says "The
|
|
12
|
+
// session's client_secret can be used to launch the flow using Stripe.js", and stripe.collectFinancialConnectionsAccounts
|
|
13
|
+
// takes `clientSecret`, "The client_secret of the Financial Connections Session"
|
|
14
|
+
// (docs.stripe.com/js/financial_connections/collect_financial_connections_accounts).
|
|
15
|
+
// Where the documentation stops and the twin decides: the page is served at
|
|
16
|
+
// js.stripe.com/v3/financial-connections/{client_secret}, the twin's address for the "on-page modal UI" Stripe.js
|
|
17
|
+
// loads (the twin's stripe.js navigates there, ../stripe-js.ts); an id or an unknown secret finds no flow;
|
|
18
|
+
// the test institution holds a checking account (•••• 6789) and a savings account (•••• 4321); a linked account's
|
|
19
|
+
// balance and its three transactions are the twin's test data.
|
|
20
|
+
import { semanticsContext } from '@volter/world-core';
|
|
21
|
+
import { Consent, CONSENT_CSS, flowPage } from '@volter/world-ui';
|
|
22
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
23
|
+
import { manifest } from "../manifest.js";
|
|
24
|
+
import { created } from "../semantics/shared.js";
|
|
25
|
+
import { formOf, seeOther, STRIPE_CONSENT_SKIN } from "./consent-skin.js";
|
|
26
|
+
const FCS = 'financial_connections.session';
|
|
27
|
+
const FCA = 'financial_connections.account';
|
|
28
|
+
const FCT = 'financial_connections.transaction';
|
|
29
|
+
const OPERATION = surface.operations.find((o) => o.id === 'GetFinancialConnectionsSessionsSession');
|
|
30
|
+
const css = [CONSENT_CSS, STRIPE_CONSENT_SKIN];
|
|
31
|
+
const TEST_ACCOUNTS = [
|
|
32
|
+
{ last4: '6789', subcategory: 'checking', display_name: 'Checking', balance: 125_000 },
|
|
33
|
+
{ last4: '4321', subcategory: 'savings', display_name: 'Savings', balance: 980_000 },
|
|
34
|
+
];
|
|
35
|
+
const message = (status, text) => flowPage({ title: 'Link your account', status, css, body: _jsx("main", { className: "consent", children: _jsx("h1", { className: "consent-heading", children: text }) }) });
|
|
36
|
+
const permissionsOf = (session) => (Array.isArray(session.permissions) ? session.permissions.map(String) : []);
|
|
37
|
+
function page(secret, session) {
|
|
38
|
+
const says = { balances: 'Account balances', ownership: 'Account ownership details', payment_method: 'Account and routing numbers', transactions: 'Transactions' };
|
|
39
|
+
return flowPage({
|
|
40
|
+
title: 'Link your account',
|
|
41
|
+
css,
|
|
42
|
+
body: (_jsx(Consent, { heading: "Link your account", app: { name: 'Test Institution' }, request: `Test mode · ${TEST_ACCOUNTS.map((a) => `${a.display_name} •••• ${a.last4}`).join(', ')}`, permissions: permissionsOf(session).map((p) => ({ title: says[p] ?? p, detail: 'Shared with the business through Stripe' })), action: `/v3/financial-connections/${secret}`, fields: {}, deny: { name: 'answer', value: 'cancel', label: 'Cancel' }, allow: { name: 'answer', value: 'link', label: 'Link accounts' }, note: "Stripe will share the data above with the business." })),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** The holder links the test institution's accounts to the session. */
|
|
46
|
+
async function link(ctx, id, session) {
|
|
47
|
+
const permissions = permissionsOf(session);
|
|
48
|
+
const now = Number(ctx.now());
|
|
49
|
+
for (const a of TEST_ACCOUNTS) {
|
|
50
|
+
const account = await created(ctx, FCA, { session: id }, {
|
|
51
|
+
livemode: false, status: 'active', account_holder: session.account_holder ?? null, institution_name: 'StripeBank', display_name: a.display_name,
|
|
52
|
+
last4: a.last4, category: 'cash', subcategory: a.subcategory, supported_payment_method_types: ['us_bank_account', 'link'], permissions,
|
|
53
|
+
balance: permissions.includes('balances') ? { as_of: now, type: 'cash', cash: { available: { usd: a.balance } }, current: { usd: a.balance } } : null,
|
|
54
|
+
balance_refresh: permissions.includes('balances') ? { status: 'succeeded', last_attempted_at: now, next_refresh_available_at: null } : null,
|
|
55
|
+
transaction_refresh: permissions.includes('transactions') ? { id: `fctxnref_${a.last4}`, status: 'succeeded', last_attempted_at: now, next_refresh_available_at: null } : null,
|
|
56
|
+
ownership: null, ownership_refresh: null, subscriptions: [],
|
|
57
|
+
});
|
|
58
|
+
if (!permissions.includes('transactions'))
|
|
59
|
+
continue;
|
|
60
|
+
for (const [i, t] of [{ amount: -4_250, description: 'COFFEE ROASTERS' }, { amount: -12_000, description: 'CITY UTILITIES' }, { amount: 250_000, description: 'PAYROLL DEPOSIT' }].entries()) {
|
|
61
|
+
await created(ctx, FCT, { account: account.id }, {
|
|
62
|
+
livemode: false, amount: t.amount, currency: 'usd', description: t.description, status: 'posted',
|
|
63
|
+
status_transitions: { posted_at: now - (3 - i) * 86_400, void_at: null }, transacted_at: now - (3 - i) * 86_400, transaction_refresh: `fctxnref_${a.last4}`, updated: now,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The authentication flow Stripe.js opens, or undefined for any other request. */
|
|
69
|
+
export function stripeFinancialConnectionsFlow(scope) {
|
|
70
|
+
return async (request) => {
|
|
71
|
+
const m = /^\/v3\/financial-connections\/([A-Za-z0-9_]+)\/?$/.exec(new URL(request.url).pathname);
|
|
72
|
+
if (!m || (request.method !== 'GET' && request.method !== 'POST'))
|
|
73
|
+
return undefined;
|
|
74
|
+
const secret = m[1];
|
|
75
|
+
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
76
|
+
const ctx = await semanticsContext(manifest, request, OPERATION, scope);
|
|
77
|
+
const session = ctx.rows(FCS).find((s) => s.client_secret === secret);
|
|
78
|
+
if (!session)
|
|
79
|
+
return message(404, 'This link has expired.');
|
|
80
|
+
const id = String(session.id);
|
|
81
|
+
if (ctx.rows(FCA).some((a) => a.session === id))
|
|
82
|
+
return message(200, 'Your accounts are linked.');
|
|
83
|
+
if (request.method === 'GET')
|
|
84
|
+
return page(secret, session);
|
|
85
|
+
if (form.answer === 'link')
|
|
86
|
+
await link(ctx, id, session);
|
|
87
|
+
const back = typeof session.return_url === 'string' && session.return_url ? session.return_url : undefined;
|
|
88
|
+
return back ? seeOther(back) : message(200, form.answer === 'link' ? 'Your accounts are linked.' : 'No accounts were linked.');
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// STRIPE IDENTITY — a hosted flow (docs/contributing/architecture.md, "Screens"): an application creates a
|
|
3
|
+
// VerificationSession and sends the person to its `url` at verify.stripe.com/start/{session}; they submit their
|
|
4
|
+
// ID there, the session is processing and then verified, with a VerificationReport of what was checked, or it
|
|
5
|
+
// asks for input again with the reason in last_error; the person returns to return_url
|
|
6
|
+
// (docs.stripe.com/identity/how-sessions-work, docs.stripe.com/identity/verification-sessions). In test mode the
|
|
7
|
+
// page offers the outcome instead of a camera (the twin's decision: docs.stripe.com/identity/testing, once cited
|
|
8
|
+
// for it, no longer answers). Submitting is the person's move
|
|
9
|
+
// (the session machine's external actor) and the check Stripe's (its vendor moves). Authored from
|
|
10
|
+
// @volter/world-ui's consent piece under Stripe's skin; nothing of Stripe's page is copied.
|
|
11
|
+
//
|
|
12
|
+
// Where the documentation stops and the twin decides: test mode's person is Jenny Rosen with a driving licence,
|
|
13
|
+
// the name Stripe's test reports carry; a failed check is document_unverified_other.
|
|
14
|
+
import { semanticsContext } from '@volter/world-core';
|
|
15
|
+
import { Consent, CONSENT_CSS, flowPage } from '@volter/world-ui';
|
|
16
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
17
|
+
import { manifest } from "../manifest.js";
|
|
18
|
+
import { created } from "../semantics/shared.js";
|
|
19
|
+
import { formOf, seeOther, STRIPE_CONSENT_SKIN } from "./consent-skin.js";
|
|
20
|
+
const VS = 'identity.verification_session';
|
|
21
|
+
const OPERATION = surface.operations.find((o) => o.id === 'GetIdentityVerificationSessionsSession');
|
|
22
|
+
const css = [CONSENT_CSS, STRIPE_CONSENT_SKIN];
|
|
23
|
+
const message = (status, text) => flowPage({ title: 'Stripe Identity', status, css, body: _jsx("main", { className: "consent", children: _jsx("h1", { className: "consent-heading", children: text }) }) });
|
|
24
|
+
function page(id, session) {
|
|
25
|
+
const kind = session.type === 'id_number' ? 'ID number' : 'Government-issued photo ID';
|
|
26
|
+
return flowPage({
|
|
27
|
+
title: 'Verify your identity',
|
|
28
|
+
css,
|
|
29
|
+
body: (_jsx(Consent, { heading: "Verify your identity", app: { name: 'Stripe Identity' }, request: "Test mode: choose how the verification ends", permissions: [
|
|
30
|
+
{ title: kind, detail: 'Test data: Jenny Rosen, driving licence' },
|
|
31
|
+
...(session.options?.document?.require_matching_selfie === true ? [{ title: 'Selfie', detail: 'Matched to the ID photo' }] : []),
|
|
32
|
+
], action: `/start/${id}`, fields: {}, deny: { name: 'outcome', value: 'unverified', label: 'Fail verification' }, allow: { name: 'outcome', value: 'verified', label: 'Verify' }, note: "Stripe uses this data only to verify your identity." })),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** The person submits; Stripe checks and writes the report, and the session is verified or asks again. */
|
|
36
|
+
async function submit(ctx, id, session, verified) {
|
|
37
|
+
if (ctx.legal(VS, 'status', OPERATION.id, String(session.status), 'processing', id, 'external'))
|
|
38
|
+
return;
|
|
39
|
+
// Stripe's check decides the processing session at once: the second move of the one write below
|
|
40
|
+
ctx.legal(VS, 'status', OPERATION.id, 'processing', verified ? 'verified' : 'requires_input', id, 'vendor');
|
|
41
|
+
const type = session.type === 'id_number' ? 'id_number' : 'document';
|
|
42
|
+
const error = verified ? null : { code: 'document_unverified_other', reason: 'The document could not be verified.' };
|
|
43
|
+
// a document check holds its images: files "Array of File ids containing images for this document" (the served
|
|
44
|
+
// spec's document report). Where the documentation stops and the twin decides: the licence's front and back, two
|
|
45
|
+
// identity_document files, as the published report's two
|
|
46
|
+
const images = [];
|
|
47
|
+
if (type === 'document')
|
|
48
|
+
for (const side of ['front', 'back']) {
|
|
49
|
+
const f = await created(ctx, 'file', { purpose: 'identity_document' }, { filename: `driving_license_${side}.jpg`, size: 0, type: 'jpg', title: null, expires_at: null, url: null, links: { object: 'list', data: [], has_more: false, url: '' } });
|
|
50
|
+
images.push(String(f.id));
|
|
51
|
+
}
|
|
52
|
+
// the report records the options the session was checked with (the session's own, as its object answers them)
|
|
53
|
+
const sessionOptions = session.options ?? {};
|
|
54
|
+
const report = await created(ctx, 'identity.verification_report', { verification_session: id, type }, {
|
|
55
|
+
livemode: false, client_reference_id: session.client_reference_id ?? null,
|
|
56
|
+
document: type === 'document' ? { status: verified ? 'verified' : 'unverified', type: 'driving_license', first_name: 'Jenny', last_name: 'Rosen', address: null, dob: null, error, files: images } : null,
|
|
57
|
+
id_number: type === 'id_number' ? { status: verified ? 'verified' : 'unverified', id_number_type: 'us_ssn', first_name: 'Jenny', last_name: 'Rosen', dob: null, error } : null,
|
|
58
|
+
selfie: null, email: null, phone: null,
|
|
59
|
+
options: type === 'document' ? { document: sessionOptions.document ?? {} } : { id_number: sessionOptions.id_number ?? {} },
|
|
60
|
+
});
|
|
61
|
+
// the url "can only be used once" (the served spec): spent by the submission, as the verification sessions page's
|
|
62
|
+
// examples answer a verified and a requires_input session with url null
|
|
63
|
+
await ctx.write(VS, id, verified
|
|
64
|
+
? { status: 'verified', last_verification_report: report.id, last_error: null, url: null, client_secret: null, verified_outputs: { first_name: 'Jenny', last_name: 'Rosen' } }
|
|
65
|
+
: { status: 'requires_input', last_verification_report: report.id, last_error: error, url: null }, verified ? 'verification_session.verified' : 'verification_session.requires_input');
|
|
66
|
+
}
|
|
67
|
+
/** verify.stripe.com's pages, or undefined for any other request. */
|
|
68
|
+
export function stripeIdentityFlow(scope) {
|
|
69
|
+
return async (request) => {
|
|
70
|
+
const m = /^\/start\/(vs_[A-Za-z0-9_]+)\/?$/.exec(new URL(request.url).pathname);
|
|
71
|
+
if (!m || (request.method !== 'GET' && request.method !== 'POST'))
|
|
72
|
+
return undefined;
|
|
73
|
+
const id = m[1];
|
|
74
|
+
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
75
|
+
const ctx = await semanticsContext(manifest, request, OPERATION, scope);
|
|
76
|
+
const session = ctx.get(VS, id);
|
|
77
|
+
if (!session)
|
|
78
|
+
return message(404, 'This verification link is invalid.');
|
|
79
|
+
if (session.status !== 'requires_input')
|
|
80
|
+
return message(200, session.status === 'verified' ? 'You are verified.' : 'This verification has ended.');
|
|
81
|
+
if (request.method === 'GET')
|
|
82
|
+
return page(id, session);
|
|
83
|
+
await submit(ctx, id, session, form.outcome === 'verified');
|
|
84
|
+
return typeof session.return_url === 'string' && session.return_url ? seeOther(session.return_url) : message(200, form.outcome === 'verified' ? 'You are verified.' : 'We could not verify your identity.');
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const INDUSTRIES: ReadonlyArray<readonly [mcc: string, name: string]>;
|