@volter/twin-stripe 0.1.0 → 0.1.2

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.
@@ -12,7 +12,7 @@ import { applyTwinWrite } from '@volter/twin';
12
12
  import { projectResources } from '@volter/twin';
13
13
  import { hashFieldValue } from '@volter/twin';
14
14
  import { parseStripeForm } from './stripe-form.ts';
15
- import { emitStripeEvent, eventTypeFor, registerStripeWebhook, unregisterStripeWebhook } from './stripe-events.ts';
15
+ import { emitStripeEvent, eventTypeFor, registerStripeWebhook, requestAuthorizationDecision, STRIPE_WEBHOOK_FALLBACK_SECRET, stripeEventMatches, unregisterStripeWebhook, type StripeEvent } from './stripe-events.ts';
16
16
 
17
17
  const SERVICE = 'stripe';
18
18
 
@@ -168,12 +168,26 @@ function resolveCardRef(params: Record<string, unknown>): string | undefined {
168
168
  // params AND the params the PaymentIntent/charge was created with (Stripe accepts the
169
169
  // payment_method at either step). null = explicit success; undefined = no known test
170
170
  // card (→ default success, keeps existing tests green); object = decline.
171
- function declineFor(...paramSets: Array<Record<string, unknown>>): DeclineOutcome | null | undefined {
171
+ //
172
+ // `root` (when given) additionally lets a ref that isn't a raw PAN or named token —
173
+ // i.e. a real attached PaymentMethod id like `pm_twin_3` — resolve against the decline
174
+ // outcome captured on that PaymentMethod at creation time (see `_declineOutcome` in the
175
+ // POST /v1/payment_methods handler). Without this, a card created from a declining test
176
+ // PAN would only ever decline on the SAME request that created it — any later charge
177
+ // that references it purely by id (the normal shape for a saved-card/off-session charge,
178
+ // e.g. an invoice retry) could never reproduce the decline.
179
+ function declineFor(root: string | undefined, ...paramSets: Array<Record<string, unknown>>): DeclineOutcome | null | undefined {
172
180
  for (const params of paramSets) {
173
181
  const ref = resolveCardRef(params);
174
182
  if (ref === undefined) continue;
175
183
  if (ref in TEST_CARD_DECLINES) return TEST_CARD_DECLINES[ref];
176
184
  if (ref in TEST_TOKEN_DECLINES) return TEST_TOKEN_DECLINES[ref];
185
+ if (root) {
186
+ const pm = getOne('payment_method', ref, root);
187
+ if (pm && Object.prototype.hasOwnProperty.call(pm, '_declineOutcome')) {
188
+ return pm._declineOutcome as DeclineOutcome | null;
189
+ }
190
+ }
177
191
  }
178
192
  return undefined;
179
193
  }
@@ -280,6 +294,18 @@ function nextId(type: string, root?: string): string {
280
294
  }
281
295
  return `${prefix}_twin_${max + 1}`;
282
296
  }
297
+ // Real Stripe embeds the resource's OWN id inside its client_secret (format
298
+ // `<id>_secret_<random>`) — Stripe.js's confirmPayment/confirmSetup parse the id back
299
+ // out of the client_secret (splitting on `_secret`) to build the same-origin confirm
300
+ // URL (`/v1/payment_intents/<id>/confirm`), rather than being told the id separately.
301
+ // A secret that doesn't carry the real id breaks that round trip: a browser Stripe.js
302
+ // call would try to confirm a resource whose id it invented from the string
303
+ // (`'pi_twin_secret'` → `'pi_twin'`, which was never actually created). Every PI/SI a
304
+ // caller might confirm CLIENT-SIDE (real Stripe.js — see the QA proxy's browserRouting
305
+ // in packages/twin/stripe/src/index.ts) must mint a secret this way.
306
+ function mintClientSecret(id: string): string {
307
+ return `${id}_secret_twin`;
308
+ }
283
309
  // Stripe resource view: inject `object` + `id`, drop kernel meta.
284
310
  //
285
311
  // The kernel reserves the field name `type` as its resource-type discriminator, so a
@@ -353,10 +379,28 @@ export const OBJECT_NAME: Record<string, string> = {
353
379
  // Forwarding + Crypto onramp.
354
380
  forwarding_request: 'forwarding.request', onramp_session: 'crypto.onramp_session',
355
381
  };
356
- function view(type: string, r: Record<string, unknown>): Record<string, unknown> {
357
- const { type: _t, updatedAt: _u, _stripe_type, ...rest } = r;
382
+ // Exported for the pack's own modules that must render the SAME vendor view of a stored
383
+ // resource (stripe-emit.ts synthesizes `data.object` for emitted events from it) — one
384
+ // projection, no drift. Not a public API for consumers.
385
+ export function view(type: string, r: Record<string, unknown>): Record<string, unknown> {
386
+ // `_subscription_data` is twin-internal (a Checkout Session's create-only
387
+ // subscription_data, held for the completion transition to copy onto the created
388
+ // subscription) — real Stripe never returns it on the Session, so strip it here.
389
+ const { type: _t, updatedAt: _u, _stripe_type, _subscription_data, ...rest } = r;
358
390
  const out: Record<string, unknown> = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
359
391
  if (_stripe_type !== undefined) out.type = _stripe_type;
392
+ // Stripe's 2025-03-31.basil API moved an invoice's subscription linkage under
393
+ // `parent.subscription_details` (the top-level `subscription` field is gone there).
394
+ // Apps pinned to basil — and the webhook payloads such an account receives — read the
395
+ // subscription id from that block. Emit BOTH shapes: the legacy top-level field this
396
+ // twin's default version (TWIN_API_VERSION) serves, plus the basil block, so a
397
+ // consumer on either side of the migration resolves the same subscription.
398
+ if (type === 'invoice' && out.parent === undefined) {
399
+ const subscription = typeof out.subscription === 'string' ? out.subscription : null;
400
+ out.parent = subscription
401
+ ? { type: 'subscription_details', quote_details: null, subscription_details: { metadata: {}, subscription } }
402
+ : null;
403
+ }
360
404
  return out;
361
405
  }
362
406
  // Real Stripe list pagination. Honors limit (default 10, max 100, min 1),
@@ -408,6 +452,7 @@ async function writeResource(
408
452
  ): Promise<Record<string, unknown>> {
409
453
  const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: id, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
410
454
  const out = view(type, resource);
455
+ if (type === 'issuing_authorization') embedIssuingAuthorizationCard(out, root);
411
456
  // R17: fire the Stripe event/webhook for this state change (no-op if none registered).
412
457
  await emitStripeEvent(op, out, { occurredAt: occurredAt ?? '1970-01-01T00:00:00.000Z' });
413
458
  // events.full_types: persist the Stripe `event` envelope for this state change so it
@@ -596,6 +641,94 @@ function subscriptionItemPairs(sub: Record<string, unknown>): Array<{ price: str
596
641
  return out;
597
642
  }
598
643
 
644
+ // The raw item entries a subscription/checkout-derived-subscription create sent,
645
+ // normalized to an ordered array. parseStripeForm already turns the bracket form
646
+ // items[0][price]=… into an array of objects (see lineItemEntries() above for the
647
+ // identical `line_items` case); accept that, and a single-object shape defensively.
648
+ function subscriptionItemEntries(value: unknown): Array<Record<string, unknown>> {
649
+ if (Array.isArray(value)) return value.filter((x) => x && typeof x === 'object') as Array<Record<string, unknown>>;
650
+ if (value && typeof value === 'object') return [value as Record<string, unknown>];
651
+ return [];
652
+ }
653
+
654
+ // Real Stripe billing cadences a Price's `recurring` can carry.
655
+ type BillingInterval = 'day' | 'week' | 'month' | 'year';
656
+ const BILLING_INTERVALS = new Set<BillingInterval>(['day', 'week', 'month', 'year']);
657
+
658
+ // Resolve the (interval, interval_count) a new subscription bills on, from the FIRST
659
+ // entry's price (Stripe requires every item on a subscription to share one
660
+ // billing_cycle_anchor, so the first item's cadence drives current_period_end).
661
+ // `entries` is whatever subscriptionItemEntries()/lineItemEntries() produced — each
662
+ // entry's `price` may be a price id string or an already-resolved Price object
663
+ // (the checkout-session line_items shape). Falls back to a defensive month/1 default
664
+ // (`resolved: false`) when no entry resolves to a stored recurring price — e.g. an
665
+ // inline price_data with no persisted Price, or (real Stripe would 400) no item at
666
+ // all. This should not happen for a well-formed subscription create; it exists so
667
+ // current_period_end is always populated rather than silently NaN/undefined.
668
+ function resolveSubscriptionBillingInterval(entries: Array<Record<string, unknown>>, root?: string): { interval: BillingInterval; interval_count: number; resolved: boolean } {
669
+ for (const entry of entries) {
670
+ const p = entry.price;
671
+ const priceId = typeof p === 'string' ? p : (p && typeof p === 'object' ? String((p as Record<string, unknown>).id ?? '') : '');
672
+ if (!priceId) continue;
673
+ const price = getOne('price', priceId, root);
674
+ const recurring = price?.recurring as Record<string, unknown> | undefined;
675
+ if (recurring && typeof recurring === 'object' && typeof recurring.interval === 'string' && BILLING_INTERVALS.has(recurring.interval as BillingInterval)) {
676
+ const count = Math.max(1, Math.trunc(Number(recurring.interval_count) || 1));
677
+ return { interval: recurring.interval as BillingInterval, interval_count: count, resolved: true };
678
+ }
679
+ }
680
+ return { interval: 'month', interval_count: 1, resolved: false };
681
+ }
682
+
683
+ // Add one billing period to a unix timestamp, real-Stripe-faithful: `month`/`year`
684
+ // add CALENDAR months clamped to the target month's last day (Jan 31 + 1 month ->
685
+ // Feb 28/29, not an overflow into March, matching how Stripe advances a billing_
686
+ // cycle_anchor); `day`/`week` are exact multiples of 86400/604800 seconds.
687
+ function addBillingInterval(atUnix: number, interval: BillingInterval, count: number): number {
688
+ const n = Math.max(1, Math.trunc(count) || 1);
689
+ if (interval === 'day') return atUnix + n * 24 * 3600;
690
+ if (interval === 'week') return atUnix + n * 7 * 24 * 3600;
691
+ const months = interval === 'year' ? n * 12 : n;
692
+ const d = new Date(atUnix * 1000);
693
+ const anchorDay = d.getUTCDate();
694
+ const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()));
695
+ const daysInTargetMonth = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
696
+ first.setUTCDate(Math.min(anchorDay, daysInTargetMonth));
697
+ return Math.floor(first.getTime() / 1000);
698
+ }
699
+
700
+ // Build the canonical items.data list Stripe returns on a subscription: a real
701
+ // SubscriptionItem per entry, carrying the resolved Price object plus the
702
+ // deprecated-but-still-served `plan` mirror (some integrations still read
703
+ // item.plan.interval / .product / .id off a subscription item). `subId` is the
704
+ // subscription's own (already-assigned, see the `id` pre-generation in the POST
705
+ // handler) id, so each item's `subscription` back-reference round-trips. Fixes the
706
+ // prior always-empty `items.data` on subscription create — real Stripe's create
707
+ // response includes the actual items, mirroring what was requested.
708
+ function buildSubscriptionItemsList(entries: Array<Record<string, unknown>>, subId: string, at: number, root?: string): Record<string, unknown> {
709
+ const data = entries.map((entry, i) => {
710
+ const p = entry.price;
711
+ const priceId = typeof p === 'string' ? p : (p && typeof p === 'object' ? String((p as Record<string, unknown>).id ?? '') : '');
712
+ const price = priceId ? getOne('price', priceId, root) : undefined;
713
+ const quantity = entry.quantity !== undefined ? Math.max(1, Math.trunc(Number(entry.quantity) || 1)) : 1;
714
+ const recurring = price?.recurring as Record<string, unknown> | undefined;
715
+ const plan = price ? {
716
+ id: price.id, object: 'plan', active: price.active ?? true, amount: price.unit_amount ?? null,
717
+ amount_decimal: price.unit_amount_decimal ?? null, billing_scheme: price.billing_scheme ?? 'per_unit',
718
+ currency: price.currency ?? 'usd', interval: recurring?.interval ?? null,
719
+ interval_count: recurring?.interval_count ?? 1, livemode: false,
720
+ metadata: price.metadata ?? {}, nickname: price.nickname ?? null,
721
+ product: price.product ?? null, tiers_mode: price.tiers_mode ?? null, usage_type: 'licensed',
722
+ } : null;
723
+ return {
724
+ id: `si_twin_${subId}_${i + 1}`, object: 'subscription_item',
725
+ price: price ?? (priceId || null), plan, quantity, subscription: subId,
726
+ created: at, metadata: {}, discounts: [], billing_thresholds: null, tax_rates: [],
727
+ };
728
+ });
729
+ return { object: 'list', data, has_more: false, total_count: data.length, url: `/v1/subscription_items?subscription=${subId}` };
730
+ }
731
+
599
732
  // Rebuild a subscription's items.data sub-list from the live subscription_item rows
600
733
  // (so retrieving the subscription reflects items created/updated/deleted via the
601
734
  // /v1/subscription_items endpoint). Writes the canonical items list onto the sub.
@@ -814,6 +947,235 @@ async function create(type: string, params: Record<string, unknown>, defaults: R
814
947
  return { status: 200, body: await writeResource(type, id, fields, `${type}.create`, req.root, req.occurredAt, req.apiVersion) };
815
948
  }
816
949
 
950
+ // ── ISSUING helpers: spending controls, real-time-auth shapes ─────────────────────────
951
+ // Authorization.card is the FULL Card object in every vendor egress (API responses and
952
+ // webhook payloads) — it is not expandable (stripe@22.3.0 Issuing.Authorization.card:
953
+ // Stripe.Issuing.Card). The twin stores the id internally and embeds at the boundary;
954
+ // cardholder stays an id string (expandable, unexpanded default). Caught live: the
955
+ // issuing-bridge reads event.data.object.card.id and refused every twin presentment.
956
+ function embedIssuingAuthorizationCard(body: Record<string, unknown>, root?: string): Record<string, unknown> {
957
+ if (typeof body.card === 'string') {
958
+ const cardRow = getOne('issuing_card', body.card, root);
959
+ if (cardRow !== undefined) body.card = cardRow;
960
+ }
961
+ return body;
962
+ }
963
+
964
+ // The closed interval set for spending_controls[spending_limits][][interval]
965
+ // (stripe@22.3.0 Issuing/Cards.d.ts SpendingLimit.Interval — a documented closed enum).
966
+ const SPENDING_LIMIT_INTERVALS = new Set(['all_time', 'daily', 'monthly', 'per_authorization', 'weekly', 'yearly']);
967
+
968
+ // The stored SpendingControls shape (stripe@22.3.0 Card.SpendingControls): categories and
969
+ // countries are nullable arrays, spending_limits an array of {amount, categories, interval}.
970
+ function emptySpendingControls(): Record<string, unknown> {
971
+ return {
972
+ allowed_categories: null, allowed_merchant_countries: null,
973
+ blocked_categories: null, blocked_merchant_countries: null,
974
+ spending_limits: [], spending_limits_currency: null,
975
+ };
976
+ }
977
+
978
+ // Validate + normalize a caller-provided spending_controls dictionary (card create/update).
979
+ // Vendor rules enforced: interval must be in the closed enum; a spending limit amount is a
980
+ // positive integer; allowed_categories "Cannot be set with blocked_categories" (and the
981
+ // merchant-country pair likewise) — both restrictions verbatim from the SDK's field docs.
982
+ function normalizeSpendingControls(raw: unknown, currency: string | null): { controls?: Record<string, unknown>; error?: StripeResponse } {
983
+ if (raw === undefined) return {};
984
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
985
+ return { error: err('Invalid spending_controls: must be a dictionary.', 400, 'parameter_invalid_dictionary') };
986
+ }
987
+ const sc = raw as Record<string, unknown>;
988
+ const strArr = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
989
+ const allowedCats = strArr(sc.allowed_categories);
990
+ const blockedCats = strArr(sc.blocked_categories);
991
+ if (allowedCats?.length && blockedCats?.length) {
992
+ return { error: err('spending_controls[allowed_categories] cannot be set with spending_controls[blocked_categories].', 400) };
993
+ }
994
+ const allowedCountries = strArr(sc.allowed_merchant_countries);
995
+ const blockedCountries = strArr(sc.blocked_merchant_countries);
996
+ if (allowedCountries?.length && blockedCountries?.length) {
997
+ return { error: err('spending_controls[allowed_merchant_countries] cannot be set with spending_controls[blocked_merchant_countries].', 400) };
998
+ }
999
+ const limits: Array<Record<string, unknown>> = [];
1000
+ const rawLimits = Array.isArray(sc.spending_limits) ? sc.spending_limits : [];
1001
+ for (let i = 0; i < rawLimits.length; i++) {
1002
+ const l = rawLimits[i];
1003
+ if (!l || typeof l !== 'object' || Array.isArray(l)) {
1004
+ return { error: err(`Invalid spending_controls[spending_limits][${i}]: must be a dictionary.`, 400, 'parameter_invalid_dictionary') };
1005
+ }
1006
+ const { amount, interval, categories } = l as Record<string, unknown>;
1007
+ if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) {
1008
+ return { error: err(`Invalid integer: spending_controls[spending_limits][${i}][amount] must be a positive integer.`, 400, 'parameter_invalid_integer') };
1009
+ }
1010
+ if (typeof interval !== 'string' || !SPENDING_LIMIT_INTERVALS.has(interval)) {
1011
+ return { error: err(`Invalid spending_controls[spending_limits][${i}][interval]: must be one of 'all_time', 'daily', 'monthly', 'per_authorization', 'weekly', or 'yearly'.`, 400, 'parameter_invalid_string_enum') };
1012
+ }
1013
+ const cats = strArr(categories);
1014
+ limits.push({ amount, categories: cats?.length ? cats : null, interval });
1015
+ }
1016
+ const arrOrNull = (v: string[] | undefined) => (v === undefined ? undefined : v.length ? v : null);
1017
+ const controls: Record<string, unknown> = {
1018
+ ...emptySpendingControls(),
1019
+ spending_limits: limits,
1020
+ ...(limits.length ? { spending_limits_currency: typeof sc.spending_limits_currency === 'string' ? sc.spending_limits_currency : currency } : {}),
1021
+ };
1022
+ for (const [key, v] of [
1023
+ ['allowed_categories', arrOrNull(allowedCats)], ['blocked_categories', arrOrNull(blockedCats)],
1024
+ ['allowed_merchant_countries', arrOrNull(allowedCountries)], ['blocked_merchant_countries', arrOrNull(blockedCountries)],
1025
+ ] as Array<[string, unknown]>) {
1026
+ if (v !== undefined) controls[key] = v;
1027
+ }
1028
+ return { controls };
1029
+ }
1030
+
1031
+ // Start of the current spending-limit window, seconds since epoch (UTC calendar windows;
1032
+ // weekly starts Monday 00:00 UTC — the week-start day is not vendor-documented).
1033
+ function issuingWindowStartSec(interval: string, nowSec: number): number {
1034
+ if (interval === 'all_time') return 0;
1035
+ const d = new Date(nowSec * 1000);
1036
+ if (interval === 'daily') return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / 1000;
1037
+ if (interval === 'weekly') {
1038
+ const monOffset = (d.getUTCDay() + 6) % 7; // Monday = 0
1039
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - monOffset) / 1000;
1040
+ }
1041
+ if (interval === 'monthly') return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;
1042
+ if (interval === 'yearly') return Date.UTC(d.getUTCFullYear(), 0, 1) / 1000;
1043
+ return 0;
1044
+ }
1045
+
1046
+ type PriorAuthorization = { amount: number; created: number; category: string };
1047
+
1048
+ // Evaluate one spending_controls dictionary against a presented authorization. Returns a
1049
+ // human-readable reason_message when a control declines the authorization, else null.
1050
+ // (The request_history.reason for any violation is 'spending_controls' — the vendor enum's
1051
+ // single value for controls declines.)
1052
+ function spendingControlsViolation(
1053
+ controls: unknown,
1054
+ opts: { amount: number; category: string; country: string | null; nowSec: number; priorApproved: PriorAuthorization[] },
1055
+ ): string | null {
1056
+ if (!controls || typeof controls !== 'object') return null;
1057
+ const sc = controls as Record<string, unknown>;
1058
+ const list = (v: unknown): string[] | null => (Array.isArray(v) && v.length ? v.map(String) : null);
1059
+ const allowedCats = list(sc.allowed_categories);
1060
+ if (allowedCats && !allowedCats.includes(opts.category)) return `merchant category ${opts.category} is not in allowed_categories`;
1061
+ const blockedCats = list(sc.blocked_categories);
1062
+ if (blockedCats?.includes(opts.category)) return `merchant category ${opts.category} is in blocked_categories`;
1063
+ const allowedCountries = list(sc.allowed_merchant_countries);
1064
+ if (allowedCountries && (!opts.country || !allowedCountries.includes(opts.country))) return `merchant country ${opts.country ?? '(none)'} is not in allowed_merchant_countries`;
1065
+ const blockedCountries = list(sc.blocked_merchant_countries);
1066
+ if (blockedCountries && opts.country && blockedCountries.includes(opts.country)) return `merchant country ${opts.country} is in blocked_merchant_countries`;
1067
+ for (const rawLimit of Array.isArray(sc.spending_limits) ? sc.spending_limits : []) {
1068
+ if (!rawLimit || typeof rawLimit !== 'object') continue;
1069
+ const l = rawLimit as Record<string, unknown>;
1070
+ const limit = Number(l.amount) || 0;
1071
+ if (limit <= 0) continue;
1072
+ const limCats = list(l.categories);
1073
+ if (limCats && !limCats.includes(opts.category)) continue;
1074
+ const interval = String(l.interval ?? '');
1075
+ if (interval === 'per_authorization') {
1076
+ if (opts.amount > limit) return `amount exceeds the per_authorization spending limit of ${limit}`;
1077
+ continue;
1078
+ }
1079
+ const start = issuingWindowStartSec(interval, opts.nowSec);
1080
+ const spent = opts.priorApproved
1081
+ .filter((a) => a.created >= start && (!limCats || limCats.includes(a.category)))
1082
+ .reduce((s, a) => s + a.amount, 0);
1083
+ if (spent + opts.amount > limit) return `amount exceeds the ${interval} spending limit of ${limit}`;
1084
+ }
1085
+ return null;
1086
+ }
1087
+
1088
+ // Approved authorizations previously presented on this card/cardholder — the base for
1089
+ // interval spending-limit windows (spending limits are calculated over approved
1090
+ // authorization amounts).
1091
+ function priorApprovedAuthorizations(root: string | undefined, key: 'card' | 'cardholder', value: string): PriorAuthorization[] {
1092
+ return rows('issuing_authorization', root)
1093
+ .filter((a) => a[key] === value && a.approved === true)
1094
+ .map((a) => ({
1095
+ amount: Number(a.amount) || 0,
1096
+ created: Number(a.created) || 0,
1097
+ category: String((a.merchant_data as Record<string, unknown> | undefined)?.category ?? ''),
1098
+ }));
1099
+ }
1100
+
1101
+ // The default merchant_data the twin presents (test-helper defaults), merged under any
1102
+ // caller-provided merchant_data keys (stripe@22.3.0 MerchantData: category, city, country,
1103
+ // name, network_id, postal_code, state, tax_id, terminal_id, url).
1104
+ function issuingMerchantData(param: unknown): Record<string, unknown> {
1105
+ const base: Record<string, unknown> = {
1106
+ category: 'general', category_code: '5399', city: 'San Francisco', country: 'US',
1107
+ name: 'Twin Test Merchant', network_id: '1234567890', postal_code: '94103', state: 'CA',
1108
+ tax_id: null, terminal_id: null, url: null,
1109
+ };
1110
+ if (param && typeof param === 'object' && !Array.isArray(param)) {
1111
+ for (const [k, v] of Object.entries(param as Record<string, unknown>)) base[k] = v;
1112
+ }
1113
+ return base;
1114
+ }
1115
+
1116
+ // The canonical verification_data shape (stripe@22.3.0 Authorization.VerificationData);
1117
+ // check enums are the vendor's ('match' | 'mismatch' | 'not_provided').
1118
+ function issuingVerificationData(): Record<string, unknown> {
1119
+ return {
1120
+ address_line1_check: 'not_provided', address_postal_code_check: 'not_provided',
1121
+ authentication_exemption: null, cvc_check: 'match', expiry_check: 'match',
1122
+ postal_code: null, three_d_secure: null,
1123
+ };
1124
+ }
1125
+
1126
+ // One request_history entry (stripe@22.3.0 Authorization.RequestHistory). The
1127
+ // authorization_code "typically starts with the letter 'S', followed by a six-digit
1128
+ // number" and is "not guaranteed to be unique across authorizations" (SDK field doc) —
1129
+ // the twin derives it deterministically from the request time.
1130
+ function requestHistoryEntry(opts: {
1131
+ amount: number; currency: string; approved: boolean; reason: string; reasonMessage: string | null;
1132
+ createdSec: number; merchantAmount: number; merchantCurrency: string;
1133
+ }): Record<string, unknown> {
1134
+ return {
1135
+ amount: opts.amount, amount_details: null, approved: opts.approved,
1136
+ authorization_code: opts.approved ? `S${String(100000 + (opts.createdSec % 900000))}` : null,
1137
+ created: opts.createdSec, currency: opts.currency,
1138
+ merchant_amount: opts.merchantAmount, merchant_currency: opts.merchantCurrency,
1139
+ network_risk_score: null, reason: opts.reason, reason_message: opts.reasonMessage,
1140
+ requested_at: opts.createdSec,
1141
+ };
1142
+ }
1143
+
1144
+ // Materialize a capture against an approved authorization: an Issuing Transaction of
1145
+ // type 'capture' (negative amount — captures debit) linked to a balance_transaction of
1146
+ // type 'issuing_transaction' with balance_type 'issuing' (both literals from
1147
+ // stripe@22.3.0 BalanceTransaction.Type / .BalanceType), so the issuing balance the twin
1148
+ // serves (fund_balance / GET /v1/balance issuing section) genuinely goes down on capture.
1149
+ async function materializeIssuingCapture(
1150
+ authId: string,
1151
+ auth: Record<string, unknown>,
1152
+ amountCents: number,
1153
+ req: StripeRequest,
1154
+ ): Promise<{ txnId: string; btId: string }> {
1155
+ const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
1156
+ const bt = await create('balance_transaction', {}, {
1157
+ amount: -amountCents, currency, fee: 0, net: -amountCents, type: 'issuing_transaction',
1158
+ status: 'available', balance_type: 'issuing', reporting_category: 'issuing_transaction',
1159
+ available_on: nowUnix(req.occurredAt), fee_details: [],
1160
+ }, req);
1161
+ const btId = (bt.body as { id: string }).id;
1162
+ const txn = await create('issuing_transaction', {
1163
+ authorization: authId, card: auth.card, cardholder: auth.cardholder,
1164
+ }, {
1165
+ type: 'capture', amount: -amountCents, currency,
1166
+ merchant_amount: -amountCents, merchant_currency: typeof auth.merchant_currency === 'string' ? auth.merchant_currency : currency,
1167
+ merchant_data: auth.merchant_data ?? issuingMerchantData(undefined),
1168
+ livemode: false, metadata: {}, dispute: null, wallet: null, network_data: null,
1169
+ amount_details: null, purchase_details: null, balance_transaction: btId,
1170
+ }, req);
1171
+ return { txnId: (txn.body as { id: string }).id, btId };
1172
+ }
1173
+
1174
+ // Does a webhook endpoint's enabled_events subscribe it to `type`? The matcher lives with
1175
+ // the delivery registry (stripe-events.ts) so this synchronous issuing leg and the organic
1176
+ // asynchronous fan-out share one set of Stripe subscription semantics.
1177
+ const endpointSubscribedTo = stripeEventMatches;
1178
+
817
1179
  // Stripe's dispute.evidence is a flat bag of (mostly null) string fields; the twin
818
1180
  // emits the canonical empty shape so the object is shaped right without fabricating
819
1181
  // content. Callers can overwrite individual fields via POST /v1/disputes/:id.
@@ -937,7 +1299,11 @@ function paymentMethodSubObject(pmType: string, params: Record<string, unknown>)
937
1299
  switch (pmType) {
938
1300
  case 'card': {
939
1301
  const c = sub('card');
940
- const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '') : '';
1302
+ // parseStripeForm coerces an all-digit card[number] to a JS number (same as any
1303
+ // other numeric-looking form field) — accept both shapes so a raw test PAN still
1304
+ // produces its real last4 instead of silently falling back to the '4242' default.
1305
+ const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
1306
+ : typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
941
1307
  return { card: {
942
1308
  brand: 'visa', last4: number ? number.slice(-4) : '4242',
943
1309
  exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
@@ -987,9 +1353,18 @@ function paymentMethodSubObject(pmType: string, params: Record<string, unknown>)
987
1353
  function balanceSummary(root?: string): Record<string, unknown> {
988
1354
  const available = new Map<string, number>();
989
1355
  const pending = new Map<string, number>();
1356
+ const issuing = new Map<string, number>();
990
1357
  for (const t of rows('balance_transaction', root)) {
991
1358
  const cur = String(t.currency ?? 'usd');
992
1359
  const net = Number(t.net ?? 0);
1360
+ // Issuing funds are their own balance: real Stripe reports them under the Balance
1361
+ // object's `issuing` section (stripe@22.3.0 Balance.issuing), not in the payments
1362
+ // `available` bucket. balance_type 'issuing' rows (fund_balance topups, capture
1363
+ // debits) accrue there.
1364
+ if (t.balance_type === 'issuing') {
1365
+ issuing.set(cur, (issuing.get(cur) ?? 0) + net);
1366
+ continue;
1367
+ }
993
1368
  const bucket = t.status === 'available' ? available : pending;
994
1369
  bucket.set(cur, (bucket.get(cur) ?? 0) + net);
995
1370
  }
@@ -997,7 +1372,10 @@ function balanceSummary(root?: string): Record<string, unknown> {
997
1372
  const out = [...m.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } }));
998
1373
  return out.length ? out : [{ amount: 0, currency: 'usd', source_types: { card: 0 } }];
999
1374
  };
1000
- return { object: 'balance', available: toArr(available), pending: toArr(pending), livemode: false };
1375
+ return {
1376
+ object: 'balance', available: toArr(available), pending: toArr(pending), livemode: false,
1377
+ ...(issuing.size ? { issuing: { available: [...issuing.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } })) } } : {}),
1378
+ };
1001
1379
  }
1002
1380
 
1003
1381
  // ---- CONNECT: connected accounts (vendor-faithful) ----
@@ -1264,6 +1642,106 @@ function buildCreditNote(
1264
1642
  return { id, body };
1265
1643
  }
1266
1644
 
1645
+ // When a PaymentIntent linked to an Invoice succeeds — the 'create subscription now,
1646
+ // collect payment client-side' pattern modeled by POST /v1/subscriptions with
1647
+ // payment_behavior: 'default_incomplete' (see that handler) — real Stripe marks that
1648
+ // Invoice `paid` and, when it's a subscription's first invoice, flips the Subscription
1649
+ // from `incomplete` to `active`. This is the state a caller polling/re-fetching the
1650
+ // subscription after confirming payment client-side expects to observe (there is no
1651
+ // webhook consumer in the twin, so we apply the cascade synchronously here instead of
1652
+ // waiting on a delivered event). No-op when the PI isn't linked to a twin-tracked
1653
+ // invoice, or that invoice is already paid (idempotent against a duplicate confirm).
1654
+ async function cascadeInvoicePaidFromSucceededPaymentIntent(piBody: Record<string, unknown>, req: StripeRequest): Promise<void> {
1655
+ const invoiceId = typeof piBody.invoice === 'string' ? piBody.invoice : undefined;
1656
+ if (!invoiceId) return;
1657
+ const invoice = getOne('invoice', invoiceId, req.root);
1658
+ if (!invoice || invoice.status === 'paid') return;
1659
+ const amount = Number(invoice.total ?? invoice.amount_due) || 0;
1660
+ const at = nowUnix(req.occurredAt);
1661
+ // op 'invoice.pay' maps to the real invoice.paid event (see eventTypeFor), matching
1662
+ // the standalone POST /v1/invoices/:id/pay action's own event.
1663
+ await writeResource('invoice', invoiceId, {
1664
+ status: 'paid', paid: true, amount_paid: amount, amount_remaining: 0,
1665
+ status_transitions: { ...(invoice.status_transitions as object ?? {}), paid_at: at },
1666
+ }, 'invoice.pay', req.root, req.occurredAt);
1667
+ const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
1668
+ if (subId) {
1669
+ const sub = getOne('subscription', subId, req.root);
1670
+ if (sub && sub.status === 'incomplete') {
1671
+ await writeResource('subscription', subId, { status: 'active' }, 'subscription.update', req.root, req.occurredAt);
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ // Recompute subtotal/total from an invoice's line-item list. Shared by every mutation of
1677
+ // `lines.data` (add_lines/update_lines/remove_lines and invoiceitems.create attaching
1678
+ // directly to a draft invoice — PEAK-3102 round 2) so the two line-item entry points can
1679
+ // never again disagree about how a total is derived from its lines.
1680
+ function sumInvoiceLines(lines: Array<Record<string, unknown>>): number {
1681
+ return lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
1682
+ }
1683
+
1684
+ // Append one line to a DRAFT invoice's `lines.data` and re-derive amount_due/subtotal/total
1685
+ // from the resulting list, exactly like add_lines does (PEAK-3102 round 2: POST
1686
+ // /v1/invoiceitems with `invoice: <id>` — the call shape PeakHealth's
1687
+ // createOrderInvoicePaymentIntent/createAdHocInvoicePaymentIntent both use — previously
1688
+ // created the invoiceitem resource but never touched the invoice, so amount_due stayed 0
1689
+ // and finalize's `willNeedPaymentIntent` check never fired. Real Stripe attaches an invoice
1690
+ // item to its invoice's line list immediately, not just at finalize.
1691
+ async function attachLineToDraftInvoice(invoiceId: string, inv: Record<string, unknown>, line: Record<string, unknown>, req: StripeRequest): Promise<void> {
1692
+ const cur = ((inv.lines as { data?: Array<Record<string, unknown>> } | undefined)?.data) ?? [];
1693
+ const next = [...cur, line];
1694
+ const subtotal = sumInvoiceLines(next);
1695
+ const lines = { object: 'list', data: next, has_more: false, total_count: next.length, url: `/v1/invoices/${invoiceId}/lines` };
1696
+ await writeResource('invoice', invoiceId, { lines, subtotal, total: subtotal, amount_due: subtotal, amount_remaining: subtotal }, 'invoice.update', req.root, req.occurredAt);
1697
+ }
1698
+
1699
+ // Mint the PaymentIntent a charge_automatically invoice with a balance due gets the moment
1700
+ // it's finalized (see the finalize/send branch above — PEAK-3102: this is the object
1701
+ // PeakHealth's createStandaloneOrder reads via expand[]=payment_intent and previously never
1702
+ // got). Mirrors the subscription default_incomplete first-invoice PaymentIntent (same
1703
+ // defaults) with one addition real Stripe does here: a `requires_confirmation` status
1704
+ // instead of `requires_payment_method` when the invoice already carries a
1705
+ // default_payment_method (nothing left to collect from the customer — Stripe.js confirms
1706
+ // it directly). `invoice` on create() honors a caller-provided id via `params.id`, so the id
1707
+ // is pre-minted by the caller and threaded into the invoice's own write in the SAME
1708
+ // transaction (avoids a second invoice update + a second invoice.updated event).
1709
+ async function createInvoicePaymentIntent(piId: string, inv: Record<string, unknown>, invoiceId: string, req: StripeRequest): Promise<void> {
1710
+ const amount = Number(inv.amount_due) || 0;
1711
+ const currency = String(inv.currency ?? 'usd');
1712
+ const defaultPaymentMethod = typeof inv.default_payment_method === 'string' ? inv.default_payment_method : undefined;
1713
+ await create('payment_intent', {
1714
+ id: piId, amount, currency, customer: inv.customer, invoice: invoiceId,
1715
+ ...(defaultPaymentMethod ? { payment_method: defaultPaymentMethod } : {}),
1716
+ }, {
1717
+ status: defaultPaymentMethod ? 'requires_confirmation' : 'requires_payment_method',
1718
+ client_secret: mintClientSecret(piId), livemode: false,
1719
+ capture_method: 'automatic', amount_capturable: 0, amount_received: 0, next_action: null,
1720
+ automatic_payment_methods: null, payment_method_types: ['card', 'link'], payment_method_options: {},
1721
+ }, req);
1722
+ }
1723
+
1724
+ // Settle the invoice's PaymentIntent on a successful (non-declined, non-out-of-band)
1725
+ // POST /v1/invoices/:id/pay: real Stripe moves it to `succeeded` and attaches a `latest_charge`
1726
+ // — a Charge that itself carries `invoice` (the back-reference PeakHealth's BioReference /
1727
+ // BloodworkTab flow follows after paying). No-op if the invoice never got a PaymentIntent
1728
+ // (send_invoice / $0 invoices — see createInvoicePaymentIntent) or it's already succeeded
1729
+ // (idempotent against a duplicate pay). A DECLINED pay never reaches here — the caller
1730
+ // returns 402 before writing the invoice, so the PI is left exactly as finalize left it.
1731
+ async function settleInvoicePaymentIntentOnPay(piId: string, invoiceId: string, amount: number, req: StripeRequest): Promise<void> {
1732
+ const pi = getOne('payment_intent', piId, req.root);
1733
+ if (!pi || pi.status === 'succeeded') return;
1734
+ const chargeId = nextId('charge', req.root);
1735
+ await create('charge', { id: chargeId, amount, currency: pi.currency, customer: pi.customer, payment_intent: piId, invoice: invoiceId }, {
1736
+ status: 'succeeded', paid: true, captured: true, refunded: false, disputed: false,
1737
+ amount_captured: amount, amount_refunded: 0, metadata: {},
1738
+ billing_details: { address: null, email: null, name: null, phone: null }, livemode: false,
1739
+ }, req);
1740
+ await writeResource('payment_intent', piId, {
1741
+ status: 'succeeded', amount_received: amount, amount_capturable: 0, next_action: null, latest_charge: chargeId,
1742
+ }, 'payment_intent.succeeded', req.root, req.occurredAt);
1743
+ }
1744
+
1267
1745
  /**
1268
1746
  * Public entry: honors the Idempotency-Key (replay → stored response, no re-write)
1269
1747
  * then delegates to the router. Only POSTs are idempotent (matches Stripe; GET/DELETE
@@ -1324,7 +1802,7 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1324
1802
  const amt = Number(params.amount) || 0;
1325
1803
  // Card-decline fidelity: a known test card that declines → HTTP 402 card_error and
1326
1804
  // NO charge created (matches Stripe — a declined charge is not persisted as paid).
1327
- const decline = declineFor(params);
1805
+ const decline = declineFor(req.root, params);
1328
1806
  if (decline) return cardError(decline);
1329
1807
  // capture=false creates an UNCAPTURED authorization (Stripe holds the funds): the charge
1330
1808
  // is succeeded+paid but captured:false, amount_captured 0, and carries the capture deadline.
@@ -1556,7 +2034,13 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1556
2034
  }
1557
2035
 
1558
2036
  // ---- setup_intents ----
1559
- if (path === '/v1/setup_intents' && method === 'POST') return create('setup_intent', params, { status: 'requires_confirmation', usage: 'off_session', client_secret: 'seti_twin_secret', payment_method_types: ['card'], next_action: null, latest_attempt: null, last_setup_error: null, livemode: false }, req);
2037
+ if (path === '/v1/setup_intents' && method === 'POST') {
2038
+ // Pre-mint the id so client_secret can embed it (see mintClientSecret) — real
2039
+ // Stripe.js needs this to confirm the SI client-side.
2040
+ const siId = typeof params.id === 'string' && params.id ? params.id : nextId('setup_intent', req.root);
2041
+ const { id: _siId, ...rest } = params as Record<string, unknown>;
2042
+ return create('setup_intent', { ...rest, id: siId }, { status: 'requires_confirmation', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], next_action: null, latest_attempt: null, last_setup_error: null, livemode: false }, req);
2043
+ }
1560
2044
  if (seg[1] === 'setup_intents' && seg.length === 4 && seg[3] === 'confirm' && method === 'POST') {
1561
2045
  const si = getOne('setup_intent', idAt(2), req.root);
1562
2046
  if (!si) return err(`No such setup_intent: '${idAt(2)}'`, 404, 'resource_missing');
@@ -1661,15 +2145,30 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1661
2145
  // number; the modeled sub-object is the truth.
1662
2146
  const { card: _rawCard, sepa_debit: _sepa, us_bank_account: _ach, type: _t, ...rest } = params as Record<string, unknown>;
1663
2147
  const sub = paymentMethodSubObject(pmType, params);
1664
- return create('payment_method', { ...rest, type: pmType, customer: null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...sub }, {}, req);
2148
+ // Capture the decline outcome implied by the creating PAN/token now (null = succeeds),
2149
+ // so a LATER charge that references this PM only by id — the normal shape for a saved
2150
+ // card / off-session retry charge — can still reproduce the same decline. See declineFor.
2151
+ const declineOutcome = pmType === 'card' ? (declineFor(undefined, params) ?? null) : null;
2152
+ return create('payment_method', { ...rest, type: pmType, customer: null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...sub, _declineOutcome: declineOutcome }, {}, req);
1665
2153
  }
1666
2154
  // Attach a PaymentMethod to a customer. Both must exist; on success the PM's `customer`
1667
2155
  // is set. Unknown PM → 404; missing/unknown customer → 400 (resource_missing), like Stripe.
1668
2156
  if (seg[1] === 'payment_methods' && seg.length === 4 && seg[3] === 'attach' && method === 'POST') {
1669
- if (!getOne('payment_method', idAt(2), req.root)) return err(`No such payment_method: '${idAt(2)}'`, 404, 'resource_missing');
1670
2157
  const customer = typeof params.customer === 'string' ? params.customer : '';
1671
2158
  if (!customer) return err('Missing required param: customer.', 400, 'parameter_missing');
1672
2159
  if (!getOne('customer', customer, req.root)) return err(`No such customer: '${customer}'`, 400, 'resource_missing');
2160
+ // Stripe test mode: attaching a well-known test token (pm_card_visa, …) yields a NEW
2161
+ // PaymentMethod on the customer carrying that card's outcome. The token itself is a
2162
+ // template, never a stored object. ⚠ doc-unverified that the returned id is fresh; the
2163
+ // attachability of the tokens is documented. Live-verified 2026-09-04 that the twin
2164
+ // refused them, the gap the managed substrate tier hit.
2165
+ if (!getOne('payment_method', idAt(2), req.root) && idAt(2).startsWith('pm_card_') && idAt(2) in TEST_TOKEN_DECLINES) {
2166
+ const pan = idAt(2) === 'pm_card_mastercard' ? '5555555555554444' : '4242424242424242';
2167
+ const sub = paymentMethodSubObject('card', { card: { number: pan } });
2168
+ if (idAt(2) === 'pm_card_mastercard') (sub.card as Record<string, unknown>).brand = 'mastercard';
2169
+ return create('payment_method', { type: 'card', customer, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...sub, _declineOutcome: TEST_TOKEN_DECLINES[idAt(2)] ?? null }, {}, req);
2170
+ }
2171
+ if (!getOne('payment_method', idAt(2), req.root)) return err(`No such payment_method: '${idAt(2)}'`, 404, 'resource_missing');
1673
2172
  return { status: 200, body: await writeResource('payment_method', idAt(2), { customer }, 'payment_method.attach', req.root, req.occurredAt) };
1674
2173
  }
1675
2174
  if (path === '/v1/payment_methods' && method === 'GET') {
@@ -1702,11 +2201,14 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1702
2201
  // when APM is on and no explicit types are given, Stripe still reports a payment_method_types
1703
2202
  // list (at minimum card). Honor an explicit payment_method_types[] otherwise.
1704
2203
  const pmTypes = Array.isArray(params.payment_method_types) ? params.payment_method_types.map(String) : (apmEnabled ? ['card'] : ['card']);
1705
- const { automatic_payment_methods: _apm, payment_method_types: _pmt, ...rest } = params as Record<string, unknown>;
2204
+ const { automatic_payment_methods: _apm, payment_method_types: _pmt, id: _piId, ...rest } = params as Record<string, unknown>;
2205
+ // Pre-mint the id so client_secret can embed it (see mintClientSecret) — real
2206
+ // Stripe.js needs this to confirm the PI client-side.
2207
+ const piId = typeof params.id === 'string' && params.id ? params.id : nextId('payment_intent', req.root);
1706
2208
  // capture_method defaults to 'automatic'; amount_capturable/amount_received start at 0 and
1707
2209
  // are driven by the confirm→capture state machine (manual capture lands in requires_capture).
1708
- return create('payment_intent', rest, {
1709
- status: 'requires_confirmation', client_secret: 'pi_twin_secret', livemode: false,
2210
+ return create('payment_intent', { ...rest, id: piId }, {
2211
+ status: 'requires_confirmation', client_secret: mintClientSecret(piId), livemode: false,
1710
2212
  capture_method: 'automatic', amount_capturable: 0, amount_received: 0, next_action: null,
1711
2213
  automatic_payment_methods, payment_method_types: pmTypes,
1712
2214
  payment_method_options: (params.payment_method_options as object) ?? {},
@@ -1727,7 +2229,7 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1727
2229
  // Card-decline fidelity: resolve the card from the confirm body OR the payment_method
1728
2230
  // attached at create. A known declining test card → status requires_payment_method
1729
2231
  // (NOT succeeded), last_payment_error populated, and a 402 card_error carrying the PI id.
1730
- const decline = declineFor(params, existing);
2232
+ const decline = declineFor(req.root, params, existing);
1731
2233
  if (decline) {
1732
2234
  const lastError = {
1733
2235
  type: 'card_error', code: decline.code, ...(decline.decline_code ? { decline_code: decline.decline_code } : {}),
@@ -1766,7 +2268,9 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1766
2268
  return { status: 200, body };
1767
2269
  }
1768
2270
  const succeededAmount = Number(existing.amount ?? params.amount ?? 0);
1769
- return { status: 200, body: await writeResource('payment_intent', idAt(2), { ...params, status: 'succeeded', amount_received: succeededAmount, next_action: null }, 'payment_intent.confirm', req.root, req.occurredAt, req.apiVersion) };
2271
+ const confirmedBody = await writeResource('payment_intent', idAt(2), { ...params, status: 'succeeded', amount_received: succeededAmount, next_action: null }, 'payment_intent.confirm', req.root, req.occurredAt, req.apiVersion);
2272
+ await cascadeInvoicePaidFromSucceededPaymentIntent(confirmedBody, req);
2273
+ return { status: 200, body: confirmedBody };
1770
2274
  }
1771
2275
  // Manual capture: an authorized (requires_capture) PI is captured → succeeded, amount_received
1772
2276
  // set, amount_capturable cleared. Capturing a PI in any other state is payment_intent_unexpected_state
@@ -1782,6 +2286,7 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1782
2286
  const toCapture = params.amount_to_capture !== undefined ? Math.max(0, Math.trunc(Number(params.amount_to_capture) || 0)) : authAmount;
1783
2287
  const captured = Math.min(toCapture, authAmount);
1784
2288
  const body = await writeResource('payment_intent', idAt(2), { status: 'succeeded', amount_received: captured, amount_capturable: 0 }, 'payment_intent.succeeded', req.root, req.occurredAt);
2289
+ await cascadeInvoicePaidFromSucceededPaymentIntent(body, req);
1785
2290
  return { status: 200, body };
1786
2291
  }
1787
2292
  // POST /v1/payment_intents/:id/increment_authorization — raise the authorized amount on a
@@ -1994,14 +2499,58 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
1994
2499
  if (path === '/v1/invoiceitems' && method === 'POST') {
1995
2500
  const at = nowUnix(req.occurredAt);
1996
2501
  const qty = Number(params.quantity) || 1;
1997
- // invoiceitem uses `date` (not `created`); customer is request-supplied (declared scope when absent).
1998
- return create('invoiceitem', params, { livemode: false, discountable: true, proration: false, quantity: qty, quantity_decimal: String(qty), period: { start: at, end: at } }, req, { timeField: 'date' });
2502
+ // A price-based item (PeakHealth's catalog-product order path) carries no `amount` of
2503
+ // its own — real Stripe derives it from price.unit_amount * quantity. An ad-hoc item
2504
+ // (frozen-quote lab orders) already supplies `amount` directly; leave it untouched.
2505
+ const priceRef = typeof params.price === 'string' ? params.price : '';
2506
+ let resolvedAmount: number | undefined;
2507
+ let resolvedCurrency: string | undefined;
2508
+ if (priceRef) {
2509
+ const price = getOne('price', priceRef, req.root);
2510
+ if (!price) return err(`No such price: '${priceRef}'`, 400, 'resource_missing');
2511
+ resolvedAmount = (Number(price.unit_amount) || 0) * qty;
2512
+ resolvedCurrency = typeof price.currency === 'string' ? price.currency : undefined;
2513
+ }
2514
+ // Invoice items request-supplied `invoice: <id>` attach directly to that DRAFT invoice
2515
+ // (PeakHealth's order/lab-booking flows always attach this way, never via the pending-
2516
+ // items sweep). Real Stripe only accepts new items on a draft invoice and updates its
2517
+ // line list + totals immediately — not just at finalize.
2518
+ const invoiceRef = typeof params.invoice === 'string' ? params.invoice : '';
2519
+ let targetInvoice: Record<string, unknown> | undefined;
2520
+ if (invoiceRef) {
2521
+ targetInvoice = getOne('invoice', invoiceRef, req.root);
2522
+ if (!targetInvoice) return err(`No such invoice: '${invoiceRef}'`, 404, 'resource_missing');
2523
+ if (targetInvoice.status !== 'draft') {
2524
+ return err(`This invoice cannot have invoice items added because it has status ${String(targetInvoice.status)}.`, 400, 'invoice_not_editable');
2525
+ }
2526
+ if (resolvedCurrency === undefined && typeof targetInvoice.currency === 'string') resolvedCurrency = targetInvoice.currency;
2527
+ }
2528
+ // invoiceitem uses `date` (not `created`); customer is request-supplied (declared scope
2529
+ // when absent). `currency` is Stripe-required on every invoiceitem; a price-based item
2530
+ // (no explicit currency param) inherits the price's currency, falling back to the target
2531
+ // invoice's, then 'usd' — real Stripe requires the item's currency to match its invoice.
2532
+ const created = await create('invoiceitem', params, {
2533
+ livemode: false, discountable: true, proration: false, quantity: qty, quantity_decimal: String(qty),
2534
+ period: { start: at, end: at }, currency: resolvedCurrency ?? 'usd',
2535
+ ...(resolvedAmount !== undefined ? { amount: resolvedAmount } : {}),
2536
+ }, req, { timeField: 'date' });
2537
+ if (targetInvoice) {
2538
+ const item = created.body as Record<string, unknown>;
2539
+ const amount = Number(item.amount) || 0;
2540
+ const currency = String(targetInvoice.currency ?? item.currency ?? 'usd');
2541
+ await attachLineToDraftInvoice(invoiceRef, targetInvoice, {
2542
+ id: `il_${item.id as string}`, object: 'line_item', type: 'invoiceitem', amount, currency,
2543
+ quantity: qty, proration: false, invoice_item: item.id, price: item.price ?? null,
2544
+ period: item.period ?? { start: at, end: at }, description: (item.description as string) ?? null,
2545
+ }, req);
2546
+ }
2547
+ return created;
1999
2548
  }
2000
2549
  if (path === '/v1/invoices' && method === 'POST') {
2001
2550
  const at = nowUnix(req.occurredAt);
2002
2551
  // A minimal draft invoice. Aggregate money fields default to 0; nested objects are
2003
2552
  // emitted with Stripe's canonical empty shapes; `customer` is request-supplied.
2004
- return create('invoice', params, {
2553
+ const createdInvoice = await create('invoice', params, {
2005
2554
  status: 'draft', livemode: false, currency: 'usd', collection_method: 'charge_automatically',
2006
2555
  auto_advance: false, attempt_count: 0, attempted: false,
2007
2556
  amount_due: 0, amount_paid: 0, amount_remaining: 0, amount_overpaid: 0, amount_paid_off_stripe: 0, amount_shipping: 0,
@@ -2012,7 +2561,37 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
2012
2561
  status_transitions: { finalized_at: null, marked_uncollectible_at: null, paid_at: null, voided_at: null },
2013
2562
  issuer: { type: 'self' },
2014
2563
  payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
2564
+ // A draft invoice has no PaymentIntent yet — real Stripe mints one only on finalize
2565
+ // (draft -> open), never at creation. See the finalize/send handling below.
2566
+ payment_intent: null,
2015
2567
  }, req);
2568
+ // Stripe (documented, pending_invoice_items_behavior defaults to `include`): a new
2569
+ // invoice for a customer pulls in every pending invoice item — one created without an
2570
+ // `invoice` and not yet on any — as its lines. Live-verified 2026-09-04: three pending
2571
+ // items and then invoices.create left the real invoice with three lines; the twin had
2572
+ // left them unattached and the invoice at 0. `exclude` leaves them pending.
2573
+ if (createdInvoice.status === 200 && params.pending_invoice_items_behavior !== 'exclude') {
2574
+ const inv = createdInvoice.body as Record<string, unknown>;
2575
+ const invoiceId = String(inv.id);
2576
+ const customerId = typeof inv.customer === 'string' ? inv.customer : '';
2577
+ const pending = customerId
2578
+ ? listViews('invoiceitem', req.root, 'date').filter((it) => it.customer === customerId && !it.invoice).reverse()
2579
+ : [];
2580
+ for (const item of pending) {
2581
+ const current = getOne('invoice', invoiceId, req.root) ?? inv;
2582
+ const amount = Number(item.amount) || 0;
2583
+ const qty = Number(item.quantity) || 1;
2584
+ await attachLineToDraftInvoice(invoiceId, current, {
2585
+ id: `il_${String(item.id)}`, object: 'line_item', type: 'invoiceitem', amount, currency: String(current.currency ?? item.currency ?? 'usd'),
2586
+ quantity: qty, proration: false, invoice_item: item.id, price: item.price ?? null,
2587
+ period: item.period ?? { start: at, end: at }, description: (item.description as string) ?? null,
2588
+ discountable: item.discountable ?? true, discounts: [], livemode: false, metadata: item.metadata ?? {}, tax_amounts: [], tax_rates: [],
2589
+ }, req);
2590
+ await writeResource('invoiceitem', String(item.id), { invoice: invoiceId }, 'invoiceitem.update', req.root, req.occurredAt);
2591
+ }
2592
+ if (pending.length > 0) return { status: 200, body: getOne('invoice', invoiceId, req.root) ?? inv };
2593
+ }
2594
+ return createdInvoice;
2016
2595
  }
2017
2596
  if (seg[1] === 'invoices' && seg.length === 4 && method === 'POST' && ['finalize', 'pay', 'void', 'mark_uncollectible', 'send'].includes(seg[3]!)) {
2018
2597
  const inv = getOne('invoice', idAt(2), req.root);
@@ -2028,23 +2607,70 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
2028
2607
  if (action === 'pay' && (status === 'void' || status === 'draft')) return err(`This invoice cannot be paid because it has status ${status}.`, 400, 'invoice_not_editable');
2029
2608
  if (action === 'mark_uncollectible' && status !== 'open') return err(`Only open invoices can be marked uncollectible (this one is ${status}).`, 400, 'invoice_not_editable');
2030
2609
  if (action === 'void' && (status === 'paid' || status === 'void')) return err(`This invoice cannot be voided because it has status ${status}.`, 400, 'invoice_not_editable');
2610
+ // Card-decline fidelity: paying an invoice with a known declining test card (passed
2611
+ // explicitly via payment_method, e.g. an off-session retry charge, or falling back to
2612
+ // the invoice's own default_payment_method) must 402 like every other charge/confirm
2613
+ // path — Stripe does NOT mark the invoice paid on a declined attempt. Without this the
2614
+ // twin previously always paid invoices regardless of which card was attached.
2615
+ if (action === 'pay') {
2616
+ // Stripe (documented): paying an invoice charges a payment method — the one passed,
2617
+ // the invoice's default, or the customer's default payment method or source — unless
2618
+ // the payment is recorded out of band. With none of those the attempt fails; the twin
2619
+ // previously paid regardless (live-verified 2026-09-04 against the twin, the gap the
2620
+ // managed substrate tier hit). ⚠ exact message and code doc-unverified.
2621
+ const paidOutOfBandNow = params.paid_out_of_band !== undefined && asBool(params.paid_out_of_band);
2622
+ const payer = getOne('customer', String(inv.customer ?? ''), req.root);
2623
+ const customerDefault = ((payer?.invoice_settings as Record<string, unknown> | undefined)?.default_payment_method) || payer?.default_source;
2624
+ if (!paidOutOfBandNow && !params.payment_method && !params.source && !inv.default_payment_method && !inv.default_source && !customerDefault) {
2625
+ return err('This customer has no attached payment source or default payment method. Please consider adding a default payment method.', 400, 'missing');
2626
+ }
2627
+ const decline = declineFor(req.root, params, { payment_method: inv.default_payment_method });
2628
+ if (decline) return cardError(decline, { payment_intent: typeof inv.payment_intent === 'string' ? inv.payment_intent : undefined });
2629
+ }
2031
2630
  // status transition per action. `send` finalizes a draft (and records the send timestamp);
2032
2631
  // mark_uncollectible marks an open invoice as a write-off; pay supports out-of-band payment.
2033
2632
  const at = nowUnix(req.occurredAt);
2034
2633
  let fields: Record<string, unknown>;
2035
- if (action === 'finalize') fields = { status: 'open', status_transitions: { ...(inv.status_transitions as object ?? {}), finalized_at: at } };
2036
- else if (action === 'send') {
2634
+ let paidOutOfBand = false;
2635
+ // Real Stripe mints a PaymentIntent the moment a draft invoice is finalized (draft ->
2636
+ // open) — NOT at creation (PEAK-3102: PeakHealth's createStandaloneOrder reads
2637
+ // invoice.payment_intent right after finalize and gets nothing today). Only a
2638
+ // charge_automatically invoice with a positive balance gets one: send_invoice
2639
+ // (customer pays via the hosted page / bank transfer, no PI) and $0 / fully-discounted
2640
+ // invoices never get one, exactly like the analogous subscription default_incomplete
2641
+ // first-invoice path above. `send` shares this because it auto-finalizes a draft too.
2642
+ const becomesOpenFromDraft = (action === 'finalize' || action === 'send') && inv.status === 'draft';
2643
+ const willNeedPaymentIntent = becomesOpenFromDraft
2644
+ && ((inv.collection_method as string) ?? 'charge_automatically') === 'charge_automatically'
2645
+ && (Number(inv.amount_due) || 0) > 0;
2646
+ const newPaymentIntentId = willNeedPaymentIntent ? nextId('payment_intent', req.root) : null;
2647
+ if (action === 'finalize') {
2648
+ fields = { status: 'open', status_transitions: { ...(inv.status_transitions as object ?? {}), finalized_at: at }, payment_intent: newPaymentIntentId };
2649
+ } else if (action === 'send') {
2037
2650
  // sending a draft auto-finalizes it (Stripe behavior); an already-open invoice stays open.
2038
2651
  const becameOpen = inv.status === 'draft';
2039
- fields = { status: becameOpen ? 'open' : (inv.status as string), _sent: true, _sent_at: at, status_transitions: { ...(inv.status_transitions as object ?? {}), ...(becameOpen ? { finalized_at: at } : {}) } };
2652
+ fields = {
2653
+ status: becameOpen ? 'open' : (inv.status as string), _sent: true, _sent_at: at,
2654
+ status_transitions: { ...(inv.status_transitions as object ?? {}), ...(becameOpen ? { finalized_at: at } : {}) },
2655
+ ...(becameOpen ? { payment_intent: newPaymentIntentId } : {}),
2656
+ };
2040
2657
  } else if (action === 'pay') {
2041
- const oob = params.paid_out_of_band !== undefined && asBool(params.paid_out_of_band);
2042
- fields = { status: 'paid', paid: true, paid_out_of_band: oob, amount_paid: Number(inv.total ?? inv.amount_due) || 0, amount_remaining: 0, status_transitions: { ...(inv.status_transitions as object ?? {}), paid_at: at } };
2658
+ paidOutOfBand = params.paid_out_of_band !== undefined && asBool(params.paid_out_of_band);
2659
+ fields = { status: 'paid', paid: true, paid_out_of_band: paidOutOfBand, amount_paid: Number(inv.total ?? inv.amount_due) || 0, amount_remaining: 0, status_transitions: { ...(inv.status_transitions as object ?? {}), paid_at: at } };
2043
2660
  } else if (action === 'mark_uncollectible') fields = { status: 'uncollectible', status_transitions: { ...(inv.status_transitions as object ?? {}), marked_uncollectible_at: at } };
2044
2661
  else fields = { status: 'void', status_transitions: { ...(inv.status_transitions as object ?? {}), voided_at: at } };
2045
2662
  // event op for send maps to invoice.sent; others map 1:1 in eventTypeFor.
2046
2663
  const op = action === 'send' ? 'invoice.sent' : action === 'mark_uncollectible' ? 'invoice.marked_uncollectible' : `invoice.${action}`;
2047
- return { status: 200, body: await writeResource('invoice', idAt(2), fields, op, req.root, req.occurredAt) };
2664
+ const body = await writeResource('invoice', idAt(2), fields, op, req.root, req.occurredAt);
2665
+ if (newPaymentIntentId) await createInvoicePaymentIntent(newPaymentIntentId, inv, idAt(2), req);
2666
+ // A successful (non-declined, non-out-of-band) pay settles the PaymentIntent real Stripe
2667
+ // created at finalize: it moves to `succeeded` and gets a `latest_charge` — the field
2668
+ // PeakHealth's BioReference/BloodworkTab flow reads after `pay`. paid_out_of_band means
2669
+ // the balance was collected OUTSIDE Stripe, so the PI is left alone (never confirmed).
2670
+ if (action === 'pay' && !paidOutOfBand && typeof inv.payment_intent === 'string' && inv.payment_intent) {
2671
+ await settleInvoicePaymentIntentOnPay(inv.payment_intent, idAt(2), Number(fields.amount_paid) || 0, req);
2672
+ }
2673
+ return { status: 200, body: expandResource('invoice', body, params, req.root) };
2048
2674
  }
2049
2675
  // GET /v1/invoices/:id/lines — the invoice's line items (a paginated sub-list). The lines
2050
2676
  // live on the invoice's `lines.data`; we paginate them. Unknown invoice → 404. (Must
@@ -2090,7 +2716,7 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
2090
2716
  const remove = new Set(entries.map((e) => (typeof e.id === 'string' ? e.id : '')).filter(Boolean));
2091
2717
  next = next.filter((l) => !remove.has(String(l.id)));
2092
2718
  }
2093
- const subtotal = next.reduce((s, l) => s + (Number(l.amount) || 0), 0);
2719
+ const subtotal = sumInvoiceLines(next);
2094
2720
  const lines = { object: 'list', data: next, has_more: false, total_count: next.length, url: `/v1/invoices/${idAt(2)}/lines` };
2095
2721
  return { status: 200, body: await writeResource('invoice', idAt(2), { lines, subtotal, total: subtotal, amount_due: subtotal, amount_remaining: subtotal }, 'invoice.update', req.root, req.occurredAt) };
2096
2722
  }
@@ -2199,17 +2825,129 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
2199
2825
  }
2200
2826
  // Trials: trial_period_days / trial_end put the sub in `trialing` with trial_start/_end.
2201
2827
  const trial = resolveTrial(params, at);
2202
- const { coupon: _c, discounts: _ds, trial_period_days: _tpd, trial_end: _te, ...rest } = params as Record<string, unknown>;
2203
- return create('subscription', rest, {
2204
- status: trial ? 'trialing' : 'active', livemode: false, currency: 'usd', collection_method: 'charge_automatically',
2828
+ // Billing period: real Stripe always populates a billing period on a subscription.
2829
+ // During a trial the "current period" IS the trial window (mirrors trial_start/
2830
+ // trial_end); otherwise it's one billing interval — day/week/month/year ×
2831
+ // interval_count, read off the FIRST item's Price (`recurring`) — starting at the
2832
+ // anchor (`at`, same value stored as billing_cycle_anchor/start_date above).
2833
+ //
2834
+ // current_period_start/current_period_end were moved off the top-level
2835
+ // Subscription object onto subscription items in Stripe's 2025-03-31.basil API
2836
+ // version (see stripe-known-deviations.json's subscription.current_period_*
2837
+ // entries) — the vendored schema fixture is fetched from the LATEST published
2838
+ // spec, so it reflects that post-migration shape. This twin's own default served
2839
+ // version when a caller sends no Stripe-Version header is TWIN_API_VERSION
2840
+ // ('2024-06-20', pre-dating the migration — see its definition above), so
2841
+ // emitting these fields top-level here is version-consistent with what this twin
2842
+ // actually serves by default, not a fabrication.
2843
+ const itemEntries = subscriptionItemEntries(params.items);
2844
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(itemEntries, req.root);
2845
+ const periodStart = trial ? trial.start : at;
2846
+ const periodEnd = trial ? trial.end : addBillingInterval(at, interval, interval_count);
2847
+ const subId = typeof params.id === 'string' && params.id ? params.id : nextId('subscription', req.root);
2848
+ const itemsList = buildSubscriptionItemsList(itemEntries, subId, at, req.root);
2849
+ const itemsData = itemsList.data as Array<Record<string, unknown>>;
2850
+ const currency = typeof params.currency === 'string' && params.currency ? params.currency : 'usd';
2851
+
2852
+ // payment_behavior: 'default_incomplete' — the "create subscription now, collect
2853
+ // payment client-side" pattern (what the backend always sends — see
2854
+ // Stripe.service.ts createSubscriptionIntentForUser). Real Stripe auto-generates
2855
+ // the subscription's FIRST Invoice right away, and — unless it's $0 — a linked
2856
+ // PaymentIntent whose client_secret the frontend confirms via Stripe.js
2857
+ // (stripe.confirmPayment). Without this the subscription's `latest_invoice` stays
2858
+ // absent and the frontend never gets a clientSecret to confirm, so checkout for
2859
+ // ANY non-$0 plan can never complete (see PR #199's sibling fix, which populated
2860
+ // current_period_start/_end + items but left this gap). A trialing subscription has
2861
+ // no invoice due yet, so it's excluded — mirrors real Stripe.
2862
+ const paymentBehavior = typeof params.payment_behavior === 'string' ? params.payment_behavior : undefined;
2863
+ const needsFirstInvoice = paymentBehavior === 'default_incomplete' && !trial;
2864
+ let subtotal = 0;
2865
+ for (const it of itemsData) {
2866
+ const price = it.price && typeof it.price === 'object' ? (it.price as Record<string, unknown>) : undefined;
2867
+ subtotal += (Number(price?.unit_amount) || 0) * (Number(it.quantity) || 1);
2868
+ }
2869
+ const coupon = discounts[0]?.coupon as Record<string, unknown> | undefined;
2870
+ const discountAmount = applyCouponDiscount(subtotal, coupon);
2871
+ const invoiceTotal = Math.max(0, subtotal - discountAmount);
2872
+ // A 100%-off coupon (or an item set that totals to 0) still gets a first invoice in
2873
+ // real Stripe, but it's `paid` immediately with no PaymentIntent needed — mirrors
2874
+ // how the twin's Checkout Session setup-mode flow needs no payment either.
2875
+ const isFree = invoiceTotal <= 0;
2876
+
2877
+ // Pre-mint the invoice/PI ids so the subscription's own `latest_invoice` can be set
2878
+ // in the SAME write as its create (avoids a second update — and a second
2879
+ // customer.subscription.updated event — right after creation).
2880
+ const invoiceId = needsFirstInvoice ? nextId('invoice', req.root) : null;
2881
+ const piId = needsFirstInvoice && !isFree ? nextId('payment_intent', req.root) : null;
2882
+
2883
+ const { coupon: _c, discounts: _ds, trial_period_days: _tpd, trial_end: _te, items: _items, id: _id, ...rest } = params as Record<string, unknown>;
2884
+ const subResp = await create('subscription', { ...rest, id: subId }, {
2885
+ status: trial ? 'trialing' : (needsFirstInvoice && !isFree ? 'incomplete' : 'active'),
2886
+ livemode: false, currency, collection_method: 'charge_automatically',
2205
2887
  cancel_at_period_end: false, start_date: at, billing_cycle_anchor: at, metadata: {},
2206
2888
  discounts, billing_schedules: [],
2207
2889
  ...(trial ? { trial_start: trial.start, trial_end: trial.end } : { trial_start: null, trial_end: null }),
2208
- items: { object: 'list', data: [], has_more: false, total_count: 0 },
2890
+ current_period_start: periodStart, current_period_end: periodEnd,
2891
+ items: itemsList,
2892
+ latest_invoice: invoiceId,
2209
2893
  automatic_tax: { enabled: false, liability: null },
2210
2894
  billing_mode: { type: 'classic' },
2211
2895
  invoice_settings: { issuer: { type: 'self' } },
2212
2896
  }, req);
2897
+
2898
+ // expand[]=latest_invoice / latest_invoice.payment_intent (the two values this
2899
+ // backend sends — see Stripe.service.ts's `expand` array on subscriptions.create):
2900
+ // create() doesn't run expand generically (unlike the GET :id retrieve routes), so
2901
+ // apply it explicitly here. Reuses the same EXPANDABLE map / expandObject machinery
2902
+ // GET already relies on (subscription.latest_invoice -> invoice, invoice.payment_intent
2903
+ // -> payment_intent are already declared there) — a no-op when `expand` is absent.
2904
+ if (!needsFirstInvoice || !invoiceId) {
2905
+ return { status: 200, body: expandResource('subscription', subResp.body as Record<string, unknown>, params, req.root) };
2906
+ }
2907
+
2908
+ const invoiceLines = itemsData.map((it, i) => {
2909
+ const price = it.price && typeof it.price === 'object' ? (it.price as Record<string, unknown>) : undefined;
2910
+ const quantity = Number(it.quantity) || 1;
2911
+ return {
2912
+ id: `il_twin_${invoiceId}_${i + 1}`, object: 'line_item', type: 'subscription',
2913
+ amount: (Number(price?.unit_amount) || 0) * quantity, currency, quantity, proration: false,
2914
+ price: it.price ?? null, subscription: subId, invoice_item: null,
2915
+ period: { start: periodStart, end: periodEnd }, description: null,
2916
+ };
2917
+ });
2918
+ await create('invoice', {
2919
+ id: invoiceId,
2920
+ status: isFree ? 'paid' : 'open', livemode: false, currency, collection_method: 'charge_automatically',
2921
+ auto_advance: false, attempt_count: 0, attempted: !isFree,
2922
+ customer, subscription: subId, billing_reason: 'subscription_create',
2923
+ amount_due: invoiceTotal, amount_paid: isFree ? invoiceTotal : 0, amount_remaining: isFree ? 0 : invoiceTotal,
2924
+ amount_overpaid: 0, amount_paid_off_stripe: 0, amount_shipping: 0,
2925
+ subtotal, total: invoiceTotal, starting_balance: 0,
2926
+ post_payment_credit_notes_amount: 0, pre_payment_credit_notes_amount: 0,
2927
+ period_start: periodStart, period_end: periodEnd,
2928
+ default_tax_rates: [], discounts: discountAmount > 0 && discounts[0] ? [discounts[0]] : [],
2929
+ lines: { object: 'list', data: invoiceLines, has_more: false, total_count: invoiceLines.length, url: `/v1/invoices/${invoiceId}/lines` },
2930
+ automatic_tax: { enabled: false, liability: null, status: null },
2931
+ status_transitions: { finalized_at: at, marked_uncollectible_at: null, paid_at: isFree ? at : null, voided_at: null },
2932
+ issuer: { type: 'self' },
2933
+ payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
2934
+ payment_intent: piId,
2935
+ }, {}, req);
2936
+
2937
+ if (piId) {
2938
+ // capture_method 'automatic' + status 'requires_payment_method' matches real
2939
+ // Stripe's freshly-created (unconfirmed) PaymentIntent; requires_payment_method
2940
+ // (not requires_confirmation) is the real-Stripe status here because no
2941
+ // payment_method has been attached yet — the frontend collects it via Elements
2942
+ // before calling confirmPayment.
2943
+ await create('payment_intent', { id: piId, amount: invoiceTotal, currency, customer, invoice: invoiceId }, {
2944
+ status: 'requires_payment_method', client_secret: mintClientSecret(piId), livemode: false,
2945
+ capture_method: 'automatic', amount_capturable: 0, amount_received: 0, next_action: null,
2946
+ automatic_payment_methods: null, payment_method_types: ['card', 'link'], payment_method_options: {},
2947
+ }, req);
2948
+ }
2949
+
2950
+ return { status: 200, body: expandResource('subscription', subResp.body as Record<string, unknown>, params, req.root) };
2213
2951
  }
2214
2952
  if (path === '/v1/subscriptions/search' && method === 'GET') return searchResult('subscription', params, path, req.root);
2215
2953
  if (path === '/v1/subscriptions' && method === 'GET') {
@@ -3458,6 +4196,14 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
3458
4196
  total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 0 },
3459
4197
  metadata: (params.metadata && typeof params.metadata === 'object') ? params.metadata : {}, livemode: false,
3460
4198
  };
4199
+ // `subscription_data` is a CREATE-ONLY param in real Stripe (the Session object never
4200
+ // echoes it) but it is NOT discarded: on completion Stripe copies it onto the created
4201
+ // subscription (metadata, trial settings, description, …). Stash it under a reserved
4202
+ // `_`-key the view() projection strips, so the stored Session shape stays faithful
4203
+ // while the completion transition can copy it faithfully.
4204
+ if (params.subscription_data && typeof params.subscription_data === 'object') {
4205
+ fields._subscription_data = params.subscription_data;
4206
+ }
3461
4207
  // `id` is provided so create() does not mint a second one (url must match).
3462
4208
  return create('checkout_session', { id, ...fields }, {}, req);
3463
4209
  }
@@ -3510,15 +4256,43 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
3510
4256
  const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
3511
4257
  if (mode === 'payment') {
3512
4258
  const amount = Number(existing.amount_total) || 0;
3513
- const pi = await create('payment_intent', { amount, currency, ...(customer ? { customer } : {}) }, { status: 'succeeded', client_secret: 'pi_twin_secret', livemode: false }, req);
4259
+ const linkedPiId = nextId('payment_intent', req.root);
4260
+ const pi = await create('payment_intent', { amount, currency, id: linkedPiId, ...(customer ? { customer } : {}) }, { status: 'succeeded', client_secret: mintClientSecret(linkedPiId), livemode: false }, req);
3514
4261
  link.payment_intent = (pi.body as Record<string, unknown>).id;
3515
4262
  link.payment_status = 'paid';
3516
4263
  } else if (mode === 'subscription') {
3517
4264
  if (customer) {
3518
- const sub = await create('subscription', { customer }, {
3519
- status: 'active', livemode: false, currency, collection_method: 'charge_automatically',
3520
- cancel_at_period_end: false, metadata: {}, discounts: [], billing_schedules: [],
3521
- items: { object: 'list', data: [], has_more: false, total_count: 0 },
4265
+ // Same billing-period + items fidelity as POST /v1/subscriptions (see the
4266
+ // comments there): a completed subscription-mode Checkout Session also
4267
+ // needs a populated current_period_start/_end and a real items.data, or a
4268
+ // consumer reading either off this session's linked subscription hits the
4269
+ // same gap. Resolve the interval/items from the session's own line_items
4270
+ // (already-resolved Price objects — see buildLineItem's `priceField`).
4271
+ const at = nowUnix(req.occurredAt);
4272
+ const sessionLineItems = ((existing.line_items as { data?: unknown } | undefined)?.data as Array<Record<string, unknown>> | undefined) ?? [];
4273
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(sessionLineItems, req.root);
4274
+ const subId = nextId('subscription', req.root);
4275
+ // Real Stripe copies the session's create-only `subscription_data` onto the
4276
+ // subscription it creates at completion. The twin models the cheap, load-bearing
4277
+ // fields: `metadata` (how apps bind a checkout attempt to the resulting
4278
+ // subscription — dropping it silently orphans the attempt), `description`, and
4279
+ // `trial_period_days` (→ status 'trialing', trial_start/trial_end, and
4280
+ // current_period_end = trial end, exactly like real Stripe). Read from the RAW
4281
+ // row: view() strips the internal `_subscription_data` stash.
4282
+ const rawSession = rows('checkout_session', req.root).find((x) => x.id === idAt(3));
4283
+ const subData = (rawSession?._subscription_data && typeof rawSession._subscription_data === 'object'
4284
+ ? rawSession._subscription_data : {}) as Record<string, unknown>;
4285
+ const subMetadata = (subData.metadata && typeof subData.metadata === 'object') ? subData.metadata as Record<string, unknown> : {};
4286
+ const trialDays = typeof subData.trial_period_days === 'number' && subData.trial_period_days > 0 ? subData.trial_period_days : null;
4287
+ const trialEnd = trialDays === null ? null : at + trialDays * 86400;
4288
+ const sub = await create('subscription', { customer, id: subId }, {
4289
+ status: trialEnd === null ? 'active' : 'trialing', livemode: false, currency, collection_method: 'charge_automatically',
4290
+ cancel_at_period_end: false, start_date: at, billing_cycle_anchor: at, metadata: subMetadata,
4291
+ description: typeof subData.description === 'string' ? subData.description : null,
4292
+ discounts: [], billing_schedules: [],
4293
+ trial_start: trialEnd === null ? null : at, trial_end: trialEnd,
4294
+ current_period_start: at, current_period_end: trialEnd ?? addBillingInterval(at, interval, interval_count),
4295
+ items: buildSubscriptionItemsList(sessionLineItems, subId, at, req.root),
3522
4296
  automatic_tax: { enabled: false, liability: null }, billing_mode: { type: 'classic' },
3523
4297
  invoice_settings: { issuer: { type: 'self' } },
3524
4298
  }, req);
@@ -3527,7 +4301,8 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
3527
4301
  link.payment_status = 'paid';
3528
4302
  } else {
3529
4303
  // setup mode: create+link a succeeded setup_intent; no payment collected.
3530
- const si = await create('setup_intent', { ...(customer ? { customer } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: 'seti_twin_secret', payment_method_types: ['card'], livemode: false }, req);
4304
+ const linkedSiId = nextId('setup_intent', req.root);
4305
+ const si = await create('setup_intent', { id: linkedSiId, ...(customer ? { customer } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(linkedSiId), payment_method_types: ['card'], livemode: false }, req);
3531
4306
  link.setup_intent = (si.body as Record<string, unknown>).id;
3532
4307
  }
3533
4308
  return { status: 200, body: await writeResource('checkout_session', idAt(3), { ...params, ...link }, 'checkout.session.completed', req.root, req.occurredAt) };
@@ -3941,12 +4716,17 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
3941
4716
  const enabledEvents = Array.isArray(params.enabled_events) ? params.enabled_events.map(String) : (typeof params.enabled_events === 'string' ? [params.enabled_events] : undefined);
3942
4717
  if (!enabledEvents || enabledEvents.length === 0) return err('Missing required param: enabled_events.', 400, 'parameter_missing');
3943
4718
  const id = nextId('webhook_endpoint', req.root);
4719
+ const secret = `whsec_twin_${id}`;
3944
4720
  const resp = await create('webhook_endpoint', { ...params, id }, {
3945
4721
  url, enabled_events: enabledEvents, status: 'enabled', livemode: false, metadata: {},
3946
4722
  api_version: '2024-06-20', application: null, description: typeof params.description === 'string' ? params.description : null,
3947
- secret: `whsec_twin_${id}`,
4723
+ secret,
3948
4724
  }, req);
3949
- registerStripeWebhook(url);
4725
+ // Register the URL WITH its minted secret (live delivery signs each POST with this
4726
+ // endpoint's own whsec_twin_*; consumers verify with stripe.webhooks.constructEvent
4727
+ // against the secret returned here) AND its enabled_events, so organic fan-out only
4728
+ // reaches endpoints subscribed to the event's type.
4729
+ registerStripeWebhook(url, secret, enabledEvents);
3950
4730
  return resp;
3951
4731
  }
3952
4732
  if (path === '/v1/webhook_endpoints' && method === 'GET') {
@@ -3962,7 +4742,17 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
3962
4742
  if (!w || w._deleted) return err(`No such webhook endpoint: '${idAt(2)}'`, 404, 'resource_missing');
3963
4743
  const fields: Record<string, unknown> = { ...params };
3964
4744
  if (Array.isArray(params.enabled_events)) fields.enabled_events = params.enabled_events.map(String);
3965
- return { status: 200, body: await writeResource('webhook_endpoint', idAt(2), fields, 'webhook_endpoint.update', req.root, req.occurredAt) };
4745
+ const body = await writeResource('webhook_endpoint', idAt(2), fields, 'webhook_endpoint.update', req.root, req.occurredAt);
4746
+ // Keep the delivery registry in step: an updated url replaces the old registration, and
4747
+ // updated enabled_events replace the subscription the fan-out filters on.
4748
+ const newUrl = typeof body.url === 'string' ? body.url : String(w.url);
4749
+ if (typeof w.url === 'string' && w.url !== newUrl) unregisterStripeWebhook(w.url);
4750
+ registerStripeWebhook(
4751
+ newUrl,
4752
+ typeof body.secret === 'string' ? body.secret : (typeof w.secret === 'string' ? w.secret : undefined),
4753
+ Array.isArray(body.enabled_events) ? body.enabled_events.map(String) : undefined,
4754
+ );
4755
+ return { status: 200, body };
3966
4756
  }
3967
4757
  if (seg[1] === 'webhook_endpoints' && seg.length === 3 && method === 'DELETE') {
3968
4758
  const w = getOne('webhook_endpoint', idAt(2), req.root);
@@ -4092,6 +4882,18 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4092
4882
  // ════════════════════════════════════════════════════════════════════════════════
4093
4883
  // ISSUING — virtual/physical card issuing (cardholders → cards → authorizations →
4094
4884
  // transactions → disputes). All resources are stateful and round-trip via the kernel.
4885
+ //
4886
+ // Real-time authorization (the synchronous leg): a presented authorization first runs
4887
+ // the card's (then the cardholder's) spending_controls; a violation declines with the
4888
+ // vendor's request_history.reason 'spending_controls' BEFORE any webhook is consulted
4889
+ // (Stripe applies spending controls ahead of the issuing_authorization.request event).
4890
+ // If a registered webhook endpoint subscribes to issuing_authorization.request, the twin
4891
+ // delivers the signed request event SYNCHRONOUSLY and honors the endpoint's JSON
4892
+ // response ({approved: bool, amount?: int}) within Stripe's 2-second window; a timeout
4893
+ // declines with reason 'webhook_timeout', an invalid response with 'webhook_error'
4894
+ // (reason semantics verbatim from stripe@22.3.0 RequestHistory.Reason). With no enrolled
4895
+ // endpoint the authorization stays 'pending' awaiting the (deprecated but still real)
4896
+ // POST /v1/issuing/authorizations/:id/approve|decline API decision.
4095
4897
  // ════════════════════════════════════════════════════════════════════════════════
4096
4898
 
4097
4899
  // ---- Issuing Cardholders ----
@@ -4104,10 +4906,11 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4104
4906
  if (chType !== 'individual' && chType !== 'company') return err("Invalid type: must be one of 'individual' or 'company'.", 400, 'parameter_invalid_string_enum');
4105
4907
  const billing = params.billing && typeof params.billing === 'object' ? params.billing as Record<string, unknown> : undefined;
4106
4908
  if (!billing || !billing.address || typeof billing.address !== 'object') return err('Missing required param: billing[address].', 400, 'parameter_missing');
4107
- return create('issuing_cardholder', params, {
4909
+ const chControls = normalizeSpendingControls(params.spending_controls, null);
4910
+ if (chControls.error) return chControls.error;
4911
+ return create('issuing_cardholder', { ...params, spending_controls: chControls.controls ?? emptySpendingControls() }, {
4108
4912
  status: 'active', livemode: false, metadata: {}, phone_number: params.phone_number ?? null,
4109
4913
  email: params.email ?? null, requirements: { disabled_reason: null, past_due: [] },
4110
- spending_controls: { allowed_categories: null, blocked_categories: null, spending_limits: [], spending_limits_currency: null },
4111
4914
  }, req);
4112
4915
  }
4113
4916
  if (seg[1] === 'issuing' && seg[2] === 'cardholders' && seg.length === 3 && method === 'GET') {
@@ -4124,7 +4927,10 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4124
4927
  }
4125
4928
  if (seg[1] === 'issuing' && seg[2] === 'cardholders' && seg.length === 4 && method === 'POST') {
4126
4929
  if (!getOne('issuing_cardholder', idAt(3), req.root)) return err(`No such cardholder: '${idAt(3)}'`, 404, 'resource_missing');
4127
- return { status: 200, body: await writeResource('issuing_cardholder', idAt(3), params, 'issuing_cardholder.updated', req.root, req.occurredAt) };
4930
+ const chUpdate = normalizeSpendingControls(params.spending_controls, null);
4931
+ if (chUpdate.error) return chUpdate.error;
4932
+ const chFields = chUpdate.controls ? { ...params, spending_controls: chUpdate.controls } : params;
4933
+ return { status: 200, body: await writeResource('issuing_cardholder', idAt(3), chFields, 'issuing_cardholder.updated', req.root, req.occurredAt) };
4128
4934
  }
4129
4935
 
4130
4936
  // ---- Issuing Cards ----
@@ -4140,10 +4946,14 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4140
4946
  if (cardType !== 'virtual' && cardType !== 'physical') return err("Invalid type: must be one of 'virtual' or 'physical'.", 400, 'parameter_invalid_string_enum');
4141
4947
  const seq = rows('issuing_card', req.root).length + 1;
4142
4948
  const last4 = String(4242 + seq).slice(-4);
4143
- return create('issuing_card', params, {
4949
+ // spending_controls are validated + normalized at write time (closed interval enum,
4950
+ // positive limit amounts, allowed/blocked exclusivity) so present-authorization can
4951
+ // enforce them; an invalid dictionary 400s like the vendor.
4952
+ const sc = normalizeSpendingControls(params.spending_controls, typeof params.currency === 'string' ? params.currency : null);
4953
+ if (sc.error) return sc.error;
4954
+ return create('issuing_card', { ...params, spending_controls: sc.controls ?? emptySpendingControls() }, {
4144
4955
  status: cardType === 'virtual' ? 'active' : 'inactive', livemode: false, metadata: {},
4145
4956
  brand: 'Visa', last4, exp_month: 12, exp_year: 2030, cancellation_reason: null,
4146
- spending_controls: { allowed_categories: null, blocked_categories: null, spending_limits: [], spending_limits_currency: null },
4147
4957
  }, req);
4148
4958
  }
4149
4959
  if (seg[1] === 'issuing' && seg[2] === 'cards' && seg.length === 3 && method === 'GET') {
@@ -4159,12 +4969,16 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4159
4969
  return c ? { status: 200, body: c } : err(`No such card: '${idAt(3)}'`, 404, 'resource_missing');
4160
4970
  }
4161
4971
  if (seg[1] === 'issuing' && seg[2] === 'cards' && seg.length === 4 && method === 'POST') {
4162
- if (!getOne('issuing_card', idAt(3), req.root)) return err(`No such card: '${idAt(3)}'`, 404, 'resource_missing');
4972
+ const existingCard = getOne('issuing_card', idAt(3), req.root);
4973
+ if (!existingCard) return err(`No such card: '${idAt(3)}'`, 404, 'resource_missing');
4163
4974
  // Stripe only accepts status transitions to active|inactive|canceled here.
4164
4975
  if (params.status !== undefined && !['active', 'inactive', 'canceled'].includes(String(params.status))) {
4165
4976
  return err("Invalid status: must be one of 'active', 'inactive', or 'canceled'.", 400, 'parameter_invalid_string_enum');
4166
4977
  }
4167
- return { status: 200, body: await writeResource('issuing_card', idAt(3), params, 'issuing_card.updated', req.root, req.occurredAt) };
4978
+ const scUpdate = normalizeSpendingControls(params.spending_controls, typeof existingCard.currency === 'string' ? existingCard.currency : null);
4979
+ if (scUpdate.error) return scUpdate.error;
4980
+ const fields = scUpdate.controls ? { ...params, spending_controls: scUpdate.controls } : params;
4981
+ return { status: 200, body: await writeResource('issuing_card', idAt(3), fields, 'issuing_card.updated', req.root, req.occurredAt) };
4168
4982
  }
4169
4983
 
4170
4984
  // ---- Issuing Authorizations (+approve / decline) ----
@@ -4179,29 +4993,54 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4179
4993
  card: (a, v) => a.card === v,
4180
4994
  cardholder: (a, v) => a.cardholder === v,
4181
4995
  status: (a, v) => a.status === v,
4182
- });
4996
+ }).map((a) => embedIssuingAuthorizationCard({ ...a }, req.root));
4183
4997
  return { status: 200, body: listBody('issuing_authorization', items, path, params) };
4184
4998
  }
4185
4999
  if (seg[1] === 'issuing' && seg[2] === 'authorizations' && seg.length === 4 && method === 'GET') {
4186
5000
  const a = getOne('issuing_authorization', idAt(3), req.root);
4187
- return a ? { status: 200, body: a } : err(`No such authorization: '${idAt(3)}'`, 404, 'resource_missing');
5001
+ return a ? { status: 200, body: embedIssuingAuthorizationCard({ ...a }, req.root) } : err(`No such authorization: '${idAt(3)}'`, 404, 'resource_missing');
4188
5002
  }
4189
5003
  if (seg[1] === 'issuing' && seg[2] === 'authorizations' && seg.length === 5 && (seg[4] === 'approve' || seg[4] === 'decline') && method === 'POST') {
4190
5004
  const auth = getOne('issuing_authorization', idAt(3), req.root);
4191
5005
  if (!auth) return err(`No such authorization: '${idAt(3)}'`, 404, 'resource_missing');
4192
- if (auth.status !== 'pending') return err(`This authorization has already been finalized (status ${auth.status}).`, 400, 'authorization_already_finalized');
5006
+ if (auth.status !== 'pending' || auth.approved === true) return err(`This authorization has already been finalized (status ${auth.status}).`, 400, 'authorization_already_finalized');
4193
5007
  const approved = seg[4] === 'approve';
4194
- const updated = await writeResource('issuing_authorization', idAt(3), {
4195
- status: 'closed', approved, ...(approved && params.amount !== undefined ? { amount: Math.trunc(Number(params.amount) || 0) } : {}),
4196
- }, approved ? 'issuing_authorization.updated' : 'issuing_authorization.updated', req.root, req.occurredAt);
4197
- // An approved authorization materializes a captured Issuing Transaction (type 'capture').
4198
- if (approved) {
4199
- const amt = Number((updated as Record<string, unknown>).amount) || 0;
4200
- await create('issuing_transaction', {
4201
- authorization: idAt(3), card: auth.card, cardholder: auth.cardholder, amount: -amt,
4202
- currency: auth.currency ?? 'usd', merchant_data: auth.merchant_data ?? null,
4203
- }, { type: 'capture', livemode: false, metadata: {}, dispute: null, balance_transaction: null }, req);
5008
+ // The (deprecated but real) API decision is "you" answering the real-time request, so
5009
+ // it lands in request_history with the vendor's webhook_approved / webhook_declined
5010
+ // reason, and the open pending_request is consumed (pending_request is only non-null
5011
+ // while a request is undecided).
5012
+ const decidedSec = nowUnix(req.occurredAt);
5013
+ // A caller-provided amount mirrors the webhook path's partial-approval rule: honored
5014
+ // only when the presentment was amount-controllable (ApprovalParams.amount SDK doc),
5015
+ // and it must be a positive integer — a negative here would CREDIT the issuing balance.
5016
+ if (approved && params.amount !== undefined) {
5017
+ const requestedAmount = Math.trunc(Number(params.amount) || 0);
5018
+ if (requestedAmount <= 0) return err('Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
5019
+ const pendingRequest = auth.pending_request as Record<string, unknown> | null | undefined;
5020
+ if (pendingRequest?.is_amount_controllable !== true) {
5021
+ return err('amount may only be provided when the authorization was presented with is_amount_controllable.', 400);
5022
+ }
4204
5023
  }
5024
+ const decidedAmount = approved && params.amount !== undefined ? Math.trunc(Number(params.amount) || 0) : (Number(auth.amount) || 0);
5025
+ const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
5026
+ const history = Array.isArray(auth.request_history) ? auth.request_history as Array<Record<string, unknown>> : [];
5027
+ // An approved authorization materializes a captured Issuing Transaction (type 'capture')
5028
+ // AND debits the issuing balance (balance_transaction type 'issuing_transaction',
5029
+ // balance_type 'issuing') so the float is honest.
5030
+ const captured = approved ? await materializeIssuingCapture(idAt(3), auth, decidedAmount, req) : null;
5031
+ const priorTxns = Array.isArray(auth.transactions) ? auth.transactions : [];
5032
+ const priorBts = Array.isArray(auth.balance_transactions) ? auth.balance_transactions : [];
5033
+ const updated = await writeResource('issuing_authorization', idAt(3), {
5034
+ status: 'closed', approved, pending_request: null,
5035
+ ...(approved && params.amount !== undefined ? { amount: decidedAmount } : {}),
5036
+ request_history: [...history, requestHistoryEntry({
5037
+ amount: decidedAmount, currency, approved,
5038
+ reason: approved ? 'webhook_approved' : 'webhook_declined', reasonMessage: null,
5039
+ createdSec: decidedSec, merchantAmount: Number(auth.merchant_amount) || decidedAmount,
5040
+ merchantCurrency: typeof auth.merchant_currency === 'string' ? auth.merchant_currency : currency,
5041
+ })],
5042
+ ...(captured ? { transactions: [...priorTxns, captured.txnId], balance_transactions: [...priorBts, captured.btId] } : {}),
5043
+ }, 'issuing_authorization.updated', req.root, req.occurredAt);
4205
5044
  return { status: 200, body: updated };
4206
5045
  }
4207
5046
  if (seg[1] === 'issuing' && seg[2] === 'authorizations' && seg.length === 4 && method === 'POST') {
@@ -4267,11 +5106,13 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4267
5106
  return { status: 200, body: await writeResource('issuing_dispute', idAt(3), params, 'issuing_dispute.updated', req.root, req.occurredAt) };
4268
5107
  }
4269
5108
 
4270
- // ---- Test helpers: Issuing (fund balance, present authorization) ----
5109
+ // ---- Test helpers: Issuing (fund balance, present authorization, capture) ----
4271
5110
  // POST /v1/test_helpers/issuing/cards/:card/shipping/* — not modeled (physical shipping).
4272
5111
  // POST /v1/test_helpers/issuing/authorizations — PRESENT a card authorization (the test-mode
4273
- // way to simulate real-time card usage). Stripe requires `card` (must exist) + `amount`. The
4274
- // authorization is created 'pending' awaiting approve/decline (a real merchant request).
5112
+ // way to simulate real-time card usage). Stripe requires `card` (must exist) + `amount`.
5113
+ // Decision order (see the ISSUING section header): card status → spending_controls →
5114
+ // synchronous issuing_authorization.request webhook (when an endpoint is enrolled) →
5115
+ // otherwise 'pending' awaiting the deprecated approve/decline API.
4275
5116
  if (seg[1] === 'test_helpers' && seg[2] === 'issuing' && seg[3] === 'authorizations' && seg.length === 4 && method === 'POST') {
4276
5117
  const card = typeof params.card === 'string' ? params.card : '';
4277
5118
  if (!card) return err('Missing required param: card.', 400, 'parameter_missing');
@@ -4279,12 +5120,134 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4279
5120
  if (!c) return err(`No such card: '${card}'`, 400, 'resource_missing');
4280
5121
  if (params.amount === undefined) return err('Missing required param: amount.', 400, 'parameter_missing');
4281
5122
  const amount = Math.trunc(Number(params.amount) || 0);
4282
- return create('issuing_authorization', { ...params, card, cardholder: c.cardholder }, {
4283
- status: 'pending', approved: false, amount, currency: params.currency ?? c.currency ?? 'usd',
4284
- authorization_method: 'online', livemode: false, metadata: {},
4285
- merchant_data: { category: 'general', city: 'San Francisco', country: 'US', name: 'Twin Test Merchant', network_id: '1234567890' },
4286
- pending_request: { amount, currency: params.currency ?? c.currency ?? 'usd', is_amount_controllable: false },
4287
- }, req);
5123
+ const currency = String(params.currency ?? c.currency ?? 'usd');
5124
+ const createdSec = nowUnix(req.occurredAt);
5125
+ const cardholderId = typeof c.cardholder === 'string' ? c.cardholder : '';
5126
+ const merchantData = issuingMerchantData(params.merchant_data);
5127
+ const category = String(merchantData.category ?? '');
5128
+ const country = merchantData.country == null ? null : String(merchantData.country);
5129
+ const isAmountControllable = params.is_amount_controllable === true;
5130
+ const authMethodParam = typeof params.authorization_method === 'string' ? params.authorization_method : 'online';
5131
+ if (!['chip', 'contactless', 'keyed_in', 'online', 'swipe'].includes(authMethodParam)) {
5132
+ return err("Invalid authorization_method: must be one of 'chip', 'contactless', 'keyed_in', 'online', or 'swipe'.", 400, 'parameter_invalid_string_enum');
5133
+ }
5134
+ const merchantAmount = params.merchant_amount !== undefined ? Math.trunc(Number(params.merchant_amount) || 0) : amount;
5135
+ const merchantCurrency = typeof params.merchant_currency === 'string' ? params.merchant_currency : currency;
5136
+ const authId = nextId('issuing_authorization', req.root);
5137
+ // The full vendor authorization shape (all fields the published spec requires).
5138
+ const baseFields: Record<string, unknown> = {
5139
+ amount, amount_details: null, authorization_method: authMethodParam,
5140
+ balance_transactions: [], transactions: [],
5141
+ currency, fleet: null, fuel: null, livemode: false,
5142
+ merchant_amount: merchantAmount, merchant_currency: merchantCurrency, merchant_data: merchantData,
5143
+ metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {},
5144
+ network_data: null, verification_data: issuingVerificationData(), wallet: null,
5145
+ };
5146
+ const decline = (reason: string, reasonMessage: string | null) =>
5147
+ create('issuing_authorization', { id: authId, card, cardholder: cardholderId }, {
5148
+ ...baseFields, status: 'closed', approved: false, pending_request: null,
5149
+ request_history: [requestHistoryEntry({
5150
+ amount, currency, approved: false, reason, reasonMessage,
5151
+ createdSec, merchantAmount, merchantCurrency,
5152
+ })],
5153
+ }, req);
5154
+ // 1) Card state: an inactive/canceled card declines with the vendor's card_inactive /
5155
+ // card_canceled request_history reasons (stripe@22.3.0 RequestHistory.Reason).
5156
+ if (c.status === 'canceled') return decline('card_canceled', 'The card has been canceled.');
5157
+ if (c.status !== 'active') return decline('card_inactive', 'The card is inactive.');
5158
+ // 2) spending_controls — card-level first, then cardholder-level; either declines with
5159
+ // the vendor's single controls reason 'spending_controls'. Applied BEFORE the
5160
+ // real-time webhook, like Stripe.
5161
+ const cardholderRow = cardholderId ? getOne('issuing_cardholder', cardholderId, req.root) : undefined;
5162
+ const controlsOpts = { amount, category, country, nowSec: createdSec };
5163
+ const violation =
5164
+ spendingControlsViolation(c.spending_controls, { ...controlsOpts, priorApproved: priorApprovedAuthorizations(req.root, 'card', card) }) ??
5165
+ spendingControlsViolation(cardholderRow?.spending_controls, { ...controlsOpts, priorApproved: priorApprovedAuthorizations(req.root, 'cardholder', cardholderId) });
5166
+ if (violation) return decline('spending_controls', violation);
5167
+ // 3) Real-time authorization: the FIRST enabled webhook endpoint enrolled for
5168
+ // issuing_authorization.request receives the signed request event synchronously and
5169
+ // its HTTP response decides the authorization.
5170
+ const endpoint = rows('webhook_endpoint', req.root).find((w) =>
5171
+ w._deleted !== true && w.status === 'enabled' && endpointSubscribedTo(w.enabled_events, 'issuing_authorization.request'));
5172
+ if (!endpoint) {
5173
+ // No real-time enrollment: the authorization stays pending awaiting the (deprecated
5174
+ // but real) approve/decline API within the authorization window.
5175
+ return create('issuing_authorization', { id: authId, card, cardholder: cardholderId }, {
5176
+ ...baseFields, status: 'pending', approved: false, request_history: [],
5177
+ pending_request: {
5178
+ amount, amount_details: null, currency, is_amount_controllable: isAmountControllable,
5179
+ merchant_amount: merchantAmount, merchant_currency: merchantCurrency, network_risk_score: null,
5180
+ },
5181
+ }, req);
5182
+ }
5183
+ // data.object during an issuing_authorization.request carries the authorization with
5184
+ // pending_request POPULATED ("This field will only be non-null during an
5185
+ // issuing_authorization.request webhook" — stripe@22.3.0 Authorization.pending_request).
5186
+ const pendingRequest = {
5187
+ amount, amount_details: null, currency, is_amount_controllable: isAmountControllable,
5188
+ merchant_amount: merchantAmount, merchant_currency: merchantCurrency, network_risk_score: null,
5189
+ };
5190
+ const requestObject: Record<string, unknown> = embedIssuingAuthorizationCard({
5191
+ object: 'issuing.authorization', id: authId, created: createdSec,
5192
+ ...baseFields, card, cardholder: cardholderId,
5193
+ status: 'pending', approved: false, pending_request: pendingRequest, request_history: [],
5194
+ }, req.root);
5195
+ const requestEvent: StripeEvent = {
5196
+ id: `evt_twin_req_${authId}`, object: 'event', type: 'issuing_authorization.request',
5197
+ created: createdSec, livemode: false, data: { object: requestObject },
5198
+ };
5199
+ // The stored Events API sees the request event too, exactly like real Stripe.
5200
+ await persistStripeEvent('issuing_authorization.request', requestObject, req.root, req.occurredAt, req.apiVersion);
5201
+ const secret = typeof endpoint.secret === 'string' && endpoint.secret ? endpoint.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
5202
+ const outcome = await requestAuthorizationDecision(String(endpoint.url), requestEvent, secret);
5203
+ if (outcome.kind === 'approved') {
5204
+ // Partial-amount approval is honored only when the request was presented
5205
+ // amount-controllable (pending_request.is_amount_controllable — SDK field doc).
5206
+ const held = isAmountControllable && outcome.amount !== undefined ? outcome.amount : amount;
5207
+ return create('issuing_authorization', { id: authId, card, cardholder: cardholderId }, {
5208
+ ...baseFields, amount: held, status: 'pending', approved: true, pending_request: null,
5209
+ request_history: [requestHistoryEntry({
5210
+ amount: held, currency, approved: true, reason: 'webhook_approved', reasonMessage: null,
5211
+ createdSec, merchantAmount, merchantCurrency,
5212
+ })],
5213
+ }, req);
5214
+ }
5215
+ if (outcome.kind === 'declined') return decline('webhook_declined', null);
5216
+ if (outcome.kind === 'timeout') return decline('webhook_timeout', outcome.message);
5217
+ return decline('webhook_error', outcome.message);
5218
+ }
5219
+ // POST /v1/test_helpers/issuing/authorizations/:id/capture — capture an approved, still-
5220
+ // pending authorization: creates the Issuing Transaction (type 'capture'), debits the
5221
+ // issuing balance, and (by default — close_authorization defaults true, SDK doc) closes
5222
+ // the authorization. capture_amount (SDK: AuthorizationCaptureParams.capture_amount)
5223
+ // allows partial capture up to the held amount.
5224
+ if (seg[1] === 'test_helpers' && seg[2] === 'issuing' && seg[3] === 'authorizations' && seg.length === 6 && seg[5] === 'capture' && method === 'POST') {
5225
+ const auth = getOne('issuing_authorization', idAt(4), req.root);
5226
+ if (!auth) return err(`No such authorization: '${idAt(4)}'`, 404, 'resource_missing');
5227
+ if (auth.approved !== true || auth.status !== 'pending') {
5228
+ return err(`This authorization cannot be captured because it is not an approved pending authorization (status: ${auth.status}, approved: ${auth.approved === true}).`, 400);
5229
+ }
5230
+ const held = Number(auth.amount) || 0;
5231
+ // Cumulative bound: partial captures (close_authorization=false) consume the hold — a
5232
+ // later capture may only take what remains, never re-spend the full held amount.
5233
+ const capturedSoFar = (Array.isArray(auth.transactions) ? auth.transactions : [])
5234
+ .map((t) => getOne('issuing_transaction', String(t), req.root))
5235
+ .reduce((s, t) => s + Math.abs(Number(t?.amount) || 0), 0);
5236
+ const remaining = held - capturedSoFar;
5237
+ const captureAmount = params.capture_amount !== undefined ? Math.trunc(Number(params.capture_amount) || 0) : remaining;
5238
+ if (captureAmount <= 0 || captureAmount > remaining) {
5239
+ return err('Invalid capture_amount: must be a positive integer no greater than the uncaptured authorized amount.', 400, 'parameter_invalid_integer');
5240
+ }
5241
+ const closeAuthorization = params.close_authorization !== false; // defaults true (SDK doc)
5242
+ const captured = await materializeIssuingCapture(idAt(4), auth, captureAmount, req);
5243
+ const priorTxns = Array.isArray(auth.transactions) ? auth.transactions : [];
5244
+ const priorBts = Array.isArray(auth.balance_transactions) ? auth.balance_transactions : [];
5245
+ const updated = await writeResource('issuing_authorization', idAt(4), {
5246
+ ...(closeAuthorization ? { status: 'closed' } : {}),
5247
+ transactions: [...priorTxns, captured.txnId],
5248
+ balance_transactions: [...priorBts, captured.btId],
5249
+ }, 'issuing_authorization.updated', req.root, req.occurredAt);
5250
+ return { status: 200, body: updated };
4288
5251
  }
4289
5252
  // POST /v1/test_helpers/issuing/cards/:id/status — not modeled here (use cards POST update).
4290
5253
  // POST /v1/test_helpers/issuing/transactions/create_force_capture — present a force-captured
@@ -4296,10 +5259,21 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4296
5259
  if (!c) return err(`No such card: '${card}'`, 400, 'resource_missing');
4297
5260
  if (params.amount === undefined) return err('Missing required param: amount.', 400, 'parameter_missing');
4298
5261
  const amount = Math.trunc(Number(params.amount) || 0);
4299
- const { amount: _a, ...forceRest } = params as Record<string, unknown>;
4300
- return create('issuing_transaction', { ...forceRest, card, cardholder: c.cardholder }, {
4301
- type: 'capture', amount: -Math.abs(amount), currency: params.currency ?? c.currency ?? 'usd',
4302
- authorization: null, dispute: null, balance_transaction: null, livemode: false, metadata: {},
5262
+ if (amount <= 0) return err('Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
5263
+ const currency = String(params.currency ?? c.currency ?? 'usd');
5264
+ // A force capture is a settlement with no prior authorization — it still debits the
5265
+ // issuing balance (captures move the float, authorization or not).
5266
+ const forcedBt = await create('balance_transaction', {}, {
5267
+ amount: -amount, currency, fee: 0, net: -amount, type: 'issuing_transaction',
5268
+ status: 'available', balance_type: 'issuing', reporting_category: 'issuing_transaction',
5269
+ available_on: nowUnix(req.occurredAt), fee_details: [],
5270
+ }, req);
5271
+ return create('issuing_transaction', { card, cardholder: c.cardholder }, {
5272
+ type: 'capture', amount: -amount, currency,
5273
+ merchant_amount: -amount, merchant_currency: typeof params.merchant_currency === 'string' ? params.merchant_currency : currency,
5274
+ merchant_data: issuingMerchantData(params.merchant_data),
5275
+ authorization: null, dispute: null, balance_transaction: (forcedBt.body as { id: string }).id,
5276
+ livemode: false, metadata: {}, wallet: null, network_data: null, amount_details: null, purchase_details: null,
4303
5277
  }, req);
4304
5278
  }
4305
5279
  // POST /v1/test_helpers/issuing/fund_balance — fund the issuing balance (test mode). Stripe
@@ -4307,6 +5281,7 @@ async function routeStripeTwinRequest(req: StripeRequest): Promise<StripeRespons
4307
5281
  if (seg[1] === 'test_helpers' && seg[2] === 'issuing' && seg[3] === 'fund_balance' && seg.length === 4 && method === 'POST') {
4308
5282
  if (params.amount === undefined) return err('Missing required param: amount.', 400, 'parameter_missing');
4309
5283
  const amount = Math.trunc(Number(params.amount) || 0);
5284
+ if (amount <= 0) return err('Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
4310
5285
  if (params.currency === undefined || params.currency === '') return err('Missing required param: currency.', 400, 'parameter_missing');
4311
5286
  const currency = String(params.currency);
4312
5287
  // Record a funding entry as a balance_transaction tagged issuing so it accrues statefully.