@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,471 @@
|
|
|
1
|
+
// Connect semantics: connected accounts (and the platform's own account), their persons,
|
|
2
|
+
// capabilities, external (payout) accounts, login links, onboarding links and embedded-component
|
|
3
|
+
// sessions. A new connected account is not yet active: charges and payouts are off and its
|
|
4
|
+
// requirements are outstanding, as on Stripe before onboarding. Account retrieve and delete and
|
|
5
|
+
// the person list are the derived core's.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { ACCOUNT_TYPES, accountCapabilities, accountRequirements, accountSettings, asBool, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
8
|
+
import { currentlyDue, kindOf } from '../screens/onboarding.tsx';
|
|
9
|
+
import { at, created, externalList, fail, list, newest, path, syncExternals, type Row } from './shared.ts';
|
|
10
|
+
|
|
11
|
+
const accountMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
|
|
12
|
+
/** The account, tombstones included where the hand-written routes looked past a deletion. */
|
|
13
|
+
const accountRow = (ctx: SemanticsContext, id: string): Row | undefined => ctx.row('account', id, { withDeleted: true });
|
|
14
|
+
|
|
15
|
+
// the platform's own account: stored once it is written, otherwise Stripe's default. It "controls itself" (controller
|
|
16
|
+
// type `account`, docs.stripe.com/api/accounts/object), is fully onboarded with nothing due, and takes card payments
|
|
17
|
+
// and transfers. Where the documentation stops and the twin decides: it was created as the World began (2026-01-01),
|
|
18
|
+
// and its bank account, which its automatic payouts reach, is not modelled, so it lists none.
|
|
19
|
+
const PLATFORM_CREATED = 1_767_225_600;
|
|
20
|
+
/** The platform's own account as Stripe's default has it, before anything is written to it. */
|
|
21
|
+
export const platformAccountDefault = (): Row => ({
|
|
22
|
+
object: 'account', id: PLATFORM_ACCOUNT_ID, type: 'standard', country: 'US', default_currency: 'usd', created: PLATFORM_CREATED,
|
|
23
|
+
charges_enabled: true, payouts_enabled: true, details_submitted: true, email: null, metadata: {},
|
|
24
|
+
capabilities: { card_payments: 'active', transfers: 'active' }, controller: { type: 'account' },
|
|
25
|
+
requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
|
|
26
|
+
future_requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
|
|
27
|
+
external_accounts: externalList(PLATFORM_ACCOUNT_ID, []), tos_acceptance: { date: null, ip: null, user_agent: null }, business_profile: {},
|
|
28
|
+
settings: accountSettings(undefined), livemode: false,
|
|
29
|
+
});
|
|
30
|
+
const platform: Semantics = async (ctx) => ctx.reply(ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
|
|
31
|
+
|
|
32
|
+
/** A new Express account owes what its hosted onboarding will ask for (screens/onboarding.tsx; the US sets
|
|
33
|
+
* docs.stripe.com/connect/required-verification-information lists), or, where the twin does not model the set, the
|
|
34
|
+
* generic list. */
|
|
35
|
+
function expressRequirements(params: Row): Row {
|
|
36
|
+
const kind = kindOf({ country: params.country ?? 'US', business_type: params.business_type ?? 'individual' });
|
|
37
|
+
const base = accountRequirements();
|
|
38
|
+
return kind ? { ...base, currently_due: currentlyDue(kind), eventually_due: currentlyDue(kind) } : base;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// the requested capabilities become Stripe's status map, and settings its canonical shape
|
|
42
|
+
const create: Semantics = async (ctx) => {
|
|
43
|
+
const params = ctx.params;
|
|
44
|
+
const type = typeof params.type === 'string' ? params.type : 'standard';
|
|
45
|
+
if (!ACCOUNT_TYPES.has(type)) return fail(ctx, 'Invalid account type: must be one of express, standard, or custom.', 400, 'parameter_invalid_string_enum');
|
|
46
|
+
const { capabilities: _caps, settings: _settings, ...accountParams } = keptKyc(params);
|
|
47
|
+
const id = ctx.mint('account');
|
|
48
|
+
const made = await created(ctx, 'account', { ...accountParams, id, type }, {
|
|
49
|
+
business_type: null, charges_enabled: false, payouts_enabled: false, details_submitted: false,
|
|
50
|
+
capabilities: accountCapabilities(params), requirements: type === 'express' ? expressRequirements(params) : accountRequirements(),
|
|
51
|
+
country: typeof params.country === 'string' ? params.country : 'US',
|
|
52
|
+
default_currency: 'usd', email: params.email ?? null, metadata: {}, livemode: false,
|
|
53
|
+
settings: accountSettings(params.settings), business_profile: {},
|
|
54
|
+
controller: controllerOf(type), external_accounts: externalList(id, []),
|
|
55
|
+
future_requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
|
|
56
|
+
tos_acceptance: { date: null, ip: null, user_agent: null },
|
|
57
|
+
});
|
|
58
|
+
await review(ctx, String(made.id));
|
|
59
|
+
return ctx.reply(ctx.get('account', String(made.id))!);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The controller an account made with a `type` has: "Each of the three account types maps to values in the
|
|
63
|
+
* `controller` hash", Standard to losses.payments `stripe`, fees.payer `account`, requirement_collection `stripe` and
|
|
64
|
+
* a `full` dashboard, Express to `application`, `application_express`, `stripe` and `express`, Custom to
|
|
65
|
+
* `application`, `application_custom`, `application` and `none`; each "type": "application", "is_controller": true
|
|
66
|
+
* (docs.stripe.com/connect/migrate-to-controller-properties). */
|
|
67
|
+
function controllerOf(type: string): Row {
|
|
68
|
+
const [losses, payer, collection, dashboard] = type === 'express' ? ['application', 'application_express', 'stripe', 'express']
|
|
69
|
+
: type === 'custom' ? ['application', 'application_custom', 'application', 'none'] : ['stripe', 'account', 'stripe', 'full'];
|
|
70
|
+
return { type: 'application', is_controller: true, losses: { payments: losses }, fees: { payer }, requirement_collection: collection, stripe_dashboard: { type: dashboard } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// connected accounts only: the platform's own is not one
|
|
74
|
+
const listAccounts: Semantics = async (ctx) => list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID));
|
|
75
|
+
|
|
76
|
+
/** What a Custom account still owes, from what the platform has given for it, as Stripe's requirements endpoint lists it
|
|
77
|
+
* for a US account with no Stripe Dashboard and the full service agreement
|
|
78
|
+
* (docs.stripe.com/_endpoint/get-requirements-for-setups, the data behind
|
|
79
|
+
* docs.stripe.com/connect/required-verification-information). With transfers alone, a company owes business_profile.url,
|
|
80
|
+
* company.name, an external account and tos_acceptance.date and .ip at once (capability_limit_amount -1: paused until
|
|
81
|
+
* given) and company.tax_id before $3,000 of payouts (payout_limit_amount 300000); an individual owes the url, its
|
|
82
|
+
* first and last name, the bank account and the terms at once, and its date of birth and ssn_last_4 before $3,000 of
|
|
83
|
+
* payouts. Requesting card_payments adds business_profile.mcc and the entity's address at once (and an individual's
|
|
84
|
+
* email and ssn_last_4). A company with card_payments owes, at once, business_profile.mcc, its address, that its owners
|
|
85
|
+
* are provided, each owner's name and email, and its representative's name, email, address, title and ssn_last_4; and
|
|
86
|
+
* in time its phone, its statement descriptor and its representative's date of birth and phone (the endpoint's answer
|
|
87
|
+
* for US / dashboard none / full terms / company / card_payments and transfers, saved in scratch as
|
|
88
|
+
* req-custom-company-cards-transfers.json: capability_limit_amount -1 for the first, none for the second). The
|
|
89
|
+
* representative and the owners are the account's persons (relationship.representative, relationship.owner).
|
|
90
|
+
* Where the documentation stops and the twin decides: what has no limit, or is owed before $3,000 of payouts, is
|
|
91
|
+
* eventually_due and blocks nothing (the twin keeps no payout total against it); an owner field is met when every
|
|
92
|
+
* person marked owner has it (none marked: met). */
|
|
93
|
+
const at_path = (a: Row, path: string): unknown => path.split('.').reduce<unknown>((v, k) => (v && typeof v === 'object' ? (v as Row)[k] : undefined), a);
|
|
94
|
+
const CARD_COMPANY_NOW = ['business_profile.mcc', 'company.address.line1', 'company.address.city', 'company.address.state', 'company.address.postal_code', 'company.owners_provided', 'owners.first_name', 'owners.last_name', 'owners.email', 'representative.first_name', 'representative.last_name', 'representative.email', 'representative.address.line1', 'representative.address.city', 'representative.address.state', 'representative.address.postal_code', 'representative.relationship.title', 'representative.ssn_last_4'];
|
|
95
|
+
const CARD_COMPANY_LATER = ['company.phone', 'settings.payments.statement_descriptor', 'representative.dob.day', 'representative.dob.month', 'representative.dob.year', 'representative.phone'];
|
|
96
|
+
const CARD_INDIVIDUAL_NOW = ['business_profile.mcc', 'individual.address.line1', 'individual.address.city', 'individual.address.state', 'individual.address.postal_code', 'individual.email', 'individual.ssn_last_4'];
|
|
97
|
+
/** A field only card_payments asks for (the transfers capability's requirements leave it out). */
|
|
98
|
+
const cardOnly = (field: string): boolean => [...CARD_COMPANY_NOW, ...CARD_COMPANY_LATER, ...CARD_INDIVIDUAL_NOW].includes(field);
|
|
99
|
+
const personHas = (p: Row, field: string): boolean => {
|
|
100
|
+
if (field === 'ssn_last_4') return p.ssn_last_4_provided === true;
|
|
101
|
+
const v = at_path(p, field);
|
|
102
|
+
return v !== undefined && v !== null && v !== '';
|
|
103
|
+
};
|
|
104
|
+
const met = (a: Row, bank: boolean, field: string, persons: Row[] = []): boolean => {
|
|
105
|
+
if (field === 'external_account') return bank;
|
|
106
|
+
if (field.startsWith('representative.')) {
|
|
107
|
+
const rep = persons.find((p) => (p.relationship as Row | undefined)?.representative === true);
|
|
108
|
+
return !!rep && personHas(rep, field.slice('representative.'.length));
|
|
109
|
+
}
|
|
110
|
+
if (field.startsWith('owners.')) return persons.filter((p) => (p.relationship as Row | undefined)?.owner === true).every((p) => personHas(p, field.slice('owners.'.length)));
|
|
111
|
+
if (field === 'company.tax_id') return at_path(a, 'company.tax_id_provided') === true;
|
|
112
|
+
if (field === 'individual.ssn_last_4') return at_path(a, 'individual.ssn_last_4_provided') === true;
|
|
113
|
+
const v = at_path(a, field);
|
|
114
|
+
return v !== undefined && v !== null && v !== '';
|
|
115
|
+
};
|
|
116
|
+
function customRequirements(a: Row, bank: boolean, persons: Row[] = []): { now: string[]; later: string[] } {
|
|
117
|
+
const company = a.business_type === 'company';
|
|
118
|
+
const cards = Object.keys((a.capabilities as Row | undefined) ?? {}).includes('card_payments');
|
|
119
|
+
const now = ['business_profile.url', ...(company ? ['company.name'] : ['individual.first_name', 'individual.last_name']), 'external_account', 'tos_acceptance.date', 'tos_acceptance.ip'];
|
|
120
|
+
if (cards) now.push(...(company ? CARD_COMPANY_NOW : CARD_INDIVIDUAL_NOW));
|
|
121
|
+
const later = [...(company ? ['company.tax_id'] : ['individual.dob.day', 'individual.dob.month', 'individual.dob.year', 'individual.ssn_last_4']), ...(cards && company ? CARD_COMPANY_LATER : [])].filter((f) => !now.includes(f));
|
|
122
|
+
return { now: now.filter((f) => !met(a, bank, f, persons)), later: later.filter((f) => !met(a, bank, f, persons)) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A request's company or individual as Stripe keeps it: a tax id or SSN given is kept only as provided (the Account
|
|
126
|
+
* object answers company.tax_id_provided and individual.ssn_last_4_provided, never the numbers;
|
|
127
|
+
* docs.stripe.com/api/accounts/object). */
|
|
128
|
+
function keptKyc(params: Row): Row {
|
|
129
|
+
const out: Row = { ...params };
|
|
130
|
+
if (params.company && typeof params.company === 'object') {
|
|
131
|
+
const { tax_id, ...company } = params.company as Row;
|
|
132
|
+
out.company = { ...company, ...(tax_id !== undefined && tax_id !== '' ? { tax_id_provided: true } : {}) };
|
|
133
|
+
}
|
|
134
|
+
if (params.individual && typeof params.individual === 'object') {
|
|
135
|
+
const { ssn_last_4, id_number, ...individual } = params.individual as Row;
|
|
136
|
+
out.individual = { ...individual, ...(ssn_last_4 !== undefined && ssn_last_4 !== '' ? { ssn_last_4_provided: true } : {}), ...(id_number !== undefined && id_number !== '' ? { id_number_provided: true } : {}) };
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Stripe's review of a Custom account once the platform gives it something: with nothing left due, its requested
|
|
142
|
+
* capabilities become active and it can take charges and receive payouts; with something due again (its bank account
|
|
143
|
+
* removed), they stop. Where the documentation stops and the twin decides: test-mode verification is immediate, and
|
|
144
|
+
* it owes what customRequirements lists. An Express or Standard account is onboarded on Stripe's own pages. */
|
|
145
|
+
async function review(ctx: SemanticsContext, id: string): Promise<void> {
|
|
146
|
+
const account = ctx.get('account', id);
|
|
147
|
+
if (account && account.type === 'custom') await reviewCustom(ctx, id, account);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The review of a Custom account (see review). */
|
|
151
|
+
async function reviewCustom(ctx: SemanticsContext, id: string, account: Row): Promise<void> {
|
|
152
|
+
const bank = ctx.rows('external_account').some((e) => e.account === id);
|
|
153
|
+
const persons = ctx.rows('person').filter((p) => p.account === id);
|
|
154
|
+
const { now: due, later } = customRequirements(account, bank, persons);
|
|
155
|
+
// one account, one review: "If a connected account has both card_payments and transfers, and the status of either one
|
|
156
|
+
// is inactive, then both capabilities are disabled" (docs.stripe.com/connect/account-capabilities)
|
|
157
|
+
const ready = due.length === 0;
|
|
158
|
+
const capabilities: Row = {};
|
|
159
|
+
for (const [key, was] of Object.entries((account.capabilities as Row | undefined) ?? {})) {
|
|
160
|
+
const to = ready ? 'active' : 'inactive';
|
|
161
|
+
// Stripe's review moves each capability, as the machine declares it may
|
|
162
|
+
if (was !== to) ctx.legal('capability', 'status', ctx.call.operation.id, String(was), to, key, 'vendor');
|
|
163
|
+
capabilities[key] = to;
|
|
164
|
+
}
|
|
165
|
+
const requirements = { ...((account.requirements as Row | undefined) ?? {}), currently_due: due, eventually_due: [...due, ...later], past_due: [], disabled_reason: ready ? null : 'requirements.past_due', current_deadline: null };
|
|
166
|
+
await ctx.write('account', id, { capabilities, requirements, charges_enabled: ready, payouts_enabled: ready, details_submitted: ready }, ready ? 'account.updated' : 'account.update');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `type` cannot change; settings merge onto the existing ones
|
|
170
|
+
const update: Semantics = async (ctx) => {
|
|
171
|
+
const id = at(ctx, 'account');
|
|
172
|
+
const ex = ctx.get('account', id);
|
|
173
|
+
if (!ex) return accountMissing(ctx, id);
|
|
174
|
+
const { type: _drop, settings: rawSettings, ...rest } = keptKyc(ctx.params);
|
|
175
|
+
if (rawSettings !== undefined) rest.settings = accountSettings(rawSettings, ex.settings as Row | undefined);
|
|
176
|
+
// a company or an individual given in part is laid over what the account holds
|
|
177
|
+
for (const k of ['company', 'individual', 'business_profile', 'tos_acceptance'] as const) if (rest[k] && typeof rest[k] === 'object' && ex[k] && typeof ex[k] === 'object') rest[k] = { ...(ex[k] as Row), ...(rest[k] as Row) };
|
|
178
|
+
await ctx.write('account', id, rest, 'account.update');
|
|
179
|
+
await review(ctx, id);
|
|
180
|
+
return ctx.reply(ctx.get('account', id)!);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// a single-use Express dashboard link, not stored
|
|
184
|
+
const loginLink: Semantics = async (ctx) => {
|
|
185
|
+
const id = at(ctx, 'account');
|
|
186
|
+
if (!ctx.get('account', id)) return accountMissing(ctx, id);
|
|
187
|
+
return ctx.reply({ object: 'login_link', created: ctx.now(), url: `https://connect.twin.local/express/${id}` });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ── persons on an account ──
|
|
191
|
+
|
|
192
|
+
const personMissing = (ctx: SemanticsContext): Response => fail(ctx, `No such person: '${at(ctx, 'person')}'`, 404, 'resource_missing');
|
|
193
|
+
const accountPerson = (ctx: SemanticsContext): Row | undefined => {
|
|
194
|
+
const p = ctx.get('person', at(ctx, 'person'));
|
|
195
|
+
return p && p.account === at(ctx, 'account') ? p : undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** A person's details as Stripe keeps them: an SSN or ID number given is kept only as provided (the Person object
|
|
199
|
+
* answers ssn_last_4_provided and id_number_provided, never the numbers; docs.stripe.com/api/persons/object). */
|
|
200
|
+
function keptPerson(params: Row, was: Row = {}): Row {
|
|
201
|
+
const { ssn_last_4, id_number, ...rest } = params;
|
|
202
|
+
const out: Row = { ...rest, ...(ssn_last_4 !== undefined && ssn_last_4 !== '' ? { ssn_last_4_provided: true } : {}), ...(id_number !== undefined && id_number !== '' ? { id_number_provided: true } : {}) };
|
|
203
|
+
// a relationship, address or date of birth given in part is laid over what the person holds
|
|
204
|
+
for (const k of ['relationship', 'address', 'dob'] as const) if (out[k] && typeof out[k] === 'object' && was[k] && typeof was[k] === 'object') out[k] = { ...(was[k] as Row), ...(out[k] as Row) };
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
const RELATIONSHIP: Row = { director: false, executive: false, owner: false, representative: false, percent_ownership: null, title: null };
|
|
208
|
+
|
|
209
|
+
// a person given or changed is part of the account's review: its representative and owners are what card_payments asks for
|
|
210
|
+
const createPerson: Semantics = async (ctx) => {
|
|
211
|
+
const account = at(ctx, 'account');
|
|
212
|
+
if (!accountRow(ctx, account)) return accountMissing(ctx, account);
|
|
213
|
+
const kept = keptPerson(ctx.params, { relationship: RELATIONSHIP });
|
|
214
|
+
const body = await created(ctx, 'person', { ...kept, account }, {
|
|
215
|
+
relationship: RELATIONSHIP,
|
|
216
|
+
requirements: { currently_due: [], eventually_due: [], past_due: [], pending_verification: [], errors: [], alternatives: [] },
|
|
217
|
+
verification: { status: 'unverified', document: { back: null, details: null, details_code: null, front: null } },
|
|
218
|
+
metadata: {}, ssn_last_4_provided: false, id_number_provided: false,
|
|
219
|
+
});
|
|
220
|
+
await review(ctx, account);
|
|
221
|
+
return ctx.reply(body);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const person: Semantics = async (ctx) => {
|
|
225
|
+
const p = accountPerson(ctx);
|
|
226
|
+
return p ? ctx.reply(p) : personMissing(ctx);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const updatePerson: Semantics = async (ctx) => {
|
|
230
|
+
const was = accountPerson(ctx);
|
|
231
|
+
if (!was) return personMissing(ctx);
|
|
232
|
+
const body = await ctx.write('person', at(ctx, 'person'), keptPerson(ctx.params, was), 'person.update');
|
|
233
|
+
await review(ctx, at(ctx, 'account'));
|
|
234
|
+
return ctx.reply(body);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const deletePerson: Semantics = async (ctx) => {
|
|
238
|
+
if (!accountPerson(ctx)) return personMissing(ctx);
|
|
239
|
+
const id = at(ctx, 'person');
|
|
240
|
+
await ctx.write('person', id, { deleted: true }, 'person.delete');
|
|
241
|
+
await review(ctx, at(ctx, 'account'));
|
|
242
|
+
return ctx.reply({ id, object: 'person', deleted: true });
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ── capabilities: the account's capability map is the truth; requesting one leaves it pending ──
|
|
246
|
+
|
|
247
|
+
/** A capability as its account stands: its status, and the account's requirements that capability asks for (transfers
|
|
248
|
+
* leaves out what only card_payments asks for). Where the documentation stops and the twin decides: the account-wide
|
|
249
|
+
* fields (the bank account, the terms) belong to every capability. */
|
|
250
|
+
const capabilityBody = (acct: Row, id: string, status: unknown, requested = true, requestedAt: unknown = null): Row => {
|
|
251
|
+
const req = (acct.requirements as Row | undefined) ?? {};
|
|
252
|
+
const mine = (list: unknown): string[] => (Array.isArray(list) ? (list as string[]) : []).filter((f) => id === 'card_payments' || !cardOnly(f));
|
|
253
|
+
const due = mine(req.currently_due);
|
|
254
|
+
return {
|
|
255
|
+
id, object: 'capability', account: acct.id, status, requested, requested_at: requestedAt,
|
|
256
|
+
requirements: { currently_due: due, eventually_due: mine(req.eventually_due), past_due: [], pending_verification: [], errors: [], alternatives: [], current_deadline: null, disabled_reason: due.length ? 'requirements.past_due' : null },
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const capabilities: Semantics = async (ctx) => {
|
|
261
|
+
const account = at(ctx, 'account');
|
|
262
|
+
const acct = ctx.get('account', account);
|
|
263
|
+
if (!acct) return accountMissing(ctx, account);
|
|
264
|
+
const caps = (acct.capabilities as Row) ?? {};
|
|
265
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: false, data: Object.entries(caps).map(([id, status]) => capabilityBody(acct, id, status)) });
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const capability: Semantics = async (ctx) => {
|
|
269
|
+
const account = at(ctx, 'account');
|
|
270
|
+
const acct = ctx.get('account', account);
|
|
271
|
+
if (!acct) return accountMissing(ctx, account);
|
|
272
|
+
const caps = (acct.capabilities as Row) ?? {};
|
|
273
|
+
const cap = at(ctx, 'capability');
|
|
274
|
+
return cap in caps ? ctx.reply(capabilityBody(acct, cap, caps[cap])) : fail(ctx, `No such capability: '${cap}'`, 404, 'resource_missing');
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const requestCapability: Semantics = async (ctx) => {
|
|
278
|
+
const account = at(ctx, 'account');
|
|
279
|
+
const acct = ctx.get('account', account);
|
|
280
|
+
if (!acct) return accountMissing(ctx, account);
|
|
281
|
+
const caps = { ...((acct.capabilities as Row) ?? {}) };
|
|
282
|
+
const cap = at(ctx, 'capability');
|
|
283
|
+
const requested = ctx.params.requested === undefined ? true : asBool(ctx.params.requested);
|
|
284
|
+
const to = requested ? 'pending' : 'inactive';
|
|
285
|
+
const refused = ctx.legal('capability', 'status', 'PostAccountsAccountCapabilitiesCapability', String(caps[cap] ?? 'unrequested'), to, cap);
|
|
286
|
+
if (refused) return ctx.refuse(refused);
|
|
287
|
+
caps[cap] = to;
|
|
288
|
+
await ctx.write('account', account, { capabilities: caps }, 'account.updated');
|
|
289
|
+
// Stripe reviews the account against what the capability asks for (review)
|
|
290
|
+
await review(ctx, account);
|
|
291
|
+
const now = ctx.get('account', account)!;
|
|
292
|
+
return ctx.reply(capabilityBody(now, cap, (now.capabilities as Row)[cap], requested, requested ? ctx.now() : null));
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ── external accounts: a payout destination from a token or a bank_account hash ──
|
|
296
|
+
|
|
297
|
+
const externalMissing = (ctx: SemanticsContext): Response => fail(ctx, `No such external account: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
298
|
+
const accountExternal = (ctx: SemanticsContext): Row | undefined => {
|
|
299
|
+
const e = ctx.get('external_account', at(ctx, 'id'));
|
|
300
|
+
return e && e.account === at(ctx, 'account') ? e : undefined;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Whether the platform may create, update or delete an account's external accounts: "A platform can create, update, or
|
|
304
|
+
* delete external accounts only for connected accounts without access to either the full Stripe or Express Dashboard
|
|
305
|
+
* and where the platform is responsible for negative balances" (docs.stripe.com/connect/payouts-bank-accounts); it can
|
|
306
|
+
* still view an Express account's ("A platform can view the external accounts of connected accounts that don’t have
|
|
307
|
+
* access to the full Stripe Dashboard"). Where the documentation stops and the twin decides: the refusal. Stripe's
|
|
308
|
+
* pages give no error code for it; the twin answers 403, which the errors page gives as "The API key doesn’t have
|
|
309
|
+
* permissions to perform the request" (docs.stripe.com/api/errors), with the twin's own wording. The create page says
|
|
310
|
+
* it too: "You can only specify connected accounts where account.controller.requirement_collection is `application`"
|
|
311
|
+
* (docs.stripe.com/api/external_account_bank_accounts/create). */
|
|
312
|
+
function refuseExternalWrite(ctx: SemanticsContext, account: string): Response | undefined {
|
|
313
|
+
const row = accountRow(ctx, account);
|
|
314
|
+
const controller = (row?.controller as Row | undefined) ?? controllerOf(String(row?.type ?? 'standard'));
|
|
315
|
+
const dashboard = String(((controller.stripe_dashboard as Row | undefined)?.type) ?? '');
|
|
316
|
+
const losses = String(((controller.losses as Row | undefined)?.payments) ?? '');
|
|
317
|
+
if (dashboard === 'none' && losses === 'application') return undefined;
|
|
318
|
+
return ctx.refuse({ status: 403, message: `This application does not have the required permissions to manage the external accounts of account '${account}': they can only be managed through the account's ${dashboard === 'express' ? 'Express' : 'Stripe'} Dashboard.` });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const createExternal: Semantics = async (ctx) => {
|
|
322
|
+
const account = at(ctx, 'account');
|
|
323
|
+
if (!accountRow(ctx, account)) return accountMissing(ctx, account);
|
|
324
|
+
const forbidden = refuseExternalWrite(ctx, account);
|
|
325
|
+
if (forbidden) return forbidden;
|
|
326
|
+
const ext = ctx.params.external_account ?? ctx.params.bank_account;
|
|
327
|
+
if (ext === undefined) return fail(ctx, 'Missing required param: external_account.', 400, 'parameter_missing');
|
|
328
|
+
const ba = typeof ext === 'object' ? (ext as Row) : {};
|
|
329
|
+
const acctNum = typeof ba.account_number === 'string' ? ba.account_number.replace(/\D/g, '') : '';
|
|
330
|
+
const currency = String(ba.currency ?? 'usd');
|
|
331
|
+
const sameCurrency = ctx.rows('external_account').filter((e) => e.account === account && String(e.currency ?? 'usd') === currency);
|
|
332
|
+
const isDefault = sameCurrency.length === 0 || ctx.params.default_for_currency === true || ctx.params.default_for_currency === 'true';
|
|
333
|
+
const made = await created(ctx, 'external_account', { account }, {
|
|
334
|
+
account_holder_name: ba.account_holder_name ?? null,
|
|
335
|
+
account_holder_type: ba.account_holder_type ?? null, bank_name: 'STRIPE TEST BANK',
|
|
336
|
+
country: ba.country ?? 'US', currency,
|
|
337
|
+
fingerprint: 'twin_ext_fp', last4: acctNum ? acctNum.slice(-4) : '6789',
|
|
338
|
+
routing_number: ba.routing_number ?? '110000000', status: 'new', metadata: {},
|
|
339
|
+
// "When set to true, or if this is the first external account added in this currency, this account becomes the
|
|
340
|
+
// default external account for its currency" (docs.stripe.com/api/external_account_bank_accounts/create)
|
|
341
|
+
default_for_currency: isDefault,
|
|
342
|
+
...(ctx.params.metadata && typeof ctx.params.metadata === 'object' ? { metadata: ctx.params.metadata } : {}),
|
|
343
|
+
});
|
|
344
|
+
if (isDefault) for (const other of sameCurrency) if (other.default_for_currency === true) await ctx.write('external_account', String(other.id), { default_for_currency: false }, 'external_account.update');
|
|
345
|
+
await syncExternals(ctx, account);
|
|
346
|
+
await review(ctx, account);
|
|
347
|
+
return ctx.reply(made);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const externals: Semantics = async (ctx) => {
|
|
351
|
+
const account = at(ctx, 'account');
|
|
352
|
+
if (!accountRow(ctx, account)) return accountMissing(ctx, account);
|
|
353
|
+
return list(ctx, 'external_account', newest(ctx, 'external_account').filter((e) => e.account === account));
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const external: Semantics = async (ctx) => {
|
|
357
|
+
const e = accountExternal(ctx);
|
|
358
|
+
return e ? ctx.reply(e) : externalMissing(ctx);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const deleteExternal: Semantics = async (ctx) => {
|
|
362
|
+
if (!accountExternal(ctx)) return externalMissing(ctx);
|
|
363
|
+
const forbidden = refuseExternalWrite(ctx, at(ctx, 'account'));
|
|
364
|
+
if (forbidden) return forbidden;
|
|
365
|
+
const id = at(ctx, 'id');
|
|
366
|
+
await ctx.write('external_account', id, { deleted: true }, 'external_account.delete');
|
|
367
|
+
await syncExternals(ctx, at(ctx, 'account'));
|
|
368
|
+
await review(ctx, at(ctx, 'account'));
|
|
369
|
+
return ctx.reply({ id, object: 'bank_account', deleted: true });
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ── hosted onboarding links and embedded-component sessions ──
|
|
373
|
+
|
|
374
|
+
const accountLink: Semantics = async (ctx) => {
|
|
375
|
+
const account = typeof ctx.params.account === 'string' ? ctx.params.account : '';
|
|
376
|
+
if (!account) return fail(ctx, 'Missing required param: account.', 400, 'parameter_missing');
|
|
377
|
+
if (!ctx.get('account', account)) return accountMissing(ctx, account, 400);
|
|
378
|
+
const linkType = typeof ctx.params.type === 'string' ? ctx.params.type : '';
|
|
379
|
+
if (linkType !== 'account_onboarding' && linkType !== 'account_update') return fail(ctx, 'Invalid account link type: must be account_onboarding or account_update.', 400, 'parameter_invalid_string_enum');
|
|
380
|
+
// the owner is sent back to return_url when done, and to refresh_url for a new link once this one is used or expired
|
|
381
|
+
for (const k of ['refresh_url', 'return_url']) if (typeof ctx.params[k] !== 'string' || !ctx.params[k]) return fail(ctx, `Missing required param: ${k}.`, 400, 'parameter_missing');
|
|
382
|
+
const now = Number(ctx.now());
|
|
383
|
+
const id = ctx.mint('account_link');
|
|
384
|
+
const link = { object: 'account_link', created: now, expires_at: now + 300, url: `https://connect.stripe.com/setup/${id}` };
|
|
385
|
+
await ctx.write('account_link', id, { ...link, account, type: linkType, refresh_url: ctx.params.refresh_url, return_url: ctx.params.return_url, used: false }, 'account_link.create');
|
|
386
|
+
return ctx.reply(link);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// a session is identified by its client_secret: the row is kept for traceability, the answer has no id
|
|
390
|
+
/** Every embedded component an account session answers (the served spec requires all of them), each enabled only when
|
|
391
|
+
* the request enables it, with its features: what the request gives, else the spec's stated default, "The default
|
|
392
|
+
* value for this feature is `true`" (external_account_collection); disable_stripe_user_authentication "the opposite of
|
|
393
|
+
* the `external_account_collection` value"; edit_payout_schedule and standard_payouts "Defaults to `true` when
|
|
394
|
+
* `controller.losses.payments` is set to `stripe` for the account, otherwise `false`"; capture_payments, dispute_management
|
|
395
|
+
* and refund_management "true by default"; destination_on_behalf_of_charge_management "false by default";
|
|
396
|
+
* smart_disputes_management "Defaults to the value of `dispute_management`". Where the documentation stops and the twin
|
|
397
|
+
* decides: instant_payouts (described as `enabled` "when Stripe is responsible for negative account balances", typed
|
|
398
|
+
* boolean) is true exactly then, and the Issuing and financial-account features, which state no default, are false. */
|
|
399
|
+
const SESSION_COMPONENTS: Record<string, string[]> = {
|
|
400
|
+
account_management: ['external_account_collection', 'disable_stripe_user_authentication'],
|
|
401
|
+
account_onboarding: ['external_account_collection', 'disable_stripe_user_authentication'],
|
|
402
|
+
balance_report: [], documents: [], payout_details: [], payout_reconciliation_report: [], payouts_list: [], tax_registrations: [], tax_settings: [],
|
|
403
|
+
balances: ['external_account_collection', 'disable_stripe_user_authentication', 'edit_payout_schedule', 'instant_payouts', 'standard_payouts'],
|
|
404
|
+
payouts: ['external_account_collection', 'disable_stripe_user_authentication', 'edit_payout_schedule', 'instant_payouts', 'standard_payouts'],
|
|
405
|
+
disputes_list: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
|
|
406
|
+
payment_details: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
|
|
407
|
+
payments: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
|
|
408
|
+
payment_disputes: ['destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
|
|
409
|
+
financial_account: ['external_account_collection', 'disable_stripe_user_authentication', 'send_money', 'transfer_balance'],
|
|
410
|
+
financial_account_transactions: ['card_spend_dispute_management'],
|
|
411
|
+
instant_payouts_promotion: ['external_account_collection', 'disable_stripe_user_authentication', 'instant_payouts'],
|
|
412
|
+
issuing_card: ['card_management', 'card_spend_dispute_management', 'cardholder_management', 'spend_control_management'],
|
|
413
|
+
issuing_cards_list: ['card_management', 'card_spend_dispute_management', 'cardholder_management', 'disable_stripe_user_authentication', 'spend_control_management'],
|
|
414
|
+
notification_banner: ['external_account_collection', 'disable_stripe_user_authentication'],
|
|
415
|
+
payment_method_settings: ['disable_stripe_user_authentication'],
|
|
416
|
+
};
|
|
417
|
+
function sessionComponents(account: Row, given: Row): Row {
|
|
418
|
+
const stripeLosses = (((account.controller as Row | undefined)?.losses as Row | undefined)?.payments) === 'stripe';
|
|
419
|
+
const out: Row = {};
|
|
420
|
+
for (const [name, featureNames] of Object.entries(SESSION_COMPONENTS)) {
|
|
421
|
+
const c = given[name] && typeof given[name] === 'object' ? (given[name] as Row) : {};
|
|
422
|
+
const asked = c.features && typeof c.features === 'object' ? (c.features as Row) : {};
|
|
423
|
+
const f: Row = {};
|
|
424
|
+
const val = (k: string, fallback: boolean): boolean => (asked[k] !== undefined ? asBool(asked[k]) : fallback);
|
|
425
|
+
for (const k of featureNames) {
|
|
426
|
+
if (k === 'external_account_collection') f[k] = val(k, true);
|
|
427
|
+
else if (k === 'disable_stripe_user_authentication') f[k] = val(k, !val('external_account_collection', true));
|
|
428
|
+
else if (k === 'edit_payout_schedule' || k === 'standard_payouts' || k === 'instant_payouts') f[k] = val(k, stripeLosses);
|
|
429
|
+
else if (k === 'capture_payments' || k === 'dispute_management' || k === 'refund_management') f[k] = val(k, true);
|
|
430
|
+
else if (k === 'smart_disputes_management') f[k] = val(k, val('dispute_management', true));
|
|
431
|
+
else f[k] = val(k, false);
|
|
432
|
+
}
|
|
433
|
+
out[name] = { enabled: c.enabled !== undefined ? asBool(c.enabled) : false, features: f };
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const accountSession: Semantics = async (ctx) => {
|
|
439
|
+
const account = typeof ctx.params.account === 'string' ? ctx.params.account : '';
|
|
440
|
+
if (!account) return fail(ctx, 'Missing required param: account.', 400, 'parameter_missing');
|
|
441
|
+
if (!ctx.get('account', account)) return accountMissing(ctx, account, 400);
|
|
442
|
+
const comps = ctx.params.components && typeof ctx.params.components === 'object' ? (ctx.params.components as Row) : undefined;
|
|
443
|
+
if (!comps || Object.keys(comps).length === 0) return fail(ctx, 'Missing required param: components.', 400, 'parameter_missing');
|
|
444
|
+
const components = sessionComponents(ctx.get('account', account)!, comps);
|
|
445
|
+
const now = Number(ctx.now());
|
|
446
|
+
const seq = ctx.rowsRaw('account_session', { withDeleted: true }).length + 1;
|
|
447
|
+
const secret = `_twin_acct_sess_${seq}_secret`;
|
|
448
|
+
await created(ctx, 'account_session', { id: `accts_twin_${seq}`, account }, { livemode: false, client_secret: secret, expires_at: now + 3600, components });
|
|
449
|
+
return ctx.reply({ object: 'account_session', account, client_secret: secret, expires_at: now + 3600, components, livemode: false });
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const connect: Record<string, Semantics> = {
|
|
453
|
+
GetAccount: platform,
|
|
454
|
+
PostAccounts: create,
|
|
455
|
+
GetAccounts: listAccounts,
|
|
456
|
+
PostAccountsAccount: update,
|
|
457
|
+
PostAccountsAccountLoginLinks: loginLink,
|
|
458
|
+
PostAccountsAccountPersons: createPerson,
|
|
459
|
+
GetAccountsAccountPersonsPerson: person,
|
|
460
|
+
PostAccountsAccountPersonsPerson: updatePerson,
|
|
461
|
+
DeleteAccountsAccountPersonsPerson: deletePerson,
|
|
462
|
+
GetAccountsAccountCapabilities: capabilities,
|
|
463
|
+
GetAccountsAccountCapabilitiesCapability: capability,
|
|
464
|
+
PostAccountsAccountCapabilitiesCapability: requestCapability,
|
|
465
|
+
PostAccountsAccountExternalAccounts: createExternal,
|
|
466
|
+
GetAccountsAccountExternalAccounts: externals,
|
|
467
|
+
GetAccountsAccountExternalAccountsId: external,
|
|
468
|
+
DeleteAccountsAccountExternalAccountsId: deleteExternal,
|
|
469
|
+
PostAccountLinks: accountLink,
|
|
470
|
+
PostAccountSessions: accountSession,
|
|
471
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Coupon and PromotionCode semantics. A coupon is exactly one of percent_off or amount_off (with
|
|
2
|
+
// its currency) over a duration; a promotion code is a customer-facing code for an existing
|
|
3
|
+
// coupon, active until switched off (the machine in ../manifest.ts). Coupon list, retrieve, update
|
|
4
|
+
// and delete and promotion-code retrieve and update (`active`, the machine) are the derived core's.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { asBool } from '../stripe-twin.ts';
|
|
7
|
+
import { at, at_, created, fail, list, newest, where, type Row } from './shared.ts';
|
|
8
|
+
|
|
9
|
+
/** A coupon for a fixed amount off, in its currency (docs.stripe.com/api/coupons/create#create_coupon-amount_off). */
|
|
10
|
+
function amountOffOf(ctx: SemanticsContext, params: Record<string, unknown>): number | Response {
|
|
11
|
+
const amountOff = Number(params.amount_off);
|
|
12
|
+
if (!Number.isInteger(amountOff) || amountOff <= 0) return fail(ctx, 'Invalid integer: amount_off must be a positive integer.', 400, 'parameter_invalid_integer');
|
|
13
|
+
if (params.currency === undefined || params.currency === '') return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
|
|
14
|
+
return amountOff;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const createCoupon: Semantics = async (ctx) => {
|
|
18
|
+
const params = ctx.params;
|
|
19
|
+
const hasPct = params.percent_off !== undefined;
|
|
20
|
+
const hasAmt = params.amount_off !== undefined;
|
|
21
|
+
if (hasPct === hasAmt) return fail(ctx, 'You must pass exactly one of `amount_off` and `percent_off`.', 400, 'parameter_missing');
|
|
22
|
+
let percentOff: number | null = null;
|
|
23
|
+
let amountOff: number | null = null;
|
|
24
|
+
if (hasPct) {
|
|
25
|
+
percentOff = Number(params.percent_off);
|
|
26
|
+
if (!Number.isFinite(percentOff) || percentOff <= 0 || percentOff > 100) return fail(ctx, 'Invalid number: percent_off must be > 0 and <= 100.', 400, 'parameter_invalid_number');
|
|
27
|
+
}
|
|
28
|
+
const off = hasPct ? null : amountOffOf(ctx, params);
|
|
29
|
+
if (off instanceof Response) return off;
|
|
30
|
+
amountOff = off;
|
|
31
|
+
const duration = typeof params.duration === 'string' ? params.duration : 'once';
|
|
32
|
+
if (!['once', 'repeating', 'forever'].includes(duration)) return fail(ctx, 'Invalid duration: must be one of once, repeating, or forever.', 400, 'parameter_invalid_string_enum');
|
|
33
|
+
if (duration === 'repeating' && params.duration_in_months === undefined) return fail(ctx, 'Missing required param: duration_in_months (required when duration=repeating).', 400, 'parameter_missing');
|
|
34
|
+
return ctx.reply(
|
|
35
|
+
await created(ctx, 'coupon', params, {
|
|
36
|
+
percent_off: percentOff, amount_off: amountOff,
|
|
37
|
+
currency: amountOff !== null ? params.currency : null,
|
|
38
|
+
duration, duration_in_months: duration === 'repeating' ? Number(params.duration_in_months) : null,
|
|
39
|
+
name: typeof params.name === 'string' ? params.name : null,
|
|
40
|
+
valid: true, livemode: false, times_redeemed: 0, metadata: {},
|
|
41
|
+
max_redemptions: params.max_redemptions !== undefined ? Number(params.max_redemptions) : null,
|
|
42
|
+
redeem_by: params.redeem_by !== undefined ? Number(params.redeem_by) : null,
|
|
43
|
+
applies_to: null,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// a code the caller does not give is derived from the promotion code's id
|
|
49
|
+
const createPromotionCode: Semantics = async (ctx) => {
|
|
50
|
+
// what it promotes: promotion[type]=coupon and promotion[coupon] (2025-09-30.clover,
|
|
51
|
+
// docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon), or the top-level coupon a caller pinned to an
|
|
52
|
+
// earlier version sends; the twin keeps the coupon's id on the code for its rules
|
|
53
|
+
const { promotion, coupon: legacy, ...params } = ctx.params;
|
|
54
|
+
const promo = promotion && typeof promotion === 'object' ? (promotion as Record<string, unknown>) : undefined;
|
|
55
|
+
if (promo && promo.type !== 'coupon') return fail(ctx, 'Invalid promotion[type]: must be coupon.', 400, 'parameter_invalid_string_enum');
|
|
56
|
+
const coupon = typeof promo?.coupon === 'string' ? promo.coupon : typeof legacy === 'string' ? legacy : '';
|
|
57
|
+
if (!coupon) return fail(ctx, promo ? 'Missing required param: promotion[coupon].' : 'Missing required param: promotion.', 400, 'parameter_missing');
|
|
58
|
+
if (!ctx.get('coupon', coupon)) return fail(ctx, `No such coupon: '${coupon}'`, 400, 'resource_missing');
|
|
59
|
+
const id = ctx.mint('promotion_code');
|
|
60
|
+
const code = typeof params.code === 'string' && params.code ? params.code : `TWIN${id.toUpperCase().replace(/[^A-Z0-9]/g, '')}`;
|
|
61
|
+
return ctx.reply(
|
|
62
|
+
await created(ctx, 'promotion_code', { ...params, coupon, promotion: { type: 'coupon', coupon }, id, code }, {
|
|
63
|
+
active: params.active !== undefined ? asBool(params.active) : true,
|
|
64
|
+
customer: typeof params.customer === 'string' ? params.customer : null,
|
|
65
|
+
expires_at: params.expires_at !== undefined ? Number(params.expires_at) : null,
|
|
66
|
+
max_redemptions: params.max_redemptions !== undefined ? Number(params.max_redemptions) : null,
|
|
67
|
+
times_redeemed: 0, livemode: false, metadata: {}, restrictions: { first_time_transaction: false, minimum_amount: null, minimum_amount_currency: null },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Time's lapsing of coupons, caught up to the World's clock: a valid coupon whose redeem_by has passed is no longer
|
|
73
|
+
* valid from that moment (manifest.ts). Where the documentation stops and the twin decides: the lapse sends no event
|
|
74
|
+
* (the events page names none for it). */
|
|
75
|
+
export async function lapseCoupons(ctx: SemanticsContext): Promise<void> {
|
|
76
|
+
const now = Number(ctx.now());
|
|
77
|
+
for (const c of ctx.rowsRaw('coupon')) {
|
|
78
|
+
if (c.valid !== true || typeof c.redeem_by !== 'number' || c.redeem_by > now) continue;
|
|
79
|
+
const t = await at_(ctx)(c.redeem_by);
|
|
80
|
+
t.legal('coupon', 'valid', ctx.call.operation.id, 'true', 'false', String(c.id), 'time');
|
|
81
|
+
await t.write('coupon', String(c.id), { valid: false }, 'coupon.lapsed');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const listPromotionCodes: Semantics = async (ctx) =>
|
|
86
|
+
list(ctx, 'promotion_code', where(ctx, newest(ctx, 'promotion_code'), {
|
|
87
|
+
code: (p, v) => p.code === v,
|
|
88
|
+
active: (p, v) => asBool(p.active) === asBool(v),
|
|
89
|
+
coupon: (p, v) => p.coupon === v,
|
|
90
|
+
customer: (p, v) => p.customer === v,
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
export const coupons: Record<string, Semantics> = {
|
|
94
|
+
PostCoupons: createCoupon,
|
|
95
|
+
PostPromotionCodes: createPromotionCode,
|
|
96
|
+
GetPromotionCodes: listPromotionCodes,
|
|
97
|
+
};
|