@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.
- package/README.md +39 -5
- package/package.json +2 -2
- package/src/cli.ts +10 -3
- package/src/index.ts +44 -2
- package/src/stripe-budget.ts +181 -0
- package/src/stripe-capabilities.ts +338 -20
- package/src/stripe-conformance.ts +2 -0
- package/src/stripe-connector.ts +65 -4
- package/src/stripe-emit.ts +150 -0
- package/src/stripe-events.ts +176 -6
- package/src/stripe-mirror-ui.ts +25 -10
- package/src/stripe-server.ts +67 -32
- package/src/stripe-twin.ts +1046 -71
- package/test-fixtures/stripe-known-deviations.json +25 -0
- package/test-fixtures/stripe-openapi-operations.json +6384 -0
- package/test-fixtures/stripe-schemas.json +376 -0
package/src/stripe-twin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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 {
|
|
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')
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
1998
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2036
|
-
|
|
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 = {
|
|
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
|
-
|
|
2042
|
-
fields = { status: 'paid', paid: true, paid_out_of_band:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
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
|
|
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
|
|
4723
|
+
secret,
|
|
3948
4724
|
}, req);
|
|
3949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
//
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
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`.
|
|
4274
|
-
//
|
|
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
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
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
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
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.
|