@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,297 @@
|
|
|
1
|
+
// Checkout Session semantics. The machine in ../manifest.ts says an open session expires or
|
|
2
|
+
// completes and nothing else does. Stripe completes a session when the customer pays on its hosted
|
|
3
|
+
// page (src/screens/checkout.tsx, the session's url), which has no API verb; completing links what
|
|
4
|
+
// the payment made: a succeeded PaymentIntent, an active (or
|
|
5
|
+
// trialing) subscription, or a succeeded SetupIntent. List and retrieve are the derived core's.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { addBillingInterval, applyCouponDiscount, buildDiscount, buildSubscriptionItemsList, declineFor, lineItemEntries, mintClientSecret, paginate, paymentMethodSubObject, resolveLineItem, resolveSubscriptionBillingInterval, resolveTrial } from '../stripe-twin.ts';
|
|
8
|
+
import { afterSuccessOf } from './after-payment.ts';
|
|
9
|
+
import { actingAccount } from './ledger.ts';
|
|
10
|
+
import { mintCharge } from './payment-intents.ts';
|
|
11
|
+
import { collectSubscriptionInvoice, draftSubscriptionInvoice } from './renewals.ts';
|
|
12
|
+
import { storeItems } from './subscriptions.ts';
|
|
13
|
+
import { at, created, fail, finder, path, redeemCoupon, type Row } from './shared.ts';
|
|
14
|
+
|
|
15
|
+
const CS = 'checkout.session';
|
|
16
|
+
const sessionMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such checkout.session: '${id}'`, 404, 'resource_missing');
|
|
17
|
+
|
|
18
|
+
// Stripe normalizes the create input into the Session: line_items become the list envelope, and
|
|
19
|
+
// subscription_data (create-only) is kept aside for completion to copy onto the subscription
|
|
20
|
+
/** The discount a session is created with (discounts[0][promotion_code] or [coupon]), or Stripe's refusal: an unknown
|
|
21
|
+
* code or coupon, and one that has expired or been deactivated, are refused at create
|
|
22
|
+
* (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts, docs.stripe.com/error-codes#coupon-expired).
|
|
23
|
+
* Where the documentation stops and the twin decides: the refusals' wording. */
|
|
24
|
+
function discountOf(ctx: SemanticsContext, params: Row, now: number): { coupon: Row; promotion?: string } | Response | undefined {
|
|
25
|
+
const first = Array.isArray(params.discounts) ? (params.discounts[0] as Row | undefined) : undefined;
|
|
26
|
+
if (!first || typeof first !== 'object') return undefined;
|
|
27
|
+
const expired = (what: string) => ctx.refuse({ status: 400, code: 'coupon_expired', param: 'discounts', message: `This ${what} has expired.` });
|
|
28
|
+
let promotion: string | undefined;
|
|
29
|
+
let couponId = typeof first.coupon === 'string' ? first.coupon : undefined;
|
|
30
|
+
if (typeof first.promotion_code === 'string') {
|
|
31
|
+
const pc = ctx.get('promotion_code', first.promotion_code);
|
|
32
|
+
if (!pc) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][promotion_code]', message: `No such promotion code: '${first.promotion_code}'` });
|
|
33
|
+
if (typeof pc.expires_at === 'number' && pc.expires_at <= now) return expired('promotion code');
|
|
34
|
+
if (pc.active === false) return ctx.refuse({ status: 400, param: 'discounts[0][promotion_code]', message: 'This promotion code is inactive.' });
|
|
35
|
+
promotion = String(pc.id);
|
|
36
|
+
couponId = typeof pc.coupon === 'string' ? pc.coupon : String((pc.coupon as Row | undefined)?.id ?? '');
|
|
37
|
+
}
|
|
38
|
+
const coupon = couponId ? ctx.get('coupon', couponId) : undefined;
|
|
39
|
+
if (!coupon) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][coupon]', message: `No such coupon: '${couponId ?? ''}'` });
|
|
40
|
+
if (coupon.valid === false || (typeof coupon.redeem_by === 'number' && coupon.redeem_by <= now)) return expired('coupon');
|
|
41
|
+
return { coupon, ...(promotion ? { promotion } : {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A payment or subscription session with nothing to sell (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items). */
|
|
45
|
+
function noLineItems(ctx: SemanticsContext): Response {
|
|
46
|
+
return fail(ctx, 'You must provide at least one recurring price in `subscription` mode when not using `line_items[].price_data` or a Price with `recurring`, and at least one `line_items` for `payment` mode.', 400, 'parameter_missing');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A destination charge to an account the platform does not have (docs.stripe.com/connect/destination-charges). */
|
|
50
|
+
function noDestination(ctx: SemanticsContext, destination: unknown): Response {
|
|
51
|
+
return ctx.refuse({ status: 400, code: 'resource_missing', param: 'payment_intent_data[transfer_data][destination]', message: `No such destination: '${String(destination)}'` });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A session's trial Stripe refuses: `subscription_data.trial_end` "Has to be at least 48 hours in the future", and
|
|
55
|
+
* `subscription_data.trial_period_days` "Has to be at least 1" (docs.stripe.com/api/checkout/sessions/create). Where the
|
|
56
|
+
* documentation stops and the twin decides: the refusals' wording. */
|
|
57
|
+
function sessionTrialRefused(ctx: SemanticsContext, data: unknown, now: number): Response | undefined {
|
|
58
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
59
|
+
const { trial_end: end, trial_period_days: days } = data as Row;
|
|
60
|
+
const endRefused = end !== undefined && !(Number.isInteger(Number(end)) && Number(end) >= now + 48 * 3600);
|
|
61
|
+
const daysRefused = days !== undefined && !(Number.isInteger(Number(days)) && Number(days) >= 1);
|
|
62
|
+
return endRefused || daysRefused ? sessionTrialRefusal(ctx, endRefused ? 'trial_end' : 'trial_period_days') : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sessionTrialRefusal(ctx: SemanticsContext, param: 'trial_end' | 'trial_period_days'): Response {
|
|
66
|
+
const message = param === 'trial_end' ? 'The `subscription_data[trial_end]` timestamp has to be at least 48 hours in the future.' : 'The `subscription_data[trial_period_days]` has to be at least 1.';
|
|
67
|
+
return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const create: Semantics = async (ctx) => {
|
|
71
|
+
const params = ctx.params;
|
|
72
|
+
const mode = typeof params.mode === 'string' ? params.mode : 'payment';
|
|
73
|
+
if (!['payment', 'subscription', 'setup'].includes(mode)) return fail(ctx, 'Invalid mode: must be one of payment, subscription, or setup.', 400, 'parameter_invalid_string_enum');
|
|
74
|
+
const entries = lineItemEntries(params);
|
|
75
|
+
if (mode !== 'setup' && entries.length === 0) return noLineItems(ctx);
|
|
76
|
+
// hosted Checkout (the only ui_mode the twin models) needs success_url
|
|
77
|
+
if (params.success_url === undefined || params.success_url === '') return fail(ctx, 'Missing required param: success_url.', 400, 'parameter_missing');
|
|
78
|
+
const sessionCurrency = typeof params.currency === 'string' ? params.currency : 'usd';
|
|
79
|
+
const resolved = entries.map((e, i) => resolveLineItem(e, i, sessionCurrency, finder(ctx)));
|
|
80
|
+
const missing = resolved.find((r) => r.priceMissing);
|
|
81
|
+
if (missing) return fail(ctx, `No such price: '${missing.priceMissing}'`, 400, 'resource_missing');
|
|
82
|
+
const items = resolved.map((r) => r.item);
|
|
83
|
+
const now = Number(ctx.now());
|
|
84
|
+
const trialRefused = sessionTrialRefused(ctx, params.subscription_data, now);
|
|
85
|
+
if (trialRefused) return trialRefused;
|
|
86
|
+
const discount = discountOf(ctx, params, now);
|
|
87
|
+
if (discount instanceof Response) return discount;
|
|
88
|
+
const subtotal = items.reduce((n, it) => n + it.amount_subtotal, 0);
|
|
89
|
+
const off = discount ? applyCouponDiscount(subtotal, discount.coupon) : 0;
|
|
90
|
+
// the hosted page is at Stripe's own host (the World routes it to src/screens/checkout.tsx)
|
|
91
|
+
const id = ctx.mint(CS);
|
|
92
|
+
const fields: Row = {
|
|
93
|
+
mode, status: 'open',
|
|
94
|
+
payment_status: mode === 'setup' ? 'no_payment_required' : 'unpaid',
|
|
95
|
+
url: `https://checkout.stripe.com/c/pay/${id}`, success_url: params.success_url ?? null, cancel_url: params.cancel_url ?? null,
|
|
96
|
+
customer: params.customer ?? null, client_reference_id: params.client_reference_id ?? null,
|
|
97
|
+
customer_email: params.customer_email ?? null,
|
|
98
|
+
// whether paying makes a customer: in payment mode only when asked (`always`), `if_required` by default
|
|
99
|
+
customer_creation: mode === 'payment' ? (params.customer_creation === 'always' ? 'always' : 'if_required') : null,
|
|
100
|
+
amount_subtotal: entries.length ? items.reduce((s, it) => s + it.amount_subtotal, 0) : null,
|
|
101
|
+
amount_total: entries.length ? items.reduce((s, it) => s + it.amount_total, 0) - off : null,
|
|
102
|
+
currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: ['card'], expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
|
|
103
|
+
discounts: discount ? [{ coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null }] : [],
|
|
104
|
+
metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {}, livemode: false,
|
|
105
|
+
// "Details on the state of phone number collection for the session" (docs.stripe.com/api/checkout/sessions/object),
|
|
106
|
+
// off unless the create enables it
|
|
107
|
+
phone_number_collection: { enabled: (params.phone_number_collection as Row | undefined)?.enabled === true || (params.phone_number_collection as Row | undefined)?.enabled === 'true' },
|
|
108
|
+
};
|
|
109
|
+
if (params.subscription_data && typeof params.subscription_data === 'object') fields._subscription_data = params.subscription_data;
|
|
110
|
+
// each line's inline price_data, kept for what the session's line cannot carry: whether it recurs (isOneTime) and the
|
|
111
|
+
// Price a subscription's item is made from at completion (sessionPrices)
|
|
112
|
+
if (mode === 'subscription' && entries.some((e) => e.price_data && typeof e.price_data === 'object')) fields._price_data = entries.map((e) => (e.price_data && typeof e.price_data === 'object' ? e.price_data : null));
|
|
113
|
+
// the hosted page's brand name for this session (screens/checkout.tsx, merchantOf), and the account the session was
|
|
114
|
+
// made on (Stripe-Account: a direct charge), whose name the page shows. Kept apart from `_account`, the books a row is
|
|
115
|
+
// kept on: paying on the hosted page still makes the PaymentIntent and charge on the platform's books (a gap of the
|
|
116
|
+
// twin's direct-charge Checkout), so the session's events stay with them.
|
|
117
|
+
if (params.branding_settings && typeof params.branding_settings === 'object') fields._branding_settings = params.branding_settings;
|
|
118
|
+
if (actingAccount(ctx)) fields._brand_account = actingAccount(ctx);
|
|
119
|
+
// a payment session's payment_intent_data shapes the PaymentIntent paying makes: a destination charge's transfer and
|
|
120
|
+
// the platform's application fee (docs.stripe.com/connect/destination-charges?platform=web&ui=stripe-hosted)
|
|
121
|
+
const pid = params.payment_intent_data && typeof params.payment_intent_data === 'object' ? (params.payment_intent_data as Row) : undefined;
|
|
122
|
+
if (pid && mode === 'payment') {
|
|
123
|
+
const destination = (pid.transfer_data as Row | undefined)?.destination;
|
|
124
|
+
if (destination !== undefined && (typeof destination !== 'string' || !ctx.get('account', destination))) return noDestination(ctx, destination);
|
|
125
|
+
fields._payment_intent_data = pid;
|
|
126
|
+
}
|
|
127
|
+
if (discount) fields._discount = { coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null };
|
|
128
|
+
return ctx.reply(await created(ctx, CS, { id, ...fields }, {}));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// the session's items, a page at a time
|
|
132
|
+
const lineItems: Semantics = async (ctx) => {
|
|
133
|
+
const s = ctx.get(CS, at(ctx, 'session'));
|
|
134
|
+
if (!s) return sessionMissing(ctx, at(ctx, 'session'));
|
|
135
|
+
const li = s.line_items as { data?: Row[] } | undefined;
|
|
136
|
+
const { page, hasMore } = paginate(Array.isArray(li?.data) ? li!.data! : [], ctx.params);
|
|
137
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const expire: Semantics = async (ctx) => {
|
|
141
|
+
const id = at(ctx, 'session');
|
|
142
|
+
const s = ctx.get(CS, id);
|
|
143
|
+
if (!s) return sessionMissing(ctx, id);
|
|
144
|
+
const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSessionExpire', s.status, undefined, id);
|
|
145
|
+
if (refused) return ctx.refuse(refused);
|
|
146
|
+
return ctx.reply(await ctx.write(CS, id, { status: 'expired', url: null }, 'checkout.session.expire'));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The card the customer entered on the page. */
|
|
150
|
+
export type EnteredCard = { number: string; exp_month: number; exp_year: number };
|
|
151
|
+
|
|
152
|
+
/** What completing a session makes, by its mode, as the fields that link it: a payment is a PaymentIntent paid with
|
|
153
|
+
* the entered card, its charge in the ledger as any payment's is. */
|
|
154
|
+
async function complete(ctx: SemanticsContext, id: string, existing: Row, card?: EnteredCard): Promise<Row> {
|
|
155
|
+
const link: Row = { status: 'complete' };
|
|
156
|
+
const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
|
|
157
|
+
const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
|
|
158
|
+
// the entered card becomes a PaymentMethod, on the customer when there is one, carrying what a later charge to it
|
|
159
|
+
// answers and what Stripe does after it succeeds (docs.stripe.com/testing#declined-payments, #disputes)
|
|
160
|
+
const pm = card && existing.mode !== 'setup' ? await created(ctx, 'payment_method', {}, { type: 'card', customer: customer ?? null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null, ...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}) }) : undefined;
|
|
161
|
+
if (existing.mode === 'payment') {
|
|
162
|
+
const amount = Number(existing.amount_total) || 0;
|
|
163
|
+
const piId = ctx.mint('payment_intent');
|
|
164
|
+
const pid = (ctx.row(CS, id)?._payment_intent_data ?? {}) as Row;
|
|
165
|
+
const connect = {
|
|
166
|
+
...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
|
|
167
|
+
...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: (pid.transfer_data as Row).destination } } : {}),
|
|
168
|
+
...(typeof pid.description === 'string' ? { description: pid.description } : {}),
|
|
169
|
+
...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
|
|
170
|
+
};
|
|
171
|
+
const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
|
|
172
|
+
await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: ['card'] });
|
|
173
|
+
const charge = await mintCharge(ctx, piId, fields, amount);
|
|
174
|
+
await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
|
|
175
|
+
link.payment_intent = piId;
|
|
176
|
+
link.payment_status = 'paid';
|
|
177
|
+
} else if (existing.mode === 'subscription') {
|
|
178
|
+
if (customer) {
|
|
179
|
+
// the subscription bills the session's own (already resolved) line items, and takes the
|
|
180
|
+
// session's subscription_data: metadata, description, and a trial as its first period
|
|
181
|
+
const now = Number(ctx.now());
|
|
182
|
+
// "Line items with one-time Prices will be on the initial invoice only" (docs.stripe.com/api/checkout/sessions/
|
|
183
|
+
// create#create_checkout_session-line_items): the subscription's items are the recurring ones
|
|
184
|
+
const inline = (ctx.row(CS, id)?._price_data as Array<Row | null> | undefined) ?? [];
|
|
185
|
+
const lines = ((existing.line_items as { data?: unknown } | undefined)?.data as Row[] | undefined) ?? [];
|
|
186
|
+
const allLineItems = inline.some(Boolean) ? await sessionPrices(ctx, lines, inline) : lines;
|
|
187
|
+
const sessionLineItems = allLineItems.filter((it) => !isOneTime(it));
|
|
188
|
+
const oneTime = allLineItems.filter((it) => isOneTime(it));
|
|
189
|
+
const { interval, interval_count } = resolveSubscriptionBillingInterval(sessionLineItems, finder(ctx));
|
|
190
|
+
const subId = ctx.mint('subscription');
|
|
191
|
+
// the session's view never carries its create-only subscription_data: read the stored row
|
|
192
|
+
const raw = ctx.row(CS, id)?._subscription_data;
|
|
193
|
+
const subData = (raw && typeof raw === 'object' ? raw : {}) as Row;
|
|
194
|
+
const subMetadata = subData.metadata && typeof subData.metadata === 'object' ? (subData.metadata as Row) : {};
|
|
195
|
+
// a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
|
|
196
|
+
const trialEnd = resolveTrial(subData, now)?.end ?? null;
|
|
197
|
+
const sub = await created(ctx, 'subscription', { customer, id: subId }, {
|
|
198
|
+
status: trialEnd === null ? 'active' : 'trialing', livemode: false, currency, collection_method: 'charge_automatically',
|
|
199
|
+
// a trial's end is the billing anchor (docs.stripe.com/api/subscriptions/create#create_subscription-trial_end)
|
|
200
|
+
cancel_at_period_end: false, start_date: now, billing_cycle_anchor: trialEnd ?? now, metadata: subMetadata,
|
|
201
|
+
description: typeof subData.description === 'string' ? subData.description : null,
|
|
202
|
+
// the session's discount is the subscription's (docs.stripe.com/payments/checkout/discounts)
|
|
203
|
+
discounts: sessionDiscount(ctx, id, customer, now), billing_schedules: [],
|
|
204
|
+
trial_start: trialEnd === null ? null : now, trial_end: trialEnd,
|
|
205
|
+
current_period_start: now, current_period_end: trialEnd ?? addBillingInterval(now, interval, interval_count),
|
|
206
|
+
// Checkout saves the card as the subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works)
|
|
207
|
+
default_payment_method: pm?.id ?? null,
|
|
208
|
+
items: buildSubscriptionItemsList(sessionLineItems, subId, now, finder(ctx)),
|
|
209
|
+
automatic_tax: { enabled: false, liability: null }, billing_mode: { type: 'classic' },
|
|
210
|
+
invoice_settings: { issuer: { type: 'self' } },
|
|
211
|
+
});
|
|
212
|
+
await storeItems(ctx, subId, ((sub.items as Row | undefined)?.data as Row[] | undefined) ?? []);
|
|
213
|
+
// its first invoice, paid on the page by the card just entered (nothing is due on a trial's)
|
|
214
|
+
// (docs.stripe.com/billing/subscriptions/overview#how-payments-work-subscriptions)
|
|
215
|
+
const invoice = await draftSubscriptionInvoice(ctx, sub, { start: now, end: Number(sub.current_period_end), reason: 'subscription_create', trial: trialEnd !== null, once: oneTime });
|
|
216
|
+
await collectSubscriptionInvoice(ctx, invoice, now, 'external');
|
|
217
|
+
await ctx.write('subscription', subId, { latest_invoice: invoice }, 'subscription.update');
|
|
218
|
+
link.subscription = sub.id;
|
|
219
|
+
link.invoice = invoice;
|
|
220
|
+
}
|
|
221
|
+
link.payment_status = 'paid';
|
|
222
|
+
} else link.setup_intent = await completeSetup(ctx, customer);
|
|
223
|
+
return link;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A line item of a one-time Price: a stored Price, or an inline `price_data`, without `recurring` ("Line items with
|
|
227
|
+
* one-time Prices will be on the initial invoice only", docs.stripe.com/api/checkout/sessions/create). */
|
|
228
|
+
export function isOneTime(item: Row, priceData?: Row | null): boolean {
|
|
229
|
+
if (priceData) return !priceData.recurring;
|
|
230
|
+
return !!item.price && typeof item.price === 'object' && !(item.price as Row).recurring;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A subscription session's lines at completion, each inline `price_data` made the Price it generates ("Data used to
|
|
234
|
+
* generate a new Price object inline", the same page), under the product it names or one made from its product_data,
|
|
235
|
+
* so the subscription's items bill it. Where the documentation stops and the twin decides: such a Price is archived
|
|
236
|
+
* (active=false), as a subscription item's price_data makes one (docs.stripe.com/products-prices/manage-prices). */
|
|
237
|
+
async function sessionPrices(ctx: SemanticsContext, lines: Row[], inline: Array<Row | null>): Promise<Row[]> {
|
|
238
|
+
const out: Row[] = [];
|
|
239
|
+
for (const [i, line] of lines.entries()) {
|
|
240
|
+
const d = inline[i];
|
|
241
|
+
if (!d) { out.push(line); continue; }
|
|
242
|
+
const pd = d.product_data && typeof d.product_data === 'object' ? (d.product_data as Row) : undefined;
|
|
243
|
+
const product = typeof d.product === 'string' ? d.product : String((await created(ctx, 'product', { name: pd?.name ?? 'Item' }, { active: true, livemode: false, images: [], marketing_features: [], metadata: {}, updated: ctx.now() })).id);
|
|
244
|
+
const r = d.recurring && typeof d.recurring === 'object' ? (d.recurring as Row) : undefined;
|
|
245
|
+
const price = await created(ctx, 'price', {
|
|
246
|
+
product, currency: String(d.currency ?? line.currency ?? 'usd').toLowerCase(),
|
|
247
|
+
unit_amount: d.unit_amount_decimal !== undefined ? null : Math.trunc(Number(d.unit_amount) || 0),
|
|
248
|
+
unit_amount_decimal: d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0)),
|
|
249
|
+
...(r ? { recurring: { interval: r.interval, interval_count: Math.max(1, Math.trunc(Number(r.interval_count ?? 1)) || 1), usage_type: 'licensed', trial_period_days: null, meter: null } } : { recurring: null }),
|
|
250
|
+
}, { active: false, livemode: false, billing_scheme: 'per_unit', type: r ? 'recurring' : 'one_time', metadata: {}, lookup_key: null, nickname: null, tax_behavior: 'unspecified' });
|
|
251
|
+
out.push({ ...line, price });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
|
|
257
|
+
async function completeSetup(ctx: SemanticsContext, customer: string | undefined): Promise<unknown> {
|
|
258
|
+
const siId = ctx.mint('setup_intent');
|
|
259
|
+
const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], livemode: false });
|
|
260
|
+
return si.id;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The discount a completed subscription session gives its subscription. */
|
|
264
|
+
function sessionDiscount(ctx: SemanticsContext, id: string, customer: string, now: number): Row[] {
|
|
265
|
+
const d = ctx.row(CS, id)?._discount as Row | undefined;
|
|
266
|
+
const coupon = d && typeof d.coupon === 'string' ? ctx.get('coupon', d.coupon) : undefined;
|
|
267
|
+
return coupon ? [{ ...buildDiscount(coupon, customer, now), promotion_code: d?.promotion_code ?? null, checkout_session: id }] : [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The customer paying on the hosted page (src/screens/checkout.tsx): the external actor's move on the
|
|
271
|
+
* session's machine, then what the payment made, linked, and the session stored complete. */
|
|
272
|
+
export async function completeSession(ctx: SemanticsContext, id: string, existing: Row, details: Row = {}, card?: EnteredCard): Promise<Row | Response> {
|
|
273
|
+
const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSession', existing.status, 'complete', id, 'external');
|
|
274
|
+
if (refused) return ctx.refuse(refused);
|
|
275
|
+
const link = await complete(ctx, id, existing, card);
|
|
276
|
+
// the discount the buyer took is redeemed (shared.ts redeemCoupon)
|
|
277
|
+
const d = ctx.row(CS, id)?._discount as Row | undefined;
|
|
278
|
+
if (d) await redeemCoupon(ctx, d.coupon, d.promotion_code);
|
|
279
|
+
// the session's url "is only present when the session is active" (docs.stripe.com/api/checkout/sessions/object)
|
|
280
|
+
return ctx.write(CS, id, { ...details, ...link, url: null }, 'checkout.session.completed');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const update: Semantics = async (ctx) => {
|
|
284
|
+
const id = at(ctx, 'session');
|
|
285
|
+
const existing = ctx.get(CS, id);
|
|
286
|
+
if (!existing) return sessionMissing(ctx, id);
|
|
287
|
+
// a session completes only when its customer pays on the hosted page (src/screens/checkout.tsx); the API takes no
|
|
288
|
+
// status, which the parameter check refuses as Stripe does (../stripe-params.ts)
|
|
289
|
+
return ctx.reply(await ctx.write(CS, id, ctx.params, 'checkout.session.update'));
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export const checkout: Record<string, Semantics> = {
|
|
293
|
+
PostCheckoutSessions: create,
|
|
294
|
+
GetCheckoutSessionsSessionLineItems: lineItems,
|
|
295
|
+
PostCheckoutSessionsSessionExpire: expire,
|
|
296
|
+
PostCheckoutSessionsSession: update,
|
|
297
|
+
};
|