@firedrill-tools/stripe 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +433 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/api-unavailable.scenario.json +11 -0
  5. package/firedrill/baseline.scenario.json +5 -0
  6. package/firedrill/conformance.suite.json +17 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/refund-committed-lost.scenario.json +11 -0
  9. package/firedrill/stripe-api-unavailable.drill.json +463 -0
  10. package/firedrill/stripe-denied.drill.json +74 -0
  11. package/firedrill/stripe-large-pages.drill.json +153 -0
  12. package/firedrill/stripe-live-mode.drill.json +136 -0
  13. package/firedrill/stripe-mcp-aliases.drill.json +406 -0
  14. package/firedrill/stripe-no-permissions.drill.json +1143 -0
  15. package/firedrill/stripe-rate-limited.drill.json +616 -0
  16. package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
  17. package/firedrill/stripe-rest-flow.drill.json +1401 -0
  18. package/firedrill/stripe-restricted-key.drill.json +171 -0
  19. package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
  20. package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
  21. package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
  22. package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
  23. package/firedrill/tools/stripe/app/site/app.js +456 -0
  24. package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
  25. package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
  26. package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
  27. package/firedrill/tools/stripe/app/site/icons.js +90 -0
  28. package/firedrill/tools/stripe/app/site/index.html +137 -0
  29. package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
  30. package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
  31. package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
  32. package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
  33. package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
  34. package/firedrill/tools/stripe/app/site/store.js +99 -0
  35. package/firedrill/tools/stripe/app/site/styles.css +2512 -0
  36. package/firedrill/tools/stripe/app/site/ui.js +767 -0
  37. package/firedrill/tools/stripe/app/site/widgets.js +707 -0
  38. package/firedrill/tools/stripe/behavior.mjs +148 -0
  39. package/firedrill/tools/stripe/lib/cards.mjs +53 -0
  40. package/firedrill/tools/stripe/lib/form.mjs +204 -0
  41. package/firedrill/tools/stripe/lib/ids.mjs +85 -0
  42. package/firedrill/tools/stripe/lib/money.mjs +35 -0
  43. package/firedrill/tools/stripe/lib/objects.mjs +229 -0
  44. package/firedrill/tools/stripe/lib/periods.mjs +41 -0
  45. package/firedrill/tools/stripe/lib/size.mjs +55 -0
  46. package/firedrill/tools/stripe/lib/state.mjs +230 -0
  47. package/firedrill/tools/stripe/lib/validate.mjs +184 -0
  48. package/firedrill/tools/stripe/lib/wire.mjs +98 -0
  49. package/firedrill/tools/stripe/ops/billing.mjs +914 -0
  50. package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
  51. package/firedrill/tools/stripe/ops/customers.mjs +241 -0
  52. package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
  53. package/firedrill/tools/stripe/ops/payments.mjs +608 -0
  54. package/firedrill/tools/stripe/stripe.tool.json +28833 -0
  55. package/firedrill/world.json +8527 -0
  56. package/firedrill.json +5 -0
  57. package/package.json +64 -0
  58. package/starter.json +7999 -0
  59. package/test/conformance.mjs +1133 -0
@@ -0,0 +1,608 @@
1
+ // PaymentIntents, charges, refunds and the computed balance. `confirmAttempt` is the single place a synthetic
2
+ // card outcome is applied. On `payment_intents.create` (confirm=true), `payment_intents.confirm` and
3
+ // `invoices.pay` a decline is a declared `CARD_DECLINED` failure (HTTP 402). Stripe answers 402 *and* keeps the
4
+ // intent in `requires_payment_method` with `last_payment_error`; Firedrill cannot do both in one operation: an
5
+ // expected failure aborts the state transaction (world-kernel `executeOperation` inside `store.transact`) and an
6
+ // `ok` outcome is always the route's 2xx `successStatus` (protocol-http `outcomeStatus`), with no status override
7
+ // in the codec response. So the attempt is rolled back — including the id counters — and the 402 body names only
8
+ // objects that existed before the operation (see `declinedFailure`). The failed attempt is persisted where the
9
+ // operation itself succeeds (`subscriptions.create` with an incomplete payment behaviour), which is where
10
+ // `payment_intent.declined` is emitted.
11
+
12
+ import { declineFor } from "../lib/cards.mjs";
13
+ import { acquirerReference, clientSecret, nextId, renderId, riskScore, sequenceOf } from "../lib/ids.mjs";
14
+ import { formatAmount } from "../lib/money.mjs";
15
+ import { applyExpand, makeView, renderCharge, renderPaymentIntent, renderPaymentMethod, renderRefund, validateExpand } from "../lib/objects.mjs";
16
+ import { DAY } from "../lib/periods.mjs";
17
+ import { allRows, fail, getRow, invalid, invalidState, matchesRange, paginate, parameterMissing, requirePermission, requirePermissions, requireRow } from "../lib/state.mjs";
18
+ import { docUrl } from "../lib/wire.mjs";
19
+ import { isPlainObject, mergeMetadata, optionalBoolean, optionalEnum, optionalInteger, optionalString, optionalStringArray, rejectMangled, requireChargeAmount, requireCurrency, requireExactlyOne, validateEmail } from "../lib/validate.mjs";
20
+ import { materializeCard, resolvePaymentMethod } from "./customers.mjs";
21
+
22
+ const CAPTURE_METHODS = ["automatic", "automatic_async", "manual"];
23
+ const SETUP_FUTURE_USAGE = ["off_session", "on_session"];
24
+ const CANCELLATION_REASONS = ["duplicate", "fraudulent", "requested_by_customer", "abandoned"];
25
+ const REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
26
+ const PENDING_WINDOW = 2 * DAY;
27
+
28
+ const DEFAULT_ACCOUNT = Object.freeze({
29
+ id: "acct_synthetic",
30
+ business_name: "Synthetic account",
31
+ country: "US",
32
+ default_currency: "usd",
33
+ statement_descriptor: "SYNTHETIC",
34
+ support_email: null,
35
+ });
36
+
37
+ /** The simulated account (`meta/account`), or a documented default when the world has no starter rows. */
38
+ export function accountInfo(context) {
39
+ const row = context.state.get("meta", "account");
40
+ return row === null ? DEFAULT_ACCOUNT : { ...DEFAULT_ACCOUNT, ...row };
41
+ }
42
+
43
+ function statementDescriptor(account, intent) {
44
+ const base = account.statement_descriptor.toUpperCase();
45
+ return intent.statement_descriptor_suffix === null ? base : `${base}* ${intent.statement_descriptor_suffix.toUpperCase()}`;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------------------------
49
+ // Row builders
50
+ // ---------------------------------------------------------------------------------------------
51
+
52
+ export function newIntentRow(view, id, fields) {
53
+ return {
54
+ id,
55
+ object: "payment_intent",
56
+ amount: fields.amount,
57
+ amount_capturable: 0,
58
+ amount_details: { tip: {} },
59
+ amount_received: 0,
60
+ application: null,
61
+ application_fee_amount: null,
62
+ automatic_payment_methods: fields.automatic_payment_methods ?? null,
63
+ canceled_at: null,
64
+ cancellation_reason: null,
65
+ capture_method: fields.capture_method ?? "automatic",
66
+ client_secret: clientSecret(id),
67
+ confirmation_method: "automatic",
68
+ created: view.now,
69
+ currency: fields.currency,
70
+ customer: fields.customer ?? null,
71
+ description: fields.description ?? null,
72
+ last_payment_error: null,
73
+ latest_charge: null,
74
+ livemode: view.livemode,
75
+ metadata: fields.metadata ?? {},
76
+ next_action: null,
77
+ on_behalf_of: null,
78
+ payment_method: fields.payment_method ?? null,
79
+ payment_method_configuration_details: null,
80
+ payment_method_options: { card: { installments: null, mandate_options: null, network: null, request_three_d_secure: "automatic" } },
81
+ payment_method_types: ["card"],
82
+ processing: null,
83
+ receipt_email: fields.receipt_email ?? null,
84
+ review: null,
85
+ setup_future_usage: fields.setup_future_usage ?? null,
86
+ shipping: null,
87
+ source: null,
88
+ statement_descriptor: fields.statement_descriptor ?? null,
89
+ statement_descriptor_suffix: fields.statement_descriptor_suffix ?? null,
90
+ status: fields.payment_method === undefined || fields.payment_method === null ? "requires_payment_method" : "requires_confirmation",
91
+ transfer_data: null,
92
+ transfer_group: null,
93
+ invoice: fields.invoice ?? null,
94
+ };
95
+ }
96
+
97
+ function chargeOutcome(id, decline) {
98
+ if (decline === undefined) {
99
+ return { network_status: "approved_by_network", reason: null, risk_level: "normal", risk_score: riskScore(id), seller_message: "Payment complete.", type: "authorized" };
100
+ }
101
+ return {
102
+ network_status: "declined_by_network",
103
+ reason: decline.decline_code,
104
+ risk_level: "normal",
105
+ risk_score: riskScore(id),
106
+ seller_message: "The bank did not return any further details with this decline.",
107
+ type: "issuer_declined",
108
+ };
109
+ }
110
+
111
+ function buildCharge(context, view, id, intent, method, kind, decline) {
112
+ const account = accountInfo(context);
113
+ const failed = kind === "failed";
114
+ const captured = kind === "succeeded";
115
+ const card = method.card;
116
+ return {
117
+ id,
118
+ object: "charge",
119
+ amount: intent.amount,
120
+ amount_captured: captured ? intent.amount : 0,
121
+ amount_refunded: 0,
122
+ application: null,
123
+ application_fee: null,
124
+ application_fee_amount: null,
125
+ balance_transaction: captured ? renderId("txn", sequenceOf(id)) : null,
126
+ billing_details: { ...method.billing_details, address: { ...method.billing_details.address } },
127
+ calculated_statement_descriptor: failed ? null : statementDescriptor(account, intent),
128
+ captured,
129
+ created: view.now,
130
+ currency: intent.currency,
131
+ customer: intent.customer,
132
+ description: intent.description,
133
+ disputed: false,
134
+ failure_balance_transaction: null,
135
+ failure_code: failed ? "card_declined" : null,
136
+ failure_message: failed ? decline.message : null,
137
+ fraud_details: {},
138
+ livemode: view.livemode,
139
+ metadata: { ...intent.metadata },
140
+ on_behalf_of: null,
141
+ outcome: chargeOutcome(id, decline),
142
+ paid: !failed,
143
+ payment_intent: intent.id,
144
+ payment_method: method.id,
145
+ payment_method_details: {
146
+ card: {
147
+ amount_authorized: failed ? null : intent.amount,
148
+ brand: card.brand,
149
+ capture_before: kind === "uncaptured" ? view.now + 7 * DAY : null,
150
+ checks: { ...card.checks },
151
+ country: card.country,
152
+ exp_month: card.exp_month,
153
+ exp_year: card.exp_year,
154
+ extended_authorization: { status: "disabled" },
155
+ fingerprint: card.fingerprint,
156
+ funding: card.funding,
157
+ incremental_authorization: { status: "unavailable" },
158
+ installments: null,
159
+ last4: card.last4,
160
+ mandate: null,
161
+ multicapture: { status: "unavailable" },
162
+ network: card.brand,
163
+ network_token: null,
164
+ overcapture: { status: "unavailable", maximum_amount_capturable: intent.amount },
165
+ three_d_secure: null,
166
+ wallet: null,
167
+ },
168
+ type: "card",
169
+ },
170
+ radar_options: {},
171
+ receipt_email: intent.receipt_email,
172
+ receipt_number: null,
173
+ receipt_url: failed ? null : `https://pay.stripe.test/receipts/${id}`,
174
+ refunded: false,
175
+ review: null,
176
+ shipping: null,
177
+ source: null,
178
+ source_transfer: null,
179
+ statement_descriptor: intent.statement_descriptor,
180
+ statement_descriptor_suffix: intent.statement_descriptor_suffix,
181
+ status: failed ? "failed" : "succeeded",
182
+ transfer_data: null,
183
+ transfer_group: null,
184
+ };
185
+ }
186
+
187
+ function emitSucceeded(context, intent, charge) {
188
+ context.events.emit("payment_intent.succeeded", {
189
+ id: intent.id,
190
+ amount_received: intent.amount_received,
191
+ currency: intent.currency,
192
+ customer: intent.customer,
193
+ latest_charge: charge.id,
194
+ invoice: intent.invoice,
195
+ });
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------------------------
199
+ // Confirmation
200
+ // ---------------------------------------------------------------------------------------------
201
+
202
+ /** Resolve the method used by an attempt: a test card is materialised (attached when the intent has a customer). */
203
+ function methodForAttempt(context, view, intent, resolved) {
204
+ if (resolved.card !== undefined) {
205
+ const customer = intent.customer === null ? null : getRow(context, "customers", intent.customer);
206
+ return materializeCard(context, view, resolved.card, customer);
207
+ }
208
+ const row = resolved.row;
209
+ if (row.customer !== null && row.customer !== intent.customer) {
210
+ if (intent.customer === null) {
211
+ return invalidState(context, `The payment method '${row.id}' belongs to the Customer '${row.customer}' but this PaymentIntent doesn't belong to a Customer. Set the PaymentIntent's customer to use it.`, "payment_method_customer_mismatch");
212
+ }
213
+ return invalidState(context, `The payment method '${row.id}' belongs to the Customer '${row.customer}' but this PaymentIntent belongs to the Customer '${intent.customer}'.`, "payment_method_customer_mismatch");
214
+ }
215
+ return row;
216
+ }
217
+
218
+ /**
219
+ * Apply a synthetic card outcome to an intent. Writes the intent (and a charge for success/decline) and returns
220
+ * `{ status, intent, method, charge?, decline? }` with `status` one of `succeeded | requires_capture |
221
+ * requires_action | declined`. Callers decide whether a decline is persisted (returned) or raised.
222
+ */
223
+ export function confirmAttempt(context, view, intent, resolved) {
224
+ const method = methodForAttempt(context, view, intent, resolved);
225
+ // A catalogue test card is materialised inside this operation; a resolved row existed before it.
226
+ const materialized = resolved.card !== undefined;
227
+ const base = { ...intent, payment_method: method.id, next_action: null, last_payment_error: null };
228
+ if (method.outcome === "requires_action") {
229
+ const next = {
230
+ ...base,
231
+ status: "requires_action",
232
+ next_action: { type: "use_stripe_sdk", use_stripe_sdk: { type: "three_d_secure_redirect", stripe_js: `https://hooks.stripe.test/3d_secure_2/hosted?merchant=${accountInfo(context).id}&intent=${intent.id}` } },
233
+ };
234
+ context.state.put("payment_intents", next.id, next);
235
+ return { status: "requires_action", intent: next, method, materialized };
236
+ }
237
+ const decline = declineFor(method.outcome);
238
+ const chargeId = nextId(context, "ch");
239
+ if (decline !== undefined) {
240
+ const charge = buildCharge(context, view, chargeId, base, method, "failed", decline);
241
+ context.state.put("charges", chargeId, charge);
242
+ const next = {
243
+ ...base,
244
+ status: "requires_payment_method",
245
+ payment_method: null,
246
+ latest_charge: chargeId,
247
+ last_payment_error: {
248
+ charge: chargeId,
249
+ code: "card_declined",
250
+ decline_code: decline.decline_code,
251
+ doc_url: docUrl("card_declined"),
252
+ message: decline.message,
253
+ payment_method: method.id,
254
+ type: "card_error",
255
+ },
256
+ };
257
+ context.state.put("payment_intents", next.id, next);
258
+ context.events.emit("payment_intent.declined", { id: next.id, decline_code: decline.decline_code, invoice: next.invoice });
259
+ return { status: "declined", intent: next, method, charge, decline, materialized };
260
+ }
261
+ if (base.capture_method === "manual") {
262
+ const charge = buildCharge(context, view, chargeId, base, method, "uncaptured");
263
+ context.state.put("charges", chargeId, charge);
264
+ const next = { ...base, status: "requires_capture", amount_capturable: intent.amount, latest_charge: chargeId };
265
+ context.state.put("payment_intents", next.id, next);
266
+ return { status: "requires_capture", intent: next, method, charge, materialized };
267
+ }
268
+ const charge = buildCharge(context, view, chargeId, base, method, "succeeded");
269
+ context.state.put("charges", chargeId, charge);
270
+ const next = { ...base, status: "succeeded", amount_received: intent.amount, latest_charge: chargeId };
271
+ context.state.put("payment_intents", next.id, next);
272
+ emitSucceeded(context, next, charge);
273
+ return { status: "succeeded", intent: next, method, charge, materialized };
274
+ }
275
+
276
+ /**
277
+ * Raise the declared `CARD_DECLINED` failure for a declined attempt (HTTP 402 `card_error`). The framework rolls
278
+ * back every write of the operation, id counters included, so the body may only name objects that existed
279
+ * before the operation started — an id of a rolled-back object answers 404 and is handed to the next object
280
+ * created, and an agent retrying with it would act on an unrelated record.
281
+ *
282
+ * - `intentCreated: true` (`payment_intents.create` with `confirm=true`, `subscriptions.create` with
283
+ * `error_if_incomplete`): the PaymentIntent (and any PaymentMethod, charge, invoice or subscription) was created
284
+ * inside the operation, so the body carries only the Stripe error envelope — no `payment_intent`, no
285
+ * `payment_method`, no ids.
286
+ * - `intentCreated: false` (`payment_intents.confirm`, `invoices.pay`): the PaymentIntent exists and keeps its
287
+ * previous state; the body names it as the attempt would have left it (`requires_payment_method`,
288
+ * `last_payment_error`), never the unpersisted charge (`latest_charge: null`, no `last_payment_error.charge`),
289
+ * and the PaymentMethod only when it existed before the call (a test card materialised by the call is omitted).
290
+ */
291
+ export function declinedFailure(context, view, result, { intentCreated }) {
292
+ const envelope = { code: "card_declined", decline_code: result.decline.decline_code };
293
+ if (intentCreated) return fail(context, "CARD_DECLINED", result.decline.message, envelope);
294
+ const { charge: _unpersistedCharge, payment_method: _attemptMethod, ...lastPaymentError } = result.intent.last_payment_error;
295
+ void _unpersistedCharge;
296
+ void _attemptMethod;
297
+ const methodExisted = !result.materialized;
298
+ const described = {
299
+ ...result.intent,
300
+ latest_charge: null,
301
+ last_payment_error: methodExisted ? { ...lastPaymentError, payment_method: result.method.id } : lastPaymentError,
302
+ };
303
+ return fail(context, "CARD_DECLINED", result.decline.message, {
304
+ ...envelope,
305
+ payment_intent: renderPaymentIntent(view, described),
306
+ ...(methodExisted ? { payment_method: renderPaymentMethod(view, result.method) } : {}),
307
+ });
308
+ }
309
+
310
+ function unexpectedState(context, intent, verb) {
311
+ const reason =
312
+ intent.status === "succeeded"
313
+ ? "it has already succeeded after being previously confirmed"
314
+ : intent.status === "canceled"
315
+ ? "it has a status of canceled"
316
+ : intent.status === "requires_capture"
317
+ ? "it has a status of requires_capture; capture or cancel it instead"
318
+ : intent.status === "requires_action"
319
+ ? "it has a status of requires_action; the customer must complete authentication"
320
+ : `it has a status of ${intent.status}`;
321
+ return invalidState(context, `You cannot ${verb} this PaymentIntent because ${reason}.`, "payment_intent_unexpected_state");
322
+ }
323
+
324
+ function invoiceGuard(context, intent, verb) {
325
+ if (intent.invoice === null) return;
326
+ const invoice = getRow(context, "invoices", intent.invoice);
327
+ if (invoice !== null && invoice.status === "open") {
328
+ return invalidState(context, `You cannot ${verb} this PaymentIntent because it belongs to invoice ${invoice.id}; use /v1/invoices/${invoice.id}/pay or /v1/invoices/${invoice.id}/void instead.`, "payment_intent_invoice_managed");
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------------------------
333
+ // PaymentIntents
334
+ // ---------------------------------------------------------------------------------------------
335
+
336
+ function automaticPaymentMethodsInput(context, value) {
337
+ if (value === undefined) return null;
338
+ if (!isPlainObject(value)) return invalid(context, "Invalid automatic_payment_methods: must be a hash.", "parameter_invalid", "automatic_payment_methods");
339
+ for (const key of Object.keys(value)) if (key !== "enabled") return invalid(context, `Received unknown parameter: automatic_payment_methods[${key}]`, "parameter_unknown", `automatic_payment_methods[${key}]`);
340
+ const enabled = optionalBoolean(context, value, "enabled", undefined);
341
+ if (enabled === undefined) return parameterMissing(context, "automatic_payment_methods[enabled]");
342
+ return { allow_redirects: "always", enabled };
343
+ }
344
+
345
+ export function paymentIntentsCreate(input, context) {
346
+ requirePermission(context, "payment_intents", "write");
347
+ const view = makeView(context);
348
+ const expand = validateExpand(context, input.expand, "payment_intent");
349
+ const currency = requireCurrency(context, input.currency);
350
+ const amount = requireChargeAmount(context, input.amount, currency);
351
+ const customerId = optionalString(context, input, "customer", null, 255);
352
+ const customer = customerId === null ? null : requireRow(context, "customers", customerId, "customer");
353
+ const types = optionalStringArray(context, input, "payment_method_types", undefined);
354
+ if (types !== undefined && (types.length !== 1 || types[0] !== "card")) {
355
+ return invalid(context, `Invalid payment_method_types: only 'card' is supported by this account.`, "parameter_invalid", "payment_method_types");
356
+ }
357
+ const setupFutureUsage = input.setup_future_usage === "" ? null : optionalEnum(context, input, "setup_future_usage", SETUP_FUTURE_USAGE, null);
358
+ optionalBoolean(context, input, "off_session", undefined);
359
+ optionalString(context, input, "return_url", undefined, 2_048);
360
+ const fields = {
361
+ amount,
362
+ currency,
363
+ customer: customer === null ? null : customer.id,
364
+ capture_method: optionalEnum(context, input, "capture_method", CAPTURE_METHODS, "automatic"),
365
+ description: optionalString(context, input, "description", null, 1_000),
366
+ metadata: mergeMetadata(context, input.metadata),
367
+ receipt_email: validateEmail(context, optionalString(context, input, "receipt_email", null, 512), "receipt_email"),
368
+ statement_descriptor_suffix: optionalString(context, input, "statement_descriptor_suffix", null, 22),
369
+ setup_future_usage: setupFutureUsage,
370
+ automatic_payment_methods: automaticPaymentMethodsInput(context, input.automatic_payment_methods),
371
+ statement_descriptor: null,
372
+ };
373
+ const confirm = optionalBoolean(context, input, "confirm", false);
374
+ const resolved = input.payment_method === undefined || input.payment_method === "" ? undefined : resolvePaymentMethod(context, view, input.payment_method);
375
+ const id = nextId(context, "pi");
376
+ let intent = newIntentRow(view, id, fields);
377
+ let method;
378
+ if (resolved !== undefined) {
379
+ method = methodForAttempt(context, view, intent, resolved);
380
+ intent = { ...intent, payment_method: method.id, status: "requires_confirmation" };
381
+ }
382
+ context.state.put("payment_intents", id, intent);
383
+ if (confirm) {
384
+ if (method === undefined) {
385
+ return invalidState(context, "You cannot confirm this PaymentIntent because it's missing a payment method. Create it with payment_method, or confirm it later with one.", "payment_intent_payment_method_missing");
386
+ }
387
+ const result = confirmAttempt(context, view, intent, { row: method });
388
+ if (result.status === "declined") return declinedFailure(context, view, result, { intentCreated: true });
389
+ intent = result.intent;
390
+ }
391
+ return applyExpand(view, renderPaymentIntent(view, intent), expand, "payment_intent");
392
+ }
393
+
394
+ export function paymentIntentsRetrieve(input, context) {
395
+ requirePermission(context, "payment_intents", "read");
396
+ const view = makeView(context);
397
+ const expand = validateExpand(context, input.expand, "payment_intent");
398
+ optionalString(context, input, "client_secret", undefined, 255);
399
+ const intent = requireRow(context, "payment_intents", input.payment_intent, "payment_intent");
400
+ return applyExpand(view, renderPaymentIntent(view, intent), expand, "payment_intent");
401
+ }
402
+
403
+ export function paymentIntentsList(input, context) {
404
+ requirePermission(context, "payment_intents", "read");
405
+ rejectMangled(context, input, ["customer"]);
406
+ const view = makeView(context);
407
+ const expand = validateExpand(context, input.expand, "payment_intent", true);
408
+ const customer = optionalString(context, input, "customer", undefined, 255);
409
+ const rows = allRows(context, "payment_intents").filter(
410
+ (intent) => (customer === undefined || intent.customer === customer) && matchesRange(intent.created, input.created, context, "created"),
411
+ );
412
+ return paginate(context, "payment_intents", rows, input, "/v1/payment_intents", (row) => applyExpand(view, renderPaymentIntent(view, row), expand, "payment_intent"));
413
+ }
414
+
415
+ export function paymentIntentsConfirm(input, context) {
416
+ requirePermission(context, "payment_intents", "write");
417
+ const view = makeView(context);
418
+ const expand = validateExpand(context, input.expand, "payment_intent");
419
+ const stored = requireRow(context, "payment_intents", input.payment_intent, "payment_intent");
420
+ if (stored.status !== "requires_payment_method" && stored.status !== "requires_confirmation") return unexpectedState(context, stored, "confirm");
421
+ invoiceGuard(context, stored, "confirm");
422
+ optionalBoolean(context, input, "off_session", undefined);
423
+ optionalString(context, input, "return_url", undefined, 2_048);
424
+ const setupFutureUsage = input.setup_future_usage === "" ? null : optionalEnum(context, input, "setup_future_usage", SETUP_FUTURE_USAGE, stored.setup_future_usage);
425
+ const intent = {
426
+ ...stored,
427
+ receipt_email: validateEmail(context, optionalString(context, input, "receipt_email", stored.receipt_email, 512), "receipt_email"),
428
+ setup_future_usage: setupFutureUsage,
429
+ };
430
+ let resolved;
431
+ if (input.payment_method !== undefined && input.payment_method !== "") resolved = resolvePaymentMethod(context, view, input.payment_method);
432
+ else if (intent.payment_method !== null) resolved = resolvePaymentMethod(context, view, intent.payment_method);
433
+ else {
434
+ return invalidState(context, "You cannot confirm this PaymentIntent because it's missing a payment method. You can either update the PaymentIntent with a payment method and then confirm it again, or confirm it again directly with a payment method.", "payment_intent_payment_method_missing");
435
+ }
436
+ const result = confirmAttempt(context, view, intent, resolved);
437
+ if (result.status === "declined") return declinedFailure(context, view, result, { intentCreated: false });
438
+ return applyExpand(view, renderPaymentIntent(view, result.intent), expand, "payment_intent");
439
+ }
440
+
441
+ export function paymentIntentsCapture(input, context) {
442
+ requirePermission(context, "payment_intents", "write");
443
+ const view = makeView(context);
444
+ const expand = validateExpand(context, input.expand, "payment_intent");
445
+ const intent = requireRow(context, "payment_intents", input.payment_intent, "payment_intent");
446
+ if (intent.status !== "requires_capture") return unexpectedState(context, intent, "capture");
447
+ const amount = optionalInteger(context, input, "amount_to_capture", intent.amount_capturable, { min: 1 });
448
+ if (amount > intent.amount_capturable) {
449
+ return invalid(context, `The amount_to_capture (${formatAmount(amount, intent.currency)}) is greater than the amount capturable (${formatAmount(intent.amount_capturable, intent.currency)}).`, "amount_too_large", "amount_to_capture");
450
+ }
451
+ const charge = getRow(context, "charges", intent.latest_charge);
452
+ const next = { ...intent, status: "succeeded", amount_received: amount, amount_capturable: 0 };
453
+ context.state.put("payment_intents", next.id, next);
454
+ if (charge !== null) {
455
+ const captured = {
456
+ ...charge,
457
+ captured: true,
458
+ amount_captured: amount,
459
+ balance_transaction: renderId("txn", sequenceOf(charge.id)),
460
+ payment_method_details: { ...charge.payment_method_details, card: { ...charge.payment_method_details.card, capture_before: null } },
461
+ };
462
+ context.state.put("charges", captured.id, captured);
463
+ emitSucceeded(context, next, captured);
464
+ }
465
+ return applyExpand(view, renderPaymentIntent(view, next), expand, "payment_intent");
466
+ }
467
+
468
+ export function paymentIntentsCancel(input, context) {
469
+ requirePermission(context, "payment_intents", "write");
470
+ const view = makeView(context);
471
+ const expand = validateExpand(context, input.expand, "payment_intent");
472
+ const intent = requireRow(context, "payment_intents", input.payment_intent, "payment_intent");
473
+ const reason = optionalEnum(context, input, "cancellation_reason", CANCELLATION_REASONS, null);
474
+ if (!["requires_payment_method", "requires_confirmation", "requires_action", "requires_capture"].includes(intent.status)) return unexpectedState(context, intent, "cancel");
475
+ invoiceGuard(context, intent, "cancel");
476
+ const next = { ...intent, status: "canceled", canceled_at: view.now, cancellation_reason: reason, next_action: null, amount_capturable: 0 };
477
+ context.state.put("payment_intents", next.id, next);
478
+ if (intent.status === "requires_capture" && intent.latest_charge !== null) {
479
+ const charge = getRow(context, "charges", intent.latest_charge);
480
+ if (charge !== null) context.state.put("charges", charge.id, { ...charge, refunded: true, amount_refunded: charge.amount });
481
+ }
482
+ return applyExpand(view, renderPaymentIntent(view, next), expand, "payment_intent");
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------------------------
486
+ // Charges
487
+ // ---------------------------------------------------------------------------------------------
488
+
489
+ export function chargesRetrieve(input, context) {
490
+ requirePermission(context, "charges", "read");
491
+ const view = makeView(context);
492
+ const expand = validateExpand(context, input.expand, "charge");
493
+ const charge = requireRow(context, "charges", input.charge, "charge");
494
+ return applyExpand(view, renderCharge(view, charge), expand, "charge");
495
+ }
496
+
497
+ export function chargesList(input, context) {
498
+ requirePermission(context, "charges", "read");
499
+ rejectMangled(context, input, ["customer", "payment_intent"]);
500
+ const view = makeView(context);
501
+ const expand = validateExpand(context, input.expand, "charge", true);
502
+ const customer = optionalString(context, input, "customer", undefined, 255);
503
+ const intent = optionalString(context, input, "payment_intent", undefined, 255);
504
+ const rows = allRows(context, "charges").filter(
505
+ (charge) => (customer === undefined || charge.customer === customer) && (intent === undefined || charge.payment_intent === intent) && matchesRange(charge.created, input.created, context, "created"),
506
+ );
507
+ return paginate(context, "charges", rows, input, "/v1/charges", (row) => applyExpand(view, renderCharge(view, row), expand, "charge"));
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------------------------
511
+ // Refunds
512
+ // ---------------------------------------------------------------------------------------------
513
+
514
+ export function refundsCreate(input, context) {
515
+ requirePermissions(context, [["refunds", "write"], ["charges", "read"]]);
516
+ const view = makeView(context);
517
+ const expand = validateExpand(context, input.expand, "refund");
518
+ const selector = requireExactlyOne(context, input, ["payment_intent", "charge"]);
519
+ let charge;
520
+ if (selector === "charge") charge = requireRow(context, "charges", input.charge, "charge");
521
+ else {
522
+ const intent = requireRow(context, "payment_intents", input.payment_intent, "payment_intent");
523
+ if (intent.latest_charge === null) return invalidState(context, `This PaymentIntent (${intent.id}) does not have a successful charge to refund.`, "payment_intent_unexpected_state");
524
+ charge = getRow(context, "charges", intent.latest_charge);
525
+ if (charge === null) return invalidState(context, `This PaymentIntent (${intent.id}) does not have a successful charge to refund.`, "payment_intent_unexpected_state");
526
+ }
527
+ if (charge.status !== "succeeded") return invalidState(context, `Charge ${charge.id} has been declined and cannot be refunded.`, "charge_not_refundable");
528
+ if (!charge.captured) return invalidState(context, `Charge ${charge.id} has not been captured; cancel its PaymentIntent to release the authorization instead of refunding.`, "charge_not_captured");
529
+ const remaining = charge.amount - charge.amount_refunded;
530
+ if (remaining <= 0) return invalidState(context, `Charge ${charge.id} has already been refunded.`, "charge_already_refunded");
531
+ const amount = optionalInteger(context, input, "amount", remaining, { min: 1 });
532
+ if (amount > remaining) {
533
+ return invalid(context, `Refund amount (${formatAmount(amount, charge.currency)}) is greater than unrefunded amount on charge (${formatAmount(remaining, charge.currency)})`, "amount_too_large", "amount");
534
+ }
535
+ const reason = optionalEnum(context, input, "reason", REFUND_REASONS, null);
536
+ const metadata = mergeMetadata(context, input.metadata);
537
+ const id = nextId(context, "re");
538
+ const refund = {
539
+ id,
540
+ object: "refund",
541
+ amount,
542
+ balance_transaction: renderId("txn", sequenceOf(id)),
543
+ charge: charge.id,
544
+ created: view.now,
545
+ currency: charge.currency,
546
+ destination_details: { card: { reference: acquirerReference(id), reference_status: "available", reference_type: "acquirer_reference_number", type: "refund" }, type: "card" },
547
+ metadata,
548
+ payment_intent: charge.payment_intent,
549
+ reason,
550
+ receipt_number: null,
551
+ source_transfer_reversal: null,
552
+ status: "succeeded",
553
+ transfer_reversal: null,
554
+ };
555
+ context.state.put("refunds", id, refund);
556
+ const refunded = charge.amount_refunded + amount;
557
+ context.state.put("charges", charge.id, { ...charge, amount_refunded: refunded, refunded: refunded >= charge.amount });
558
+ context.events.emit("charge.refunded", { charge: charge.id, refund: id, amount, amount_refunded: refunded, fully_refunded: refunded >= charge.amount });
559
+ return applyExpand(view, renderRefund(view, refund), expand, "refund");
560
+ }
561
+
562
+ export function refundsList(input, context) {
563
+ requirePermission(context, "refunds", "read");
564
+ rejectMangled(context, input, ["charge", "payment_intent"]);
565
+ const view = makeView(context);
566
+ const expand = validateExpand(context, input.expand, "refund", true);
567
+ const charge = optionalString(context, input, "charge", undefined, 255);
568
+ const intent = optionalString(context, input, "payment_intent", undefined, 255);
569
+ const rows = allRows(context, "refunds").filter(
570
+ (refund) => (charge === undefined || refund.charge === charge) && (intent === undefined || refund.payment_intent === intent) && matchesRange(refund.created, input.created, context, "created"),
571
+ );
572
+ return paginate(context, "refunds", rows, input, "/v1/refunds", (row) => applyExpand(view, renderRefund(view, row), expand, "refund"));
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------------------------
576
+ // Balance
577
+ // ---------------------------------------------------------------------------------------------
578
+
579
+ export function balanceRetrieve(input, context) {
580
+ requirePermission(context, "balance", "read");
581
+ const view = makeView(context);
582
+ validateExpand(context, input.expand, "balance");
583
+ const available = new Map();
584
+ const pending = new Map();
585
+ const bump = (map, currency, amount) => map.set(currency, (map.get(currency) ?? 0) + amount);
586
+ const charges = allRows(context, "charges");
587
+ const refunds = allRows(context, "refunds");
588
+ const chargeById = new Map(charges.map((charge) => [charge.id, charge]));
589
+ const threshold = view.now - PENDING_WINDOW;
590
+ for (const charge of charges) {
591
+ if (charge.status !== "succeeded" || !charge.captured) continue;
592
+ bump(charge.created <= threshold ? available : pending, charge.currency, charge.amount_captured);
593
+ }
594
+ for (const refund of refunds) {
595
+ const charge = chargeById.get(refund.charge);
596
+ if (charge === undefined || charge.status !== "succeeded" || !charge.captured) continue;
597
+ bump(charge.created <= threshold ? available : pending, refund.currency, -refund.amount);
598
+ }
599
+ const currencies = [...new Set([...available.keys(), ...pending.keys()])].sort();
600
+ const rows = (map) => currencies.map((currency) => ({ amount: map.get(currency) ?? 0, currency, source_types: { card: map.get(currency) ?? 0 } }));
601
+ return {
602
+ object: "balance",
603
+ available: rows(available),
604
+ connect_reserved: currencies.map((currency) => ({ amount: 0, currency })),
605
+ livemode: view.livemode,
606
+ pending: rows(pending),
607
+ };
608
+ }