@volter/twin-stripe 0.1.2 → 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,218 @@
|
|
|
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 { created, type Row } from '../semantics/shared.ts';
|
|
25
|
+
import { publicBusinessName } from '../stripe-shared.ts';
|
|
26
|
+
import { declineFor, paymentMethodSubObject, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
27
|
+
import { cardAnswer, COUNTRIES, money } from './checkout.tsx';
|
|
28
|
+
import { formOf } from './consent-skin.ts';
|
|
29
|
+
|
|
30
|
+
const SUB = 'subscription';
|
|
31
|
+
const PORTAL_OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostBillingPortalSessions')!;
|
|
32
|
+
|
|
33
|
+
// Stripe's skin: its type, its purple accent, the merchant's side tinted.
|
|
34
|
+
const SKIN = `
|
|
35
|
+
body { color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; }
|
|
36
|
+
.portal-side { background: #f7f7f9; }
|
|
37
|
+
.portal-back { color: #635bff; }
|
|
38
|
+
.portal-section h2 { color: #6b7280; border-color: #e5e7eb; }
|
|
39
|
+
.portal-item { border-bottom: 1px solid #f0f0f3; }
|
|
40
|
+
.portal-detail, .portal-note { color: #6b7280; }
|
|
41
|
+
.portal-badge { background: #eef0f4; color: #414552; }
|
|
42
|
+
.portal-button { border-color: #d5dbe1; color: #1a1a1a; }
|
|
43
|
+
.portal-primary { background: #635bff; border-color: #635bff; color: #ffffff; }
|
|
44
|
+
.portal-danger { color: #df1b41; border-color: #f4c7d0; }
|
|
45
|
+
.portal-empty { color: #6b7280; }
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const day = (epoch: unknown): string => new Date(Number(epoch) * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
49
|
+
const gone = (): Response => flowPage({ title: 'Billing', status: 404, css: [PORTAL_CSS, SKIN], body: <main className="portal-main"><h1>This link has expired.</h1></main> });
|
|
50
|
+
|
|
51
|
+
type Features = { cancel: { enabled: boolean; mode: 'at_period_end' | 'immediately' }; invoices: boolean; paymentMethod: boolean };
|
|
52
|
+
function featuresOf(ctx: SemanticsContext, session: Row): Features {
|
|
53
|
+
const f = (ctx.get('billing_portal.configuration', String(session.configuration))?.features ?? undefined) as Row | undefined;
|
|
54
|
+
const cancel = (f?.subscription_cancel ?? {}) as Row;
|
|
55
|
+
const invoices = (f?.invoice_history ?? {}) as Row;
|
|
56
|
+
const update = (f?.payment_method_update ?? {}) as Row;
|
|
57
|
+
return {
|
|
58
|
+
cancel: { enabled: f ? cancel.enabled === true : true, mode: cancel.mode === 'immediately' ? 'immediately' : 'at_period_end' },
|
|
59
|
+
invoices: f ? invoices.enabled !== false : true,
|
|
60
|
+
paymentMethod: f ? update.enabled === true : true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function planOf(ctx: SemanticsContext, sub: Row): { name: string; price: string } {
|
|
65
|
+
const item = (((sub.items as { data?: Row[] } | undefined)?.data) ?? [])[0];
|
|
66
|
+
const price = (typeof item?.price === 'object' ? item.price : ctx.get('price', String(item?.price ?? ''))) as Row | undefined;
|
|
67
|
+
const product = typeof price?.product === 'string' ? ctx.get('product', price.product) : (price?.product as Row | undefined);
|
|
68
|
+
const recurring = price?.recurring as Row | undefined;
|
|
69
|
+
return {
|
|
70
|
+
name: String(product?.name ?? price?.nickname ?? 'Plan'),
|
|
71
|
+
price: price ? `${money(price.unit_amount, price.currency)}${recurring?.interval ? ` per ${String(recurring.interval)}` : ''}` : '',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function page(ctx: SemanticsContext, id: string, session: Row, problem?: { error: string; values: Record<string, string> }): Response {
|
|
76
|
+
const features = featuresOf(ctx, session);
|
|
77
|
+
const customer = ctx.get('customer', String(session.customer)) ?? {};
|
|
78
|
+
const subs = ctx.rows(SUB).filter((s) => s.customer === session.customer && s.status !== 'canceled' && s.status !== 'incomplete_expired');
|
|
79
|
+
const plans: PortalItem[] = subs.map((s) => {
|
|
80
|
+
const plan = planOf(ctx, s);
|
|
81
|
+
const ending = s.cancel_at_period_end === true;
|
|
82
|
+
return {
|
|
83
|
+
title: plan.name,
|
|
84
|
+
detail: plan.price,
|
|
85
|
+
badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
|
|
86
|
+
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)}.`,
|
|
87
|
+
actions: ending
|
|
88
|
+
? [{ label: 'Renew plan', action: `/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
|
|
89
|
+
: features.cancel.enabled ? [{ label: 'Cancel plan', action: `/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const defaultPm = ((customer.invoice_settings as Row | undefined)?.default_payment_method as string | undefined) ?? undefined;
|
|
93
|
+
const pms = ctx.rows('payment_method').filter((p) => p.customer === session.customer);
|
|
94
|
+
const subDefault = subs.map((sub) => payerOf(ctx, sub)).find((p) => typeof p === 'string');
|
|
95
|
+
const pm = (defaultPm ? pms.find((p) => p.id === defaultPm) : undefined) ?? (subDefault ? pms.find((p) => p.id === subDefault) : undefined) ?? pms[0];
|
|
96
|
+
const card = pm?.card as Row | undefined;
|
|
97
|
+
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' }] : [];
|
|
98
|
+
const address = customer.address as Row | undefined;
|
|
99
|
+
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(', ') } : {}) }];
|
|
100
|
+
const invoices: PortalItem[] = features.invoices
|
|
101
|
+
? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
|
|
102
|
+
.map((i) => ({
|
|
103
|
+
title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
|
|
104
|
+
...(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 }] } : {}),
|
|
105
|
+
}))
|
|
106
|
+
: [];
|
|
107
|
+
const back = typeof session.return_url === 'string' ? session.return_url : undefined;
|
|
108
|
+
// the portal shows the public business name (stripe-shared.ts, publicBusinessName) of the session's on_behalf_of
|
|
109
|
+
// account ("Use the Accounts API to modify the on_behalf_of account's branding settings, which the portal displays",
|
|
110
|
+
// docs.stripe.com/api/customer_portal/sessions/object), else of the account it was made on (Stripe-Account), else the
|
|
111
|
+
// platform's
|
|
112
|
+
const owner = session.on_behalf_of ?? ctx.row('billing_portal.session', String(session.id))?._brand_account;
|
|
113
|
+
const merchant = publicBusinessName(ctx.get('account', typeof owner === 'string' ? owner : PLATFORM_ACCOUNT_ID));
|
|
114
|
+
const v = problem?.values ?? {};
|
|
115
|
+
const cardFields: PaymentField[] = [
|
|
116
|
+
{ id: 'cardNumber', label: 'Card number', autoComplete: 'cc-number', placeholder: '1234 1234 1234 1234', value: v.cardNumber ?? '' },
|
|
117
|
+
{ id: 'cardExpiry', label: 'Expiration', autoComplete: 'cc-exp', placeholder: 'MM / YY', value: v.cardExpiry ?? '' },
|
|
118
|
+
{ id: 'cardCvc', label: 'CVC', autoComplete: 'cc-csc', placeholder: 'CVC', value: v.cardCvc ?? '' },
|
|
119
|
+
{ id: 'billingName', label: 'Name on card', autoComplete: 'cc-name', value: v.billingName ?? '' },
|
|
120
|
+
{ id: 'billingCountry', label: 'Country or region', options: COUNTRIES, value: v.billingCountry ?? 'US' },
|
|
121
|
+
{ id: 'billingPostalCode', label: 'ZIP', autoComplete: 'postal-code', value: v.billingPostalCode ?? '' },
|
|
122
|
+
];
|
|
123
|
+
return flowPage({
|
|
124
|
+
title: 'Billing',
|
|
125
|
+
css: [PORTAL_CSS, SKIN],
|
|
126
|
+
...(problem ? { status: 402 } : {}),
|
|
127
|
+
body: (
|
|
128
|
+
<Portal
|
|
129
|
+
merchant={merchant}
|
|
130
|
+
{...(problem ? { notice: problem.error } : {})}
|
|
131
|
+
{...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
|
|
132
|
+
{...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {})}
|
|
133
|
+
sections={[
|
|
134
|
+
{ heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
|
|
135
|
+
{ heading: 'Payment method', items: methods, empty: 'No payment method on file.' },
|
|
136
|
+
{ heading: 'Billing information', items: details },
|
|
137
|
+
...(features.invoices ? [{ heading: 'Invoice history', items: invoices, empty: 'No invoices yet.' }] : []),
|
|
138
|
+
]}
|
|
139
|
+
/>
|
|
140
|
+
),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The customer's move on one of their subscriptions, then back to the portal. */
|
|
145
|
+
async function move(ctx: SemanticsContext, id: string, session: Row, subscription: string, kind: 'cancel' | 'renew'): Promise<Response> {
|
|
146
|
+
const sub = ctx.get(SUB, subscription);
|
|
147
|
+
if (!sub || sub.customer !== session.customer || sub.status === 'canceled') return gone();
|
|
148
|
+
const back = new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
149
|
+
if (kind === 'renew') {
|
|
150
|
+
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');
|
|
151
|
+
return back;
|
|
152
|
+
}
|
|
153
|
+
const features = featuresOf(ctx, session);
|
|
154
|
+
if (!features.cancel.enabled) return gone();
|
|
155
|
+
const now = Number(ctx.now());
|
|
156
|
+
const details = { comment: null, feedback: null, reason: 'cancellation_requested' };
|
|
157
|
+
if (features.cancel.mode === 'at_period_end') {
|
|
158
|
+
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');
|
|
159
|
+
return back;
|
|
160
|
+
}
|
|
161
|
+
const refused = ctx.legal(SUB, 'status', PORTAL_OPERATION.id, sub.status, 'canceled', subscription, 'external');
|
|
162
|
+
if (refused) return gone();
|
|
163
|
+
await ctx.write(SUB, subscription, { status: 'canceled', canceled_at: now, ended_at: now, cancellation_details: details }, 'subscription.cancel');
|
|
164
|
+
return back;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The customer's new card: saved on them (no charge) as the default their invoices and subscriptions charge. */
|
|
168
|
+
async function updatePaymentMethod(ctx: SemanticsContext, id: string, session: Row, v: Record<string, string>): Promise<Response> {
|
|
169
|
+
if (!featuresOf(ctx, session).paymentMethod) return gone();
|
|
170
|
+
const refused = cardAnswer(v, Number(ctx.now()), false);
|
|
171
|
+
if (refused) return page(ctx, id, session, { error: refused, values: v });
|
|
172
|
+
const customer = String(session.customer);
|
|
173
|
+
const [month, year] = (v.cardExpiry ?? '').split('/').map((x) => Number(x.trim()));
|
|
174
|
+
const card = { number: (v.cardNumber ?? '').replace(/\D/g, ''), exp_month: month ?? 12, exp_year: (year ?? 0) < 100 ? 2000 + (year ?? 0) : (year ?? 0) };
|
|
175
|
+
const pm = await created(ctx, 'payment_method', {}, {
|
|
176
|
+
type: 'card', customer, livemode: false,
|
|
177
|
+
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 },
|
|
178
|
+
...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null,
|
|
179
|
+
});
|
|
180
|
+
const holder = ctx.get('customer', customer) ?? {};
|
|
181
|
+
await ctx.write('customer', customer, { invoice_settings: { ...((holder.invoice_settings as Row | undefined) ?? {}), default_payment_method: pm.id } }, 'customer.update');
|
|
182
|
+
for (const sub of ctx.rows(SUB).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
|
|
183
|
+
await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
|
|
184
|
+
}
|
|
185
|
+
return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The customer pays one of their open invoices with the card on file. */
|
|
189
|
+
async function payInvoice(ctx: SemanticsContext, id: string, session: Row, invoiceId: string): Promise<Response> {
|
|
190
|
+
const inv = ctx.get('invoice', invoiceId);
|
|
191
|
+
if (!inv || inv.customer !== session.customer || inv.status !== 'open') return gone();
|
|
192
|
+
const sub = typeof inv.subscription === 'string' ? ctx.get(SUB, inv.subscription) : undefined;
|
|
193
|
+
const holder = ctx.get('customer', String(session.customer)) ?? {};
|
|
194
|
+
const pm = (sub ? payerOf(ctx, sub) : undefined) ?? ((holder.invoice_settings as Row | undefined)?.default_payment_method as string | undefined);
|
|
195
|
+
if (!pm) return page(ctx, id, session, { error: 'Add a payment method to pay this invoice.', values: {} });
|
|
196
|
+
const declined = await payOpenInvoice(ctx, invoiceId, pm);
|
|
197
|
+
if (declined) return page(ctx, id, session, { error: declined.message, values: {} });
|
|
198
|
+
return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** billing.stripe.com's portal pages, or undefined for any other request. */
|
|
202
|
+
export function stripePortalFlow(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
|
|
203
|
+
return async (request) => {
|
|
204
|
+
const m = /^\/p\/session\/(bps_[A-Za-z0-9_]+)(?:\/(cancel|renew|payment_method|pay))?\/?$/.exec(new URL(request.url).pathname);
|
|
205
|
+
if (!m) return undefined;
|
|
206
|
+
const [, id, kind] = m as unknown as [string, string, 'cancel' | 'renew' | 'payment_method' | 'pay' | undefined];
|
|
207
|
+
if ((kind && request.method !== 'POST') || (!kind && request.method !== 'GET')) return undefined;
|
|
208
|
+
// the form is read before the context, which reads the request's body itself
|
|
209
|
+
const form = kind ? await formOf(request.clone()) : {};
|
|
210
|
+
const ctx = await semanticsContext(manifest, request, PORTAL_OPERATION, scope);
|
|
211
|
+
const session = ctx.get('billing_portal.session', id);
|
|
212
|
+
if (!session) return gone();
|
|
213
|
+
if (!kind) return page(ctx, id, session);
|
|
214
|
+
if (kind === 'payment_method') return updatePaymentMethod(ctx, id, session, form);
|
|
215
|
+
if (kind === 'pay') return payInvoice(ctx, id, session, form.invoice ?? '');
|
|
216
|
+
return move(ctx, id, session, form.subscription ?? '', kind);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What Stripe does itself once a payment succeeds, which no API call asks for:
|
|
2
|
+
// - a test card that is disputed opens a dispute on its charge (docs.stripe.com/testing#disputes: 4000000000000259
|
|
3
|
+
// "charge succeeds, then disputed as fraudulent", 4000000000002685 disputed as product not received,
|
|
4
|
+
// 4000000000001976 an inquiry), and Stripe debits the disputed
|
|
5
|
+
// amount and its dispute fee from the balance (docs.stripe.com/disputes/how-disputes-work);
|
|
6
|
+
// - a test card Radar scores as elevated risk opens a review of its charge (docs.stripe.com/testing#fraud-prevention:
|
|
7
|
+
// 4000000000009235, "elevated risk", which Radar places in review; docs.stripe.com/radar/reviews);
|
|
8
|
+
// - a charge a platform makes with `application_fee_amount` earns the platform an application fee, from the connected
|
|
9
|
+
// account it pays (a destination charge's `transfer_data.destination`, or the account a direct charge is made on;
|
|
10
|
+
// docs.stripe.com/connect/destination-charges, docs.stripe.com/connect/direct-charges);
|
|
11
|
+
// - a bank account verified by micro-deposits for a payment or a setup carries a mandate, the customer's acceptance
|
|
12
|
+
// of debits (docs.stripe.com/payments/ach-direct-debit/accept-a-payment, docs.stripe.com/api/mandates).
|
|
13
|
+
//
|
|
14
|
+
// Where the documentation stops and the twin decides: evidence is due seven days after a dispute opens (Stripe's
|
|
15
|
+
// deadline depends on the card network); the dispute fee is 1500 cents in any currency (Stripe's US fee is $15); an
|
|
16
|
+
// elevated-risk review's risk score is 67 (Radar's elevated band); the application is `ca_twin`.
|
|
17
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
18
|
+
import { disputeEvidence } from '../stripe-twin.ts';
|
|
19
|
+
import { actingAccount, settleApplicationFee, settleDispute, settleTransfer } from './ledger.ts';
|
|
20
|
+
import { created, type Row } from './shared.ts';
|
|
21
|
+
|
|
22
|
+
type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review';
|
|
23
|
+
const DAY = 86_400;
|
|
24
|
+
|
|
25
|
+
/** The test cards whose success Stripe follows with its own act, by number and by their documented test names. */
|
|
26
|
+
export const AFTER_SUCCESS_CARDS: Record<string, { brand: string; number: string; outcome: Outcome }> = {
|
|
27
|
+
pm_card_createDispute: { brand: 'visa', number: '4000000000000259', outcome: 'dispute' },
|
|
28
|
+
pm_card_createDisputeProductNotReceived: { brand: 'visa', number: '4000000000002685', outcome: 'dispute_not_received' },
|
|
29
|
+
pm_card_createDisputeInquiry: { brand: 'visa', number: '4000000000001976', outcome: 'inquiry' },
|
|
30
|
+
pm_card_riskLevelElevated: { brand: 'visa', number: '4000000000009235', outcome: 'review' },
|
|
31
|
+
};
|
|
32
|
+
const BY_NUMBER: Record<string, Outcome> = Object.fromEntries(Object.values(AFTER_SUCCESS_CARDS).map((c) => [c.number, c.outcome]));
|
|
33
|
+
|
|
34
|
+
/** What Stripe does after a card succeeds: from a raw number, a test name (pm_card_* or tok_*), or a stored
|
|
35
|
+
* PaymentMethod that recorded it when it was made. */
|
|
36
|
+
export function afterSuccessOf(ctx: SemanticsContext, ref: unknown): Outcome | undefined {
|
|
37
|
+
if (ref && typeof ref === 'object') return BY_NUMBER[String((ref as Row).number ?? '').replace(/\D/g, '')];
|
|
38
|
+
if (typeof ref !== 'string' || !ref) return undefined;
|
|
39
|
+
const stored = ctx.row('payment_method', ref);
|
|
40
|
+
if (stored) return (stored._afterSuccess as Outcome | undefined) ?? undefined;
|
|
41
|
+
return AFTER_SUCCESS_CARDS[ref.replace(/^tok_/, 'pm_card_')]?.outcome ?? BY_NUMBER[ref.replace(/\D/g, '')];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stripe's acts on a charge that just succeeded: a dispute or a review its card brings, and the platform's fee. */
|
|
45
|
+
export async function afterCharge(ctx: SemanticsContext, chargeId: string, charge: { amount: number; currency: string; payment_intent?: string | undefined; application_fee_amount?: unknown; destination?: unknown }, ref: unknown): Promise<void> {
|
|
46
|
+
const outcome = afterSuccessOf(ctx, ref);
|
|
47
|
+
const pi = charge.payment_intent ? { payment_intent: charge.payment_intent } : { payment_intent: null };
|
|
48
|
+
const now = Number(ctx.now());
|
|
49
|
+
if (outcome === 'dispute' || outcome === 'dispute_not_received' || outcome === 'inquiry') {
|
|
50
|
+
const dispute = await created(ctx, 'dispute', { charge: chargeId, ...pi, amount: charge.amount, currency: charge.currency }, {
|
|
51
|
+
reason: outcome === 'dispute_not_received' ? 'product_not_received' : 'fraudulent', status: outcome === 'inquiry' ? 'warning_needs_response' : 'needs_response', is_charge_refundable: false,
|
|
52
|
+
evidence: disputeEvidence(), evidence_details: { due_by: now + 7 * DAY, has_evidence: false, past_due: false, submission_count: 0, enhanced_eligibility: {} },
|
|
53
|
+
balance_transactions: [], livemode: false, metadata: {}, enhanced_eligibility_types: [],
|
|
54
|
+
});
|
|
55
|
+
// an inquiry moves no money; a dispute takes the amount and its fee until it is decided
|
|
56
|
+
if (outcome !== 'inquiry') {
|
|
57
|
+
const bt = await settleDispute(ctx, String(dispute.id), charge.amount, charge.currency);
|
|
58
|
+
await ctx.write('dispute', String(dispute.id), { balance_transactions: [ctx.get('balance_transaction', bt) ?? bt] }, 'dispute.funds_withdrawn');
|
|
59
|
+
}
|
|
60
|
+
await ctx.write('charge', chargeId, { disputed: true }, 'charge.dispute.created');
|
|
61
|
+
}
|
|
62
|
+
if (outcome === 'review') await openReview(ctx, chargeId, pi);
|
|
63
|
+
const feeAmount = Math.trunc(Number(charge.application_fee_amount) || 0);
|
|
64
|
+
const destination = typeof charge.destination === 'string' && charge.destination ? charge.destination : undefined;
|
|
65
|
+
const direct = actingAccount(ctx);
|
|
66
|
+
const account = destination ?? direct;
|
|
67
|
+
// a destination charge transfers what it collected, less the platform's fee, available when the charge's funds are
|
|
68
|
+
if (destination) {
|
|
69
|
+
const amount = charge.amount - feeAmount;
|
|
70
|
+
const chargeBt = ctx.get('charge', chargeId)?.balance_transaction;
|
|
71
|
+
const availableOn = Number(ctx.get('balance_transaction', String(chargeBt))?.available_on ?? ctx.now());
|
|
72
|
+
const tr = await created(ctx, 'transfer', { amount, currency: charge.currency, destination }, {
|
|
73
|
+
amount_reversed: 0, balance_transaction: null, livemode: false, metadata: {}, reversed: false, source_type: 'card', source_transaction: chargeId,
|
|
74
|
+
reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: '' },
|
|
75
|
+
});
|
|
76
|
+
const bt = await settleTransfer(ctx, String(tr.id), amount, charge.currency, destination, availableOn);
|
|
77
|
+
await ctx.write('transfer', String(tr.id), { balance_transaction: bt, destination_payment: `py_${String(tr.id).replace(/^tr_/, '')}`, reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/transfers/${String(tr.id)}/reversals` } }, 'transfer.created');
|
|
78
|
+
await ctx.write('charge', chargeId, { transfer: tr.id }, 'charge.updated');
|
|
79
|
+
}
|
|
80
|
+
if (feeAmount > 0 && account) {
|
|
81
|
+
const fee = await created(ctx, 'application_fee', { account, amount: feeAmount, charge: chargeId, currency: charge.currency }, {
|
|
82
|
+
amount_refunded: 0, application: 'ca_twin', balance_transaction: null, originating_transaction: null, refunded: false, livemode: false,
|
|
83
|
+
});
|
|
84
|
+
// a direct charge's fee moves from the connected account to the platform (a destination charge kept it back)
|
|
85
|
+
const bt = !destination && direct ? await settleApplicationFee(ctx, String(fee.id), feeAmount, charge.currency, direct) : null;
|
|
86
|
+
await ctx.write('application_fee', String(fee.id), { balance_transaction: bt, refunds: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.created');
|
|
87
|
+
await ctx.write('charge', chargeId, { application_fee: fee.id, application_fee_amount: feeAmount }, 'charge.updated');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Radar places an elevated-risk card's charge in review (docs.stripe.com/radar/reviews). */
|
|
92
|
+
async function openReview(ctx: SemanticsContext, chargeId: string, pi: { payment_intent: string | null }): Promise<void> {
|
|
93
|
+
const review = await created(ctx, 'review', { charge: chargeId, ...pi }, {
|
|
94
|
+
open: true, opened_reason: 'rule', reason: 'rule', closed_reason: null, billing_zip: null, ip_address: null, ip_address_location: null, session: null, livemode: false,
|
|
95
|
+
});
|
|
96
|
+
const existing = (ctx.get('charge', chargeId)?.outcome as Row | undefined) ?? {};
|
|
97
|
+
await ctx.write('charge', chargeId, {
|
|
98
|
+
review: review.id,
|
|
99
|
+
outcome: { ...existing, type: 'manual_review', risk_level: 'elevated', risk_score: 67, seller_message: 'Stripe evaluated this payment as having elevated risk, and placed it in review.' },
|
|
100
|
+
}, 'review.opened');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The mandate a verified bank account carries for a payment (single use) or a setup (multi use). */
|
|
104
|
+
export async function mintMandate(ctx: SemanticsContext, paymentMethod: unknown, use: 'single_use' | 'multi_use', amount?: number, currency?: string): Promise<string> {
|
|
105
|
+
const now = Number(ctx.now());
|
|
106
|
+
const mandate = await created(ctx, 'mandate', { payment_method: typeof paymentMethod === 'string' ? paymentMethod : null }, {
|
|
107
|
+
status: 'active', type: use, livemode: false,
|
|
108
|
+
customer_acceptance: { type: 'online', accepted_at: now, online: { ip_address: '127.0.0.1', user_agent: 'twin' } },
|
|
109
|
+
payment_method_details: { type: 'us_bank_account', us_bank_account: { collection_method: 'paper' } },
|
|
110
|
+
...(use === 'single_use' ? { single_use: { amount: amount ?? 0, currency: currency ?? 'usd' } } : { multi_use: {} }),
|
|
111
|
+
}, { timeField: '_created' });
|
|
112
|
+
return String(mandate.id);
|
|
113
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Apps secret-store semantics: a secret is named within a scope (the account, or one user); setting
|
|
2
|
+
// the same name in the same scope overwrites it. The scope is kept as a bookkeeping key.
|
|
3
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
4
|
+
import { created, fail, list, newest, type Row, kept } from './shared.ts';
|
|
5
|
+
|
|
6
|
+
const SECRET = 'apps.secret';
|
|
7
|
+
|
|
8
|
+
function scopeKeyOf(ctx: SemanticsContext): string {
|
|
9
|
+
const scope = ctx.params.scope && typeof ctx.params.scope === 'object' ? (ctx.params.scope as Row) : undefined;
|
|
10
|
+
const scopeType = scope && typeof scope.type === 'string' ? scope.type : 'account';
|
|
11
|
+
return scopeType === 'user' ? `user:${String(scope!.user)}` : 'account';
|
|
12
|
+
}
|
|
13
|
+
const named = (ctx: SemanticsContext, name: string): Row | undefined => ctx.rows(SECRET).find((s) => s.name === name && kept(ctx, SECRET, s, '_scope_key') === scopeKeyOf(ctx));
|
|
14
|
+
|
|
15
|
+
const set: Semantics = async (ctx) => {
|
|
16
|
+
const params = ctx.params;
|
|
17
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
18
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
19
|
+
const scope = params.scope && typeof params.scope === 'object' ? (params.scope as Row) : undefined;
|
|
20
|
+
const scopeType = scope && typeof scope.type === 'string' ? scope.type : '';
|
|
21
|
+
if (scopeType !== 'account' && scopeType !== 'user') return fail(ctx, 'Invalid scope[type]: must be account or user.', 400, 'parameter_invalid_string_enum');
|
|
22
|
+
if (scopeType === 'user' && typeof scope!.user !== 'string') return fail(ctx, 'Missing required param: scope[user].', 400, 'parameter_missing');
|
|
23
|
+
if (params.payload === undefined) return fail(ctx, 'Missing required param: payload.', 400, 'parameter_missing');
|
|
24
|
+
const expiresAt = params.expires_at !== undefined ? Math.trunc(Number(params.expires_at)) : null;
|
|
25
|
+
const existing = named(ctx, name);
|
|
26
|
+
if (existing) return ctx.reply(await ctx.write(SECRET, String(existing.id), { payload: params.payload, expires_at: expiresAt, _updated: ctx.now() }, 'apps_secret.update'));
|
|
27
|
+
return ctx.reply(
|
|
28
|
+
await created(ctx, SECRET, { name }, {
|
|
29
|
+
livemode: false, deleted: false, expires_at: expiresAt, payload: params.payload,
|
|
30
|
+
scope: scopeType === 'user' ? { type: 'user', user: scope!.user } : { type: 'account' },
|
|
31
|
+
_scope_key: scopeKeyOf(ctx),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const find: Semantics = async (ctx) => {
|
|
37
|
+
const name = typeof ctx.params.name === 'string' ? ctx.params.name : '';
|
|
38
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
39
|
+
const s = named(ctx, name);
|
|
40
|
+
return s ? ctx.reply(s) : fail(ctx, `No such secret: '${name}'`, 404, 'resource_missing');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const remove: Semantics = async (ctx) => {
|
|
44
|
+
const name = typeof ctx.params.name === 'string' ? ctx.params.name : '';
|
|
45
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
46
|
+
const s = named(ctx, name);
|
|
47
|
+
if (!s) return fail(ctx, `No such secret: '${name}'`, 404, 'resource_missing');
|
|
48
|
+
return ctx.reply(await ctx.write(SECRET, String(s.id), { deleted: true }, 'apps_secret.delete'));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const listSecrets: Semantics = async (ctx) => list(ctx, SECRET, newest(ctx, SECRET).filter((s) => kept(ctx, SECRET, s, '_scope_key') === scopeKeyOf(ctx)));
|
|
52
|
+
|
|
53
|
+
export const appsSecrets: Record<string, Semantics> = {
|
|
54
|
+
PostAppsSecrets: set,
|
|
55
|
+
GetAppsSecretsFind: find,
|
|
56
|
+
PostAppsSecretsDelete: remove,
|
|
57
|
+
GetAppsSecrets: listSecrets,
|
|
58
|
+
};
|