@volter/twin-stripe 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +75 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1070 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +255 -0
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +216 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +99 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +209 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +316 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +493 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +545 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +575 -0
- package/dist/src/semantics/ledger.d.ts +59 -0
- package/dist/src/semantics/ledger.js +200 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +140 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +82 -0
- package/dist/src/semantics/shared.js +203 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5695 -0
- package/dist/src/stripe-conformance.d.ts +43 -0
- package/dist/src/stripe-conformance.js +105 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +392 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +393 -0
- package/dist/src/stripe-shared.d.ts +109 -0
- package/dist/src/stripe-shared.js +276 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1232 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +12 -0
- package/dist/src/stripe-version.js +287 -0
- package/dist/test-fixtures/stripe-known-deviations.json +110 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3813 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +34 -10
- package/src/manifest.ts +1102 -0
- package/src/screens/checkout.tsx +267 -0
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +220 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +118 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +220 -0
- package/src/semantics/checkout.ts +310 -0
- package/src/semantics/connect.ts +487 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +545 -0
- package/src/semantics/issuing.ts +590 -0
- package/src/semantics/ledger.ts +253 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +145 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +226 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +2258 -380
- package/src/stripe-conformance.ts +19 -7
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +15 -8
- package/src/stripe-events.ts +102 -40
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +318 -38
- package/src/stripe-shared.ts +297 -0
- package/src/stripe-twin.ts +434 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +281 -0
- package/test-fixtures/stripe-known-deviations.json +8 -8
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/test-fixtures/stripe-schemas.json +85 -12
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { asBool, cardError, chargeDefaults, declineFor, searchOver, validateMoney } from "../stripe-twin.js";
|
|
2
|
+
import { afterCharge } from "./after-payment.js";
|
|
3
|
+
import { settleCharge } from "./ledger.js";
|
|
4
|
+
import { paymentMethodDetails } from "./payment-methods.js";
|
|
5
|
+
import { refundCharge } from "./refunds.js";
|
|
6
|
+
import { at, created, expanded, fail, finder, list, newest, path, refundDestination, send, where } from "./shared.js";
|
|
7
|
+
const chargeMissing = (ctx, id) => fail(ctx, `No such charge: '${id}'`, 404, 'resource_missing');
|
|
8
|
+
/** Every egress of a charge carries its refunds, derived from the refund rows in list order. */
|
|
9
|
+
export function chargeBody(ctx, c) {
|
|
10
|
+
const data = newest(ctx, 'refund').filter((r) => r.charge === c.id);
|
|
11
|
+
return { ...c, refunds: { object: 'list', data, has_more: false, total_count: data.length, url: `/v1/charges/${String(c.id)}/refunds` } };
|
|
12
|
+
}
|
|
13
|
+
// A charge is written as the event Stripe sends for it (docs.stripe.com/api/events/types; Stripe has no
|
|
14
|
+
// `charge.created`): charge.succeeded, "Occurs whenever a charge is successful", for one made or authorized
|
|
15
|
+
// (capture=false: its status is succeeded, captured false), and charge.failed, "Occurs whenever a failed charge attempt
|
|
16
|
+
// occurs", for a declined attempt. The twin makes no pending charge (charge.pending, "Occurs whenever a pending charge is
|
|
17
|
+
// created"): its charges settle when made.
|
|
18
|
+
/** The create's parameters as the charge keeps them: without `card`, the request's card details ("A token, like the ones
|
|
19
|
+
* returned by Stripe.js", or a hash of the number, expiry and CVC). The Charge object has no `card` property (served
|
|
20
|
+
* spec, charge); the card it was made with is its payment_method_details. Kept, it answered the card's full number and
|
|
21
|
+
* CVC back on every read of the charge. */
|
|
22
|
+
function withoutCard(params) {
|
|
23
|
+
const { card: _card, ...rest } = params;
|
|
24
|
+
return rest;
|
|
25
|
+
}
|
|
26
|
+
/** A Charges-API charge a declining test card refuses: a 402 card_error naming the failed charge Stripe records for the
|
|
27
|
+
* attempt (docs.stripe.com/declines). */
|
|
28
|
+
async function declinedCharge(ctx, decline) {
|
|
29
|
+
const params = ctx.params;
|
|
30
|
+
const failedId = ctx.mint('charge');
|
|
31
|
+
const amount = Number(params.amount) || 0;
|
|
32
|
+
await created(ctx, 'charge', { ...withoutCard(params), id: failedId }, {
|
|
33
|
+
...chargeDefaults(failedId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
|
|
34
|
+
failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
|
|
35
|
+
outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
|
|
36
|
+
payment_method_details: paymentMethodDetails(ctx, params.payment_method ?? params.source ?? params.card) ?? null,
|
|
37
|
+
}, { operation: 'charge.failed' });
|
|
38
|
+
return send(ctx, cardError(decline, { charge: failedId }));
|
|
39
|
+
}
|
|
40
|
+
/** A charge's fraud report: "user_report … Assessments reported by you. If set, possible values of are `safe` and
|
|
41
|
+
* `fraudulent`" (docs.stripe.com/api/charges/object, fraud_details). */
|
|
42
|
+
function fraudReport(ctx, existing, fd) {
|
|
43
|
+
const report = String(fd.user_report);
|
|
44
|
+
if (report !== 'safe' && report !== 'fraudulent')
|
|
45
|
+
return fail(ctx, "Invalid fraud_details[user_report]: must be 'safe' or 'fraudulent'.", 400, 'parameter_invalid_string_enum');
|
|
46
|
+
const prior = existing.fraud_details && typeof existing.fraud_details === 'object' ? existing.fraud_details : {};
|
|
47
|
+
return { user_report: report, stripe_report: prior.stripe_report ?? null };
|
|
48
|
+
}
|
|
49
|
+
const create = async (ctx) => {
|
|
50
|
+
const params = ctx.params;
|
|
51
|
+
const bad = validateMoney(params);
|
|
52
|
+
if (bad)
|
|
53
|
+
return send(ctx, bad);
|
|
54
|
+
// a declining test card answers a 402 card_error naming the failed charge Stripe records for the attempt
|
|
55
|
+
const decline = declineFor(finder(ctx), params);
|
|
56
|
+
if (decline)
|
|
57
|
+
return declinedCharge(ctx, decline);
|
|
58
|
+
// capture=false authorizes only: succeeded and paid, but not captured, with a capture deadline
|
|
59
|
+
const captured = !(params.capture !== undefined && !asBool(params.capture));
|
|
60
|
+
// the id comes first so the charge's own `refunds` list can carry its URL
|
|
61
|
+
const id = typeof params.id === 'string' && params.id ? params.id : ctx.mint('charge');
|
|
62
|
+
const amount = Number(params.amount) || 0;
|
|
63
|
+
// a captured charge credits the balance; an authorization does when it is captured
|
|
64
|
+
const card = String(params.source ?? params.payment_method ?? params.card?.number ?? '');
|
|
65
|
+
const bt = captured ? await settleCharge(ctx, id, amount, String(params.currency ?? 'usd'), card) : null;
|
|
66
|
+
const details = paymentMethodDetails(ctx, params.payment_method ?? params.source ?? params.card);
|
|
67
|
+
await created(ctx, 'charge', { ...withoutCard(params), id }, { ...chargeDefaults(id, amount, captured, ctx.occurredAt), balance_transaction: bt, payment_method_details: details ?? null }, { operation: 'charge.succeeded' });
|
|
68
|
+
await afterCharge(ctx, id, { amount, currency: String(params.currency ?? 'usd'), application_fee_amount: params.application_fee_amount, destination: params.transfer_data?.destination }, params.payment_method ?? params.source ?? params.card);
|
|
69
|
+
return ctx.reply(chargeBody(ctx, ctx.get('charge', id)));
|
|
70
|
+
};
|
|
71
|
+
// capture takes `amount` (the whole authorization by default; docs.stripe.com/api/charges/capture); the rest of a
|
|
72
|
+
// partial capture is released with no Refund (captureAuthorization). `amount_to_capture` is the
|
|
73
|
+
// PaymentIntent capture's parameter, which a charge's capture does not know.
|
|
74
|
+
const capture = async (ctx) => {
|
|
75
|
+
const id = at(ctx, 'charge');
|
|
76
|
+
const ch = ctx.get('charge', id);
|
|
77
|
+
if (!ch)
|
|
78
|
+
return chargeMissing(ctx, id);
|
|
79
|
+
const refused = ctx.legal('charge', 'captured', 'PostChargesChargeCapture', ch.captured === true ? 'true' : 'false', undefined, id);
|
|
80
|
+
if (refused)
|
|
81
|
+
return ctx.refuse(refused);
|
|
82
|
+
// "Capturing a charge will always succeed, unless the charge is already refunded, expired, captured, or an invalid
|
|
83
|
+
// capture amount is specified" (docs.stripe.com/api/charges/capture): a released authorization is refunded
|
|
84
|
+
if (ch.refunded === true)
|
|
85
|
+
return fail(ctx, `Charge ${id} has already been refunded.`, 400, 'charge_already_refunded');
|
|
86
|
+
// a canceled PaymentIntent's authorization is released: "After it's canceled, no additional charges are made by the
|
|
87
|
+
// PaymentIntent and any operations on the PaymentIntent fail with an error" (docs.stripe.com/api/payment_intents/cancel).
|
|
88
|
+
// Where the documentation stops and the twin decides: its charge's capture answers payment_intent_unexpected_state
|
|
89
|
+
// ("The PaymentIntent's state was incompatible with the operation", docs.stripe.com/error-codes), in the twin's words.
|
|
90
|
+
const intent = typeof ch.payment_intent === 'string' ? ctx.get('payment_intent', ch.payment_intent) : undefined;
|
|
91
|
+
if (intent?.status === 'canceled')
|
|
92
|
+
return fail(ctx, `Charge ${id} belongs to PaymentIntent ${String(intent.id)}, which is canceled; its authorization was released.`, 400, 'payment_intent_unexpected_state');
|
|
93
|
+
const full = Number(ch.amount) || 0;
|
|
94
|
+
const toCapture = ctx.params.amount !== undefined ? Math.min(full, Math.max(0, Math.trunc(Number(ctx.params.amount) || 0))) : full;
|
|
95
|
+
return ctx.reply(chargeBody(ctx, await captureAuthorization(ctx, ch, toCapture, 'PostChargesChargeCapture')));
|
|
96
|
+
};
|
|
97
|
+
/** A capture=false charge refunded while uncaptured: its authorization is released by the refund, as it would be
|
|
98
|
+
* "automatically refunded if uncaptured" (spec/openapi.json.gz, `capture_before`). The charge stays uncaptured and
|
|
99
|
+
* becomes refunded in full, a Refund records the release, and no money moves: none was ever received. Written as
|
|
100
|
+
* charge.refunded. Where the documentation stops and the twin decides: Stripe's basil change ("Partially capturing or
|
|
101
|
+
* canceling payments no longer creates a Refund", docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-partial-
|
|
102
|
+
* capture-and-payment-cancellation-flow) names partial capture and cancellation, not a refund asked for, so a refund
|
|
103
|
+
* still makes one; its reason is null and it has no balance transaction. Answers the Refund. */
|
|
104
|
+
export async function releaseAuthorization(ctx, ch, operationId, given = {}) {
|
|
105
|
+
const id = String(ch.id);
|
|
106
|
+
const full = Number(ch.amount) || 0;
|
|
107
|
+
if (ch.refunded !== true)
|
|
108
|
+
ctx.legal('charge', 'refunded', operationId, 'false', 'true', id);
|
|
109
|
+
const refund = await created(ctx, 'refund', { ...given, charge: id, amount: full - (Number(ch.amount_refunded) || 0), currency: String(ch.currency ?? 'usd') }, {
|
|
110
|
+
status: 'succeeded', metadata: {}, reason: null, receipt_number: null,
|
|
111
|
+
balance_transaction: null, source_transfer_reversal: null, transfer_reversal: null, ...refundDestination(ch, 'reversal'),
|
|
112
|
+
...(typeof ch.payment_intent === 'string' ? { payment_intent: ch.payment_intent } : {}),
|
|
113
|
+
});
|
|
114
|
+
await ctx.write('charge', id, { amount_refunded: full, refunded: true }, 'charge.refunded');
|
|
115
|
+
return refund;
|
|
116
|
+
}
|
|
117
|
+
/** A PaymentIntent's authorization released by its cancel: "For PaymentIntents with a `status` of `requires_capture`, the
|
|
118
|
+
* remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel), and since basil
|
|
119
|
+
* a cancellation makes no Refund: "`amount_captured` will be 0 instead of `nil` in payment cancellation flows",
|
|
120
|
+
* "`amount_refunded` will no longer be updated by these actions", "`refunded` will no longer be `true` for payment
|
|
121
|
+
* cancellation flows", and no charge.refunded is sent (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-
|
|
122
|
+
* partial-capture-and-payment-cancellation-flow). The charge stays uncaptured; nothing is written as an event. */
|
|
123
|
+
export async function cancelAuthorization(ctx, ch) {
|
|
124
|
+
return ctx.write('charge', String(ch.id), { amount_captured: 0 }, 'charge.authorization_released');
|
|
125
|
+
}
|
|
126
|
+
/** An authorized charge captured, by the charge's capture or its PaymentIntent's: what is captured credits the balance.
|
|
127
|
+
* Written as charge.captured, "Occurs whenever a previously uncaptured charge is captured" (docs.stripe.com/api/events/types).
|
|
128
|
+
* A partial capture releases the rest with no Refund and leaves amount_refunded and refunded as they were: "The following
|
|
129
|
+
* flows no longer result in a `Refund` object created and linked to the payment: Partial capture", "`amount_refunded`
|
|
130
|
+
* will no longer be updated by these actions", and "There will only be a single balance transaction for partial captures"
|
|
131
|
+
* (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-partial-capture-and-payment-cancellation-flow).
|
|
132
|
+
* The caller has asked the machine whether it may capture. */
|
|
133
|
+
export async function captureAuthorization(ctx, ch, toCapture, _operationId) {
|
|
134
|
+
const id = String(ch.id);
|
|
135
|
+
const bt = toCapture > 0 ? await settleCharge(ctx, id, toCapture, String(ch.currency ?? 'usd'), String(ch.source ?? ch.payment_method ?? '')) : null;
|
|
136
|
+
return ctx.write('charge', id, { captured: true, amount_captured: toCapture, balance_transaction: bt }, 'charge.captured');
|
|
137
|
+
}
|
|
138
|
+
const listCharges = async (ctx) => {
|
|
139
|
+
const items = where(ctx, newest(ctx, 'charge'), {
|
|
140
|
+
customer: (c, v) => c.customer === v,
|
|
141
|
+
payment_intent: (c, v) => c.payment_intent === v,
|
|
142
|
+
});
|
|
143
|
+
return list(ctx, 'charge', items.map((c) => chargeBody(ctx, c)));
|
|
144
|
+
};
|
|
145
|
+
const searchCharges = async (ctx) => {
|
|
146
|
+
const found = searchOver(newest(ctx, 'charge'), ctx.params, path(ctx));
|
|
147
|
+
const body = found.body;
|
|
148
|
+
if (found.status === 200 && Array.isArray(body.data))
|
|
149
|
+
body.data = body.data.map((c) => chargeBody(ctx, c));
|
|
150
|
+
return send(ctx, found);
|
|
151
|
+
};
|
|
152
|
+
const retrieve = async (ctx) => {
|
|
153
|
+
const c = ctx.get('charge', at(ctx, 'charge'));
|
|
154
|
+
return c ? ctx.reply(expanded(ctx, 'charge', chargeBody(ctx, c))) : chargeMissing(ctx, at(ctx, 'charge'));
|
|
155
|
+
};
|
|
156
|
+
// fraud_details[user_report] marks a charge safe or fraudulent; Stripe keeps its own stripe_report
|
|
157
|
+
const update = async (ctx) => {
|
|
158
|
+
const id = at(ctx, 'charge');
|
|
159
|
+
const existing = ctx.get('charge', id);
|
|
160
|
+
if (!existing)
|
|
161
|
+
return chargeMissing(ctx, id);
|
|
162
|
+
const fields = { ...ctx.params };
|
|
163
|
+
const fd = ctx.params.fraud_details;
|
|
164
|
+
const fraud = fd && typeof fd === 'object' && 'user_report' in fd ? fraudReport(ctx, existing, fd) : undefined;
|
|
165
|
+
if (fraud instanceof Response)
|
|
166
|
+
return fraud;
|
|
167
|
+
if (fraud)
|
|
168
|
+
fields.fraud_details = fraud;
|
|
169
|
+
return ctx.reply(chargeBody(ctx, await ctx.write('charge', id, fields, 'charge.update')));
|
|
170
|
+
};
|
|
171
|
+
// ── the charge's own refunds: listed, created and read through it; another charge's refund is not found ──
|
|
172
|
+
const refunds = async (ctx) => {
|
|
173
|
+
const id = at(ctx, 'charge');
|
|
174
|
+
if (!ctx.get('charge', id))
|
|
175
|
+
return chargeMissing(ctx, id);
|
|
176
|
+
return list(ctx, 'refund', newest(ctx, 'refund').filter((r) => r.charge === id));
|
|
177
|
+
};
|
|
178
|
+
const createRefund = async (ctx) => {
|
|
179
|
+
const ch = ctx.get('charge', at(ctx, 'charge'));
|
|
180
|
+
if (!ch)
|
|
181
|
+
return chargeMissing(ctx, at(ctx, 'charge'));
|
|
182
|
+
return refundCharge(ctx, ch, ctx.params);
|
|
183
|
+
};
|
|
184
|
+
const chargeRefund = (ctx) => {
|
|
185
|
+
if (!ctx.get('charge', at(ctx, 'charge')))
|
|
186
|
+
return chargeMissing(ctx, at(ctx, 'charge'));
|
|
187
|
+
const r = ctx.get('refund', at(ctx, 'refund'));
|
|
188
|
+
return r && r.charge === at(ctx, 'charge') ? r : fail(ctx, `No such refund: '${at(ctx, 'refund')}'`, 404, 'resource_missing');
|
|
189
|
+
};
|
|
190
|
+
const refund = async (ctx) => {
|
|
191
|
+
const r = chargeRefund(ctx);
|
|
192
|
+
return r instanceof Response ? r : ctx.reply(r);
|
|
193
|
+
};
|
|
194
|
+
const updateRefund = async (ctx) => {
|
|
195
|
+
const r = chargeRefund(ctx);
|
|
196
|
+
return r instanceof Response ? r : ctx.reply(await ctx.write('refund', at(ctx, 'refund'), ctx.params, 'refund.update'));
|
|
197
|
+
};
|
|
198
|
+
export const charges = {
|
|
199
|
+
PostCharges: create,
|
|
200
|
+
PostChargesChargeCapture: capture,
|
|
201
|
+
GetCharges: listCharges,
|
|
202
|
+
GetChargesSearch: searchCharges,
|
|
203
|
+
GetChargesCharge: retrieve,
|
|
204
|
+
PostChargesCharge: update,
|
|
205
|
+
GetChargesChargeRefunds: refunds,
|
|
206
|
+
PostChargesChargeRefunds: createRefund,
|
|
207
|
+
GetChargesChargeRefundsRefund: refund,
|
|
208
|
+
PostChargesChargeRefundsRefund: updateRefund,
|
|
209
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from './shared.js';
|
|
3
|
+
/** The card the customer entered on the page. */
|
|
4
|
+
export type EnteredCard = {
|
|
5
|
+
number: string;
|
|
6
|
+
exp_month: number;
|
|
7
|
+
exp_year: number;
|
|
8
|
+
};
|
|
9
|
+
/** A line item of a one-time Price: a stored Price, or an inline `price_data`, without `recurring` ("Line items with
|
|
10
|
+
* one-time Prices will be on the initial invoice only", docs.stripe.com/api/checkout/sessions/create). */
|
|
11
|
+
export declare function isOneTime(item: Row, priceData?: Row | null): boolean;
|
|
12
|
+
/** The customer paying on the hosted page (src/screens/checkout.tsx): the external actor's move on the
|
|
13
|
+
* session's machine, then what the payment made, linked, and the session stored complete. */
|
|
14
|
+
export declare function completeSession(ctx: SemanticsContext, id: string, existing: Row, details?: Row, card?: EnteredCard): Promise<Row | Response>;
|
|
15
|
+
export declare const checkout: Record<string, Semantics>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { addBillingInterval, applyCouponDiscount, buildDiscount, buildSubscriptionItemsList, declineFor, lineItemEntries, mintClientSecret, paginate, paymentMethodSubObject, resolveLineItem, resolveSubscriptionBillingInterval, resolveTrial } from "../stripe-twin.js";
|
|
2
|
+
import { afterSuccessOf } from "./after-payment.js";
|
|
3
|
+
import { actingAccount } from "./ledger.js";
|
|
4
|
+
import { mintCharge } from "./payment-intents.js";
|
|
5
|
+
import { collectSubscriptionInvoice, draftSubscriptionInvoice } from "./renewals.js";
|
|
6
|
+
import { storeItems } from "./subscriptions.js";
|
|
7
|
+
import { at, created, fail, finder, path, redeemCoupon } from "./shared.js";
|
|
8
|
+
const CS = 'checkout.session';
|
|
9
|
+
const sessionMissing = (ctx, id) => fail(ctx, `No such checkout.session: '${id}'`, 404, 'resource_missing');
|
|
10
|
+
// Stripe normalizes the create input into the Session: line_items become the list envelope, and
|
|
11
|
+
// subscription_data (create-only) is kept aside for completion to copy onto the subscription
|
|
12
|
+
/** The discount a session is created with (discounts[0][promotion_code] or [coupon]), or Stripe's refusal: an unknown
|
|
13
|
+
* code or coupon, and one that has expired or been deactivated, are refused at create
|
|
14
|
+
* (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts, docs.stripe.com/error-codes#coupon-expired).
|
|
15
|
+
* Where the documentation stops and the twin decides: the refusals' wording. */
|
|
16
|
+
function discountOf(ctx, params, now) {
|
|
17
|
+
const first = Array.isArray(params.discounts) ? params.discounts[0] : undefined;
|
|
18
|
+
if (!first || typeof first !== 'object')
|
|
19
|
+
return undefined;
|
|
20
|
+
const expired = (what) => ctx.refuse({ status: 400, code: 'coupon_expired', param: 'discounts', message: `This ${what} has expired.` });
|
|
21
|
+
let promotion;
|
|
22
|
+
let couponId = typeof first.coupon === 'string' ? first.coupon : undefined;
|
|
23
|
+
if (typeof first.promotion_code === 'string') {
|
|
24
|
+
const pc = ctx.get('promotion_code', first.promotion_code);
|
|
25
|
+
if (!pc)
|
|
26
|
+
return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][promotion_code]', message: `No such promotion code: '${first.promotion_code}'` });
|
|
27
|
+
if (typeof pc.expires_at === 'number' && pc.expires_at <= now)
|
|
28
|
+
return expired('promotion code');
|
|
29
|
+
if (pc.active === false)
|
|
30
|
+
return ctx.refuse({ status: 400, param: 'discounts[0][promotion_code]', message: 'This promotion code is inactive.' });
|
|
31
|
+
promotion = String(pc.id);
|
|
32
|
+
couponId = typeof pc.coupon === 'string' ? pc.coupon : String(pc.coupon?.id ?? '');
|
|
33
|
+
}
|
|
34
|
+
const coupon = couponId ? ctx.get('coupon', couponId) : undefined;
|
|
35
|
+
if (!coupon)
|
|
36
|
+
return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][coupon]', message: `No such coupon: '${couponId ?? ''}'` });
|
|
37
|
+
if (coupon.valid === false || (typeof coupon.redeem_by === 'number' && coupon.redeem_by <= now))
|
|
38
|
+
return expired('coupon');
|
|
39
|
+
return { coupon, ...(promotion ? { promotion } : {}) };
|
|
40
|
+
}
|
|
41
|
+
/** A payment or subscription session with nothing to sell (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items). */
|
|
42
|
+
function noLineItems(ctx) {
|
|
43
|
+
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');
|
|
44
|
+
}
|
|
45
|
+
/** A destination charge to an account the platform does not have (docs.stripe.com/connect/destination-charges). */
|
|
46
|
+
function noDestination(ctx, destination) {
|
|
47
|
+
return ctx.refuse({ status: 400, code: 'resource_missing', param: 'payment_intent_data[transfer_data][destination]', message: `No such destination: '${String(destination)}'` });
|
|
48
|
+
}
|
|
49
|
+
/** A session's trial Stripe refuses: `subscription_data.trial_end` "Has to be at least 48 hours in the future", and
|
|
50
|
+
* `subscription_data.trial_period_days` "Has to be at least 1" (docs.stripe.com/api/checkout/sessions/create). Where the
|
|
51
|
+
* documentation stops and the twin decides: the refusals' wording. */
|
|
52
|
+
function sessionTrialRefused(ctx, data, now) {
|
|
53
|
+
if (!data || typeof data !== 'object')
|
|
54
|
+
return undefined;
|
|
55
|
+
const { trial_end: end, trial_period_days: days } = data;
|
|
56
|
+
const endRefused = end !== undefined && !(Number.isInteger(Number(end)) && Number(end) >= now + 48 * 3600);
|
|
57
|
+
const daysRefused = days !== undefined && !(Number.isInteger(Number(days)) && Number(days) >= 1);
|
|
58
|
+
return endRefused || daysRefused ? sessionTrialRefusal(ctx, endRefused ? 'trial_end' : 'trial_period_days') : undefined;
|
|
59
|
+
}
|
|
60
|
+
function sessionTrialRefusal(ctx, param) {
|
|
61
|
+
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.';
|
|
62
|
+
return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
|
|
63
|
+
}
|
|
64
|
+
const create = async (ctx) => {
|
|
65
|
+
const params = ctx.params;
|
|
66
|
+
const mode = typeof params.mode === 'string' ? params.mode : 'payment';
|
|
67
|
+
if (!['payment', 'subscription', 'setup'].includes(mode))
|
|
68
|
+
return fail(ctx, 'Invalid mode: must be one of payment, subscription, or setup.', 400, 'parameter_invalid_string_enum');
|
|
69
|
+
const entries = lineItemEntries(params);
|
|
70
|
+
if (mode !== 'setup' && entries.length === 0)
|
|
71
|
+
return noLineItems(ctx);
|
|
72
|
+
// hosted Checkout (the only ui_mode the twin models) needs success_url
|
|
73
|
+
if (params.success_url === undefined || params.success_url === '')
|
|
74
|
+
return fail(ctx, 'Missing required param: success_url.', 400, 'parameter_missing');
|
|
75
|
+
const sessionCurrency = typeof params.currency === 'string' ? params.currency : 'usd';
|
|
76
|
+
const resolved = entries.map((e, i) => resolveLineItem(e, i, sessionCurrency, finder(ctx)));
|
|
77
|
+
const missing = resolved.find((r) => r.priceMissing);
|
|
78
|
+
if (missing)
|
|
79
|
+
return fail(ctx, `No such price: '${missing.priceMissing}'`, 400, 'resource_missing');
|
|
80
|
+
const items = resolved.map((r) => r.item);
|
|
81
|
+
const now = Number(ctx.now());
|
|
82
|
+
const trialRefused = sessionTrialRefused(ctx, params.subscription_data, now);
|
|
83
|
+
if (trialRefused)
|
|
84
|
+
return trialRefused;
|
|
85
|
+
const discount = discountOf(ctx, params, now);
|
|
86
|
+
if (discount instanceof Response)
|
|
87
|
+
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 = {
|
|
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?.enabled === true || params.phone_number_collection?.enabled === 'true' },
|
|
108
|
+
};
|
|
109
|
+
if (params.subscription_data && typeof params.subscription_data === 'object')
|
|
110
|
+
fields._subscription_data = params.subscription_data;
|
|
111
|
+
// each line's inline price_data, kept for what the session's line cannot carry: whether it recurs (isOneTime) and the
|
|
112
|
+
// Price a subscription's item is made from at completion (sessionPrices)
|
|
113
|
+
if (mode === 'subscription' && entries.some((e) => e.price_data && typeof e.price_data === 'object'))
|
|
114
|
+
fields._price_data = entries.map((e) => (e.price_data && typeof e.price_data === 'object' ? e.price_data : null));
|
|
115
|
+
// the hosted page's brand name for this session (screens/checkout.tsx, merchantOf), and the account the session was
|
|
116
|
+
// made on (Stripe-Account: a direct charge), whose name the page shows. Kept apart from `_account`, the books a row is
|
|
117
|
+
// kept on: paying on the hosted page still makes the PaymentIntent and charge on the platform's books (a gap of the
|
|
118
|
+
// twin's direct-charge Checkout), so the session's events stay with them.
|
|
119
|
+
if (params.branding_settings && typeof params.branding_settings === 'object')
|
|
120
|
+
fields._branding_settings = params.branding_settings;
|
|
121
|
+
if (actingAccount(ctx))
|
|
122
|
+
fields._brand_account = actingAccount(ctx);
|
|
123
|
+
// a payment session's payment_intent_data shapes the PaymentIntent paying makes: a destination charge's transfer and
|
|
124
|
+
// the platform's application fee (docs.stripe.com/connect/destination-charges?platform=web&ui=stripe-hosted)
|
|
125
|
+
const pid = params.payment_intent_data && typeof params.payment_intent_data === 'object' ? params.payment_intent_data : undefined;
|
|
126
|
+
if (pid && mode === 'payment') {
|
|
127
|
+
const destination = pid.transfer_data?.destination;
|
|
128
|
+
if (destination !== undefined && (typeof destination !== 'string' || !ctx.get('account', destination)))
|
|
129
|
+
return noDestination(ctx, destination);
|
|
130
|
+
fields._payment_intent_data = pid;
|
|
131
|
+
}
|
|
132
|
+
if (discount)
|
|
133
|
+
fields._discount = { coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null };
|
|
134
|
+
return ctx.reply(await created(ctx, CS, { id, ...fields }, {}));
|
|
135
|
+
};
|
|
136
|
+
// the session's items, a page at a time
|
|
137
|
+
const lineItems = async (ctx) => {
|
|
138
|
+
const s = ctx.get(CS, at(ctx, 'session'));
|
|
139
|
+
if (!s)
|
|
140
|
+
return sessionMissing(ctx, at(ctx, 'session'));
|
|
141
|
+
const li = s.line_items;
|
|
142
|
+
const { page, hasMore } = paginate(Array.isArray(li?.data) ? li.data : [], ctx.params);
|
|
143
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
144
|
+
};
|
|
145
|
+
const expire = async (ctx) => {
|
|
146
|
+
const id = at(ctx, 'session');
|
|
147
|
+
const s = ctx.get(CS, id);
|
|
148
|
+
if (!s)
|
|
149
|
+
return sessionMissing(ctx, id);
|
|
150
|
+
const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSessionExpire', s.status, undefined, id);
|
|
151
|
+
if (refused)
|
|
152
|
+
return ctx.refuse(refused);
|
|
153
|
+
return ctx.reply(await ctx.write(CS, id, { status: 'expired', url: null }, 'checkout.session.expire'));
|
|
154
|
+
};
|
|
155
|
+
/** What completing a session makes, by its mode, as the fields that link it: a payment is a PaymentIntent paid with
|
|
156
|
+
* the entered card, its charge in the ledger as any payment's is. */
|
|
157
|
+
async function complete(ctx, id, existing, card) {
|
|
158
|
+
const link = { status: 'complete' };
|
|
159
|
+
const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
|
|
160
|
+
const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
|
|
161
|
+
// the entered card becomes a PaymentMethod carrying what a later charge to it answers and what Stripe does after it
|
|
162
|
+
// succeeds (docs.stripe.com/testing#declined-payments, #disputes)
|
|
163
|
+
const made = card ? await created(ctx, 'payment_method', {}, { type: 'card', 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;
|
|
164
|
+
// and Checkout attaches it to the customer when it saves it: a subscription's card ("If your Checkout Session uses
|
|
165
|
+
// subscription mode, Stripe saves the payment method by default", docs.stripe.com/payments/checkout/how-checkout-works),
|
|
166
|
+
// a setup session's (it exists to save one), and a payment's only under payment_intent_data.setup_future_usage ("to have
|
|
167
|
+
// Checkout automatically attach the payment method to the Customer you pass in", docs.stripe.com/api/checkout/sessions/
|
|
168
|
+
// create#create_checkout_session-customer). Attaching is its own write, so payment_method.attached is delivered ("Occurs
|
|
169
|
+
// whenever a new payment method is attached to a customer", docs.stripe.com/api/events/types) before what the card then
|
|
170
|
+
// pays for. Where the documentation stops and the twin decides: the order of the events, which Stripe does not
|
|
171
|
+
// guarantee (docs.stripe.com/webhooks#event-ordering). A gap: the customer's own opt-in to save a payment's card
|
|
172
|
+
// (saved_payment_method_options.payment_method_save) is not modelled; the page offers no such box.
|
|
173
|
+
const saves = existing.mode !== 'payment' || !!(ctx.row(CS, id)?._payment_intent_data ?? {}).setup_future_usage;
|
|
174
|
+
const pm = made && customer && saves ? await ctx.write('payment_method', String(made.id), { customer }, 'payment_method.attach') : made;
|
|
175
|
+
if (existing.mode === 'payment') {
|
|
176
|
+
const amount = Number(existing.amount_total) || 0;
|
|
177
|
+
const piId = ctx.mint('payment_intent');
|
|
178
|
+
const pid = (ctx.row(CS, id)?._payment_intent_data ?? {});
|
|
179
|
+
const connect = {
|
|
180
|
+
...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
|
|
181
|
+
...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: pid.transfer_data.destination } } : {}),
|
|
182
|
+
...(typeof pid.description === 'string' ? { description: pid.description } : {}),
|
|
183
|
+
// the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
|
|
184
|
+
...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
|
|
185
|
+
...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
|
|
186
|
+
};
|
|
187
|
+
const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
|
|
188
|
+
await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: ['card'] });
|
|
189
|
+
const charge = await mintCharge(ctx, piId, fields, amount);
|
|
190
|
+
await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
|
|
191
|
+
link.payment_intent = piId;
|
|
192
|
+
link.payment_status = 'paid';
|
|
193
|
+
}
|
|
194
|
+
else if (existing.mode === 'subscription') {
|
|
195
|
+
if (customer) {
|
|
196
|
+
// the subscription bills the session's own (already resolved) line items, and takes the
|
|
197
|
+
// session's subscription_data: metadata, description, and a trial as its first period
|
|
198
|
+
const now = Number(ctx.now());
|
|
199
|
+
// "Line items with one-time Prices will be on the initial invoice only" (docs.stripe.com/api/checkout/sessions/
|
|
200
|
+
// create#create_checkout_session-line_items): the subscription's items are the recurring ones
|
|
201
|
+
const inline = ctx.row(CS, id)?._price_data ?? [];
|
|
202
|
+
const lines = existing.line_items?.data ?? [];
|
|
203
|
+
const allLineItems = inline.some(Boolean) ? await sessionPrices(ctx, lines, inline) : lines;
|
|
204
|
+
const sessionLineItems = allLineItems.filter((it) => !isOneTime(it));
|
|
205
|
+
const oneTime = allLineItems.filter((it) => isOneTime(it));
|
|
206
|
+
const { interval, interval_count } = resolveSubscriptionBillingInterval(sessionLineItems, finder(ctx));
|
|
207
|
+
const subId = ctx.mint('subscription');
|
|
208
|
+
// the session's view never carries its create-only subscription_data: read the stored row
|
|
209
|
+
const raw = ctx.row(CS, id)?._subscription_data;
|
|
210
|
+
const subData = (raw && typeof raw === 'object' ? raw : {});
|
|
211
|
+
const subMetadata = subData.metadata && typeof subData.metadata === 'object' ? subData.metadata : {};
|
|
212
|
+
// a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
|
|
213
|
+
const trialEnd = resolveTrial(subData, now)?.end ?? null;
|
|
214
|
+
const sub = await created(ctx, 'subscription', { customer, id: subId }, {
|
|
215
|
+
status: trialEnd === null ? 'active' : 'trialing', livemode: false, currency, collection_method: 'charge_automatically',
|
|
216
|
+
// a trial's end is the billing anchor (docs.stripe.com/api/subscriptions/create#create_subscription-trial_end)
|
|
217
|
+
cancel_at_period_end: false, start_date: now, billing_cycle_anchor: trialEnd ?? now, metadata: subMetadata,
|
|
218
|
+
description: typeof subData.description === 'string' ? subData.description : null,
|
|
219
|
+
// the session's discount is the subscription's (docs.stripe.com/payments/checkout/discounts)
|
|
220
|
+
discounts: sessionDiscount(ctx, id, customer, now), billing_schedules: [],
|
|
221
|
+
trial_start: trialEnd === null ? null : now, trial_end: trialEnd,
|
|
222
|
+
current_period_start: now, current_period_end: trialEnd ?? addBillingInterval(now, interval, interval_count),
|
|
223
|
+
// Checkout saves the card as the subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works)
|
|
224
|
+
default_payment_method: pm?.id ?? null,
|
|
225
|
+
items: buildSubscriptionItemsList(sessionLineItems, subId, now, finder(ctx)),
|
|
226
|
+
automatic_tax: { enabled: false, liability: null }, billing_mode: { type: 'classic' },
|
|
227
|
+
invoice_settings: { issuer: { type: 'self' } },
|
|
228
|
+
});
|
|
229
|
+
await storeItems(ctx, subId, sub.items?.data ?? []);
|
|
230
|
+
// its first invoice, paid on the page by the card just entered (nothing is due on a trial's)
|
|
231
|
+
// (docs.stripe.com/billing/subscriptions/overview#how-payments-work-subscriptions)
|
|
232
|
+
const invoice = await draftSubscriptionInvoice(ctx, sub, { start: now, end: Number(sub.current_period_end), reason: 'subscription_create', trial: trialEnd !== null, once: oneTime });
|
|
233
|
+
await collectSubscriptionInvoice(ctx, invoice, now, 'external');
|
|
234
|
+
await ctx.write('subscription', subId, { latest_invoice: invoice }, 'subscription.update');
|
|
235
|
+
link.subscription = sub.id;
|
|
236
|
+
link.invoice = invoice;
|
|
237
|
+
}
|
|
238
|
+
link.payment_status = 'paid';
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
link.setup_intent = await completeSetup(ctx, customer, pm);
|
|
242
|
+
return link;
|
|
243
|
+
}
|
|
244
|
+
/** A line item of a one-time Price: a stored Price, or an inline `price_data`, without `recurring` ("Line items with
|
|
245
|
+
* one-time Prices will be on the initial invoice only", docs.stripe.com/api/checkout/sessions/create). */
|
|
246
|
+
export function isOneTime(item, priceData) {
|
|
247
|
+
if (priceData)
|
|
248
|
+
return !priceData.recurring;
|
|
249
|
+
return !!item.price && typeof item.price === 'object' && !item.price.recurring;
|
|
250
|
+
}
|
|
251
|
+
/** A subscription session's lines at completion, each inline `price_data` made the Price it generates ("Data used to
|
|
252
|
+
* generate a new Price object inline", the same page), under the product it names or one made from its product_data,
|
|
253
|
+
* so the subscription's items bill it. Where the documentation stops and the twin decides: such a Price is archived
|
|
254
|
+
* (active=false), as a subscription item's price_data makes one (docs.stripe.com/products-prices/manage-prices). */
|
|
255
|
+
async function sessionPrices(ctx, lines, inline) {
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const [i, line] of lines.entries()) {
|
|
258
|
+
const d = inline[i];
|
|
259
|
+
if (!d) {
|
|
260
|
+
out.push(line);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const pd = d.product_data && typeof d.product_data === 'object' ? d.product_data : undefined;
|
|
264
|
+
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);
|
|
265
|
+
const r = d.recurring && typeof d.recurring === 'object' ? d.recurring : undefined;
|
|
266
|
+
const price = await created(ctx, 'price', {
|
|
267
|
+
product, currency: String(d.currency ?? line.currency ?? 'usd').toLowerCase(),
|
|
268
|
+
unit_amount: d.unit_amount_decimal !== undefined ? null : Math.trunc(Number(d.unit_amount) || 0),
|
|
269
|
+
unit_amount_decimal: d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0)),
|
|
270
|
+
...(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 }),
|
|
271
|
+
}, { active: false, livemode: false, billing_scheme: 'per_unit', type: r ? 'recurring' : 'one_time', metadata: {}, lookup_key: null, nickname: null, tax_behavior: 'unspecified' });
|
|
272
|
+
out.push({ ...line, price });
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
|
|
277
|
+
async function completeSetup(ctx, customer, pm) {
|
|
278
|
+
const siId = ctx.mint('setup_intent');
|
|
279
|
+
const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], livemode: false });
|
|
280
|
+
return si.id;
|
|
281
|
+
}
|
|
282
|
+
/** The discount a completed subscription session gives its subscription. */
|
|
283
|
+
function sessionDiscount(ctx, id, customer, now) {
|
|
284
|
+
const d = ctx.row(CS, id)?._discount;
|
|
285
|
+
const coupon = d && typeof d.coupon === 'string' ? ctx.get('coupon', d.coupon) : undefined;
|
|
286
|
+
return coupon ? [{ ...buildDiscount(coupon, customer, now), promotion_code: d?.promotion_code ?? null, checkout_session: id }] : [];
|
|
287
|
+
}
|
|
288
|
+
/** The customer paying on the hosted page (src/screens/checkout.tsx): the external actor's move on the
|
|
289
|
+
* session's machine, then what the payment made, linked, and the session stored complete. */
|
|
290
|
+
export async function completeSession(ctx, id, existing, details = {}, card) {
|
|
291
|
+
const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSession', existing.status, 'complete', id, 'external');
|
|
292
|
+
if (refused)
|
|
293
|
+
return ctx.refuse(refused);
|
|
294
|
+
const link = await complete(ctx, id, existing, card);
|
|
295
|
+
// the discount the buyer took is redeemed (shared.ts redeemCoupon)
|
|
296
|
+
const d = ctx.row(CS, id)?._discount;
|
|
297
|
+
if (d)
|
|
298
|
+
await redeemCoupon(ctx, d.coupon, d.promotion_code);
|
|
299
|
+
// the session's url "is only present when the session is active" (docs.stripe.com/api/checkout/sessions/object)
|
|
300
|
+
return ctx.write(CS, id, { ...details, ...link, url: null }, 'checkout.session.completed');
|
|
301
|
+
}
|
|
302
|
+
const update = async (ctx) => {
|
|
303
|
+
const id = at(ctx, 'session');
|
|
304
|
+
const existing = ctx.get(CS, id);
|
|
305
|
+
if (!existing)
|
|
306
|
+
return sessionMissing(ctx, id);
|
|
307
|
+
// a session completes only when its customer pays on the hosted page (src/screens/checkout.tsx); the API takes no
|
|
308
|
+
// status, which the parameter check refuses as Stripe does (../stripe-params.ts)
|
|
309
|
+
return ctx.reply(await ctx.write(CS, id, ctx.params, 'checkout.session.update'));
|
|
310
|
+
};
|
|
311
|
+
export const checkout = {
|
|
312
|
+
PostCheckoutSessions: create,
|
|
313
|
+
GetCheckoutSessionsSessionLineItems: lineItems,
|
|
314
|
+
PostCheckoutSessionsSessionExpire: expire,
|
|
315
|
+
PostCheckoutSessionsSession: update,
|
|
316
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Semantics } from '@volter/world-core';
|
|
2
|
+
import { type Row } from './shared.js';
|
|
3
|
+
/** The platform's own account as Stripe's default has it, before anything is written to it. */
|
|
4
|
+
export declare const platformAccountDefault: () => Row;
|
|
5
|
+
export declare const connect: Record<string, Semantics>;
|