@volter/twin-stripe 0.1.2 → 2.0.1
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 +96 -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 +75 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1070 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +255 -0
- 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/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 +216 -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 +99 -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 +209 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +316 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +493 -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 +545 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +575 -0
- package/dist/src/semantics/ledger.d.ts +59 -0
- package/dist/src/semantics/ledger.js +200 -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 +140 -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 +82 -0
- package/dist/src/semantics/shared.js +203 -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-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -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 +5695 -0
- package/dist/src/stripe-conformance.d.ts +43 -0
- package/dist/src/stripe-conformance.js +105 -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 +392 -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 +393 -0
- package/dist/src/stripe-shared.d.ts +109 -0
- package/dist/src/stripe-shared.js +276 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1232 -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 +12 -0
- package/dist/src/stripe-version.js +287 -0
- package/dist/test-fixtures/stripe-known-deviations.json +110 -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 +3813 -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 +34 -10
- package/src/manifest.ts +1102 -0
- package/src/screens/checkout.tsx +267 -0
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -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 +220 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +118 -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 +220 -0
- package/src/semantics/checkout.ts +310 -0
- package/src/semantics/connect.ts +487 -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 +545 -0
- package/src/semantics/issuing.ts +590 -0
- package/src/semantics/ledger.ts +253 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +145 -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 +226 -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-cards.ts +7 -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 +2258 -380
- package/src/stripe-conformance.ts +19 -7
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +15 -8
- package/src/stripe-events.ts +102 -40
- 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 +318 -38
- package/src/stripe-shared.ts +297 -0
- package/src/stripe-twin.ts +434 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +281 -0
- package/test-fixtures/stripe-known-deviations.json +8 -8
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/test-fixtures/stripe-schemas.json +85 -12
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// STRIPE CONNECT HOSTED ONBOARDING — a hosted flow (docs/contributing/architecture.md, "Screens"): a platform
|
|
2
|
+
// creates an Account Link for a connected account and sends the account's owner to its `url`. The link is a
|
|
3
|
+
// single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
|
|
4
|
+
// expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
|
|
5
|
+
// At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
|
|
6
|
+
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece; nothing of Stripe's page is copied.
|
|
7
|
+
//
|
|
8
|
+
// The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
|
|
9
|
+
// take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
|
|
10
|
+
// requirements endpoint answers, docs.stripe.com/_endpoint/get-requirements-for-setups with apiVersion=v1,
|
|
11
|
+
// platformCountry=US, accountCountry=US, dashboardType=express, tosType=full, capabilities card_payments and transfers):
|
|
12
|
+
// thirty requirements for a company (the business profile, the statement descriptor, the company with its tax id,
|
|
13
|
+
// phone and address, its owners, its representative with their title and address, the agreement and the payout bank
|
|
14
|
+
// account) and eighteen for an individual. A submit that leaves any out is refused with what is missing. The account's
|
|
15
|
+
// requirements.currently_due lists what is still missing, and charges and payouts are enabled, and its requested
|
|
16
|
+
// capabilities become active (Stripe's move on the capability machine), only once none is. The agreement is the
|
|
17
|
+
// page's own checkbox, recorded as tos_acceptance with the request's address and the time.
|
|
18
|
+
//
|
|
19
|
+
// Where the documentation stops and the twin decides: only these two US sets are modelled; an account elsewhere, or
|
|
20
|
+
// of another business type, is told its requirements are not modelled. Test mode verifies at once whatever is
|
|
21
|
+
// submitted (docs.stripe.com/connect/testing lists the values that pass: date of birth 1901-01-01, SSN last four
|
|
22
|
+
// 0000, tax id 000000000, routing 110000000, account 000123456789); the industries offered are Stripe's supported
|
|
23
|
+
// merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
|
|
24
|
+
// business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
|
|
25
|
+
// records 127.0.0.1. An update link (account_update) shows the same page.
|
|
26
|
+
import { semanticsContext, type SemanticsContext } from '@volter/world-core';
|
|
27
|
+
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentField } from '@volter/world-ui';
|
|
28
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
29
|
+
import { manifest } from '../manifest.ts';
|
|
30
|
+
import { created, syncExternals, type Row } from '../semantics/shared.ts';
|
|
31
|
+
import { formOf, seeOther, STRIPE_CONSENT_SKIN } from './consent-skin.ts';
|
|
32
|
+
import { INDUSTRIES as MCCS } from './industries.ts';
|
|
33
|
+
|
|
34
|
+
const LINK = 'account_link';
|
|
35
|
+
const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostAccountsAccount')!;
|
|
36
|
+
|
|
37
|
+
const gone = (): Response => flowPage({ title: 'Stripe', status: 404, css: [CONSENT_CSS, STRIPE_CONSENT_SKIN], body: <main className="consent"><h1 className="consent-heading">This link is invalid.</h1></main> });
|
|
38
|
+
|
|
39
|
+
/** A link that can still be used: not used and not past its expiry. */
|
|
40
|
+
const usable = (ctx: SemanticsContext, link: Row): boolean => link.used !== true && Number(link.expires_at) > Number(ctx.now());
|
|
41
|
+
|
|
42
|
+
// the field asks, with no industry chosen until the person chooses one (an unanswered field leaves the MCC due)
|
|
43
|
+
const INDUSTRIES = [{ value: '', label: 'Select your industry' }, ...MCCS.map(([value, label]) => ({ value, label }))];
|
|
44
|
+
const OFFERED = new Set(MCCS.map(([mcc]) => mcc));
|
|
45
|
+
// the fifty states and the District of Columbia, by their USPS codes (the address's `state`, ISO 3166-2 without its
|
|
46
|
+
// country, docs.stripe.com/api/accounts/object)
|
|
47
|
+
const STATES = ([
|
|
48
|
+
['AL', 'Alabama'], ['AK', 'Alaska'], ['AZ', 'Arizona'], ['AR', 'Arkansas'], ['CA', 'California'], ['CO', 'Colorado'], ['CT', 'Connecticut'],
|
|
49
|
+
['DE', 'Delaware'], ['DC', 'District of Columbia'], ['FL', 'Florida'], ['GA', 'Georgia'], ['HI', 'Hawaii'], ['ID', 'Idaho'], ['IL', 'Illinois'],
|
|
50
|
+
['IN', 'Indiana'], ['IA', 'Iowa'], ['KS', 'Kansas'], ['KY', 'Kentucky'], ['LA', 'Louisiana'], ['ME', 'Maine'], ['MD', 'Maryland'],
|
|
51
|
+
['MA', 'Massachusetts'], ['MI', 'Michigan'], ['MN', 'Minnesota'], ['MS', 'Mississippi'], ['MO', 'Missouri'], ['MT', 'Montana'], ['NE', 'Nebraska'],
|
|
52
|
+
['NV', 'Nevada'], ['NH', 'New Hampshire'], ['NJ', 'New Jersey'], ['NM', 'New Mexico'], ['NY', 'New York'], ['NC', 'North Carolina'],
|
|
53
|
+
['ND', 'North Dakota'], ['OH', 'Ohio'], ['OK', 'Oklahoma'], ['OR', 'Oregon'], ['PA', 'Pennsylvania'], ['RI', 'Rhode Island'],
|
|
54
|
+
['SC', 'South Carolina'], ['SD', 'South Dakota'], ['TN', 'Tennessee'], ['TX', 'Texas'], ['UT', 'Utah'], ['VT', 'Vermont'], ['VA', 'Virginia'],
|
|
55
|
+
['WA', 'Washington'], ['WV', 'West Virginia'], ['WI', 'Wisconsin'], ['WY', 'Wyoming'],
|
|
56
|
+
] as const).map(([value, label]) => ({ value, label }));
|
|
57
|
+
|
|
58
|
+
type Form = Record<string, string>;
|
|
59
|
+
type Kind = 'company' | 'individual';
|
|
60
|
+
const has = (v: Form, k: string): boolean => (v[k] ?? '').trim() !== '';
|
|
61
|
+
const dobPart = (v: Form, i: number): boolean => /^\d{4}-\d{2}-\d{2}$/.test(v.dob ?? '') && Number((v.dob ?? '').split('-')[i]) > 0;
|
|
62
|
+
|
|
63
|
+
/** Each requirement Stripe names, the form's answer that meets it, and which of the two sets it belongs to. */
|
|
64
|
+
const REQUIREMENTS: Array<[string, (v: Form) => boolean, Kind[]]> = [
|
|
65
|
+
['business_profile.mcc', (v) => OFFERED.has((v.mcc ?? '').trim()), ['company', 'individual']],
|
|
66
|
+
['business_profile.url', (v) => has(v, 'url'), ['company', 'individual']],
|
|
67
|
+
['settings.payments.statement_descriptor', (v) => has(v, 'statement_descriptor'), ['company', 'individual']],
|
|
68
|
+
['company.name', (v) => has(v, 'company_name'), ['company']],
|
|
69
|
+
['company.tax_id', (v) => has(v, 'company_tax_id'), ['company']],
|
|
70
|
+
['company.phone', (v) => has(v, 'company_phone'), ['company']],
|
|
71
|
+
['company.address.line1', (v) => has(v, 'company_line1'), ['company']],
|
|
72
|
+
['company.address.city', (v) => has(v, 'company_city'), ['company']],
|
|
73
|
+
['company.address.state', (v) => has(v, 'company_state'), ['company']],
|
|
74
|
+
['company.address.postal_code', (v) => has(v, 'company_postal_code'), ['company']],
|
|
75
|
+
['company.owners_provided', (v) => v.owners_provided === 'provided', ['company']],
|
|
76
|
+
['owners.first_name', (v) => has(v, 'owner_first_name'), ['company']],
|
|
77
|
+
['owners.last_name', (v) => has(v, 'owner_last_name'), ['company']],
|
|
78
|
+
['owners.email', (v) => has(v, 'owner_email'), ['company']],
|
|
79
|
+
...(['first_name', 'last_name', 'email', 'phone'] as const).flatMap((f): Array<[string, (v: Form) => boolean, Kind[]]> => [[`representative.${f}`, (v) => has(v, f), ['company']], [`individual.${f}`, (v) => has(v, f), ['individual']]]),
|
|
80
|
+
...(['day', 'month', 'year'] as const).flatMap((f, i): Array<[string, (v: Form) => boolean, Kind[]]> => [[`representative.dob.${f}`, (v) => dobPart(v, 2 - i), ['company']], [`individual.dob.${f}`, (v) => dobPart(v, 2 - i), ['individual']]]),
|
|
81
|
+
...(['line1', 'city', 'state', 'postal_code'] as const).flatMap((f): Array<[string, (v: Form) => boolean, Kind[]]> => [[`representative.address.${f}`, (v) => has(v, f), ['company']], [`individual.address.${f}`, (v) => has(v, f), ['individual']]]),
|
|
82
|
+
['representative.relationship.title', (v) => has(v, 'title'), ['company']],
|
|
83
|
+
['representative.ssn_last_4', (v) => /^\d{4}$/.test(v.ssn_last_4 ?? ''), ['company']],
|
|
84
|
+
['individual.ssn_last_4', (v) => /^\d{4}$/.test(v.ssn_last_4 ?? ''), ['individual']],
|
|
85
|
+
['tos_acceptance.date', (v) => v.tos === 'accepted', ['company', 'individual']],
|
|
86
|
+
['tos_acceptance.ip', (v) => v.tos === 'accepted', ['company', 'individual']],
|
|
87
|
+
['external_account', (v) => has(v, 'routing_number') && has(v, 'account_number'), ['company', 'individual']],
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** The set an account onboards against: the two US sets the twin models, or none. */
|
|
91
|
+
export function kindOf(account: Row): Kind | undefined {
|
|
92
|
+
const type = account.business_type ?? 'individual';
|
|
93
|
+
return (account.country ?? 'US') === 'US' && (type === 'company' || type === 'individual') ? type : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What an account of this kind still owes, given the form's answers (none: all of it). */
|
|
97
|
+
export function currentlyDue(kind: Kind, v: Form = {}): string[] {
|
|
98
|
+
return REQUIREMENTS.filter(([, met, kinds]) => kinds.includes(kind) && !met(v)).map(([req]) => req);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const field = (id: string, label: string, v: Form, extra: Partial<PaymentField> = {}): PaymentField => ({ id, label, value: v[id] ?? '', ...extra });
|
|
102
|
+
const addressFields = (prefix: string, v: Form): PaymentField[] => [
|
|
103
|
+
field(`${prefix}line1`, 'Address', v, { autoComplete: 'address-line1' }), field(`${prefix}city`, 'City', v),
|
|
104
|
+
field(`${prefix}state`, 'State', v, { options: STATES, value: v[`${prefix}state`] ?? 'OR' }), field(`${prefix}postal_code`, 'ZIP', v, { autoComplete: 'postal-code' }),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** The industry the field shows: what the form sent, else what the account already holds, when the page offers it. */
|
|
108
|
+
const industryOf = (mcc: unknown): string => (typeof mcc === 'string' && OFFERED.has(mcc.trim()) ? mcc.trim() : '');
|
|
109
|
+
|
|
110
|
+
function page(ctx: SemanticsContext, sessionUrl: string, link: Row, kind: Kind, values: Form = {}, error?: string): Response {
|
|
111
|
+
const v = values;
|
|
112
|
+
const business: PaymentField[] = [
|
|
113
|
+
field('mcc', 'Industry', v, { options: INDUSTRIES, value: industryOf(v.mcc ?? (ctx.get('account', String(link.account))?.business_profile as Row | undefined)?.mcc) }),
|
|
114
|
+
field('url', 'Business website', v, { placeholder: 'https://' }),
|
|
115
|
+
field('statement_descriptor', 'Statement descriptor (what customers see on their card statements)', v),
|
|
116
|
+
];
|
|
117
|
+
const company: PaymentField[] = [
|
|
118
|
+
field('company_name', 'Legal business name', v), field('company_tax_id', 'Employer Identification Number (EIN)', v), field('company_phone', 'Business phone', v, { type: 'tel' }),
|
|
119
|
+
...addressFields('company_', v),
|
|
120
|
+
];
|
|
121
|
+
const owners: PaymentField[] = [
|
|
122
|
+
field('owner_first_name', 'Owner first name', v), field('owner_last_name', 'Owner last name', v), field('owner_email', 'Owner email', v, { type: 'email' }),
|
|
123
|
+
field('owners_provided', 'All owners of 25% or more are listed', v, { type: 'checkbox', value: 'provided' }),
|
|
124
|
+
];
|
|
125
|
+
const person: PaymentField[] = [
|
|
126
|
+
field('first_name', 'First name', v, { autoComplete: 'given-name' }), field('last_name', 'Last name', v, { autoComplete: 'family-name' }),
|
|
127
|
+
field('email', 'Email', v, { type: 'email' }), field('phone', 'Phone', v, { type: 'tel' }), field('dob', 'Date of birth', v, { type: 'date' }),
|
|
128
|
+
...(kind === 'company' ? [field('title', 'Job title', v)] : []),
|
|
129
|
+
...addressFields('', v), field('ssn_last_4', 'Last 4 digits of SSN', v),
|
|
130
|
+
];
|
|
131
|
+
const payout: PaymentField[] = [field('routing_number', 'Routing number', v), field('account_number', 'Account number', v)];
|
|
132
|
+
const agreement: PaymentField[] = [field('tos', 'I agree to the Stripe Connected Account Agreement', v, { type: 'checkbox', value: 'accepted' })];
|
|
133
|
+
const sections = kind === 'company'
|
|
134
|
+
? [{ heading: 'Business details', fields: business }, { heading: 'Company', fields: company }, { heading: 'Owners', fields: owners }, { heading: 'Representative', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }]
|
|
135
|
+
: [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
|
|
136
|
+
return flowPage({
|
|
137
|
+
title: 'Stripe · Account onboarding',
|
|
138
|
+
css: [PAYMENT_CSS],
|
|
139
|
+
...(error ? { status: 400 } : {}),
|
|
140
|
+
body: (
|
|
141
|
+
<Payment
|
|
142
|
+
merchant={`Verify your ${kind === 'company' ? 'company' : 'business'} with Stripe · ${String(link.account)} · test mode`}
|
|
143
|
+
lines={[]}
|
|
144
|
+
action={sessionUrl}
|
|
145
|
+
sections={sections}
|
|
146
|
+
submit={{ label: 'Submit' }}
|
|
147
|
+
back={{ href: String(link.return_url), label: '← Return to platform' }}
|
|
148
|
+
{...(error ? { error } : {})}
|
|
149
|
+
/>
|
|
150
|
+
),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An account whose requirements the twin does not model: outside the US, or of another business type. */
|
|
155
|
+
function unmodelled(account: Row): Response {
|
|
156
|
+
return flowPage({ title: 'Stripe', status: 501, css: [CONSENT_CSS, STRIPE_CONSENT_SKIN], body: <main className="consent"><h1 className="consent-heading">{`Onboarding requirements for a ${String(account.business_type ?? 'business')} account in ${String(account.country ?? 'this country')} are not modelled by this twin.`}</h1></main> });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A submit that leaves something out is refused on the page with what is missing, the owner's answers kept. */
|
|
160
|
+
function incomplete(ctx: SemanticsContext, sessionUrl: string, link: Row, kind: Kind, v: Form, missing: string[]): Response {
|
|
161
|
+
return page(ctx, sessionUrl, link, kind, v, `Still required: ${missing.join(', ')}.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The owner submits everything: it is stored on the account, nothing is due, and charges, payouts and the requested
|
|
165
|
+
* capabilities are enabled. */
|
|
166
|
+
async function submit(ctx: SemanticsContext, link: Row, kind: Kind, v: Form, ip: string): Promise<void> {
|
|
167
|
+
const id = String(link.account);
|
|
168
|
+
const account = ctx.get('account', id);
|
|
169
|
+
if (!account) return;
|
|
170
|
+
const caps: Row = { ...((account.capabilities as Row | undefined) ?? {}) };
|
|
171
|
+
for (const [cap, status] of Object.entries(caps)) {
|
|
172
|
+
if (status !== 'active' && !ctx.legal('capability', 'status', OPERATION.id, String(status), 'active', cap, 'vendor')) caps[cap] = 'active';
|
|
173
|
+
}
|
|
174
|
+
const due = currentlyDue(kind, v);
|
|
175
|
+
const requirements = { ...((account.requirements as Row | undefined) ?? {}), currently_due: due, eventually_due: due, past_due: [], disabled_reason: null, current_deadline: null };
|
|
176
|
+
const addressOf = (p: string): Row => ({ line1: v[`${p}line1`], line2: null, city: v[`${p}city`], state: v[`${p}state`], postal_code: v[`${p}postal_code`], country: 'US' });
|
|
177
|
+
const [year, month, day] = (v.dob ?? '').split('-').map(Number);
|
|
178
|
+
const person = { first_name: v.first_name, last_name: v.last_name, email: v.email, phone: v.phone, dob: { day, month, year }, address: addressOf(''), ssn_last_4_provided: true, id_number_provided: false };
|
|
179
|
+
const company = kind === 'company';
|
|
180
|
+
const settings = (account.settings as Row | undefined) ?? {};
|
|
181
|
+
await ctx.write('account', id, {
|
|
182
|
+
details_submitted: true, charges_enabled: due.length === 0, payouts_enabled: due.length === 0, capabilities: caps, requirements,
|
|
183
|
+
business_type: kind,
|
|
184
|
+
business_profile: { ...((account.business_profile as Row | undefined) ?? {}), name: company ? v.company_name : `${String(v.first_name)} ${String(v.last_name)}`, mcc: (v.mcc ?? '').trim(), url: v.url },
|
|
185
|
+
settings: { ...settings, payments: { ...((settings.payments as Row | undefined) ?? {}), statement_descriptor: v.statement_descriptor } },
|
|
186
|
+
...(company
|
|
187
|
+
? { company: { name: v.company_name, phone: v.company_phone, address: addressOf('company_'), tax_id_provided: true, owners_provided: true, directors_provided: false, executives_provided: false } }
|
|
188
|
+
: { individual: { ...person, id: `person_${id}` } }),
|
|
189
|
+
tos_acceptance: { date: Number(ctx.now()), ip, user_agent: null },
|
|
190
|
+
}, 'account.updated');
|
|
191
|
+
if (company) {
|
|
192
|
+
await created(ctx, 'person', { account: id }, { ...person, relationship: { representative: true, executive: true, director: false, owner: false, percent_ownership: null, title: v.title }, metadata: {} });
|
|
193
|
+
await created(ctx, 'person', { account: id }, { first_name: v.owner_first_name, last_name: v.owner_last_name, email: v.owner_email, relationship: { owner: true, representative: false, executive: false, director: false, percent_ownership: null, title: null }, metadata: {} });
|
|
194
|
+
}
|
|
195
|
+
const digits = (v.account_number ?? '').replace(/\D/g, '');
|
|
196
|
+
await created(ctx, 'external_account', { account: id }, {
|
|
197
|
+
object: 'bank_account', account_holder_name: company ? v.company_name : `${String(v.first_name)} ${String(v.last_name)}`, account_holder_type: company ? 'company' : 'individual', bank_name: 'STRIPE TEST BANK', country: 'US', currency: 'usd',
|
|
198
|
+
default_for_currency: true, fingerprint: `twin_ext_${digits.slice(-4)}`, last4: digits.slice(-4), routing_number: v.routing_number, status: 'new', metadata: {},
|
|
199
|
+
});
|
|
200
|
+
await syncExternals(ctx, id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Where an onboarding session lives, on connect.stripe.com. */
|
|
204
|
+
const SESSIONS = 'https://connect.stripe.com/setup/s/';
|
|
205
|
+
|
|
206
|
+
/** Visiting an Account Link consumes it and sends the owner to their onboarding session; a used or expired one sends
|
|
207
|
+
* them to refresh_url for a fresh link. */
|
|
208
|
+
async function visit(ctx: SemanticsContext, id: string): Promise<Response> {
|
|
209
|
+
const link = ctx.get(LINK, id);
|
|
210
|
+
if (!link) return gone();
|
|
211
|
+
if (!usable(ctx, link)) return seeOther(String(link.refresh_url));
|
|
212
|
+
const session = `onbs_${id.replace(/^acctlink_/, '')}`;
|
|
213
|
+
await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
|
|
214
|
+
return seeOther(`${SESSIONS}${session}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** connect.stripe.com's onboarding pages, or undefined for any other request. */
|
|
218
|
+
export function stripeOnboardingFlow(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
|
|
219
|
+
return async (request) => {
|
|
220
|
+
const path = new URL(request.url).pathname;
|
|
221
|
+
const entry = /^\/setup\/(acctlink_[A-Za-z0-9_]+)\/?$/.exec(path);
|
|
222
|
+
const at = /^\/setup\/s\/(onbs_[A-Za-z0-9_]+)\/?$/.exec(path);
|
|
223
|
+
if (entry && request.method === 'GET') return visit(await semanticsContext(manifest, request, OPERATION, scope), entry[1]!);
|
|
224
|
+
if (!at || (request.method !== 'GET' && request.method !== 'POST')) return undefined;
|
|
225
|
+
const sid = at[1]!;
|
|
226
|
+
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
227
|
+
const ctx = await semanticsContext(manifest, request, OPERATION, scope);
|
|
228
|
+
// the session the visited link opened, until it is submitted
|
|
229
|
+
const stored = ctx.rowsRaw(LINK).find((l) => l._session === sid && l._submitted !== true);
|
|
230
|
+
const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
|
|
231
|
+
if (!stored || !link) return gone();
|
|
232
|
+
const sessionUrl = `${SESSIONS}${sid}`;
|
|
233
|
+
const account = ctx.get('account', String(link.account)) ?? {};
|
|
234
|
+
const kind = kindOf(account);
|
|
235
|
+
if (!kind) return unmodelled(account);
|
|
236
|
+
if (request.method === 'GET') return page(ctx, sessionUrl, link, kind);
|
|
237
|
+
const missing = currentlyDue(kind, form);
|
|
238
|
+
if (missing.length) return incomplete(ctx, sessionUrl, link, kind, form, missing);
|
|
239
|
+
await ctx.write(LINK, String(stored.id), { _submitted: true }, 'account_link.submitted');
|
|
240
|
+
await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
|
|
241
|
+
return seeOther(String(link.return_url));
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// STRIPE'S CUSTOMER PORTAL — a hosted flow (docs/contributing/architecture.md, "Screens"): an application creates
|
|
2
|
+
// a portal session for its customer and sends them to the session's `url` at billing.stripe.com/p/session/{id};
|
|
3
|
+
// there the customer sees their plans, payment method, billing details and invoices, and makes the moves the
|
|
4
|
+
// portal configuration allows, then returns to return_url (docs.stripe.com/customer-management). Canceling is the
|
|
5
|
+
// customer's own move (the subscription machine's external actor): at the period's end by default, or at once
|
|
6
|
+
// when the configuration's subscription_cancel.mode says immediately, and a plan set to cancel can be renewed.
|
|
7
|
+
// Each move goes through the same handler context the API's handlers use, so the application sees it as it
|
|
8
|
+
// would from Stripe: on the subscription, and in its events. Authored from @volter/world-ui's portal piece under
|
|
9
|
+
// Stripe's skin; nothing of Stripe's page is copied.
|
|
10
|
+
//
|
|
11
|
+
// The customer also replaces their card, which becomes the default for their invoices and subscriptions, and pays
|
|
12
|
+
// an open invoice with it, which makes a subscription waiting on it (past_due) active again
|
|
13
|
+
// (docs.stripe.com/customer-management/configure-portal: payment_method_update, invoice_history).
|
|
14
|
+
//
|
|
15
|
+
// Where the documentation stops and the twin decides: a session's configuration that the World never created
|
|
16
|
+
// (the account's default) enables cancellation at period end, updating the payment method and invoice history;
|
|
17
|
+
// changing the plan from the portal is not offered yet. A new card is saved without a charge, so a card that
|
|
18
|
+
// declines only when charged (4000000000000341) is saved and declines when the invoice is paid.
|
|
19
|
+
import { semanticsContext, type SemanticsContext } from '@volter/world-core';
|
|
20
|
+
import { flowPage, Portal, PORTAL_CSS, type PaymentField, type PortalItem } from '@volter/world-ui';
|
|
21
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
22
|
+
import { manifest } from '../manifest.ts';
|
|
23
|
+
import { payerOf, payOpenInvoice } from '../semantics/renewals.ts';
|
|
24
|
+
import { afterSuccessOf } from '../semantics/after-payment.ts';
|
|
25
|
+
import { created, type Row } from '../semantics/shared.ts';
|
|
26
|
+
import { publicBusinessName } from '../stripe-shared.ts';
|
|
27
|
+
import { declineFor, paymentMethodSubObject, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
28
|
+
import { cardAnswer, COUNTRIES, money } from './checkout.tsx';
|
|
29
|
+
import { formOf } from './consent-skin.ts';
|
|
30
|
+
|
|
31
|
+
const SUB = 'subscription';
|
|
32
|
+
const PORTAL_OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostBillingPortalSessions')!;
|
|
33
|
+
|
|
34
|
+
// Stripe's skin: its type, its purple accent, the merchant's side tinted.
|
|
35
|
+
const SKIN = `
|
|
36
|
+
body { color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; }
|
|
37
|
+
.portal-side { background: #f7f7f9; }
|
|
38
|
+
.portal-back { color: #635bff; }
|
|
39
|
+
.portal-section h2 { color: #6b7280; border-color: #e5e7eb; }
|
|
40
|
+
.portal-item { border-bottom: 1px solid #f0f0f3; }
|
|
41
|
+
.portal-detail, .portal-note { color: #6b7280; }
|
|
42
|
+
.portal-badge { background: #eef0f4; color: #414552; }
|
|
43
|
+
.portal-button { border-color: #d5dbe1; color: #1a1a1a; }
|
|
44
|
+
.portal-primary { background: #635bff; border-color: #635bff; color: #ffffff; }
|
|
45
|
+
.portal-danger { color: #df1b41; border-color: #f4c7d0; }
|
|
46
|
+
.portal-empty { color: #6b7280; }
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const day = (epoch: unknown): string => new Date(Number(epoch) * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
50
|
+
const gone = (): Response => flowPage({ title: 'Billing', status: 404, css: [PORTAL_CSS, SKIN], body: <main className="portal-main"><h1>This link has expired.</h1></main> });
|
|
51
|
+
|
|
52
|
+
type Features = { cancel: { enabled: boolean; mode: 'at_period_end' | 'immediately' }; invoices: boolean; paymentMethod: boolean };
|
|
53
|
+
function featuresOf(ctx: SemanticsContext, session: Row): Features {
|
|
54
|
+
const f = (ctx.get('billing_portal.configuration', String(session.configuration))?.features ?? undefined) as Row | undefined;
|
|
55
|
+
const cancel = (f?.subscription_cancel ?? {}) as Row;
|
|
56
|
+
const invoices = (f?.invoice_history ?? {}) as Row;
|
|
57
|
+
const update = (f?.payment_method_update ?? {}) as Row;
|
|
58
|
+
return {
|
|
59
|
+
cancel: { enabled: f ? cancel.enabled === true : true, mode: cancel.mode === 'immediately' ? 'immediately' : 'at_period_end' },
|
|
60
|
+
invoices: f ? invoices.enabled !== false : true,
|
|
61
|
+
paymentMethod: f ? update.enabled === true : true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function planOf(ctx: SemanticsContext, sub: Row): { name: string; price: string } {
|
|
66
|
+
const item = (((sub.items as { data?: Row[] } | undefined)?.data) ?? [])[0];
|
|
67
|
+
const price = (typeof item?.price === 'object' ? item.price : ctx.get('price', String(item?.price ?? ''))) as Row | undefined;
|
|
68
|
+
const product = typeof price?.product === 'string' ? ctx.get('product', price.product) : (price?.product as Row | undefined);
|
|
69
|
+
const recurring = price?.recurring as Row | undefined;
|
|
70
|
+
return {
|
|
71
|
+
name: String(product?.name ?? price?.nickname ?? 'Plan'),
|
|
72
|
+
price: price ? `${money(price.unit_amount, price.currency)}${recurring?.interval ? ` per ${String(recurring.interval)}` : ''}` : '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function page(ctx: SemanticsContext, id: string, session: Row, problem?: { error: string; values: Record<string, string> }): Response {
|
|
77
|
+
const features = featuresOf(ctx, session);
|
|
78
|
+
const customer = ctx.get('customer', String(session.customer)) ?? {};
|
|
79
|
+
const subs = ctx.rows(SUB).filter((s) => s.customer === session.customer && s.status !== 'canceled' && s.status !== 'incomplete_expired');
|
|
80
|
+
const plans: PortalItem[] = subs.map((s) => {
|
|
81
|
+
const plan = planOf(ctx, s);
|
|
82
|
+
const ending = s.cancel_at_period_end === true;
|
|
83
|
+
return {
|
|
84
|
+
title: plan.name,
|
|
85
|
+
detail: plan.price,
|
|
86
|
+
badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
|
|
87
|
+
note: ending ? `Your plan will be canceled on ${day(s.current_period_end)}.` : s.status === 'trialing' ? `Trial ends ${day(s.trial_end)}.` : `Renews on ${day(s.current_period_end)}.`,
|
|
88
|
+
actions: ending
|
|
89
|
+
? [{ label: 'Renew plan', action: `/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
|
|
90
|
+
: features.cancel.enabled ? [{ label: 'Cancel plan', action: `/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const defaultPm = ((customer.invoice_settings as Row | undefined)?.default_payment_method as string | undefined) ?? undefined;
|
|
94
|
+
const pms = ctx.rows('payment_method').filter((p) => p.customer === session.customer);
|
|
95
|
+
const subDefault = subs.map((sub) => payerOf(ctx, sub)).find((p) => typeof p === 'string');
|
|
96
|
+
const pm = (defaultPm ? pms.find((p) => p.id === defaultPm) : undefined) ?? (subDefault ? pms.find((p) => p.id === subDefault) : undefined) ?? pms[0];
|
|
97
|
+
const card = pm?.card as Row | undefined;
|
|
98
|
+
const methods: PortalItem[] = pm ? [{ title: `${String(card?.brand ?? 'card').replace(/^./, (c) => c.toUpperCase())} •••• ${String(card?.last4 ?? '')}`, detail: card ? `Expires ${String(card.exp_month).padStart(2, '0')}/${String(card.exp_year)}` : undefined, badge: 'Default' }] : [];
|
|
99
|
+
const address = customer.address as Row | undefined;
|
|
100
|
+
const details: PortalItem[] = [{ title: String(customer.name ?? customer.email ?? ''), detail: String(customer.email ?? ''), ...(address?.country ? { note: [address.line1, address.city, address.postal_code, address.country].filter(Boolean).join(', ') } : {}) }];
|
|
101
|
+
const invoices: PortalItem[] = features.invoices
|
|
102
|
+
? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
|
|
103
|
+
.map((i) => ({
|
|
104
|
+
title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
|
|
105
|
+
...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' as const }] } : {}),
|
|
106
|
+
}))
|
|
107
|
+
: [];
|
|
108
|
+
const back = typeof session.return_url === 'string' ? session.return_url : undefined;
|
|
109
|
+
// the portal shows the public business name (stripe-shared.ts, publicBusinessName) of the session's on_behalf_of
|
|
110
|
+
// account ("Use the Accounts API to modify the on_behalf_of account's branding settings, which the portal displays",
|
|
111
|
+
// docs.stripe.com/api/customer_portal/sessions/object), else of the account it was made on (Stripe-Account), else the
|
|
112
|
+
// platform's
|
|
113
|
+
const owner = session.on_behalf_of ?? ctx.row('billing_portal.session', String(session.id))?._brand_account;
|
|
114
|
+
const merchant = publicBusinessName(ctx.get('account', typeof owner === 'string' ? owner : PLATFORM_ACCOUNT_ID));
|
|
115
|
+
const v = problem?.values ?? {};
|
|
116
|
+
const cardFields: PaymentField[] = [
|
|
117
|
+
{ id: 'cardNumber', label: 'Card number', autoComplete: 'cc-number', placeholder: '1234 1234 1234 1234', value: v.cardNumber ?? '' },
|
|
118
|
+
{ id: 'cardExpiry', label: 'Expiration', autoComplete: 'cc-exp', placeholder: 'MM / YY', value: v.cardExpiry ?? '' },
|
|
119
|
+
{ id: 'cardCvc', label: 'CVC', autoComplete: 'cc-csc', placeholder: 'CVC', value: v.cardCvc ?? '' },
|
|
120
|
+
{ id: 'billingName', label: 'Name on card', autoComplete: 'cc-name', value: v.billingName ?? '' },
|
|
121
|
+
{ id: 'billingCountry', label: 'Country or region', options: COUNTRIES, value: v.billingCountry ?? 'US' },
|
|
122
|
+
{ id: 'billingPostalCode', label: 'ZIP', autoComplete: 'postal-code', value: v.billingPostalCode ?? '' },
|
|
123
|
+
];
|
|
124
|
+
return flowPage({
|
|
125
|
+
title: 'Billing',
|
|
126
|
+
css: [PORTAL_CSS, SKIN],
|
|
127
|
+
...(problem ? { status: 402 } : {}),
|
|
128
|
+
body: (
|
|
129
|
+
<Portal
|
|
130
|
+
merchant={merchant}
|
|
131
|
+
{...(problem ? { notice: problem.error } : {})}
|
|
132
|
+
{...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
|
|
133
|
+
{...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {})}
|
|
134
|
+
sections={[
|
|
135
|
+
{ heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
|
|
136
|
+
{ heading: 'Payment method', items: methods, empty: 'No payment method on file.' },
|
|
137
|
+
{ heading: 'Billing information', items: details },
|
|
138
|
+
...(features.invoices ? [{ heading: 'Invoice history', items: invoices, empty: 'No invoices yet.' }] : []),
|
|
139
|
+
]}
|
|
140
|
+
/>
|
|
141
|
+
),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The customer's move on one of their subscriptions, then back to the portal. */
|
|
146
|
+
async function move(ctx: SemanticsContext, id: string, session: Row, subscription: string, kind: 'cancel' | 'renew'): Promise<Response> {
|
|
147
|
+
const sub = ctx.get(SUB, subscription);
|
|
148
|
+
if (!sub || sub.customer !== session.customer || sub.status === 'canceled') return gone();
|
|
149
|
+
const back = new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
150
|
+
if (kind === 'renew') {
|
|
151
|
+
await ctx.write(SUB, subscription, { cancel_at_period_end: false, cancel_at: null, canceled_at: null, cancellation_details: { comment: null, feedback: null, reason: null } }, 'subscription.update');
|
|
152
|
+
return back;
|
|
153
|
+
}
|
|
154
|
+
const features = featuresOf(ctx, session);
|
|
155
|
+
if (!features.cancel.enabled) return gone();
|
|
156
|
+
const now = Number(ctx.now());
|
|
157
|
+
const details = { comment: null, feedback: null, reason: 'cancellation_requested' };
|
|
158
|
+
if (features.cancel.mode === 'at_period_end') {
|
|
159
|
+
await ctx.write(SUB, subscription, { cancel_at_period_end: true, cancel_at: sub.current_period_end ?? null, canceled_at: now, cancellation_details: details }, 'subscription.update');
|
|
160
|
+
return back;
|
|
161
|
+
}
|
|
162
|
+
const refused = ctx.legal(SUB, 'status', PORTAL_OPERATION.id, sub.status, 'canceled', subscription, 'external');
|
|
163
|
+
if (refused) return gone();
|
|
164
|
+
await ctx.write(SUB, subscription, { status: 'canceled', canceled_at: now, ended_at: now, cancellation_details: details }, 'subscription.cancel');
|
|
165
|
+
return back;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The customer's new card: saved on them (no charge) as the default their invoices and subscriptions charge. */
|
|
169
|
+
async function updatePaymentMethod(ctx: SemanticsContext, id: string, session: Row, v: Record<string, string>): Promise<Response> {
|
|
170
|
+
if (!featuresOf(ctx, session).paymentMethod) return gone();
|
|
171
|
+
const refused = cardAnswer(v, Number(ctx.now()), false);
|
|
172
|
+
if (refused) return page(ctx, id, session, { error: refused, values: v });
|
|
173
|
+
const customer = String(session.customer);
|
|
174
|
+
const [month, year] = (v.cardExpiry ?? '').split('/').map((x) => Number(x.trim()));
|
|
175
|
+
const card = { number: (v.cardNumber ?? '').replace(/\D/g, ''), exp_month: month ?? 12, exp_year: (year ?? 0) < 100 ? 2000 + (year ?? 0) : (year ?? 0) };
|
|
176
|
+
const pm = await created(ctx, 'payment_method', {}, {
|
|
177
|
+
type: 'card', customer, livemode: false,
|
|
178
|
+
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 },
|
|
179
|
+
...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null,
|
|
180
|
+
...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}),
|
|
181
|
+
});
|
|
182
|
+
const holder = ctx.get('customer', customer) ?? {};
|
|
183
|
+
await ctx.write('customer', customer, { invoice_settings: { ...((holder.invoice_settings as Row | undefined) ?? {}), default_payment_method: pm.id } }, 'customer.update');
|
|
184
|
+
for (const sub of ctx.rows(SUB).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
|
|
185
|
+
await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
|
|
186
|
+
}
|
|
187
|
+
return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The customer pays one of their open invoices with the card on file. */
|
|
191
|
+
async function payInvoice(ctx: SemanticsContext, id: string, session: Row, invoiceId: string): Promise<Response> {
|
|
192
|
+
const inv = ctx.get('invoice', invoiceId);
|
|
193
|
+
if (!inv || inv.customer !== session.customer || inv.status !== 'open') return gone();
|
|
194
|
+
const sub = typeof inv.subscription === 'string' ? ctx.get(SUB, inv.subscription) : undefined;
|
|
195
|
+
const holder = ctx.get('customer', String(session.customer)) ?? {};
|
|
196
|
+
const pm = (sub ? payerOf(ctx, sub) : undefined) ?? ((holder.invoice_settings as Row | undefined)?.default_payment_method as string | undefined);
|
|
197
|
+
if (!pm) return page(ctx, id, session, { error: 'Add a payment method to pay this invoice.', values: {} });
|
|
198
|
+
const declined = await payOpenInvoice(ctx, invoiceId, pm);
|
|
199
|
+
if (declined) return page(ctx, id, session, { error: declined.message, values: {} });
|
|
200
|
+
return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** billing.stripe.com's portal pages, or undefined for any other request. */
|
|
204
|
+
export function stripePortalFlow(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
|
|
205
|
+
return async (request) => {
|
|
206
|
+
const m = /^\/p\/session\/(bps_[A-Za-z0-9_]+)(?:\/(cancel|renew|payment_method|pay))?\/?$/.exec(new URL(request.url).pathname);
|
|
207
|
+
if (!m) return undefined;
|
|
208
|
+
const [, id, kind] = m as unknown as [string, string, 'cancel' | 'renew' | 'payment_method' | 'pay' | undefined];
|
|
209
|
+
if ((kind && request.method !== 'POST') || (!kind && request.method !== 'GET')) return undefined;
|
|
210
|
+
// the form is read before the context, which reads the request's body itself
|
|
211
|
+
const form = kind ? await formOf(request.clone()) : {};
|
|
212
|
+
const ctx = await semanticsContext(manifest, request, PORTAL_OPERATION, scope);
|
|
213
|
+
const session = ctx.get('billing_portal.session', id);
|
|
214
|
+
if (!session) return gone();
|
|
215
|
+
if (!kind) return page(ctx, id, session);
|
|
216
|
+
if (kind === 'payment_method') return updatePaymentMethod(ctx, id, session, form);
|
|
217
|
+
if (kind === 'pay') return payInvoice(ctx, id, session, form.invoice ?? '');
|
|
218
|
+
return move(ctx, id, session, form.subscription ?? '', kind);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// THE DASHBOARD'S PUBLIC DETAILS — a workspace page (docs/contributing/architecture.md, "Screens") of the Dashboard
|
|
2
|
+
// (manifest screen `dashboard`), at dashboard.stripe.com/settings/public: Settings → Business → Public details, where
|
|
3
|
+
// the operator sets what their customers see ("Public Details → Business Settings",
|
|
4
|
+
// support.stripe.com/questions/update-account-name-shown-in-the-dashboard). Its business name is the account's
|
|
5
|
+
// `business_profile.name`, "The customer-facing business name" (docs.stripe.com/api/accounts/object), and it is the
|
|
6
|
+
// name Checkout shows: "You can change a Checkout page's name by modifying the Business name field"
|
|
7
|
+
// (docs.stripe.com/payments/checkout/customization/appearance). Stripe's API has no call that changes the platform's
|
|
8
|
+
// own account (POST /v1/accounts/{account} updates a connected one), so this page is the only way to the act, and
|
|
9
|
+
// saving it writes the platform account's stored record (semantics/connect.ts `platform`), which GET /v1/account,
|
|
10
|
+
// Checkout and the customer portal then read. The Dashboard's account name (`settings.dashboard.display_name`,
|
|
11
|
+
// Settings → Account) is a separate, internal setting, "for internal use only" (the same support article), and this
|
|
12
|
+
// page does not touch it. Authored from plain markup under Stripe's type; nothing of Stripe's page is copied.
|
|
13
|
+
//
|
|
14
|
+
// Where the documentation stops and the twin decides: the page holds the business name only (the real page's
|
|
15
|
+
// support details, website and statement descriptor are not modelled here); the name is required and at most 5000
|
|
16
|
+
// characters (the spec's bound on business_profile.name), which the browser holds the field to and the page checks
|
|
17
|
+
// again for a client that is not a browser; no sign-in guards the page, as none guards the Dashboard mirror. The page
|
|
18
|
+
// is matched by its path on whatever host reaches the twin (the World routes only dashboard.stripe.com's
|
|
19
|
+
// /settings/public here; the local mirror serves it on its own origin), and the form posts to the page's own address
|
|
20
|
+
// and the save redirects to its own path, so it also works under the hosted mirror's path prefix. Saving is an update of an account that exists (the first save included, though
|
|
21
|
+
// the twin stores the platform's record only then), so every save is sent as account.updated.
|
|
22
|
+
import { semanticsContext, type 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.ts';
|
|
26
|
+
import { platformAccountDefault } from '../semantics/connect.ts';
|
|
27
|
+
import { created, type Row } from '../semantics/shared.ts';
|
|
28
|
+
import { publicBusinessName } from '../stripe-shared.ts';
|
|
29
|
+
import { PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
30
|
+
import { formOf } from './consent-skin.ts';
|
|
31
|
+
|
|
32
|
+
export const PUBLIC_DETAILS_PATH = '/settings/public';
|
|
33
|
+
const MAX_NAME = 5000;
|
|
34
|
+
const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostAccountsAccount')!;
|
|
35
|
+
|
|
36
|
+
const CSS = `
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; }
|
|
39
|
+
.pd { max-width: 640px; margin: 48px auto; padding: 0 16px; }
|
|
40
|
+
.pd h1 { font-size: 28px; margin: 16px 0 4px; }
|
|
41
|
+
.pd-lead { color: #697386; margin: 0 0 24px; }
|
|
42
|
+
.pd-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; }
|
|
43
|
+
.pd-card label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
44
|
+
.pd-card small { display: block; color: #697386; margin-bottom: 8px; }
|
|
45
|
+
.pd-card input { width: 100%; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: inherit; }
|
|
46
|
+
.pd-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
|
|
47
|
+
.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
48
|
+
.pd-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
|
|
49
|
+
.pd-error { background: #fff0f3; color: #df1b41; }
|
|
50
|
+
.pd-saved { background: #e7f8ef; color: #0e6245; }
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
function page(account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
|
|
54
|
+
const named = (account?.business_profile as Row | undefined)?.name;
|
|
55
|
+
const value = opts.value ?? (typeof named === 'string' ? named : '');
|
|
56
|
+
return flowPage({
|
|
57
|
+
title: 'Public details – Stripe',
|
|
58
|
+
css: [CSS],
|
|
59
|
+
...(opts.error ? { status: 400 } : {}),
|
|
60
|
+
body: (
|
|
61
|
+
<main className="pd">
|
|
62
|
+
<h1>Public details</h1>
|
|
63
|
+
<p className="pd-lead">What your customers see on Checkout, the customer portal, receipts and invoices. Currently shown: <strong data-testid="public-business-name">{publicBusinessName(account)}</strong></p>
|
|
64
|
+
{opts.error ? <p className="pd-notice pd-error" role="alert">{opts.error}</p> : null}
|
|
65
|
+
{opts.saved ? <p className="pd-notice pd-saved" role="status">Your public details were saved.</p> : null}
|
|
66
|
+
<form className="pd-card" method="post">
|
|
67
|
+
<label htmlFor="business_name">Business name</label>
|
|
68
|
+
<small>The name customers see when they pay you.</small>
|
|
69
|
+
<input id="business_name" name="business_name" autoComplete="organization" defaultValue={value} required maxLength={MAX_NAME} />
|
|
70
|
+
<div className="pd-actions"><button type="submit">Save</button></div>
|
|
71
|
+
</form>
|
|
72
|
+
</main>
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Save the business name on the platform's own account: the stored record, made from Stripe's default the first
|
|
78
|
+
* time anything is saved, and otherwise changed in place (its other public details kept). */
|
|
79
|
+
async function save(ctx: SemanticsContext, name: string): Promise<void> {
|
|
80
|
+
const held = ctx.get('account', PLATFORM_ACCOUNT_ID);
|
|
81
|
+
const profile = { business_profile: { ...(((held ?? platformAccountDefault()).business_profile as Row | undefined) ?? {}), name } };
|
|
82
|
+
await (held ? ctx.write('account', PLATFORM_ACCOUNT_ID, profile, 'account.update') : created(ctx, 'account', { ...platformAccountDefault(), ...profile }, {}, { operation: 'account.update' }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What the page says of a name no browser sends (the field is required and bounded), for a client that sends it. */
|
|
86
|
+
function nameRefused(name: string): string | undefined {
|
|
87
|
+
return !name ? 'Enter your business name.' : name.length > MAX_NAME ? `Your business name must be at most ${MAX_NAME} characters.` : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function stripePublicDetailsFlow(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
|
|
91
|
+
return async (request) => {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
if (url.pathname.replace(/\/+$/, '') !== PUBLIC_DETAILS_PATH || (request.method !== 'GET' && request.method !== 'POST')) return undefined;
|
|
94
|
+
// the form is read before the context, which reads the request's body itself
|
|
95
|
+
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
96
|
+
const ctx = await semanticsContext(manifest, request, OPERATION, scope);
|
|
97
|
+
if (request.method === 'GET') return page(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
98
|
+
const name = (form.business_name ?? '').trim();
|
|
99
|
+
const refused = nameRefused(name);
|
|
100
|
+
if (refused) return page(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
101
|
+
await save(ctx, name);
|
|
102
|
+
// a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
|
|
103
|
+
return new Response(null, { status: 303, headers: { location: '?saved=1' } });
|
|
104
|
+
};
|
|
105
|
+
}
|