@volter/twin-stripe 3.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -21
- package/dist/generated/pack-facts.json +3 -3
- package/dist/journeys/coverage.json +596 -0
- package/dist/journeys/customer-life.json +6108 -0
- package/dist/journeys/customer-life.outline.md +371 -0
- package/dist/journeys/decisions.json +677 -0
- package/dist/journeys/demand.json +706 -0
- package/dist/journeys/score.json +853 -0
- package/dist/journeys/unreachable.json +102 -0
- package/dist/journeys/vendor-examples.json +8010 -0
- package/dist/spec/SOURCE.md +5 -1
- package/dist/spec/doc-examples.json +445 -0
- package/dist/spec/sources.json +135 -207
- package/dist/src/cli.d.ts +0 -1
- package/dist/src/cli.js +0 -2
- package/dist/src/engine/after-payment.d.ts +0 -1
- package/dist/src/engine/after-payment.js +0 -1
- package/dist/src/engine/common.d.ts +5 -5
- package/dist/src/engine/common.js +13 -11
- package/dist/src/engine/connect.d.ts +0 -21
- package/dist/src/engine/connect.js +1 -82
- package/dist/src/engine/customers.d.ts +0 -8
- package/dist/src/engine/customers.js +0 -22
- package/dist/src/engine/display.d.ts +0 -3
- package/dist/src/engine/display.js +0 -3
- package/dist/src/engine/files.d.ts +0 -7
- package/dist/src/engine/files.js +0 -10
- package/dist/src/engine/invoices.d.ts +6 -9
- package/dist/src/engine/invoices.js +56 -29
- package/dist/src/engine/issuing.d.ts +6 -8
- package/dist/src/engine/issuing.js +19 -10
- package/dist/src/engine/ledger.d.ts +0 -1
- package/dist/src/engine/ledger.js +0 -1
- package/dist/src/engine/oauth.js +1 -2
- package/dist/src/engine/refunds.js +2 -27
- package/dist/src/engine/stripe.d.ts +2 -7
- package/dist/src/engine/stripe.js +34 -83
- package/dist/src/engine/subscriptions.d.ts +0 -1
- package/dist/src/engine/subscriptions.js +0 -1
- package/dist/src/engine/version.d.ts +0 -2
- package/dist/src/engine/version.js +17 -6
- package/dist/src/fetch.js +2 -2
- package/dist/src/generated/surface.gen.json +1 -1
- package/dist/src/generated/ui.gen.json +1 -1
- package/dist/src/index.js +5 -3
- package/dist/src/manifest.js +348 -220
- package/dist/src/screens/billing-portal.js +5 -5
- package/dist/src/screens/{connect-oauth.d.ts → checkout-js.d.ts} +0 -1
- package/dist/src/screens/checkout-js.js +84 -0
- package/dist/src/screens/connect-onboarding.js +3 -3
- package/dist/src/screens/dashboard.d.ts +1 -1
- package/dist/src/screens/dashboard.js +52 -53
- package/dist/src/screens/stripe-js.js +53 -93
- package/dist/src/semantics/account.d.ts +2 -0
- package/dist/src/semantics/account.js +8 -0
- package/dist/src/semantics/accounts.js +3 -1
- package/dist/src/semantics/around.js +3 -6
- package/dist/src/semantics/billing_portal.d.ts +0 -1
- package/dist/src/semantics/billing_portal.js +1 -7
- package/dist/src/semantics/checkout.d.ts +1 -1
- package/dist/src/semantics/checkout.js +18 -15
- package/dist/src/semantics/clock.js +1 -7
- package/dist/src/semantics/customers.d.ts +1 -2
- package/dist/src/semantics/customers.js +32 -38
- package/dist/src/semantics/entitlements.d.ts +0 -2
- package/dist/src/semantics/entitlements.js +4 -50
- package/dist/src/semantics/events.d.ts +2 -3
- package/dist/src/semantics/events.js +6 -11
- package/dist/src/semantics/index.js +16 -38
- package/dist/src/semantics/invoices.d.ts +3 -8
- package/dist/src/semantics/invoices.js +134 -127
- package/dist/src/semantics/issuing.d.ts +6 -0
- package/dist/src/semantics/issuing.js +77 -0
- package/dist/src/semantics/payment_intents.d.ts +0 -1
- package/dist/src/semantics/payment_intents.js +5 -13
- package/dist/src/semantics/payment_methods.d.ts +0 -2
- package/dist/src/semantics/payment_methods.js +2 -26
- package/dist/src/semantics/payouts.d.ts +1 -0
- package/dist/src/semantics/payouts.js +8 -1
- package/dist/src/semantics/products.d.ts +1 -3
- package/dist/src/semantics/products.js +3 -14
- package/dist/src/semantics/refunds.d.ts +1 -2
- package/dist/src/semantics/refunds.js +1 -5
- package/dist/src/semantics/shared.d.ts +64 -336
- package/dist/src/semantics/shared.js +326 -1336
- package/dist/src/semantics/states.d.ts +20 -89
- package/dist/src/semantics/states.js +116 -334
- package/dist/src/semantics/subscriptions.d.ts +1 -2
- package/dist/src/semantics/subscriptions.js +23 -145
- package/dist/src/semantics/test_helpers.d.ts +2 -5
- package/dist/src/semantics/test_helpers.js +121 -43
- package/dist/src/semantics/webhook_endpoints.d.ts +2 -0
- package/dist/src/semantics/webhook_endpoints.js +26 -0
- package/generated/pack-facts.json +3 -3
- package/journeys/coverage.json +596 -0
- package/journeys/customer-life.json +6108 -0
- package/journeys/customer-life.outline.md +371 -0
- package/journeys/decisions.json +677 -0
- package/journeys/demand.json +706 -0
- package/journeys/score.json +853 -0
- package/journeys/unreachable.json +102 -0
- package/journeys/vendor-examples.json +8010 -0
- package/package.json +4 -3
- package/spec/SOURCE.md +5 -1
- package/spec/doc-examples.json +445 -0
- package/spec/sources.json +135 -207
- package/src/cli.ts +0 -3
- package/src/engine/after-payment.ts +0 -2
- package/src/engine/balance.ts +0 -1
- package/src/engine/checkout.ts +0 -1
- package/src/engine/common.ts +19 -17
- package/src/engine/connect.ts +1 -79
- package/src/engine/customers.ts +0 -25
- package/src/engine/display.ts +2 -3
- package/src/engine/files.ts +0 -13
- package/src/engine/invoices.ts +64 -33
- package/src/engine/issuing.ts +16 -11
- package/src/engine/ledger.ts +0 -2
- package/src/engine/oauth.ts +1 -2
- package/src/engine/onboarding.ts +0 -1
- package/src/engine/params.ts +0 -1
- package/src/engine/payment-intents.ts +0 -1
- package/src/engine/payment-methods.ts +0 -1
- package/src/engine/refunds.ts +2 -28
- package/src/engine/renewals.ts +0 -1
- package/src/engine/stripe.ts +35 -83
- package/src/engine/subscriptions.ts +0 -3
- package/src/engine/transfers.ts +0 -1
- package/src/engine/version.ts +15 -7
- package/src/engine/webhooks.ts +0 -2
- package/src/fetch.ts +2 -2
- package/src/generated/surface.gen.json +1 -1
- package/src/generated/ui.gen.json +1 -1
- package/src/index.ts +6 -4
- package/src/manifest.ts +348 -220
- package/src/screens/billing-portal.tsx +5 -7
- package/src/screens/checkout-js.ts +80 -0
- package/src/screens/checkout.tsx +0 -2
- package/src/screens/connect-onboarding.tsx +3 -5
- package/src/screens/dashboard.tsx +53 -73
- package/src/screens/stripe-js.ts +57 -125
- package/src/semantics/account.ts +10 -0
- package/src/semantics/account_links.ts +0 -1
- package/src/semantics/accounts.ts +8 -7
- package/src/semantics/apps.ts +1 -2
- package/src/semantics/around.ts +3 -6
- package/src/semantics/balance.ts +0 -1
- package/src/semantics/balance_transactions.ts +1 -3
- package/src/semantics/billing_portal.ts +1 -7
- package/src/semantics/charges.ts +1 -6
- package/src/semantics/checkout.ts +16 -12
- package/src/semantics/clock.ts +1 -8
- package/src/semantics/coupons.ts +0 -1
- package/src/semantics/customers.ts +32 -35
- package/src/semantics/doors.ts +2 -3
- package/src/semantics/entitlements.ts +1 -45
- package/src/semantics/events.ts +7 -14
- package/src/semantics/index.ts +16 -38
- package/src/semantics/invoice_payments.ts +1 -3
- package/src/semantics/invoices.ts +124 -119
- package/src/semantics/issuing.ts +74 -0
- package/src/semantics/payment_intents.ts +10 -17
- package/src/semantics/payment_methods.ts +2 -25
- package/src/semantics/payouts.ts +9 -3
- package/src/semantics/prices.ts +0 -1
- package/src/semantics/products.ts +4 -14
- package/src/semantics/promotion_codes.ts +0 -1
- package/src/semantics/radar.ts +1 -3
- package/src/semantics/refunds.ts +2 -11
- package/src/semantics/shared.ts +350 -1382
- package/src/semantics/states.ts +109 -330
- package/src/semantics/subscriptions.ts +25 -145
- package/src/semantics/test_helpers.ts +109 -43
- package/src/semantics/transfers.ts +1 -3
- package/src/semantics/webhook_endpoints.ts +27 -0
- package/src/server.ts +0 -1
- package/dist/src/engine/billing.d.ts +0 -5
- package/dist/src/engine/billing.js +0 -6
- package/dist/src/engine/charges.d.ts +0 -6
- package/dist/src/engine/charges.js +0 -13
- package/dist/src/engine/connect-settings.d.ts +0 -16
- package/dist/src/engine/connect-settings.js +0 -26
- package/dist/src/engine/coupons.d.ts +0 -0
- package/dist/src/engine/coupons.js +0 -5
- package/dist/src/engine/credit-notes.d.ts +0 -1
- package/dist/src/engine/credit-notes.js +0 -5
- package/dist/src/engine/disputes.d.ts +0 -0
- package/dist/src/engine/disputes.js +0 -9
- package/dist/src/engine/entitlements.d.ts +0 -1
- package/dist/src/engine/entitlements.js +0 -4
- package/dist/src/engine/ephemeral-keys.d.ts +0 -0
- package/dist/src/engine/ephemeral-keys.js +0 -4
- package/dist/src/engine/front.d.ts +0 -5
- package/dist/src/engine/front.js +0 -25
- package/dist/src/engine/payment-links.d.ts +0 -1
- package/dist/src/engine/payment-links.js +0 -5
- package/dist/src/engine/plans.d.ts +0 -6
- package/dist/src/engine/plans.js +0 -26
- package/dist/src/engine/platform.d.ts +0 -39
- package/dist/src/engine/platform.js +0 -34
- package/dist/src/engine/products.d.ts +0 -0
- package/dist/src/engine/products.js +0 -4
- package/dist/src/engine/setup-intents.d.ts +0 -1
- package/dist/src/engine/setup-intents.js +0 -4
- package/dist/src/engine/subscription-schedules.d.ts +0 -3
- package/dist/src/engine/subscription-schedules.js +0 -1
- package/dist/src/engine/tax.d.ts +0 -7
- package/dist/src/engine/tax.js +0 -28
- package/dist/src/engine/terminal.d.ts +0 -4
- package/dist/src/engine/terminal.js +0 -20
- package/dist/src/engine/test-clocks.d.ts +0 -5
- package/dist/src/engine/test-clocks.js +0 -9
- package/dist/src/engine/tokens.d.ts +0 -7
- package/dist/src/engine/tokens.js +0 -20
- package/dist/src/engine/treasury.d.ts +0 -23
- package/dist/src/engine/treasury.js +0 -45
- package/dist/src/screens/connect-oauth.js +0 -333
- package/dist/src/semantics/disputes.d.ts +0 -2
- package/dist/src/semantics/disputes.js +0 -5
- package/dist/src/semantics/invoiceitems.d.ts +0 -3
- package/dist/src/semantics/invoiceitems.js +0 -59
- package/dist/src/semantics/setup_intents.d.ts +0 -3
- package/dist/src/semantics/setup_intents.js +0 -41
- package/dist/src/semantics/subscription_items.d.ts +0 -5
- package/dist/src/semantics/subscription_items.js +0 -55
- package/dist/src/semantics/subscription_schedules.d.ts +0 -4
- package/dist/src/semantics/subscription_schedules.js +0 -60
- package/src/engine/billing.ts +0 -16
- package/src/engine/charges.ts +0 -19
- package/src/engine/connect-settings.ts +0 -23
- package/src/engine/coupons.ts +0 -5
- package/src/engine/credit-notes.ts +0 -6
- package/src/engine/disputes.ts +0 -9
- package/src/engine/entitlements.ts +0 -5
- package/src/engine/ephemeral-keys.ts +0 -3
- package/src/engine/front.ts +0 -31
- package/src/engine/payment-links.ts +0 -6
- package/src/engine/plans.ts +0 -42
- package/src/engine/platform.ts +0 -47
- package/src/engine/products.ts +0 -3
- package/src/engine/setup-intents.ts +0 -5
- package/src/engine/subscription-schedules.ts +0 -10
- package/src/engine/tax.ts +0 -39
- package/src/engine/terminal.ts +0 -23
- package/src/engine/test-clocks.ts +0 -11
- package/src/engine/tokens.ts +0 -22
- package/src/engine/treasury.ts +0 -47
- package/src/screens/connect-oauth.tsx +0 -376
- package/src/semantics/disputes.ts +0 -12
- package/src/semantics/invoiceitems.ts +0 -59
- package/src/semantics/setup_intents.ts +0 -45
- package/src/semantics/subscription_items.ts +0 -54
- package/src/semantics/subscription_schedules.ts +0 -66
package/src/semantics/shared.ts
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
|
-
// What Stripe's manifest takes from its handlers' side: the events its writes send, as data the kernel renders, stores,
|
|
2
|
-
// signs and delivers (world-core events.ts; ./events.ts renders each event's `data` and its further values).
|
|
3
1
|
import type { EventsDecl, Handler, HandlerContext, TwinResource } from '@volter/world-core';
|
|
4
2
|
import { NAMED_EVENTS, readDecision, STRIPE_EVENT_TYPES, STRIPE_REALTIME_AUTH_TIMEOUT_MS, type StripeAuthRequestOutcome, stripeSignature } from '../engine/webhooks.ts';
|
|
5
3
|
import { redactKey, requestKey } from '../engine/oauth.ts';
|
|
6
|
-
import { HOUR, INV, itemQuantity, itemsOf, keptAfter, oneTimeLines, RENEWING, SUB
|
|
4
|
+
import { HOUR, INV, itemQuantity, itemsOf, keptAfter, oneTimeLines, RENEWING, SUB } from '../engine/renewals.ts';
|
|
7
5
|
import { DAY, payoutDay } from '../engine/balance.ts';
|
|
8
6
|
import { AFTER_SUCCESS_CARDS, BY_NUMBER, type Outcome } from '../engine/after-payment.ts';
|
|
9
|
-
import { accountSettings, addBillingInterval, applyCouponDiscount, asBool, buildDiscount, buildSubscriptionItemsList, cardError, chargeDefaults,
|
|
10
|
-
import { AUTO_VALIDATED, euVatCountry } from '../engine/customers.ts';
|
|
11
|
-
import { SI } from '../engine/setup-intents.ts';
|
|
12
|
-
import { BY_CLIENT_SECRET, PUBLISHABLE_CREATES } from '../engine/front.ts';
|
|
13
|
-
import { WE } from '../engine/webhook-endpoints.ts';
|
|
14
|
-
import { ENTRY, ENTRY_TYPES, FA, TX } from '../engine/treasury.ts';
|
|
7
|
+
import { accountSettings, addBillingInterval, applyCouponDiscount, asBool, buildDiscount, buildSubscriptionItemsList, cardError, chargeDefaults, declineFor, disputeEvidence, microdepositsNextAction, mintClientSecret, nowUnix, OBJECT_NAME, paginate, paymentMethodSubObject, PLATFORM_ACCOUNT_ID, priceAmount, requiresAuthentication, requiresMicrodeposits, resolveSubscriptionBillingInterval, resolveTrial, searchOver, type StripeResponse, subscriptionItemEntries, subscriptionItemPrice, TEST_TOKEN_DECLINES, threeDsNextAction, view } from '../engine/stripe.ts';
|
|
15
8
|
import { SECRET } from '../engine/apps-secrets.ts';
|
|
16
|
-
import {
|
|
17
|
-
import { CN } from '../engine/credit-notes.ts';
|
|
9
|
+
import { collectsThroughIntent, invoicePrefix, transitions } from '../engine/invoices.ts';
|
|
18
10
|
import { CS, type EnteredCard, isOneTime, nestedMetadata } from '../engine/checkout.ts';
|
|
19
|
-
import { externalList, type Find,
|
|
11
|
+
import { externalList, type Find, type Row, SPEC_FIELDS } from '../engine/common.ts';
|
|
20
12
|
import { noBilling, PM, TEST_PM_CARDS, testCardOf } from '../engine/payment-methods.ts';
|
|
21
|
-
import { type Interval, SS } from '../engine/subscription-schedules.ts';
|
|
22
|
-
import { OAUTH_CONNECTIONS } from '../engine/display.ts';
|
|
23
13
|
import { intentCarries, PI, TYPES_GIVEN } from '../engine/payment-intents.ts';
|
|
24
14
|
import { BT, BYPASS_NUMBERS, feeOf, onAccount, UNSENT } from '../engine/ledger.ts';
|
|
25
|
-
import { withoutCard } from '../engine/charges.ts';
|
|
26
15
|
import { MAX_TRIAL, movedTrial } from '../engine/subscriptions.ts';
|
|
27
16
|
import { VL, VLI } from '../engine/radar.ts';
|
|
28
|
-
import { AUTH,
|
|
29
|
-
import { has } from '../engine/onboarding.ts';
|
|
30
|
-
import { REFUND_REASONS } from '../engine/refunds.ts';
|
|
31
|
-
import { ADVANCE_SECONDS, CLOCK } from '../engine/test-clocks.ts';
|
|
17
|
+
import { AUTH, historyEntry, merchantData, REALTIME_WINDOW_SECONDS, TXN } from '../engine/issuing.ts';
|
|
32
18
|
import { BYPASS_PENDING_CARDS } from '../engine/test-cards.ts';
|
|
33
|
-
import {
|
|
34
|
-
import { controllerOf, customRequirements } from '../engine/connect.ts';
|
|
35
|
-
import { READER } from '../engine/terminal.ts';
|
|
19
|
+
import { customRequirements } from '../engine/connect.ts';
|
|
36
20
|
import { expandOf, render, SERVED_VERSION, servesCurrent, withoutEndpointSecret } from '../engine/version.ts';
|
|
37
|
-
import { ALERT, GRANT, METER } from '../engine/billing.ts';
|
|
38
21
|
import { CHECKOUT_METHOD_TYPES, DOCUMENTED, methodTypesRemoved, oneOf, OPS, PAYMENT_METHOD_TYPES, REMOVED_TYPES, TYPED, TYPES_DOC } from '../engine/params.ts';
|
|
39
|
-
|
|
22
|
+
import { linesOf, sumLines } from '../engine/invoices.ts';
|
|
23
|
+
import { refundDestination } from '../engine/common.ts';
|
|
24
|
+
import { REFUND_REASONS } from '../engine/refunds.ts';
|
|
40
25
|
/** Stripe's events: `Stripe-Signature: t=…,v1=…` over `${t}.${body}` keyed by the endpoint's secret
|
|
41
26
|
* (docs.stripe.com/webhooks#verify-manually); the Event object (docs.stripe.com/api/events/object) kept as the account's
|
|
42
27
|
* own (`GET /v1/events`), whether or not an endpoint takes it; an endpoint live while `enabled`, taking the types its
|
|
@@ -54,24 +39,6 @@ export const STRIPE_EVENTS: EventsDecl = {
|
|
|
54
39
|
endpoints: { storedAs: 'webhook_endpoint', url: 'url', secret: 'secret', filter: 'enabled_events', status: { field: 'status', live: 'enabled' }, scope: { field: 'connect', value: '$account' } },
|
|
55
40
|
};
|
|
56
41
|
|
|
57
|
-
// ── what the handlers share: Stripe's behaviour over the context ──
|
|
58
|
-
|
|
59
|
-
export type Reads = Pick<HandlerContext, 'rowsRaw'>;
|
|
60
|
-
|
|
61
|
-
/** Whether a connected account's OAuth connection to the platform was revoked (deauthorized, or its code reused). */
|
|
62
|
-
export function connectionRevoked(ctx: Reads, account: string): boolean {
|
|
63
|
-
return ctx.rowsRaw(OAUTH_CONNECTIONS).some((c) => c.id === account && c.revoked === true);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The connected account an OAuth key acts as — the connection's current access_token (sk_test_oauth_…) or its
|
|
67
|
-
* stripe_publishable_key (pk_test_oauth_…) — with the scope it was issued; undefined for any other key, and null for an
|
|
68
|
-
* OAuth-shaped key no live connection holds (revoked, replaced by a refresh, or never issued). */
|
|
69
|
-
export function oauthKeyAccount(ctx: Reads, key: string): { account: string; scope: string } | null | undefined {
|
|
70
|
-
if (!/^(sk|pk)_test_oauth_/.test(key)) return undefined;
|
|
71
|
-
const held = ctx.rowsRaw(OAUTH_CONNECTIONS).find((c) => c.revoked !== true && (c.access_token === key || c.publishable_key === key));
|
|
72
|
-
return held ? { account: String(held.id), scope: String(key.startsWith('pk_') ? held.scope : held.access_scope ?? held.scope) } : null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
42
|
/** A customer's now: its test clock's frozen time when it is on one (docs.stripe.com/billing/testing/test-clocks), the
|
|
76
43
|
* World's otherwise. */
|
|
77
44
|
export function clockNow(ctx: HandlerContext, customer: unknown): number {
|
|
@@ -96,7 +63,7 @@ export function customerDefault(ctx: HandlerContext, sub: Row): string | undefin
|
|
|
96
63
|
* it did not take (added after it was drafted) stays for the next (unspentDiscounts). */
|
|
97
64
|
export async function spendOnceCoupon(ctx: HandlerContext, invoice: Row): Promise<void> {
|
|
98
65
|
const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
|
|
99
|
-
const sub = subId ? ctx
|
|
66
|
+
const sub = subId ? resourceView(ctx, SUB, subId) : undefined;
|
|
100
67
|
if (!sub || !subId) return;
|
|
101
68
|
const all = (sub.discounts as Row[] | undefined) ?? [];
|
|
102
69
|
const kept = keptAfter(all, invoice);
|
|
@@ -150,7 +117,7 @@ export async function draftSubscriptionInvoice(ctx: HandlerContext, sub: Row, o:
|
|
|
150
117
|
* whether it was paid. */
|
|
151
118
|
export async function collectSubscriptionInvoice(ctx: HandlerContext, invoiceId: string, at: number, actor: 'time' | 'external' | 'api', opts: { payer?: string; attempt?: boolean } = {}): Promise<boolean> {
|
|
152
119
|
const inv = ctx.get(INV, invoiceId);
|
|
153
|
-
const sub = inv && typeof inv.subscription === 'string' ? ctx
|
|
120
|
+
const sub = inv && typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
|
|
154
121
|
if (!inv || !sub || inv.status !== 'draft') return false;
|
|
155
122
|
const total = Number(inv.total) || 0;
|
|
156
123
|
const numbered = { ...finalizedFields(ctx, inv, at), status_transitions: { ...(inv.status_transitions as Row), finalized_at: at } };
|
|
@@ -172,7 +139,7 @@ export async function collectSubscriptionInvoice(ctx: HandlerContext, invoiceId:
|
|
|
172
139
|
const piId = ctx.mint('payment_intent');
|
|
173
140
|
const currency = String(inv.currency ?? 'usd');
|
|
174
141
|
const chargeId = pays ? await mintCharge(ctx, piId, { currency, customer: inv.customer, payment_method: payer, invoice: invoiceId }, total) : null;
|
|
175
|
-
await created(ctx, 'payment_intent', { id: piId, amount: total, currency, customer: inv.customer, invoice: invoiceId, payment_method: pays ? payer : null }, {
|
|
142
|
+
const intent = await created(ctx, 'payment_intent', { id: piId, amount: total, currency, customer: inv.customer, invoice: invoiceId, payment_method: pays ? payer : null }, {
|
|
176
143
|
status: pays ? 'succeeded' : 'requires_payment_method', client_secret: mintClientSecret(piId, await ctx.secret(`client-secret:${piId}`)), livemode: false,
|
|
177
144
|
capture_method: 'automatic', amount_capturable: 0, amount_received: pays ? total : 0, next_action: null, latest_charge: chargeId,
|
|
178
145
|
last_payment_error: decline ? { type: 'card_error', code: decline.code, decline_code: decline.decline_code ?? null, message: decline.message, payment_method: { id: payer } } : null,
|
|
@@ -180,6 +147,8 @@ export async function collectSubscriptionInvoice(ctx: HandlerContext, invoiceId:
|
|
|
180
147
|
});
|
|
181
148
|
await ctx.write(INV, invoiceId, {
|
|
182
149
|
...numbered, status: pays ? 'paid' : 'open', paid: pays, attempt_count: attempt ? 1 : 0, attempted: attempt, payment_intent: piId, charge: chargeId,
|
|
150
|
+
// source: spec:/components/schemas/invoice/properties/confirmation_secret/description "client_secret of the PaymentIntent"
|
|
151
|
+
confirmation_secret: { type: 'payment_intent', client_secret: intent.client_secret },
|
|
183
152
|
amount_paid: pays ? total : 0, amount_remaining: pays ? 0 : total, auto_advance: attempt && !pays, next_payment_attempt: null,
|
|
184
153
|
status_transitions: { ...numbered.status_transitions, paid_at: pays ? at : null },
|
|
185
154
|
}, pays ? 'invoice.paid' : attempt ? 'invoice.payment_failed' : 'invoice.finalized');
|
|
@@ -213,7 +182,7 @@ export async function payOpenInvoice(ctx: HandlerContext, invoiceId: string, pay
|
|
|
213
182
|
attempt_count: (Number(inv.attempt_count) || 0) + 1, attempted: true,
|
|
214
183
|
status_transitions: { ...(inv.status_transitions as Row), paid_at: now },
|
|
215
184
|
}, 'invoice.paid');
|
|
216
|
-
const sub = typeof inv.subscription === 'string' ? ctx
|
|
185
|
+
const sub = typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
|
|
217
186
|
if (sub && sub.status === 'past_due' && !ctx.legal(SUB, 'status', op, 'past_due', 'active', String(sub.id), 'external')) {
|
|
218
187
|
await ctx.write(SUB, String(sub.id), { status: 'active' }, 'customer.subscription.updated');
|
|
219
188
|
}
|
|
@@ -235,14 +204,14 @@ export async function advanceBilling(ctx: HandlerContext, only?: (sub: Row) => b
|
|
|
235
204
|
export async function billingStep(ctx: HandlerContext, at: (t: number) => Promise<HandlerContext>, only?: (sub: Row) => boolean): Promise<boolean> {
|
|
236
205
|
// the earliest move that has come due, of any subscription
|
|
237
206
|
let next: { t: number; kind: 'period' | 'collect'; id: string } | undefined;
|
|
238
|
-
for (const sub of ctx.rows(SUB)) {
|
|
207
|
+
for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s))) {
|
|
239
208
|
if (only && !only(sub)) continue;
|
|
240
209
|
if (!RENEWING.includes(String(sub.status)) || typeof sub.current_period_end !== 'number' || sub.pause_collection) continue;
|
|
241
210
|
if (sub.current_period_end <= clockNow(ctx, sub.customer) && (!next || sub.current_period_end < next.t)) next = { t: sub.current_period_end, kind: 'period', id: String(sub.id) };
|
|
242
211
|
}
|
|
243
212
|
for (const inv of ctx.rows(INV)) {
|
|
244
213
|
if (inv.status !== 'draft' || inv.billing_reason !== 'subscription_cycle' || inv.auto_advance !== true) continue;
|
|
245
|
-
const sub = typeof inv.subscription === 'string' ? ctx
|
|
214
|
+
const sub = typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
|
|
246
215
|
if (!sub || (only && !only(sub))) continue;
|
|
247
216
|
const t = Number(inv.created) + HOUR;
|
|
248
217
|
if (t <= clockNow(ctx, sub.customer) && (!next || t < next.t)) next = { t, kind: 'collect', id: String(inv.id) };
|
|
@@ -254,7 +223,7 @@ export async function billingStep(ctx: HandlerContext, at: (t: number) => Promis
|
|
|
254
223
|
return true;
|
|
255
224
|
}
|
|
256
225
|
const id = next.id;
|
|
257
|
-
const sub = c
|
|
226
|
+
const sub = resourceView(c, SUB, id)!;
|
|
258
227
|
const op = c.call.operation.id;
|
|
259
228
|
// one set to cancel at its period's end is canceled then, with no renewal
|
|
260
229
|
// (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle)
|
|
@@ -275,8 +244,6 @@ export async function billingStep(ctx: HandlerContext, at: (t: number) => Promis
|
|
|
275
244
|
|
|
276
245
|
export const account = (ctx: HandlerContext): string | undefined => actingAccount(ctx);
|
|
277
246
|
|
|
278
|
-
export const payoutMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such payout: '${id}'`, 404, 'resource_missing');
|
|
279
|
-
|
|
280
247
|
/** The acting account's Balance object (docs.stripe.com/api/balance/balance_object). */
|
|
281
248
|
export function balanceBody(ctx: HandlerContext, acct: string | undefined = account(ctx)): Row {
|
|
282
249
|
const { available, pending, issuing } = balanceOf(ctx, acct);
|
|
@@ -446,120 +413,8 @@ export async function openReview(ctx: HandlerContext, chargeId: string, pi: { pa
|
|
|
446
413
|
}, 'review.opened');
|
|
447
414
|
}
|
|
448
415
|
|
|
449
|
-
/** The mandate a verified bank account carries for a payment (single use) or a setup (multi use). */
|
|
450
|
-
export async function mintMandate(ctx: HandlerContext, paymentMethod: unknown, use: 'single_use' | 'multi_use', amount?: number, currency?: string): Promise<string> {
|
|
451
|
-
const now = Number(nowUnix(ctx.occurredAt));
|
|
452
|
-
const mandate = await created(ctx, 'mandate', { payment_method: typeof paymentMethod === 'string' ? paymentMethod : null }, {
|
|
453
|
-
status: 'active', type: use, livemode: false,
|
|
454
|
-
customer_acceptance: { type: 'online', accepted_at: now, online: { ip_address: '127.0.0.1', user_agent: 'twin' } },
|
|
455
|
-
payment_method_details: { type: 'us_bank_account', us_bank_account: { collection_method: 'paper' } },
|
|
456
|
-
...(use === 'single_use' ? { single_use: { amount: amount ?? 0, currency: currency ?? 'usd' } } : { multi_use: {} }),
|
|
457
|
-
}, { timeField: '_created' });
|
|
458
|
-
return String(mandate.id);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
416
|
export const customerMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such customer: '${id}'`, 404, 'resource_missing');
|
|
462
417
|
|
|
463
|
-
/** The discount a customer's `coupon` makes, a parameter of API versions before the served one. */
|
|
464
|
-
export function customerDiscount(ctx: HandlerContext, couponId: string): Row | Response {
|
|
465
|
-
const coupon = ctx.get('coupon', couponId);
|
|
466
|
-
return coupon ? buildDiscount(coupon, '', Number(nowUnix(ctx.occurredAt))) : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** A tax ID for its owner: the account itself, or one of its customers. */
|
|
470
|
-
export async function makeTaxId(ctx: HandlerContext, customer: string | undefined): Promise<Response> {
|
|
471
|
-
const given = ctx.params;
|
|
472
|
-
const { owner: _owner, ...params } = given;
|
|
473
|
-
if (params.type === undefined || params.type === '') return fail(ctx, 'Missing required param: type.', 400, 'parameter_missing');
|
|
474
|
-
if (params.value === undefined || params.value === '') return fail(ctx, 'Missing required param: value.', 400, 'parameter_missing');
|
|
475
|
-
const vatCountry = params.type === 'eu_vat' ? euVatCountry(String(params.value)) : undefined;
|
|
476
|
-
if (params.type === 'eu_vat' && !vatCountry) return ctx.refuse({ status: 400, code: 'tax_id_invalid', param: 'value', message: `Invalid value for eu_vat.` });
|
|
477
|
-
return ctx.reply(
|
|
478
|
-
await created(ctx, 'tax_id', { ...params, ...(customer ? { customer } : {}) }, {
|
|
479
|
-
livemode: false,
|
|
480
|
-
country: vatCountry ?? (typeof params.country === 'string' ? params.country : null),
|
|
481
|
-
owner: customer ? { type: 'customer', customer } : { type: 'self' },
|
|
482
|
-
verification: { status: AUTO_VALIDATED.has(String(params.type)) ? 'pending' : 'unavailable', verified_address: null, verified_name: null },
|
|
483
|
-
}),
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** The owner a top-level tax-ID call names: the account itself unless `owner[type]=customer` names a customer
|
|
488
|
-
* (docs.stripe.com/api/tax_ids/list: owner defaults to self). */
|
|
489
|
-
export function ownerOf(ctx: HandlerContext): { customer?: string } | Response {
|
|
490
|
-
const owner = (ctx.params.owner ?? {}) as Row;
|
|
491
|
-
const type = String(owner.type ?? 'self');
|
|
492
|
-
if (type === 'self') return {};
|
|
493
|
-
if (type !== 'customer') return fail(ctx, `Invalid owner[type]: must be one of self or customer.`, 400, 'parameter_invalid_string_enum');
|
|
494
|
-
const customer = typeof owner.customer === 'string' ? owner.customer : '';
|
|
495
|
-
if (!customer) return fail(ctx, 'Missing required param: owner[customer].', 400, 'parameter_missing');
|
|
496
|
-
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
497
|
-
return { customer };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ── the cash balance: per-currency sums of the funding ledger, and the reconciliation setting ──
|
|
501
|
-
|
|
502
|
-
export function cashBalance(ctx: HandlerContext, customer: string): Row {
|
|
503
|
-
const byCurrency: Record<string, number> = {};
|
|
504
|
-
for (const t of ctx.rows('customer_cash_balance_transaction')) {
|
|
505
|
-
if (t.customer !== customer) continue;
|
|
506
|
-
const cur = String(t.currency ?? 'usd');
|
|
507
|
-
byCurrency[cur] = (byCurrency[cur] ?? 0) + (Number(t.net_amount) || 0);
|
|
508
|
-
}
|
|
509
|
-
const cust = ctx.row('customer', customer);
|
|
510
|
-
const mode = typeof cust?._cash_balance_mode === 'string' ? cust._cash_balance_mode : 'automatic';
|
|
511
|
-
return {
|
|
512
|
-
object: 'cash_balance', customer,
|
|
513
|
-
available: Object.keys(byCurrency).length ? byCurrency : null,
|
|
514
|
-
settings: { reconciliation_mode: mode, using_merchant_default: mode === 'merchant_default' },
|
|
515
|
-
livemode: false,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** Automatic reconciliation (docs.stripe.com/payments/customer-balance/reconciliation): funds that arrive pay the
|
|
520
|
-
* customer's open invoices that take bank transfers (payment_method_types customer_balance), the one whose number
|
|
521
|
-
* the transfer's reference names first, then the oldest. Where the documentation stops and the twin decides: an
|
|
522
|
-
* invoice is paid only when the balance covers all of what remains; a smaller balance waits for more funds. */
|
|
523
|
-
export async function reconcile(ctx: HandlerContext, customer: string, currency: string, reference: string | null): Promise<void> {
|
|
524
|
-
const takesTransfers = (inv: Row): boolean => ((inv.payment_settings as Row | undefined)?.payment_method_types as unknown[] | undefined)?.includes('customer_balance') === true;
|
|
525
|
-
const open = ctx.rows('invoice').filter((inv) => inv.customer === customer && inv.status === 'open' && String(inv.currency ?? 'usd') === currency && takesTransfers(inv))
|
|
526
|
-
.sort((a, b) => (reference && a.number === reference ? -1 : reference && b.number === reference ? 1 : Number(a.created) - Number(b.created)));
|
|
527
|
-
for (const inv of open) {
|
|
528
|
-
const available = Number(((cashBalance(ctx, customer).available as Row | null) ?? {})[currency] ?? 0);
|
|
529
|
-
const amount = Number(inv.amount_remaining ?? inv.amount_due) || 0;
|
|
530
|
-
if (amount <= 0 || available < amount) continue;
|
|
531
|
-
const id = String(inv.id);
|
|
532
|
-
if (ctx.legal('invoice', 'status', 'PostTestHelpersCustomersCustomerFundCashBalance', inv.status, 'paid', id)) continue;
|
|
533
|
-
// the payment the balance makes: the invoice's own PaymentIntent (every invoice finalized with an amount due has one,
|
|
534
|
-
// semantics/invoices.ts), paid from the cash balance, its charge, and the ledger's entry
|
|
535
|
-
const piId = String(inv.payment_intent);
|
|
536
|
-
await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'applied_to_payment', net_amount: -amount, ending_balance: available - amount, applied_to_payment: { payment_intent: piId }, livemode: false });
|
|
537
|
-
const charge = await mintCharge(ctx, piId, { currency, customer, invoice: id }, amount);
|
|
538
|
-
await ctx.write('payment_intent', piId, { status: 'succeeded', amount_received: amount, latest_charge: charge }, 'payment_intent.succeeded');
|
|
539
|
-
await ctx.write('invoice', id, {
|
|
540
|
-
status: 'paid', paid: true, amount_paid: amount, amount_remaining: 0, payment_intent: piId, charge,
|
|
541
|
-
status_transitions: { ...((inv.status_transitions as Row | undefined) ?? {}), paid_at: nowUnix(ctx.occurredAt) },
|
|
542
|
-
}, 'invoice.paid');
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/** The customer's source the path names, or undefined. */
|
|
547
|
-
export const customerSource = (ctx: HandlerContext): Row | undefined => {
|
|
548
|
-
const s = ctx.get('source', at(ctx, 'id'));
|
|
549
|
-
return s && s.customer === at(ctx, 'customer') ? s : undefined;
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
export const sourceMissing = (ctx: HandlerContext): Response => fail(ctx, `No such source: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
553
|
-
|
|
554
|
-
/** The intent the path names, and the machine's refusal when this operation may not move it. */
|
|
555
|
-
export function load(ctx: HandlerContext, operationId: string): { si: Row } | { answer: Response } {
|
|
556
|
-
const id = at(ctx, 'intent');
|
|
557
|
-
const si = ctx.get(SI, id);
|
|
558
|
-
if (!si) return { answer: fail(ctx, `No such setup_intent: '${id}'`, 404, 'resource_missing') };
|
|
559
|
-
const refused = ctx.legal(SI, 'status', operationId, si.status, undefined, id);
|
|
560
|
-
return refused ? { answer: ctx.refuse(refused) } : { si };
|
|
561
|
-
}
|
|
562
|
-
|
|
563
418
|
/** The request event asked of the endpoint, signed with its secret and rendered in the event's API version, and its
|
|
564
419
|
* answer read as the decision. */
|
|
565
420
|
export async function askForAuthorization(ctx: HandlerContext, url: string, event: Record<string, unknown>, secret: string): Promise<StripeAuthRequestOutcome> {
|
|
@@ -576,65 +431,20 @@ export function publishableKeyRefused(ctx: HandlerContext): Response | undefined
|
|
|
576
431
|
// the key however it is sent, Bearer or Basic's user (requestKey): a publishable key is public, and sent as Basic it
|
|
577
432
|
// is still one
|
|
578
433
|
if (!requestKey(ctx.call.request).startsWith('pk_')) return undefined;
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (!scoped) return ctx.refuse({ status: 403, code: 'secret_key_required', message: 'The API key provided is a publishable key, but a secret key is required.' });
|
|
583
|
-
const secret = typeof ctx.params.client_secret === 'string' ? ctx.params.client_secret : '';
|
|
584
|
-
if (!secret) return ctx.refuse({ status: 400, code: 'parameter_missing', param: 'client_secret', message: 'Missing required param: client_secret.' });
|
|
585
|
-
const id = ctx.call.params[scoped.param] ?? '';
|
|
586
|
-
return ctx.get(scoped.type, id)?.client_secret === secret ? undefined : ctx.refuse({ status: 404, code: 'resource_missing', param: scoped.param, message: `No such ${scoped.type}: '${id}'` });
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// ── an account whose OAuth connection was revoked ──
|
|
590
|
-
// "After revocation, the account can't be accessed by your platform in the Dashboard or through the API"
|
|
591
|
-
// (docs.stripe.com/connect/oauth-reference, deauthorize; so too a connection revoked by a reused code). A call made as it
|
|
592
|
-
// (the Stripe-Account header) or naming it (/v1/accounts/{account}…) is refused with the error Stripe documents for a
|
|
593
|
-
// Stripe-Account the key cannot use: "account_invalid | The account ID provided as a value for the Stripe-Account header
|
|
594
|
-
// is invalid" (docs.stripe.com/error-codes), as 403. Where the documentation stops and the twin decides: the message's
|
|
595
|
-
// wording; an account the platform created itself, or never connected by OAuth, is not affected.
|
|
596
|
-
export function revokedAccountRefused(ctx: HandlerContext): Response | undefined {
|
|
597
|
-
const request = ctx.call.request;
|
|
598
|
-
const named = [request.headers.get('stripe-account') ?? undefined, ctx.call.operation.path.startsWith('/v1/accounts/{account}') ? ctx.call.params.account : undefined].filter((a): a is string => !!a);
|
|
599
|
-
const revoked = named.find((a) => connectionRevoked(ctx, a));
|
|
600
|
-
if (!revoked) return undefined;
|
|
601
|
-
return ctx.refuse({ status: 403, code: 'account_invalid', message: `The provided key '${redactKey(requestKey(request))}' does not have access to account '${revoked}' (or that account does not exist). Application access may have been revoked.` });
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// A connected account's OAuth keys (../screens/connect-oauth.tsx): the token endpoint's access_token is "Use the
|
|
605
|
-
// Stripe-Account header with your platform's secret key (that can make requests on behalf of this Stripe account)" and
|
|
606
|
-
// its stripe_publishable_key the same with the publishable key (docs.stripe.com/connect/oauth-reference): a request made
|
|
607
|
-
// with one acts as that account, as a request with the Stripe-Account header does. A key no live connection holds (its
|
|
608
|
-
// connection revoked, or replaced by a refresh: "Any existing access token with the same scope and mode ... is
|
|
609
|
-
// revoked") is Stripe's unknown key, 401 "Invalid API Key provided" (docs.stripe.com/api/authentication); one sent with
|
|
610
|
-
// a Stripe-Account header naming another account cannot act as it (account_invalid). Where the documentation stops and
|
|
611
|
-
// the twin decides: the messages' wording; a read_only key is not held to reads (manifest todo
|
|
612
|
-
// stripe.connect.oauth_scope_enforcement).
|
|
434
|
+
return ctx.refuse({ status: 403, code: 'secret_key_required', message: 'The API key provided is a publishable key, but a secret key is required.' });
|
|
435
|
+
}
|
|
436
|
+
|
|
613
437
|
/** The platform's own keys, as the dashboard's API keys page makes them (the `appCredentials` door): a secret key and a
|
|
614
438
|
* publishable key, held by their SHA-256. A request with no key is refused, as is a platform key Stripe never issued
|
|
615
|
-
* ("Invalid API Key provided")
|
|
439
|
+
* ("Invalid API Key provided"). */
|
|
616
440
|
// source: https://docs.stripe.com/api/authentication "API requests without authentication also fail."
|
|
617
441
|
export function platformKeyRefused(ctx: HandlerContext, request: Request): Response | undefined {
|
|
618
442
|
const key = requestKey(request);
|
|
619
443
|
if (!key) return ctx.refuse({ status: 401, message: "You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY'). See https://stripe.com/docs/api#authentication for details, or we can help at https://support.stripe.com/." });
|
|
620
|
-
if (/^(sk|pk)_test_oauth_/.test(key)) return undefined;
|
|
621
444
|
const held = ctx.rowsRaw('_platform_key').some((k) => k._sha256 === ctx.crypto.sha256(key) && k.deleted !== true);
|
|
622
445
|
return held ? undefined : ctx.refuse({ status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
|
|
623
446
|
}
|
|
624
447
|
|
|
625
|
-
export async function actingAsOAuthKey(ctx: HandlerContext, request: Request): Promise<Request | Response> {
|
|
626
|
-
const key = requestKey(request);
|
|
627
|
-
if (!/^(sk|pk)_test_oauth_/.test(key)) return request;
|
|
628
|
-
const held = oauthKeyAccount(ctx, key);
|
|
629
|
-
if (!held) return ctx.refuse({ status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
|
|
630
|
-
const named = request.headers.get('stripe-account');
|
|
631
|
-
if (named && named !== held.account) return ctx.refuse({ status: 403, code: 'account_invalid', message: `The provided key '${redactKey(key)}' does not have access to account '${named}' (or that account does not exist). Application access may have been revoked.` });
|
|
632
|
-
const headers = new Headers(request.headers);
|
|
633
|
-
headers.set('stripe-account', held.account);
|
|
634
|
-
const bodied = request.method !== 'GET' && request.method !== 'HEAD';
|
|
635
|
-
return new Request(request.url, { method: request.method, headers, ...(bodied ? { body: await request.arrayBuffer() } : {}) });
|
|
636
|
-
}
|
|
637
|
-
|
|
638
448
|
// ── metadata ──
|
|
639
449
|
// "This parameter uses a merge mechanism, which allows you to add new key-value pairs to an object in an update call
|
|
640
450
|
// without affecting any existing metadata"; "Pass in the key with an empty string as the value to remove the key from the
|
|
@@ -655,8 +465,7 @@ export function withMetadata(ctx: HandlerContext, request: Request): Request {
|
|
|
655
465
|
else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) metadata = Object.fromEntries(Object.entries(given as Row).filter(([, v]) => v !== ''));
|
|
656
466
|
else return request;
|
|
657
467
|
} else if (op.class === 'update' && op.resource) {
|
|
658
|
-
|
|
659
|
-
const resource = op.resource === 'plan' ? 'price' : op.resource;
|
|
468
|
+
const resource = op.resource;
|
|
660
469
|
const id = Object.values(ctx.call.params).at(-1) ?? '';
|
|
661
470
|
const held = ctx.get(resource, id)?.metadata;
|
|
662
471
|
const prior = held && typeof held === 'object' ? (held as Row) : {};
|
|
@@ -671,191 +480,6 @@ export function withMetadata(ctx: HandlerContext, request: Request): Request {
|
|
|
671
480
|
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...ctx.params, metadata }) });
|
|
672
481
|
}
|
|
673
482
|
|
|
674
|
-
/** `disabled` switches delivery off or back on (docs.stripe.com/api/webhook_endpoints/update#update_webhook_endpoint-disabled). */
|
|
675
|
-
export async function switchDelivery(ctx: HandlerContext, id: string, we: Row, fields: Row, off: boolean): Promise<Response> {
|
|
676
|
-
const status = off ? 'disabled' : 'enabled';
|
|
677
|
-
const refused = ctx.legal(WE, 'status', 'PostWebhookEndpointsWebhookEndpoint', we.status, status, id);
|
|
678
|
-
if (refused) return ctx.refuse(refused);
|
|
679
|
-
return ctx.reply(await ctx.write(WE, id, { ...fields, status }, 'webhook_endpoint.update'));
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/** Whether a stored event is the acting account's (no `account`: the platform's). */
|
|
683
|
-
export const ownEvent = (ctx: HandlerContext, e: Row): boolean => (typeof e.account === 'string' ? e.account : undefined) === actingAccount(ctx);
|
|
684
|
-
|
|
685
|
-
export const faMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such financial account: '${id}'`, status, 'resource_missing');
|
|
686
|
-
|
|
687
|
-
export const metadataOf = (ctx: HandlerContext): unknown => (ctx.params.metadata && typeof ctx.params.metadata === 'object' ? ctx.params.metadata : {});
|
|
688
|
-
|
|
689
|
-
/** The account's bank address once financial_addresses.aba is active: "The FinancialAccount gains a FinancialAddress
|
|
690
|
-
* when the `financial_addresses.aba` feature is active", and "A FinancialAddress is not added until the
|
|
691
|
-
* financial_addresses.aba feature has been activated" (docs.stripe.com/treasury/account-management/financial-accounts),
|
|
692
|
-
* whose test example answers "bank_name": "Stripe Test Bank", "routing_number": "000000001" and the networks "ach",
|
|
693
|
-
* "us_domestic_wire", "rtp"; "The full account_number is only returned if the request expands it" (the same page).
|
|
694
|
-
* Where the documentation stops and the twin decides: the account number is ten digits drawn from the account's id,
|
|
695
|
-
* answered by its last four (the full number is null: expanding it is not modelled), and the holder's name is the
|
|
696
|
-
* platform account's business name, its dashboard name or, with neither, its id. */
|
|
697
|
-
export function abaAddress(ctx: HandlerContext, faId: string): Row {
|
|
698
|
-
let n = 0;
|
|
699
|
-
for (const ch of faId) n = (n * 31 + ch.charCodeAt(0)) % 10_000_000_000;
|
|
700
|
-
const number = String(n).padStart(10, '0');
|
|
701
|
-
const platform = ctx.get('account', PLATFORM_ACCOUNT_ID) as Row | undefined;
|
|
702
|
-
const name = ((platform?.business_profile as Row | undefined)?.name ?? ((platform?.settings as Row | undefined)?.dashboard as Row | undefined)?.display_name ?? PLATFORM_ACCOUNT_ID) as string;
|
|
703
|
-
return { type: 'aba', supported_networks: ['ach', 'us_domestic_wire', 'rtp'], aba: { account_holder_name: name, account_number: null, account_number_last4: number.slice(-4), bank_name: 'Stripe Test Bank', routing_number: '000000001' } };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
export const addressesOf = (ctx: HandlerContext, faId: string, active: string[]): Row[] => (active.includes('financial_addresses.aba') ? [abaAddress(ctx, faId)] : []);
|
|
707
|
-
|
|
708
|
-
/** Post a flow's double entry: a ledger transaction with its net balance impact, and its one entry. */
|
|
709
|
-
export async function post(ctx: HandlerContext, o: {
|
|
710
|
-
financial_account: string; amount: number; currency: string; flow: string; flow_type: string;
|
|
711
|
-
status: 'open' | 'posted'; balance_impact: Row; description: string; flowDetailKey?: string;
|
|
712
|
-
}): Promise<string> {
|
|
713
|
-
const now = nowUnix(ctx.occurredAt);
|
|
714
|
-
const txnId = ctx.mint(TX);
|
|
715
|
-
await ctx.write(TX, txnId, {
|
|
716
|
-
object: 'treasury.transaction', created: now, livemode: false,
|
|
717
|
-
amount: o.amount, currency: o.currency, financial_account: o.financial_account,
|
|
718
|
-
flow: o.flow, flow_type: o.flow_type, flow_details: null,
|
|
719
|
-
status: o.status, balance_impact: o.balance_impact, description: o.description,
|
|
720
|
-
entries: { object: 'list', has_more: false, url: `/v1/treasury/transaction_entries?transaction=${txnId}`, data: [] },
|
|
721
|
-
status_transitions: { posted_at: o.status === 'posted' ? now : null, void_at: null },
|
|
722
|
-
}, 'treasury_transaction.created');
|
|
723
|
-
await ctx.write(ENTRY, ctx.mint(ENTRY), {
|
|
724
|
-
object: 'treasury.transaction_entry', created: now, livemode: false,
|
|
725
|
-
amount: o.amount, currency: o.currency, financial_account: o.financial_account,
|
|
726
|
-
flow: o.flow, flow_type: o.flow_type,
|
|
727
|
-
flow_details: o.flowDetailKey ? { type: o.flowDetailKey, [o.flowDetailKey]: o.flow } : null,
|
|
728
|
-
transaction: txnId, effective_at: now,
|
|
729
|
-
balance_impact: o.balance_impact,
|
|
730
|
-
// what the entry records: its flow's kind where Stripe names one (docs.stripe.com/api/treasury/transaction_entries/object);
|
|
731
|
-
// a vendor `type`, kept apart from the kernel's row type
|
|
732
|
-
type: ENTRY_TYPES.has(String(o.flow_type)) ? o.flow_type : 'other',
|
|
733
|
-
}, 'treasury_transaction_entry.created');
|
|
734
|
-
// the account's balance moves by the entry's impact: cash "Funds the user can spend right now", inbound_pending
|
|
735
|
-
// "Funds not spendable yet", outbound_pending "held for pending outbound flows" (the served spec's balance)
|
|
736
|
-
const account = ctx.get(FA, o.financial_account);
|
|
737
|
-
if (account) {
|
|
738
|
-
const bal = (account.balance as Record<string, Record<string, number>> | undefined) ?? { cash: {}, inbound_pending: {}, outbound_pending: {} };
|
|
739
|
-
const moved = Object.fromEntries((['cash', 'inbound_pending', 'outbound_pending'] as const).map((k) => [k, { ...(bal[k] ?? {}), [o.currency]: ((bal[k] ?? {})[o.currency] ?? 0) + (Number(o.balance_impact[k]) || 0) }]));
|
|
740
|
-
await ctx.write(FA, o.financial_account, { balance: moved }, 'financial_account.balance_updated');
|
|
741
|
-
}
|
|
742
|
-
return txnId;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/** The request's funding account, or the refusal. */
|
|
746
|
-
export function fundingAccount(ctx: HandlerContext): string | Response {
|
|
747
|
-
const bad = validateMoney(ctx.params);
|
|
748
|
-
if (bad) return send(ctx, bad);
|
|
749
|
-
const fa = typeof ctx.params.financial_account === 'string' ? ctx.params.financial_account : '';
|
|
750
|
-
if (!fa) return fail(ctx, 'Missing required param: financial_account.', 400, 'parameter_missing');
|
|
751
|
-
return ctx.get(FA, fa) ? fa : faMissing(ctx, fa, 400);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/** Where an outbound flow sends its money, as the served spec's payment method details give it: the bank account's
|
|
755
|
-
* holder type, last four digits and routing number from what the request sent (a saved method's, or the test bank's
|
|
756
|
-
* when none is given), and its billing details (required): the name and email sent, the address lines null where not
|
|
757
|
-
* given. Where the documentation stops and the twin decides: a saved method's details are the test bank's. */
|
|
758
|
-
export function destinationDetails(ctx: HandlerContext, dest: string | null): Row {
|
|
759
|
-
const data = ctx.params.destination_payment_method_data && typeof ctx.params.destination_payment_method_data === 'object' ? (ctx.params.destination_payment_method_data as Row) : {};
|
|
760
|
-
const bank = data.us_bank_account && typeof data.us_bank_account === 'object' ? (data.us_bank_account as Row) : {};
|
|
761
|
-
const billing = data.billing_details && typeof data.billing_details === 'object' ? (data.billing_details as Row) : {};
|
|
762
|
-
const address = billing.address && typeof billing.address === 'object' ? (billing.address as Row) : {};
|
|
763
|
-
const number = typeof bank.account_number === 'string' ? bank.account_number.replace(/\D/g, '') : '';
|
|
764
|
-
const billing_details = { name: billing.name ?? null, email: billing.email ?? null, address: Object.fromEntries(['city', 'country', 'line1', 'line2', 'postal_code', 'state'].map((k) => [k, address[k] ?? null])) };
|
|
765
|
-
if (dest && data.type === undefined && dest.startsWith('fa_')) return { type: 'financial_account', financial_account: { id: dest, network: 'stripe' }, billing_details };
|
|
766
|
-
return {
|
|
767
|
-
type: 'us_bank_account', billing_details,
|
|
768
|
-
us_bank_account: { last4: number ? number.slice(-4) : '6789', network: 'ach', routing_number: typeof bank.routing_number === 'string' ? bank.routing_number : '110000000', account_holder_type: typeof bank.account_holder_type === 'string' ? bank.account_holder_type : 'individual', account_type: typeof bank.account_type === 'string' ? bank.account_type : 'checking', bank_name: 'STRIPE TEST BANK', mandate: null },
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/** An outbound payment that names no destination: "You must provide either destination_payment_method or
|
|
773
|
-
* destination_payment_method_data" (the served spec's outbound payment create). */
|
|
774
|
-
export function noDestination(ctx: HandlerContext): Response {
|
|
775
|
-
return fail(ctx, 'You must provide either `destination_payment_method` or `destination_payment_method_data`.', 400, 'parameter_missing');
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/** The bank account an inbound transfer may pull from: "You must first set up the account-attached payment method for
|
|
779
|
-
* inbound flows and verify the bank account using a SetupIntent", and invalid ones ("of unsupported types, containing
|
|
780
|
-
* an unverified bank account, or not set up for inbound flows") "throw the same errors as in live mode"
|
|
781
|
-
* (docs.stripe.com/treasury/moving-money/financial-accounts/into/inbound-transfers). A payment method is set up for
|
|
782
|
-
* inbound flows by a SetupIntent whose flow_directions holds `inbound`, and verified when that SetupIntent succeeded
|
|
783
|
-
* ("The bank account has been instantly verified or verification isn't necessary", its `succeeded` status,
|
|
784
|
-
* docs.stripe.com/treasury/connect/legacy/v1/moving-money/working-with-bankaccount-objects).
|
|
785
|
-
* Where the documentation stops and the twin decides: the page quotes no error, so the twin answers the documented code
|
|
786
|
-
* for the class, payment_method_unexpected_state ("The provided payment method's state was incompatible with the
|
|
787
|
-
* operation you were trying to perform", docs.stripe.com/error-codes), in its own words; an existing verified
|
|
788
|
-
* BankAccount (`ba_`), which the page also allows, is not checked. Answers the payment method, or the refusal. */
|
|
789
|
-
export function inboundOrigin(ctx: HandlerContext, origin: string): Row | Response | undefined {
|
|
790
|
-
if (origin.startsWith('ba_')) return undefined;
|
|
791
|
-
const pm = ctx.get('payment_method', origin);
|
|
792
|
-
if (!pm) return fail(ctx, `No such PaymentMethod: '${origin}'`, 400, 'resource_missing');
|
|
793
|
-
if (pm.type !== 'us_bank_account') return fail(ctx, `The PaymentMethod '${origin}' is of type ${String(pm.type)}; an InboundTransfer pulls from a us_bank_account.`, 400, 'payment_method_unexpected_state');
|
|
794
|
-
const setUp = ctx.rows('setup_intent').some((si) => si.payment_method === origin && si.status === 'succeeded' && Array.isArray(si.flow_directions) && si.flow_directions.map(String).includes('inbound'));
|
|
795
|
-
if (!setUp) return fail(ctx, `The PaymentMethod '${origin}' has not been set up for inbound flows and verified with a SetupIntent.`, 400, 'payment_method_unexpected_state');
|
|
796
|
-
return pm;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// test mode's stand-ins for money another party sends to the account or pulls from it
|
|
800
|
-
// (docs.stripe.com/treasury/moving-money/receiving-funds, docs.stripe.com/api/treasury/received_credits/test_mode_create)
|
|
801
|
-
export function received(kind: 'received_credit' | 'received_debit'): Handler {
|
|
802
|
-
return async (ctx) => {
|
|
803
|
-
const fa = fundingAccount(ctx);
|
|
804
|
-
if (fa instanceof Response) return fa;
|
|
805
|
-
const network = typeof ctx.params.network === 'string' ? ctx.params.network : '';
|
|
806
|
-
if (!['ach', 'us_domestic_wire', 'stripe'].includes(network)) return fail(ctx, 'Missing required param: network.', 400, 'parameter_missing');
|
|
807
|
-
const amount = ctx.params.amount as number;
|
|
808
|
-
const currency = String(ctx.params.currency);
|
|
809
|
-
const id = ctx.mint(`treasury.${kind}`);
|
|
810
|
-
const signed = kind === 'received_credit' ? amount : -amount;
|
|
811
|
-
const txn = await post(ctx, { financial_account: fa, amount: signed, currency, flow: id, flow_type: kind, status: 'posted', balance_impact: { cash: signed, inbound_pending: 0, outbound_pending: 0 }, description: kind === 'received_credit' ? 'ReceivedCredit' : 'ReceivedDebit', flowDetailKey: kind });
|
|
812
|
-
return ctx.reply(
|
|
813
|
-
await created(ctx, `treasury.${kind}`, { id }, {
|
|
814
|
-
amount, currency, financial_account: fa, network, livemode: false, status: 'succeeded', failure_code: null,
|
|
815
|
-
description: typeof ctx.params.description === 'string' ? ctx.params.description : kind === 'received_credit' ? 'Received credit' : 'Received debit',
|
|
816
|
-
hosted_regulatory_receipt_url: null, transaction: txn, reversal_details: null,
|
|
817
|
-
initiating_payment_method_details: { type: 'us_bank_account', billing_details: { address: {}, email: null, name: null }, us_bank_account: { bank_name: 'STRIPE TEST BANK', last4: '6789', routing_number: '110000000' } },
|
|
818
|
-
linked_flows: kind === 'received_credit' ? { credit_reversal: null, issuing_authorization: null, issuing_transaction: null, source_flow: null, source_flow_type: null } : { debit_reversal: null, inbound_transfer: null, issuing_authorization: null, issuing_transaction: null, payout: null },
|
|
819
|
-
}),
|
|
820
|
-
);
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/** Cancel a flow still processing; an outbound one must also still be cancelable. */
|
|
825
|
-
export function cancel(resource: string, param: string, name: string, operationId: string, op: string, checkCancelable: boolean): Handler {
|
|
826
|
-
return async (ctx) => {
|
|
827
|
-
const id = at(ctx, param);
|
|
828
|
-
const flow = ctx.get(resource, id);
|
|
829
|
-
if (!flow) return fail(ctx, `No such ${name}: '${id}'`, 404, 'resource_missing');
|
|
830
|
-
// a processing flow that is no longer cancelable is refused as one no longer processing
|
|
831
|
-
const refused = ctx.legal(resource, 'status', operationId, checkCancelable && flow.cancelable !== true ? 'not_cancelable' : flow.status, undefined, id);
|
|
832
|
-
if (refused) return ctx.refuse(refused);
|
|
833
|
-
// a canceled flow's open transaction is voided and what it held returns to the account (the reverse of its
|
|
834
|
-
// balance impact). Where the documentation stops and the twin decides: a flow already posted is only marked canceled
|
|
835
|
-
const txn = typeof flow.transaction === 'string' ? ctx.get(TX, flow.transaction) : undefined;
|
|
836
|
-
if (txn && txn.status === 'open') {
|
|
837
|
-
await ctx.write(TX, String(txn.id), { status: 'void', status_transitions: { ...(txn.status_transitions as object), void_at: nowUnix(ctx.occurredAt) } }, 'treasury_transaction.voided');
|
|
838
|
-
const account = ctx.get(FA, String(txn.financial_account));
|
|
839
|
-
const impact = (txn.balance_impact as Record<string, number> | undefined) ?? {};
|
|
840
|
-
const cur = String(txn.currency ?? 'usd');
|
|
841
|
-
if (account) {
|
|
842
|
-
const bal = (account.balance as Record<string, Record<string, number>> | undefined) ?? { cash: {}, inbound_pending: {}, outbound_pending: {} };
|
|
843
|
-
const back = Object.fromEntries((['cash', 'inbound_pending', 'outbound_pending'] as const).map((k) => [k, { ...(bal[k] ?? {}), [cur]: ((bal[k] ?? {})[cur] ?? 0) - (Number(impact[k]) || 0) }]));
|
|
844
|
-
await ctx.write(FA, String(account.id), { balance: back }, 'financial_account.balance_updated');
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return ctx.reply(await ctx.write(resource, id, { status: 'canceled', cancelable: false, status_transitions: { ...(flow.status_transitions as object), canceled_at: nowUnix(ctx.occurredAt) } }, op));
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// the ledger is always read for one account
|
|
852
|
-
export function ledger(resource: string, spec: Record<string, (r: Row, v: unknown) => boolean>): Handler {
|
|
853
|
-
return async (ctx) => {
|
|
854
|
-
if (typeof ctx.params.financial_account !== 'string' || !ctx.params.financial_account) return fail(ctx, 'Missing required param: financial_account.', 400, 'parameter_missing');
|
|
855
|
-
return list(ctx, resource, where(ctx, newest(ctx, resource), spec));
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
|
|
859
483
|
export function scopeKeyOf(ctx: HandlerContext): string {
|
|
860
484
|
const scope = ctx.params.scope && typeof ctx.params.scope === 'object' ? (ctx.params.scope as Row) : undefined;
|
|
861
485
|
const scopeType = scope && typeof scope.type === 'string' ? scope.type : 'account';
|
|
@@ -866,13 +490,6 @@ export const named = (ctx: HandlerContext, name: string): Row | undefined => ctx
|
|
|
866
490
|
|
|
867
491
|
export const invoiceMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such invoice: '${id}'`, 404, 'resource_missing');
|
|
868
492
|
|
|
869
|
-
/** Write a draft's line list and the totals derived from it. */
|
|
870
|
-
export async function writeLines(ctx: HandlerContext, invoiceId: string, next: Row[]): Promise<Row> {
|
|
871
|
-
const subtotal = sumLines(next);
|
|
872
|
-
const lines = { object: 'list', data: next, has_more: false, total_count: next.length, url: `/v1/invoices/${invoiceId}/lines` };
|
|
873
|
-
return ctx.write(INV, invoiceId, { lines, subtotal, total: subtotal, amount_due: subtotal, amount_remaining: subtotal }, 'invoice.update');
|
|
874
|
-
}
|
|
875
|
-
|
|
876
493
|
/** Paying an invoice with nothing to pay it: "If not set, defaults to the subscription's default payment method, if any,
|
|
877
494
|
* or to the default payment method in the customer's invoice settings" (docs.stripe.com/api/invoices/object,
|
|
878
495
|
* default_payment_method), and none of them is set. */
|
|
@@ -886,59 +503,6 @@ export async function activatedByPayment(ctx: HandlerContext, subId: string): Pr
|
|
|
886
503
|
await ctx.write('subscription', subId, { status: 'active' }, 'subscription.update');
|
|
887
504
|
}
|
|
888
505
|
|
|
889
|
-
/** Re-derive a draft's lines from the items still on it: an item that changed or was deleted moves the invoice with it. */
|
|
890
|
-
export async function resyncDraftLines(ctx: HandlerContext, invoiceId: string): Promise<void> {
|
|
891
|
-
const inv = ctx.get(INV, invoiceId);
|
|
892
|
-
if (!inv || inv.status !== 'draft') return;
|
|
893
|
-
const next: Row[] = [];
|
|
894
|
-
for (const line of linesOf(inv)) {
|
|
895
|
-
const itemId = typeof line.invoice_item === 'string' ? line.invoice_item : '';
|
|
896
|
-
if (!itemId) next.push(unlinkedLine(line));
|
|
897
|
-
const item = itemId ? ctx.get('invoiceitem', itemId) : undefined;
|
|
898
|
-
if (item) next.push({ ...line, amount: Number(item.amount) || 0, description: (item.description as string) ?? null });
|
|
899
|
-
}
|
|
900
|
-
await writeLines(ctx, invoiceId, next);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ── invoice items: a price-based item takes its amount from the price; one naming a draft invoice
|
|
904
|
-
// lands on its lines at once ──
|
|
905
|
-
|
|
906
|
-
/** An item priced by a Price: its unit amount times the quantity, in its currency (docs.stripe.com/api/invoiceitems/create#create_invoiceitem-pricing). */
|
|
907
|
-
export function pricedItem(ctx: HandlerContext, priceRef: string, qty: number): { amount: number; currency: string | undefined } | Response {
|
|
908
|
-
const price = ctx.get('price', priceRef);
|
|
909
|
-
if (!price) return fail(ctx, `No such price: '${priceRef}'`, 400, 'resource_missing');
|
|
910
|
-
return { amount: priceAmount(price, qty), currency: typeof price.currency === 'string' ? price.currency : undefined };
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
export const itemMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such invoiceitem: '${id}'`, 404, 'resource_missing');
|
|
914
|
-
|
|
915
|
-
/** A draft invoice another object makes (an accepted quote): its customer's, with an invoice item per line the
|
|
916
|
-
* object gives, and the object named as its parent. Answers the invoice's id. */
|
|
917
|
-
export async function draftInvoiceFor(ctx: HandlerContext, customer: string, currency: string, parent: Row, lines: Array<{ price: unknown; quantity: number; amount: number; description: unknown }>): Promise<string> {
|
|
918
|
-
const now = Number(nowUnix(ctx.occurredAt));
|
|
919
|
-
const id = ctx.mint(INV);
|
|
920
|
-
const holder = ctx.get('customer', customer);
|
|
921
|
-
const inv = await created(ctx, INV, { id, customer, currency, parent }, { ...draft(now), currency, customer_email: holder?.email ?? null, customer_name: holder?.name ?? null, lines: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/invoices/${id}/lines` } });
|
|
922
|
-
for (const l of lines) {
|
|
923
|
-
const price = typeof l.price === 'string' ? l.price : l.price && typeof l.price === 'object' ? String((l.price as Row).id) : null;
|
|
924
|
-
const item = await created(ctx, 'invoiceitem', { customer, currency, amount: l.amount, quantity: l.quantity, ...(price ? { price } : {}), description: l.description ?? null }, { date: now, livemode: false, metadata: {}, discountable: true, proration: false, period: { start: now, end: now } });
|
|
925
|
-
await pullPendingItem(ctx, id, inv, item, now);
|
|
926
|
-
}
|
|
927
|
-
return id;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
/** A customer's pending invoice item, made before the invoice, pulled onto it (docs.stripe.com/api/invoices/create#create_invoice-pending_invoice_items_behavior). */
|
|
931
|
-
export async function pullPendingItem(ctx: HandlerContext, invoiceId: string, inv: Row, item: Row, now: number): Promise<void> {
|
|
932
|
-
const current = ctx.get(INV, invoiceId) ?? inv;
|
|
933
|
-
await writeLines(ctx, invoiceId, [...linesOf(current), {
|
|
934
|
-
id: `il_${String(item.id)}`, object: 'line_item', type: 'invoiceitem', amount: Number(item.amount) || 0, currency: String(current.currency ?? item.currency ?? 'usd'),
|
|
935
|
-
quantity: Number(item.quantity) || 1, proration: false, invoice_item: item.id, price: item.price ?? null,
|
|
936
|
-
period: item.period ?? { start: now, end: now }, description: (item.description as string) ?? null,
|
|
937
|
-
discountable: item.discountable ?? true, discounts: [], livemode: false, metadata: item.metadata ?? {}, tax_amounts: [], tax_rates: [],
|
|
938
|
-
}]);
|
|
939
|
-
await ctx.write('invoiceitem', String(item.id), { invoice: invoiceId }, 'invoiceitem.update');
|
|
940
|
-
}
|
|
941
|
-
|
|
942
506
|
/** The invoice the path names, and the machine's refusal when this operation may not move it. */
|
|
943
507
|
export function invoicesLoad(ctx: HandlerContext, operationId: string): { inv: Row } | { answer: Response } {
|
|
944
508
|
const id = at(ctx, 'invoice');
|
|
@@ -948,11 +512,22 @@ export function invoicesLoad(ctx: HandlerContext, operationId: string): { inv: R
|
|
|
948
512
|
return refused ? { answer: ctx.refuse(refused) } : { inv };
|
|
949
513
|
}
|
|
950
514
|
|
|
515
|
+
/** A draft finalized on its way to being paid: numbered, open, its PaymentIntent opened (PostInvoicesInvoiceFinalize). */
|
|
516
|
+
export async function finalizeForPayment(ctx: HandlerContext, inv: Row, id: string): Promise<Row> {
|
|
517
|
+
const piId = collectsThroughIntent(inv) ? ctx.mint('payment_intent') : null;
|
|
518
|
+
const now = nowUnix(ctx.occurredAt);
|
|
519
|
+
ctx.legal(INV, 'status', 'PostInvoicesInvoicePay', 'draft', 'open', id);
|
|
520
|
+
const body = await ctx.write(INV, id, { ...finalizedFields(ctx, inv, Number(now)), status: 'open', status_transitions: { ...transitions(inv), finalized_at: now }, payment_intent: piId }, 'invoice.finalize');
|
|
521
|
+
if (piId) await openPaymentIntent(ctx, piId, inv, id);
|
|
522
|
+
await spendOnceCoupon(ctx, inv);
|
|
523
|
+
return ctx.get(INV, id) ?? body;
|
|
524
|
+
}
|
|
525
|
+
|
|
951
526
|
/** The PaymentIntent a charge_automatically invoice with a balance gets when it is finalized; it
|
|
952
527
|
* waits for confirmation when the invoice already names a default payment method. */
|
|
953
528
|
export async function openPaymentIntent(ctx: HandlerContext, piId: string, inv: Row, invoiceId: string): Promise<void> {
|
|
954
529
|
const defaultPaymentMethod = typeof inv.default_payment_method === 'string' ? inv.default_payment_method : undefined;
|
|
955
|
-
await created(ctx, 'payment_intent', {
|
|
530
|
+
const intent = await created(ctx, 'payment_intent', {
|
|
956
531
|
id: piId, amount: Number(inv.amount_due) || 0, currency: String(inv.currency ?? 'usd'), customer: inv.customer, invoice: invoiceId,
|
|
957
532
|
...(defaultPaymentMethod ? { payment_method: defaultPaymentMethod } : {}),
|
|
958
533
|
}, {
|
|
@@ -961,6 +536,8 @@ export async function openPaymentIntent(ctx: HandlerContext, piId: string, inv:
|
|
|
961
536
|
capture_method: 'automatic', amount_capturable: 0, amount_received: 0, next_action: null,
|
|
962
537
|
automatic_payment_methods: null, payment_method_types: ['card', 'link'], payment_method_options: {},
|
|
963
538
|
});
|
|
539
|
+
// source: spec:/components/schemas/invoice/properties/confirmation_secret/description "client_secret of the PaymentIntent"
|
|
540
|
+
await ctx.write(INV, invoiceId, { confirmation_secret: { type: 'payment_intent', client_secret: intent.client_secret } }, 'invoice.updated');
|
|
964
541
|
}
|
|
965
542
|
|
|
966
543
|
export function finalizedFields(ctx: HandlerContext, inv: Row, now: number): Row {
|
|
@@ -976,140 +553,6 @@ export function finalizedFields(ctx: HandlerContext, inv: Row, now: number): Row
|
|
|
976
553
|
return fields;
|
|
977
554
|
}
|
|
978
555
|
|
|
979
|
-
// add_lines, update_lines and remove_lines edit a draft's lines in bulk and re-derive its totals
|
|
980
|
-
export function editLines(kind: 'add' | 'update' | 'remove'): Handler {
|
|
981
|
-
return async (ctx) => {
|
|
982
|
-
const id = at(ctx, 'invoice');
|
|
983
|
-
const inv = ctx.get(INV, id);
|
|
984
|
-
if (!inv) return invoiceMissing(ctx, id);
|
|
985
|
-
if (inv.status !== 'draft') return fail(ctx, `You can only add/update/remove lines on a draft invoice (this one is ${String(inv.status)}).`, 400, 'invoice_not_editable');
|
|
986
|
-
const cur = linesOf(inv);
|
|
987
|
-
let next = [...cur];
|
|
988
|
-
const raw = ctx.params.lines;
|
|
989
|
-
const entries = Array.isArray(raw) ? (raw as Row[]) : raw && typeof raw === 'object' ? [raw as Row] : [];
|
|
990
|
-
if (entries.length === 0) return fail(ctx, 'Missing required param: lines.', 400, 'parameter_missing');
|
|
991
|
-
const currency = String(inv.currency ?? 'usd');
|
|
992
|
-
if (kind === 'add') {
|
|
993
|
-
for (let i = 0; i < entries.length; i++) {
|
|
994
|
-
const e = entries[i]!;
|
|
995
|
-
const priceRef = typeof e.price === 'string' ? e.price : '';
|
|
996
|
-
const price = priceRef ? ctx.get('price', priceRef) : undefined;
|
|
997
|
-
if (priceRef && !price) return fail(ctx, `No such price: '${priceRef}'`, 400, 'resource_missing');
|
|
998
|
-
const quantity = e.quantity !== undefined ? Math.max(1, Math.trunc(Number(e.quantity) || 1)) : 1;
|
|
999
|
-
const unit = Math.trunc(Number(e.amount) || 0);
|
|
1000
|
-
next.push({ id: `il_twin_${id}_${cur.length + i + 1}`, object: 'line_item', amount: price ? priceAmount(price, quantity) : unit, currency, quantity, price: price ?? (priceRef || null), description: typeof e.description === 'string' ? e.description : null, proration: false });
|
|
1001
|
-
}
|
|
1002
|
-
} else if (kind === 'update') {
|
|
1003
|
-
for (const e of entries) {
|
|
1004
|
-
const lid = typeof e.id === 'string' ? e.id : '';
|
|
1005
|
-
next = next.map((l) => (l.id === lid ? { ...l, ...(e.amount !== undefined ? { amount: Math.trunc(Number(e.amount) || 0) } : {}), ...(e.quantity !== undefined ? { quantity: Math.max(1, Math.trunc(Number(e.quantity) || 1)) } : {}), ...(e.description !== undefined ? { description: e.description } : {}) } : l));
|
|
1006
|
-
}
|
|
1007
|
-
} else {
|
|
1008
|
-
const remove = new Set(entries.map((e) => (typeof e.id === 'string' ? e.id : '')).filter(Boolean));
|
|
1009
|
-
next = next.filter((l) => !remove.has(String(l.id)));
|
|
1010
|
-
}
|
|
1011
|
-
return ctx.reply(await writeLines(ctx, id, next));
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
export const noteMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such credit note: '${id}'`, 404, 'resource_missing');
|
|
1016
|
-
|
|
1017
|
-
/** The credit note the request describes (the `amount` form: one custom line), or Stripe's refusal. */
|
|
1018
|
-
export function build(ctx: HandlerContext, id: string): Row | Response {
|
|
1019
|
-
const params = ctx.params;
|
|
1020
|
-
const invoiceId = typeof params.invoice === 'string' ? params.invoice : '';
|
|
1021
|
-
if (!invoiceId) return fail(ctx, 'Missing required param: invoice.', 400, 'parameter_missing');
|
|
1022
|
-
const invoice = ctx.get('invoice', invoiceId);
|
|
1023
|
-
if (!invoice) return fail(ctx, `No such invoice: '${invoiceId}'`, 404, 'resource_missing');
|
|
1024
|
-
if (params.amount === undefined) return fail(ctx, 'Missing required param: amount.', 400, 'parameter_missing');
|
|
1025
|
-
const amount = Number(params.amount);
|
|
1026
|
-
if (!Number.isInteger(amount) || amount <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
|
|
1027
|
-
const vendorType = invoice.status === 'paid' ? 'post_payment' : 'pre_payment';
|
|
1028
|
-
const outOfBand = Number(params.out_of_band_amount) || 0;
|
|
1029
|
-
// a paid invoice's credit is paid back: "The sum of refunds, customer balance credits, and outside of Stripe credits
|
|
1030
|
-
// must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create); the message's wording is the twin's
|
|
1031
|
-
if (vendorType === 'post_payment') {
|
|
1032
|
-
const split = (Number(params.refund_amount) || 0) + (Number(params.credit_amount) || 0) + outOfBand;
|
|
1033
|
-
if (split !== amount) return fail(ctx, `The sum of refund_amount, credit_amount and out_of_band_amount (${split}) must equal the credit note's post_payment_amount (${amount}).`, 400, 'parameter_invalid_integer');
|
|
1034
|
-
}
|
|
1035
|
-
const line = {
|
|
1036
|
-
object: 'credit_note_line_item', id: `cnli_${id}_1`, type: 'custom_line_item',
|
|
1037
|
-
amount, description: typeof params.memo === 'string' ? params.memo : null,
|
|
1038
|
-
// a custom line credits no invoice line: invoice_line_item ("ID of the invoice line item being credited") is left
|
|
1039
|
-
// out, the served spec not letting it be null
|
|
1040
|
-
discount_amount: 0, discount_amounts: [], livemode: false,
|
|
1041
|
-
pretax_credit_amounts: [], quantity: null, tax_rates: [], taxes: [],
|
|
1042
|
-
unit_amount: null, unit_amount_decimal: null,
|
|
1043
|
-
};
|
|
1044
|
-
return {
|
|
1045
|
-
amount, amount_shipping: 0, created: nowUnix(ctx.occurredAt), currency: typeof invoice.currency === 'string' ? invoice.currency : 'usd', customer: typeof invoice.customer === 'string' ? invoice.customer : null,
|
|
1046
|
-
customer_balance_transaction: null,
|
|
1047
|
-
// "The date when this credit note is in effect. Same as `created` unless overwritten" (the served spec)
|
|
1048
|
-
discount_amount: 0, discount_amounts: [], effective_at: params.effective_at !== undefined ? Number(params.effective_at) : nowUnix(ctx.occurredAt), invoice: invoiceId,
|
|
1049
|
-
lines: { object: 'list', data: [line], has_more: false, total_count: 1, url: `/v1/credit_notes/${id}/lines` },
|
|
1050
|
-
livemode: false, memo: params.memo ?? null, metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {},
|
|
1051
|
-
number: `${invoiceId}-CN-01`, out_of_band_amount: outOfBand || null,
|
|
1052
|
-
pdf: `https://files.twin.local/credit_notes/${id}.pdf`,
|
|
1053
|
-
post_payment_amount: vendorType === 'post_payment' ? amount : 0,
|
|
1054
|
-
pre_payment_amount: vendorType === 'pre_payment' ? amount : 0,
|
|
1055
|
-
pretax_credit_amounts: [],
|
|
1056
|
-
reason: typeof params.reason === 'string' ? params.reason : null,
|
|
1057
|
-
refunds: [], shipping_cost: null, status: 'issued',
|
|
1058
|
-
subtotal: amount, subtotal_excluding_tax: amount,
|
|
1059
|
-
type: vendorType, total: amount, total_excluding_tax: amount, total_taxes: [],
|
|
1060
|
-
voided_at: null,
|
|
1061
|
-
};
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
/** The invoice's credit-note sums, from its issued credit notes; a pre-payment credit lowers what is owed. */
|
|
1065
|
-
export async function syncInvoice(ctx: HandlerContext, invoiceId: string): Promise<void> {
|
|
1066
|
-
const inv = ctx.get('invoice', invoiceId);
|
|
1067
|
-
if (!inv) return;
|
|
1068
|
-
let pre = 0;
|
|
1069
|
-
let post = 0;
|
|
1070
|
-
for (const cn of ctx.rows(CN)) {
|
|
1071
|
-
if (cn.invoice !== invoiceId || cn.status === 'void') continue;
|
|
1072
|
-
if (cn.type === 'post_payment') post += Number(cn.amount) || 0;
|
|
1073
|
-
else pre += Number(cn.amount) || 0;
|
|
1074
|
-
}
|
|
1075
|
-
const total = Number(inv.total ?? inv.amount_due) || 0;
|
|
1076
|
-
const paid = Number(inv.amount_paid) || 0;
|
|
1077
|
-
await ctx.write('invoice', invoiceId, { pre_payment_credit_notes_amount: pre, post_payment_credit_notes_amount: post, amount_remaining: Math.max(0, total - paid - pre) }, 'invoice.update');
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
/** What a post-payment credit pays back: "Refunds: create a new refund (using refund_amount)"; "Customer balance credit:
|
|
1081
|
-
* credit the customer's balance (using credit_amount) which will be automatically applied to their next invoice when
|
|
1082
|
-
* it's finalized" (docs.stripe.com/api/credit_notes/create). The refund is made on the invoice's charge as a refund
|
|
1083
|
-
* call makes it (refunds.ts); the credit is a customer balance transaction of type credit_note. Where the documentation
|
|
1084
|
-
* stops and the twin decides: the refund carries no reason of its own. */
|
|
1085
|
-
export async function payBack(ctx: HandlerContext, id: string, cn: Row): Promise<Row | Response> {
|
|
1086
|
-
const params = ctx.params;
|
|
1087
|
-
const out: Row = {};
|
|
1088
|
-
const refundAmount = Number(params.refund_amount) || 0;
|
|
1089
|
-
if (refundAmount > 0) {
|
|
1090
|
-
const invoice = ctx.get('invoice', String(cn.invoice));
|
|
1091
|
-
const pi = typeof invoice?.payment_intent === 'string' ? ctx.get('payment_intent', invoice.payment_intent) : undefined;
|
|
1092
|
-
const chargeId = typeof invoice?.charge === 'string' ? invoice.charge : typeof pi?.latest_charge === 'string' ? pi.latest_charge : undefined;
|
|
1093
|
-
const charge = chargeId ? ctx.get('charge', chargeId) : undefined;
|
|
1094
|
-
if (!charge) return fail(ctx, 'This invoice was not paid with a charge that can be refunded.', 400, 'invoice_no_charge_to_refund');
|
|
1095
|
-
const answer = await refundCharge(ctx, charge, { amount: refundAmount });
|
|
1096
|
-
if (answer.status >= 400) return answer;
|
|
1097
|
-
const refund = (await answer.json()) as Row;
|
|
1098
|
-
out.refunds = [{ amount_refunded: refundAmount, refund: refund.id, type: 'refund', payment_record_refund: null }];
|
|
1099
|
-
}
|
|
1100
|
-
const creditAmount = Number(params.credit_amount) || 0;
|
|
1101
|
-
const customer = typeof cn.customer === 'string' ? cn.customer : undefined;
|
|
1102
|
-
if (creditAmount > 0 && customer) {
|
|
1103
|
-
const ending = (Number(ctx.get('customer', customer)?.balance) || 0) - creditAmount;
|
|
1104
|
-
const txn = await created(ctx, 'customer_balance_transaction', { customer, amount: -creditAmount, currency: cn.currency, credit_note: id, invoice: cn.invoice }, {
|
|
1105
|
-
ending_balance: ending, type: 'credit_note', livemode: false, metadata: {}, description: null, checkout_session: null,
|
|
1106
|
-
});
|
|
1107
|
-
await ctx.write('customer', customer, { balance: ending }, `customer.balance_adjusted:${String(txn.id)}`);
|
|
1108
|
-
out.customer_balance_transaction = txn.id;
|
|
1109
|
-
}
|
|
1110
|
-
return out;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
556
|
/** A coupon for a fixed amount off, in its currency (docs.stripe.com/api/coupons/create#create_coupon-amount_off). */
|
|
1114
557
|
export function amountOffOf(ctx: HandlerContext, params: Record<string, unknown>): number | Response {
|
|
1115
558
|
const amountOff = Number(params.amount_off);
|
|
@@ -1131,69 +574,6 @@ export async function lapseCoupons(ctx: HandlerContext): Promise<void> {
|
|
|
1131
574
|
}
|
|
1132
575
|
}
|
|
1133
576
|
|
|
1134
|
-
export const planMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such plan: '${id}'`, 404, 'resource_missing');
|
|
1135
|
-
|
|
1136
|
-
/** A recurring price the Plans API still answers: one whose plan was deleted is not a plan any more. */
|
|
1137
|
-
export const recurring = (ctx: HandlerContext, p: Row | undefined): p is Row => !!p && !!p.recurring && kept(ctx, 'price', p, '_plan_deleted') !== true;
|
|
1138
|
-
|
|
1139
|
-
/** The request's line items resolved against their prices, or the refusal for a missing price. */
|
|
1140
|
-
export function resolved(ctx: HandlerContext): CheckoutItem[] | Response {
|
|
1141
|
-
const entries = lineItemEntries(ctx.params);
|
|
1142
|
-
const items: CheckoutItem[] = [];
|
|
1143
|
-
for (let i = 0; i < entries.length; i++) {
|
|
1144
|
-
const { item, priceMissing } = resolveLineItem(entries[i]!, i, 'usd', finder(ctx));
|
|
1145
|
-
if (priceMissing) return fail(ctx, `No such price: '${priceMissing}'`, 400, 'resource_missing');
|
|
1146
|
-
items.push(item);
|
|
1147
|
-
}
|
|
1148
|
-
return items;
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/** A page of the line items a stored object carries. */
|
|
1152
|
-
export function linePage(ctx: HandlerContext, holder: Row): Response {
|
|
1153
|
-
const li = holder.line_items as { data?: Row[] } | undefined;
|
|
1154
|
-
const { page, hasMore } = paginate(Array.isArray(li?.data) ? li!.data! : [], ctx.params);
|
|
1155
|
-
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
export const linkMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such payment link: '${id}'`, 404, 'resource_missing');
|
|
1159
|
-
|
|
1160
|
-
// ── quotes ──
|
|
1161
|
-
|
|
1162
|
-
export const quoteMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such quote: '${id}'`, 404, 'resource_missing');
|
|
1163
|
-
|
|
1164
|
-
export function move(action: 'finalize' | 'accept' | 'cancel', operationId: string): Handler {
|
|
1165
|
-
return async (ctx) => {
|
|
1166
|
-
const id = at(ctx, 'quote');
|
|
1167
|
-
const q = ctx.get('quote', id);
|
|
1168
|
-
if (!q) return quoteMissing(ctx, id);
|
|
1169
|
-
const refused = ctx.legal('quote', 'status', operationId, q.status, undefined, id);
|
|
1170
|
-
if (refused) return ctx.refuse(refused);
|
|
1171
|
-
const now = nowUnix(ctx.occurredAt);
|
|
1172
|
-
const stamp = action === 'finalize' ? { finalized_at: now } : action === 'accept' ? { accepted_at: now } : { canceled_at: now };
|
|
1173
|
-
// "Accepted quotes automatically generate an invoice, subscription, or subscription schedule"; "Quotes without
|
|
1174
|
-
// recurring prices: A draft invoice is created with auto_advance set to false" (docs.stripe.com/quotes/overview),
|
|
1175
|
-
// the quote its parent ("Details about the quote that generated this invoice", the served spec). Where the
|
|
1176
|
-
// documentation stops and the twin decides: a quote with recurring prices makes no subscription yet.
|
|
1177
|
-
let made: Row = {};
|
|
1178
|
-
if (action === 'accept') {
|
|
1179
|
-
const lines = ((q.line_items as Row | undefined)?.data as Row[] | undefined) ?? [];
|
|
1180
|
-
const recurring = lines.some((l) => l.price && typeof l.price === 'object' && (l.price as Row).recurring);
|
|
1181
|
-
if (!recurring && typeof q.customer === 'string') {
|
|
1182
|
-
const invoice = await draftInvoiceFor(ctx, q.customer, String(q.currency ?? 'usd'), { type: 'quote_details', quote_details: { quote: id }, subscription_details: null }, lines.map((l) => ({ price: l.price, quantity: Number(l.quantity) || 1, amount: Number(l.amount_subtotal ?? l.amount_total) || 0, description: l.description })));
|
|
1183
|
-
made = { invoice };
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return ctx.reply(
|
|
1187
|
-
await ctx.write('quote', id, {
|
|
1188
|
-
status: action === 'finalize' ? 'open' : action === 'accept' ? 'accepted' : 'canceled',
|
|
1189
|
-
status_transitions: { ...((q.status_transitions as Row) ?? {}), ...stamp },
|
|
1190
|
-
...(action === 'finalize' ? { number: `QT-${id.toUpperCase()}` } : {}),
|
|
1191
|
-
...made,
|
|
1192
|
-
}, `quote.${action}`),
|
|
1193
|
-
);
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
577
|
export const sessionMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such checkout.session: '${id}'`, 404, 'resource_missing');
|
|
1198
578
|
|
|
1199
579
|
// Stripe normalizes the create input into the Session: line_items become the list envelope, and
|
|
@@ -1382,6 +762,7 @@ export async function completeSession(ctx: HandlerContext, id: string, existing:
|
|
|
1382
762
|
const d = ctx.row(CS, id)?._discount as Row | undefined;
|
|
1383
763
|
if (d) await redeemCoupon(ctx, d.coupon, d.promotion_code);
|
|
1384
764
|
// the session's url "is only present when the session is active" (docs.stripe.com/api/checkout/sessions/object)
|
|
765
|
+
if (link.payment_status !== undefined) ctx.legal(CS, 'payment_status', 'PostCheckoutSessionsSession', existing.payment_status, String(link.payment_status), id, 'external');
|
|
1385
766
|
return ctx.write(CS, id, { ...details, ...link, url: null }, 'checkout.session.completed');
|
|
1386
767
|
}
|
|
1387
768
|
|
|
@@ -1390,42 +771,12 @@ export const send = (ctx: HandlerContext, r: StripeResponse): Response => ctx.re
|
|
|
1390
771
|
/** Stripe's error body: an invalid_request_error with the message, and the code when there is one. */
|
|
1391
772
|
export const fail = (ctx: HandlerContext, message: string, status = 404, code?: string): Response => ctx.refuse({ status, message, ...(code ? { code } : {}) });
|
|
1392
773
|
|
|
1393
|
-
/** A file parameter whose description requires a purpose refuses a file of another one. The served spec requires it
|
|
1394
|
-
* on every such parameter ("Must have a `purpose` value of `issuing_logo`", personalization designs' card_logo;
|
|
1395
|
-
* "The file's `purpose` must be one of the following: …", file links' file). Where the documentation stops and the
|
|
1396
|
-
* twin decides: Stripe documents no error for it (docs.stripe.com/error-codes names none), so the twin answers the
|
|
1397
|
-
* 400 invalid_request_error it answers for an unknown file, naming the parameter and the purposes allowed. */
|
|
1398
|
-
export function filePurposeRefused(ctx: HandlerContext, param: string, allowed: readonly string[]): Response | undefined {
|
|
1399
|
-
const given = ctx.params[param];
|
|
1400
|
-
if (given === undefined || given === '') return undefined;
|
|
1401
|
-
const file = ctx.get('file', String(given));
|
|
1402
|
-
if (!file) return fail(ctx, `No such file upload: '${String(given)}'`, 400, 'resource_missing');
|
|
1403
|
-
if (allowed.includes(String(file.purpose))) return undefined;
|
|
1404
|
-
const need = allowed.length === 1 ? `a \`purpose\` value of \`${allowed[0]}\`` : `a \`purpose\` of one of: ${allowed.map((a) => `\`${a}\``).join(', ')}`;
|
|
1405
|
-
return fail(ctx, `Invalid ${param}: the file '${String(given)}' has a \`purpose\` of \`${String(file.purpose)}\`, but it must have ${need}.`, 400);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
774
|
/** The request's path, as the list envelope's `url` carries it. */
|
|
1409
775
|
export const path = (ctx: HandlerContext): string => new URL(ctx.call.request.url).pathname.replace(/\/+$/, '') || '/';
|
|
1410
776
|
|
|
1411
777
|
/** A path parameter, by the name the spec gives it. */
|
|
1412
778
|
export const at = (ctx: HandlerContext, name: string): string => ctx.call.params[name] ?? '';
|
|
1413
779
|
|
|
1414
|
-
/** An update's fields laid over what is stored, as the derived core does: a nested hash is merged into the stored one,
|
|
1415
|
-
* not put in its place (Stripe updates only the keys a request names; docs.stripe.com/api/metadata and each update
|
|
1416
|
-
* reference), and an empty string unsets a key. */
|
|
1417
|
-
export function mergeUpdate(ctx: HandlerContext, resource: string, id: string, fields: Row): Row {
|
|
1418
|
-
const current = ctx.get(resource, id) ?? {};
|
|
1419
|
-
const out: Row = {};
|
|
1420
|
-
for (const [k, v] of Object.entries(fields)) {
|
|
1421
|
-
const prior = (current as Row)[k];
|
|
1422
|
-
out[k] = v && typeof v === 'object' && !Array.isArray(v) && prior && typeof prior === 'object' && !Array.isArray(prior)
|
|
1423
|
-
? Object.fromEntries(Object.entries({ ...(prior as object), ...(v as object) }).filter(([, x]) => x !== ''))
|
|
1424
|
-
: v;
|
|
1425
|
-
}
|
|
1426
|
-
return out;
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
780
|
/** A resource's rows in Stripe's list order: newest first by `timeField`, ties by mint order (as the derived core breaks them). */
|
|
1430
781
|
/** A subject's bookkeeping field (`_…`): kept on the stored row, never in the wire view ctx.get and ctx.rows answer. */
|
|
1431
782
|
export const kept = (ctx: HandlerContext, resource: string, row: Row, key: string): unknown => ctx.row(resource, String(row.id), { withDeleted: true })?.[key];
|
|
@@ -1460,7 +811,7 @@ export function newest(ctx: HandlerContext, resource: string, timeField = 'creat
|
|
|
1460
811
|
const ta = when(a), tb = when(b);
|
|
1461
812
|
if (tb !== ta) return tb - ta;
|
|
1462
813
|
return made.get(b)! - made.get(a)!;
|
|
1463
|
-
});
|
|
814
|
+
}).map((r) => ctx.expand(resource, r));
|
|
1464
815
|
}
|
|
1465
816
|
|
|
1466
817
|
/** A context at the moment Stripe would have made a move time makes (a renewal, a payout on its schedule), so what
|
|
@@ -1497,7 +848,6 @@ export function commonAsOf(ctx: HandlerContext, t: Row): Row {
|
|
|
1497
848
|
* it holds, one segment at a time; an id the tree lacks stays an id. An embedded balance transaction reads as the clock
|
|
1498
849
|
* has it (asOf), as its own retrieve does. */
|
|
1499
850
|
export function expandRow(ctx: HandlerContext, resource: string, body: Row, paths: string[][]): Row {
|
|
1500
|
-
if (paths.length === 0) return body;
|
|
1501
851
|
const asOf = (v: unknown): unknown => {
|
|
1502
852
|
if (Array.isArray(v)) return v.map(asOf);
|
|
1503
853
|
if (!v || typeof v !== 'object') return v;
|
|
@@ -1511,11 +861,16 @@ export function expandRow(ctx: HandlerContext, resource: string, body: Row, path
|
|
|
1511
861
|
/** One resource with the request's `expand[]` applied. */
|
|
1512
862
|
export const expanded = (ctx: HandlerContext, resource: string, body: Row): Row => expandRow(ctx, resource, body, expandPaths(ctx));
|
|
1513
863
|
|
|
864
|
+
export const resourceView = (ctx: HandlerContext, resource: string, id: string): Row | undefined => {
|
|
865
|
+
const row = ctx.get(resource, id);
|
|
866
|
+
return row ? ctx.expand(resource, row) : undefined;
|
|
867
|
+
};
|
|
868
|
+
|
|
1514
869
|
/** Stripe's list envelope over rows already in list order: a page, `has_more`, and `expand[]=data.*`. */
|
|
1515
870
|
export function list(ctx: HandlerContext, resource: string, items: Row[]): Response {
|
|
1516
871
|
const { page, hasMore } = paginate(items, ctx.params);
|
|
1517
872
|
const paths = expandPaths(ctx).filter((p) => p[0] === 'data').map((p) => p.slice(1)).filter((p) => p.length > 0);
|
|
1518
|
-
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data:
|
|
873
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page.map((r) => expandRow(ctx, resource, r, paths)) });
|
|
1519
874
|
}
|
|
1520
875
|
|
|
1521
876
|
/** Stripe's search over a resource's rows (`query` in its search language). */
|
|
@@ -1534,12 +889,30 @@ export async function created(ctx: HandlerContext, resource: string, params: Row
|
|
|
1534
889
|
const timeField = opts.timeField ?? 'created';
|
|
1535
890
|
const stamped = !known || known.has(timeField) || timeField in defaults || timeField in rest ? timeField : '_created';
|
|
1536
891
|
const fields = { object: OBJECT_NAME[type] ?? type, [stamped]: nowUnix(ctx.occurredAt), ...defaults, ...rest };
|
|
1537
|
-
|
|
892
|
+
// source: spec:/components/schemas/subscription/properties/items/description "List of subscription items, each with an attached price."
|
|
893
|
+
const children = resource === SUB ? ((fields.items as { data?: Row[] } | undefined)?.data ?? []) : [];
|
|
894
|
+
if (resource === SUB) delete (fields as Row).items;
|
|
895
|
+
// source: spec:/components/schemas/subscription_item/properties/price "price"
|
|
896
|
+
if (resource === 'subscription_item') {
|
|
897
|
+
const item = fields as Row;
|
|
898
|
+
if (item.price && typeof item.price === 'object') item.price = (item.price as Row).id;
|
|
899
|
+
delete item.plan; // the served subscription_item schema has no plan field
|
|
900
|
+
}
|
|
901
|
+
// source: spec:/components/schemas/account/properties/individual "person"
|
|
902
|
+
if (resource === 'account' && (fields as Row).individual && typeof (fields as Row).individual === 'object') {
|
|
903
|
+
const person = await created(ctx, 'person', { ...((fields as Row).individual as Row), account: id }, { metadata: {}, relationship: { representative: true } });
|
|
904
|
+
(fields as Row).individual = person.id;
|
|
905
|
+
}
|
|
906
|
+
const row = await ctx.write(resource, id, fields, opts.operation ?? `${type}.create`);
|
|
907
|
+
if (children.length) await storeItems(ctx, id, children);
|
|
908
|
+
return ctx.expand(resource, row);
|
|
1538
909
|
}
|
|
1539
910
|
|
|
1540
911
|
export const finder = (ctx: Pick<HandlerContext, 'rowsRaw'>): Find => (storedType, id) => {
|
|
1541
912
|
const r = ctx.rowsRaw(storedType, { withDeleted: true }).find((x) => x.id === id);
|
|
1542
|
-
|
|
913
|
+
// source: https://docs.stripe.com/testing "Generic decline 4000 0000 0000 0002"
|
|
914
|
+
// Private test-card behavior is used for subsequent charges, never sent as a vendor field.
|
|
915
|
+
return r ? { ...view(storedType, r as TwinResource), ...(r._declineOutcome !== undefined ? { _declineOutcome: r._declineOutcome } : {}) } : undefined;
|
|
1543
916
|
};
|
|
1544
917
|
|
|
1545
918
|
/** The account's external_accounts list, rewritten from its external accounts after one is added or removed. */
|
|
@@ -1548,47 +921,6 @@ export async function syncExternals(ctx: HandlerContext, account: string): Promi
|
|
|
1548
921
|
await ctx.write('account', account, { external_accounts: externalList(account, newest(ctx, 'external_account').filter((e) => e.account === account)) }, 'account.external_accounts');
|
|
1549
922
|
}
|
|
1550
923
|
|
|
1551
|
-
/** A decision over the rows and the write it makes, taken together: the read and the write follow one another with
|
|
1552
|
-
* nothing between them (a code spent once, a connection revoked once). */
|
|
1553
|
-
export async function decide<T>(ctx: HandlerContext, decision: (rows: (resource: string) => Row[]) => { value: T; write?: { resource: string; id: string; fields: Row; operation: string } }): Promise<T> {
|
|
1554
|
-
const d = decision((resource) => ctx.rowsRaw(resource));
|
|
1555
|
-
if (d.write) await ctx.write(d.write.resource, d.write.id, d.write.fields, d.write.operation);
|
|
1556
|
-
return d.value;
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
export const catalog = (ctx: HandlerContext, data: Row[]): Response => ctx.reply({ object: 'list', url: path(ctx), has_more: false, data });
|
|
1560
|
-
|
|
1561
|
-
export const orderMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such order: '${id}'`, 404, 'resource_missing');
|
|
1562
|
-
|
|
1563
|
-
/** Each pending report run completes, caught up before anything is answered: "When the run completes, Stripe updates
|
|
1564
|
-
* the object, and it has a status of succeeded. It also has a nested result object, containing a URL that you can use
|
|
1565
|
-
* to access the file", the guide's example a File of purpose report_run and type csv (docs.stripe.com/reports/api).
|
|
1566
|
-
* "Most runs complete within a few minutes" (the same page). Where the documentation stops and the twin decides: a run
|
|
1567
|
-
* completes one minute after it was created (the guide's example: created 1580832900, succeeded_at 1580832960), and
|
|
1568
|
-
* its CSV holds the report type's header row alone (its size that row's bytes). */
|
|
1569
|
-
export async function finishReportRuns(ctx: HandlerContext): Promise<void> {
|
|
1570
|
-
for (const run of ctx.rowsRaw('reporting.report_run')) {
|
|
1571
|
-
if (run.status !== 'pending') continue;
|
|
1572
|
-
const done = Number(run.created) + 60;
|
|
1573
|
-
if (Number(nowUnix(ctx.occurredAt)) < done) continue;
|
|
1574
|
-
const id = String(run.id);
|
|
1575
|
-
const header = 'category,currency,description,net_amount\n';
|
|
1576
|
-
const file = await created(ctx, 'file', { purpose: 'report_run' }, {
|
|
1577
|
-
filename: `${String(run.report_type)}_${id}.csv`, size: header.length, type: 'csv', title: null, expires_at: null, url: null,
|
|
1578
|
-
links: { object: 'list', data: [], has_more: false, url: '' },
|
|
1579
|
-
});
|
|
1580
|
-
await ctx.write('file', String(file.id), { url: `https://files.twin.local/v1/files/${String(file.id)}/contents` }, 'file.updated');
|
|
1581
|
-
ctx.legal('reporting.report_run', 'status', ctx.call.operation.id, 'pending', 'succeeded', id, 'vendor');
|
|
1582
|
-
await ctx.write('reporting.report_run', id, { status: 'succeeded', succeeded_at: done, result: ctx.get('file', String(file.id)) }, 'reporting.report_run.succeeded');
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/** A feature whose lookup_key an unarchived feature already has: "A unique key you provide as your own system identifier"
|
|
1587
|
-
* (docs.stripe.com/api/entitlements/feature/create). */
|
|
1588
|
-
export function lookupKeyTaken(ctx: HandlerContext, lookupKey: string): Response {
|
|
1589
|
-
return fail(ctx, `A feature with lookup_key '${lookupKey}' already exists.`, 400, 'resource_already_exists');
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
924
|
export const pmMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such payment_method: '${id}'`, 404, 'resource_missing');
|
|
1593
925
|
|
|
1594
926
|
/** The card a charge was paid with, as its payment_method_details reports it
|
|
@@ -1649,11 +981,18 @@ export async function methodFromData(ctx: HandlerContext, data: unknown): Promis
|
|
|
1649
981
|
const d = data as Row;
|
|
1650
982
|
const type = typeof d.type === 'string' ? d.type : 'card';
|
|
1651
983
|
const made = await created(ctx, PM, {}, {
|
|
1652
|
-
type, customer: null, livemode: false,
|
|
984
|
+
type, customer: null, livemode: false,
|
|
985
|
+
// source: spec:/paths/~1v1~1setup_intents~1{intent}~1confirm/post/requestBody/content/application~1x-www-form-urlencoded/schema/properties/payment_method_data/description "this hash creates a PaymentMethod"
|
|
986
|
+
metadata: d.metadata && typeof d.metadata === 'object' ? d.metadata : {},
|
|
1653
987
|
billing_details: { ...noBilling, ...((d.billing_details as Row | undefined) ?? {}) },
|
|
1654
988
|
...paymentMethodSubObject(type, d),
|
|
1655
989
|
// what follows its card's success (a dispute, funds straight to available), as a PaymentMethod created directly carries
|
|
1656
990
|
...(type === 'card' && afterSuccessOf(ctx, d.card) ? { _afterSuccess: afterSuccessOf(ctx, d.card) } : {}),
|
|
991
|
+
// and what a payment with its card answers: a test card the issuer declines, or one that asks the cardholder to
|
|
992
|
+
// authenticate, entered as its number (Stripe.js sends it so), as the same card made a PaymentMethod directly does
|
|
993
|
+
// source: https://docs.stripe.com/testing "Generic decline 4000 0000 0000 0002"
|
|
994
|
+
// source: https://docs.stripe.com/testing "This card requires authentication on all transactions, regardless of how the card is set up."
|
|
995
|
+
...(type === 'card' ? { _declineOutcome: declineFor(() => undefined, { card: d.card }) ?? null, ...(requiresAuthentication({ card: d.card }) ? { _requiresAuthentication: true } : {}) } : {}),
|
|
1657
996
|
});
|
|
1658
997
|
return String(made.id);
|
|
1659
998
|
}
|
|
@@ -1666,103 +1005,8 @@ export function microdepositsAsked(ctx: HandlerContext, params: Row, intent: Row
|
|
|
1666
1005
|
return options?.verification_method === 'microdeposits' && !!pm && ctx.get(PM, pm)?.type === 'us_bank_account';
|
|
1667
1006
|
}
|
|
1668
1007
|
|
|
1669
|
-
export const scheduleMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such subscription_schedule: '${id}'`, 404, 'resource_missing');
|
|
1670
|
-
|
|
1671
|
-
/** A schedule's phases as Stripe answers them (the served spec's subscription_schedule_phase_configuration): each
|
|
1672
|
-
* starts where the last ended (the first at the schedule's start_date, "now" or a timestamp), and ends at its
|
|
1673
|
-
* end_date or after its duration ("duration: interval, interval_count", the served spec's phases[] parameter) on the
|
|
1674
|
-
* calendar; its currency is its first price's, each item carries its price, quantity and (empty) discounts. Where the
|
|
1675
|
-
* documentation stops and the twin decides: a phase with neither lasts one interval of its first price. */
|
|
1676
|
-
export function phasesOf(ctx: HandlerContext, given: Row[], start: number): Row[] {
|
|
1677
|
-
let cursor = start;
|
|
1678
|
-
return given.map((p) => {
|
|
1679
|
-
const items = (Array.isArray(p.items) ? (p.items as Row[]) : []).map((it) => ({ price: it.price ?? null, quantity: Math.max(1, Math.trunc(Number(it.quantity) || 1)), discounts: [], metadata: {}, tax_rates: [], billing_thresholds: null }));
|
|
1680
|
-
const price = typeof items[0]?.price === 'string' ? ctx.get('price', items[0]!.price as string) : undefined;
|
|
1681
|
-
const recurring = (price?.recurring as Row | undefined) ?? {};
|
|
1682
|
-
const d = p.duration && typeof p.duration === 'object' ? (p.duration as Row) : undefined;
|
|
1683
|
-
const startDate = cursor;
|
|
1684
|
-
const end = p.end_date !== undefined && p.end_date !== 'now' ? Number(p.end_date)
|
|
1685
|
-
: d ? addBillingInterval(startDate, String(d.interval) as Interval, Math.max(1, Number(d.interval_count) || 1))
|
|
1686
|
-
: addBillingInterval(startDate, String(recurring.interval ?? 'month') as Interval, Math.max(1, Number(recurring.interval_count) || 1));
|
|
1687
|
-
cursor = end;
|
|
1688
|
-
return {
|
|
1689
|
-
add_invoice_items: [], application_fee_percent: null, automatic_tax: { enabled: false, liability: null, disabled_reason: null }, billing_cycle_anchor: null,
|
|
1690
|
-
billing_thresholds: null, collection_method: null, currency: String(price?.currency ?? 'usd'), default_payment_method: null, default_tax_rates: [],
|
|
1691
|
-
description: null, discounts: [], end_date: end, invoice_settings: null, items, metadata: p.metadata && typeof p.metadata === 'object' ? p.metadata : {},
|
|
1692
|
-
on_behalf_of: null, proration_behavior: typeof p.proration_behavior === 'string' ? p.proration_behavior : 'create_prorations', start_date: startDate,
|
|
1693
|
-
transfer_data: null, trial_end: null,
|
|
1694
|
-
};
|
|
1695
|
-
});
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
export function end(kind: 'cancel' | 'release'): Handler {
|
|
1699
|
-
return async (ctx) => {
|
|
1700
|
-
const id = at(ctx, 'schedule');
|
|
1701
|
-
const sched = ctx.get(SS, id);
|
|
1702
|
-
if (!sched) return scheduleMissing(ctx, id);
|
|
1703
|
-
const refused = ctx.legal(SS, 'status', kind === 'cancel' ? 'PostSubscriptionSchedulesScheduleCancel' : 'PostSubscriptionSchedulesScheduleRelease', sched.status, undefined, id);
|
|
1704
|
-
if (refused) return ctx.refuse(refused);
|
|
1705
|
-
const now = nowUnix(ctx.occurredAt);
|
|
1706
|
-
const subId = typeof sched.subscription === 'string' ? sched.subscription : undefined;
|
|
1707
|
-
const sub = subId ? ctx.get('subscription', subId) : undefined;
|
|
1708
|
-
// "Cancels a subscription schedule and its associated subscription immediately (if the subscription schedule has an
|
|
1709
|
-
// active subscription)" (docs.stripe.com/api/subscription_schedules/cancel); releasing "will remove its subscription
|
|
1710
|
-
// property and set the subscription's ID to the released_subscription property", leaving the subscription in place
|
|
1711
|
-
// (docs.stripe.com/api/subscription_schedules/release)
|
|
1712
|
-
if (kind === 'cancel' && subId && sub && sub.status !== 'canceled') {
|
|
1713
|
-
ctx.legal('subscription', 'status', 'PostSubscriptionSchedulesScheduleCancel', String(sub.status), 'canceled', subId);
|
|
1714
|
-
await ctx.write('subscription', subId, { status: 'canceled', canceled_at: now, ended_at: now }, 'subscription.cancel');
|
|
1715
|
-
}
|
|
1716
|
-
if (kind === 'release' && subId && sub) await ctx.write('subscription', subId, { schedule: null }, 'subscription.update');
|
|
1717
|
-
const fields = kind === 'cancel'
|
|
1718
|
-
? { status: 'canceled', canceled_at: now, current_phase: null }
|
|
1719
|
-
: { status: 'released', released_at: now, released_subscription: subId ?? null, subscription: null, current_phase: null };
|
|
1720
|
-
return ctx.reply(await ctx.write(SS, id, fields, `subscription_schedule.${kind}`));
|
|
1721
|
-
};
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
/** A token asked for none of what a token holds. */
|
|
1725
|
-
export function noTokenSource(ctx: HandlerContext): Response {
|
|
1726
|
-
return fail(ctx, 'You must provide one of the following parameters: card, bank_account, pii.', 400, 'parameter_missing');
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
/** A bank account token's account, as the legacy BankAccount object holds it. */
|
|
1730
|
-
export function bankAccountOf(ctx: HandlerContext, ba: Row): Row {
|
|
1731
|
-
const acct = typeof ba.account_number === 'string' ? ba.account_number.replace(/\D/g, '') : '';
|
|
1732
|
-
return {
|
|
1733
|
-
id: ctx.mint('external_account'), object: 'bank_account', account_holder_name: ba.account_holder_name ?? null,
|
|
1734
|
-
account_holder_type: ba.account_holder_type ?? null, bank_name: 'STRIPE TEST BANK',
|
|
1735
|
-
country: ba.country ?? 'US', currency: ba.currency ?? 'usd', fingerprint: 'twin_ba_fp',
|
|
1736
|
-
last4: acct ? acct.slice(-4) : '6789', routing_number: ba.routing_number ?? '110000000', status: 'new',
|
|
1737
|
-
};
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
export const productMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such product: '${id}'`, 404, 'resource_missing');
|
|
1741
|
-
|
|
1742
|
-
// a product with prices cannot be deleted; Stripe says to deactivate it instead
|
|
1743
|
-
/** A product deleted while it has prices: "Deleting a product is only possible if it has no prices associated with it"
|
|
1744
|
-
* (docs.stripe.com/api/products/delete). */
|
|
1745
|
-
export function pricedProductRefused(ctx: HandlerContext): Response {
|
|
1746
|
-
return fail(ctx, 'You cannot delete the product because it has one or more prices. Deactivate the product instead.', 400, 'resource_already_exists');
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
1008
|
// ── features: an entitlements feature granted to a product, at most once ──
|
|
1750
1009
|
|
|
1751
|
-
/** A feature attached to a product that already has it. */
|
|
1752
|
-
export function alreadyAttached(ctx: HandlerContext): Response {
|
|
1753
|
-
return fail(ctx, 'This feature is already attached to the product.', 400, 'resource_already_exists');
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
export const productFeature = (ctx: HandlerContext): Row | undefined => {
|
|
1757
|
-
const pf = ctx.get('product_feature', at(ctx, 'id'));
|
|
1758
|
-
// the product a feature is attached to is its address, not a field Stripe answers
|
|
1759
|
-
return pf && kept(ctx, 'product_feature', pf, '_product') === at(ctx, 'product') ? pf : undefined;
|
|
1760
|
-
};
|
|
1761
|
-
|
|
1762
|
-
export const featureMissing = (ctx: HandlerContext): Response => fail(ctx, `No such product feature: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
1763
|
-
|
|
1764
|
-
// ── prices ──
|
|
1765
|
-
|
|
1766
1010
|
/** A tiered price: tiers_mode and tiers, each tier with up_to and a unit or flat amount; no single unit_amount, and
|
|
1767
1011
|
* the last tier's up_to null (∞) (docs.stripe.com/products-prices/pricing-models#tiered-pricing). */
|
|
1768
1012
|
export async function tieredPrice(ctx: HandlerContext, params: Row): Promise<Response> {
|
|
@@ -1840,6 +1084,11 @@ export async function payInvoiceOf(ctx: HandlerContext, pi: Row, operationId: st
|
|
|
1840
1084
|
const invoiceId = typeof pi.invoice === 'string' ? pi.invoice : undefined;
|
|
1841
1085
|
const invoice = invoiceId ? ctx.get('invoice', invoiceId) : undefined;
|
|
1842
1086
|
if (!invoiceId || !invoice || invoice.status === 'paid' || invoice.status === 'void') return;
|
|
1087
|
+
await finishInvoiceIntent(ctx, invoice, invoiceId, operationId, actor);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** An invoice-linked intent read back from a live account; the served create API cannot set invoice. */
|
|
1091
|
+
export async function finishInvoiceIntent(ctx: HandlerContext, invoice: Row, invoiceId: string, operationId: string, actor?: 'vendor'): Promise<void> {
|
|
1843
1092
|
if (ctx.legal('invoice', 'status', operationId, invoice.status, 'paid', invoiceId, actor)) return;
|
|
1844
1093
|
const amount = Number(invoice.total ?? invoice.amount_due) || 0;
|
|
1845
1094
|
// `invoice.pay` is the event the standalone pay action sends: invoice.paid
|
|
@@ -1848,20 +1097,12 @@ export async function payInvoiceOf(ctx: HandlerContext, pi: Row, operationId: st
|
|
|
1848
1097
|
status_transitions: { ...((invoice.status_transitions as object) ?? {}), paid_at: nowUnix(ctx.occurredAt) },
|
|
1849
1098
|
}, 'invoice.pay');
|
|
1850
1099
|
const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
|
|
1851
|
-
const sub = subId ? ctx
|
|
1100
|
+
const sub = subId ? resourceView(ctx, 'subscription', subId) : undefined;
|
|
1852
1101
|
if (subId && sub && sub.status === 'incomplete' && !ctx.legal('subscription', 'status', operationId, sub.status, 'active', subId, actor)) {
|
|
1853
1102
|
await ctx.write('subscription', subId, { status: 'active' }, 'subscription.update');
|
|
1854
1103
|
}
|
|
1855
1104
|
}
|
|
1856
1105
|
|
|
1857
|
-
/** The intent the path names, and the machine's refusal when this operation may not move it. */
|
|
1858
|
-
export function paymentIntentsLoad(ctx: HandlerContext, operationId: string): { pi: Record<string, unknown> } | { answer: Response } {
|
|
1859
|
-
const pi = ctx.id ? ctx.get(PI, ctx.id) : undefined;
|
|
1860
|
-
if (!pi) return { answer: ctx.notFound(PI, String(ctx.id)) };
|
|
1861
|
-
const refusal = ctx.legal(PI, 'status', operationId, pi.status);
|
|
1862
|
-
return refusal ? { answer: ctx.refuse(refusal) } : { pi };
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
1106
|
/** The type of the payment method an intent is paid with: its PaymentMethod's, or a documented test name's (pm_card_*,
|
|
1866
1107
|
* tok_* a card; pm_usBankAccount_* a US bank account), else unknown. */
|
|
1867
1108
|
export function methodType(ctx: HandlerContext, method: unknown): string | undefined {
|
|
@@ -1891,6 +1132,15 @@ export function methodNotAllowed(ctx: HandlerContext, existing: Row, params: Row
|
|
|
1891
1132
|
return ctx.refuse({ status: 400, code: 'payment_intent_invalid_parameter', param: 'payment_method', message: `The PaymentMethod provided (${type}) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: ${types.join(', ')}. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "${type}"` });
|
|
1892
1133
|
}
|
|
1893
1134
|
|
|
1135
|
+
// Confirmation controls are request instructions, absent from the pinned PaymentIntent object.
|
|
1136
|
+
// source: spec:/paths/~1v1~1payment_intents~1{intent}~1confirm/post/requestBody/content/application~1x-www-form-urlencoded/schema/properties/return_url/description "The URL to redirect your customer back to after they authenticate or cancel their payment"
|
|
1137
|
+
export function intentFields(params: Record<string, unknown>): Record<string, unknown> {
|
|
1138
|
+
const { confirm: _confirm, amount_to_confirm: _amount, confirmation_token: _token, error_on_requires_action: _error,
|
|
1139
|
+
expand: _expand, mandate: _mandate, mandate_data: _mandateData, off_session: _offSession,
|
|
1140
|
+
payment_method_data: _methodData, radar_options: _radar, return_url: _returnUrl, use_stripe_sdk: _sdk, ...fields } = params;
|
|
1141
|
+
return fields;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1894
1144
|
/** Confirm an intent the confirm machine allows to move: the confirm action, and a create with `confirm=true`. */
|
|
1895
1145
|
export async function confirmIntent(ctx: HandlerContext, existing: Record<string, unknown>, params: Record<string, unknown>): Promise<Response> {
|
|
1896
1146
|
const id = String(existing.id);
|
|
@@ -1918,7 +1168,7 @@ export async function confirmIntent(ctx: HandlerContext, existing: Record<string
|
|
|
1918
1168
|
id: chargeId, amount, currency: existing.currency ?? 'usd', payment_intent: id,
|
|
1919
1169
|
...(typeof existing.customer === 'string' ? { customer: existing.customer } : {}),
|
|
1920
1170
|
...(pmId ? { payment_method: pmId } : {}),
|
|
1921
|
-
...intentCarries({ ...existing, ...params }),
|
|
1171
|
+
...intentCarries({ ...existing, ...intentFields(params) }),
|
|
1922
1172
|
}, {
|
|
1923
1173
|
...chargeDefaults(chargeId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
|
|
1924
1174
|
failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
|
|
@@ -1927,57 +1177,37 @@ export async function confirmIntent(ctx: HandlerContext, existing: Record<string
|
|
|
1927
1177
|
}, { operation: 'charge.failed' }); // "Occurs whenever a failed charge attempt occurs." (docs.stripe.com/api/events/types)
|
|
1928
1178
|
const { payment_method: _pm, ...rest } = params;
|
|
1929
1179
|
const last_payment_error = { type: 'card_error', code: decline.code, ...(decline.decline_code ? { decline_code: decline.decline_code } : {}), message: decline.message, param: 'card', charge: chargeId, payment_method: pm ?? null };
|
|
1930
|
-
const failed = await ctx.write(PI, id, { ...rest, payment_method: null, latest_charge: chargeId, status: 'requires_payment_method', last_payment_error }, 'payment_intent.payment_failed');
|
|
1180
|
+
const failed = await ctx.write(PI, id, { ...intentFields(rest), payment_method: null, latest_charge: chargeId, status: 'requires_payment_method', last_payment_error }, 'payment_intent.payment_failed');
|
|
1931
1181
|
return paymentIntentsSend(ctx, cardError(decline, { payment_intent: failed, charge: chargeId }));
|
|
1932
1182
|
}
|
|
1933
1183
|
// a bank debit that needs micro-deposit verification waits in requires_action
|
|
1934
1184
|
const method = typeof params.payment_method === 'string' ? params.payment_method : typeof existing.payment_method === 'string' ? existing.payment_method : undefined;
|
|
1935
1185
|
// a bank transfer is paid from the customer's cash balance, or waits for the transfer (fundFromCashBalance)
|
|
1936
|
-
if (method && ctx.get('payment_method', method)?.type === 'customer_balance') return ctx.reply(ctx.expand(PI, await fundFromCashBalance(ctx, { ...existing, ...params }, params)));
|
|
1186
|
+
if (method && ctx.get('payment_method', method)?.type === 'customer_balance') return ctx.reply(ctx.expand(PI, await fundFromCashBalance(ctx, { ...existing, ...intentFields(params) }, intentFields(params))));
|
|
1937
1187
|
if (requiresMicrodeposits(params, existing) || microdepositsAsked(ctx, params, existing, method)) {
|
|
1938
|
-
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_action', next_action: microdepositsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
1188
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_action', next_action: microdepositsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
1939
1189
|
}
|
|
1940
1190
|
// 3DS: the first confirm asks for authentication; a second one completes it
|
|
1941
|
-
if (existing.status !== 'requires_action' && requiresAuthentication(params, existing)) {
|
|
1942
|
-
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_action', next_action: threeDsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
1191
|
+
if (existing.status !== 'requires_action' && (requiresAuthentication(params, existing) || (!!method && ctx.row(PM, method)?._requiresAuthentication === true))) {
|
|
1192
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_action', next_action: threeDsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
|
|
1943
1193
|
}
|
|
1944
1194
|
// manual capture authorizes and waits for /capture: the authorization is a charge, succeeded and not captured, the
|
|
1945
1195
|
// intent's latest_charge
|
|
1946
1196
|
if ((params.capture_method ?? existing.capture_method) === 'manual') {
|
|
1947
1197
|
const amount = Number(existing.amount ?? params.amount ?? 0);
|
|
1948
|
-
const charge = await authorizeCharge(ctx, id, { ...existing, ...params }, amount);
|
|
1949
|
-
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...params, status: 'requires_capture', amount_capturable: amount, amount_received: 0, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.amount_capturable_updated')));
|
|
1198
|
+
const charge = await authorizeCharge(ctx, id, { ...existing, ...intentFields(params) }, amount);
|
|
1199
|
+
return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_capture', amount_capturable: amount, amount_received: 0, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.amount_capturable_updated')));
|
|
1950
1200
|
}
|
|
1951
1201
|
// a bank debit is submitted and settles later (beginBankDebit)
|
|
1952
|
-
if (isBankDebit(ctx, method)) return ctx.reply(ctx.expand(PI, await beginBankDebit(ctx, id, params)));
|
|
1202
|
+
if (isBankDebit(ctx, method)) return ctx.reply(ctx.expand(PI, await beginBankDebit(ctx, id, intentFields(params))));
|
|
1953
1203
|
const amount = Number(existing.amount ?? params.amount ?? 0);
|
|
1954
1204
|
// a succeeded intent has a Charge, and latest_charge names it: what a refund of it lands on
|
|
1955
|
-
const charge = await mintCharge(ctx, id, { ...existing, ...params }, amount);
|
|
1956
|
-
const body = await ctx.write(PI, id, { ...params, status: 'succeeded', amount_received: amount, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.confirm');
|
|
1205
|
+
const charge = await mintCharge(ctx, id, { ...existing, ...intentFields(params) }, amount);
|
|
1206
|
+
const body = await ctx.write(PI, id, { ...intentFields(params), status: 'succeeded', amount_received: amount, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.confirm');
|
|
1957
1207
|
await payInvoiceOf(ctx, body, 'PostPaymentIntentsIntentConfirm');
|
|
1958
1208
|
return ctx.reply(ctx.expand(PI, body));
|
|
1959
1209
|
}
|
|
1960
1210
|
|
|
1961
|
-
/** An increment that does not raise the authorization. */
|
|
1962
|
-
export function incrementTooSmall(ctx: HandlerContext): Response {
|
|
1963
|
-
return ctx.refuse({ status: 400, code: 'parameter_invalid_integer', message: 'The new amount must be greater than the current amount of the PaymentIntent.' });
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// ── a bank debit is submitted, and settles ──
|
|
1967
|
-
//
|
|
1968
|
-
// ACH Direct Debit "is a delayed notification payment method ... The PaymentIntent you create initially has a status
|
|
1969
|
-
// of `processing`. After the payment has succeeded, the PaymentIntent status is updated from `processing` to
|
|
1970
|
-
// `succeeded`"; verification by micro-deposits returns "a status of `processing`, and sends a payment_intent.processing
|
|
1971
|
-
// webhook event"; and, the twin being a test-mode account: "Test transactions settle instantly and are added to your
|
|
1972
|
-
// available test balance. This behavior differs from live mode, where transactions can take multiple days to settle"
|
|
1973
|
-
// (docs.stripe.com/payments/ach-direct-debit/accept-a-payment?payment-ui=direct-api). A confirm answers processing; the
|
|
1974
|
-
// debit succeeds at that same instant of the World's clock, caught up before the next request is answered (as
|
|
1975
|
-
// renewals and payouts are). The page's test accounts give each debit its outcome: pm_usBankAccount_success
|
|
1976
|
-
// (000123456789) "The payment succeeds"; pm_usBankAccount_processing (000000000009) "The payment stays in processing
|
|
1977
|
-
// indefinitely". Where the documentation stops and the twin decides: its Charge is made when it succeeds (the page
|
|
1978
|
-
// names none while it processes); the failing test accounts (closed, no account, insufficient funds, debit not
|
|
1979
|
-
// authorized, invalid currency, dispute, weekly limit, Radar block) are not modelled and succeed.
|
|
1980
|
-
|
|
1981
1211
|
/** Whether the method an intent is confirmed with is a bank debit: a us_bank_account PaymentMethod, or one of Stripe's
|
|
1982
1212
|
* test bank accounts named as one (pm_usBankAccount_*, pm_us_bank_account). */
|
|
1983
1213
|
export function isBankDebit(ctx: HandlerContext, method: unknown): boolean {
|
|
@@ -2000,7 +1230,7 @@ export async function beginBankDebit(ctx: HandlerContext, id: string, fields: Ro
|
|
|
2000
1230
|
}
|
|
2001
1231
|
|
|
2002
1232
|
/** Time's settling of bank debits, caught up to the World's clock: each processing debit whose moment has come succeeds
|
|
2003
|
-
* then, its Charge made
|
|
1233
|
+
* then, its Charge made and the invoice it collects for paid. */
|
|
2004
1234
|
export async function settleBankDebits(ctx: HandlerContext): Promise<void> {
|
|
2005
1235
|
const now = Number(nowUnix(ctx.occurredAt));
|
|
2006
1236
|
const due = ctx.rowsRaw(PI).filter((p) => p.status === 'processing' && typeof p._settles_at === 'number' && p._settles_at <= now).sort((a, b) => Number(a._settles_at) - Number(b._settles_at));
|
|
@@ -2010,17 +1240,9 @@ export async function settleBankDebits(ctx: HandlerContext): Promise<void> {
|
|
|
2010
1240
|
c.legal(PI, 'status', ctx.call.operation.id, 'processing', 'succeeded', id, 'vendor');
|
|
2011
1241
|
const amount = Number(pi.amount) || 0;
|
|
2012
1242
|
const charge = await mintCharge(c, id, pi, amount);
|
|
2013
|
-
// the charge names the mandate its bank debit ran under (payment_method_details.us_bank_account.mandate)
|
|
2014
|
-
const details = (c.get('charge', charge)?.payment_method_details ?? null) as Row | null;
|
|
2015
|
-
const kind = typeof details?.type === 'string' ? details.type : undefined;
|
|
2016
|
-
if (details && kind && typeof pi.mandate === 'string') await c.write('charge', charge, { payment_method_details: { ...details, [kind]: { ...((details[kind] as Row | undefined) ?? {}), mandate: pi.mandate } } }, 'charge.mandate');
|
|
2017
1243
|
const body = await c.write(PI, id, { status: 'succeeded', amount_received: amount, latest_charge: charge, _settles_at: null }, 'payment_intent.succeeded');
|
|
2018
|
-
// a
|
|
2019
|
-
|
|
2020
|
-
if (used && used.type === 'single_use' && used.status === 'active') {
|
|
2021
|
-
c.legal('mandate', 'status', ctx.call.operation.id, 'active', 'inactive', String(used.id), 'vendor');
|
|
2022
|
-
await c.write('mandate', String(used.id), { status: 'inactive' }, 'mandate.updated');
|
|
2023
|
-
}
|
|
1244
|
+
// (a debit run under a mandate names it on its charge, and a single-use mandate is spent by it, at Stripe; the twin
|
|
1245
|
+
// makes no mandate: micro-deposit verification, which accepts one, is the gap here, ../manifest.ts unmodeled)
|
|
2024
1246
|
await payInvoiceOf(c, body, ctx.call.operation.id, 'vendor');
|
|
2025
1247
|
}
|
|
2026
1248
|
}
|
|
@@ -2036,17 +1258,20 @@ export async function fundFromCashBalance(ctx: HandlerContext, pi: Row, fields:
|
|
|
2036
1258
|
// what the customer's funded cash balance holds in this currency, from its ledger
|
|
2037
1259
|
const available = customer ? ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0) : 0;
|
|
2038
1260
|
const amount = Number(pi.amount) || 0;
|
|
2039
|
-
if (available >= amount) {
|
|
2040
|
-
// the balance pays the intent: its ledger records what was applied
|
|
2041
|
-
await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'applied_to_payment', net_amount: -amount, ending_balance: available - amount, applied_to_payment: { payment_intent: String(pi.id) }, livemode: false });
|
|
2042
|
-
const charge = await mintCharge(ctx, String(pi.id), { ...pi, ...fields }, amount);
|
|
2043
|
-
return ctx.write(PI, String(pi.id), { ...fields, status: 'succeeded', next_action: null, amount_received: amount, last_payment_error: null, latest_charge: charge }, 'payment_intent.succeeded');
|
|
2044
|
-
}
|
|
1261
|
+
if (available >= amount) return payFromCashBalance(ctx, pi, fields, { customer, currency, available, amount });
|
|
2045
1262
|
// not enough yet: the intent waits for the rest, with the instructions to send it
|
|
2046
1263
|
const next_action = { type: 'display_bank_transfer_instructions', display_bank_transfer_instructions: { amount_remaining: amount - available, currency, type: 'us_bank_transfer' } };
|
|
2047
1264
|
return ctx.write(PI, String(pi.id), { ...fields, status: 'requires_action', next_action }, 'payment_intent.requires_action');
|
|
2048
1265
|
}
|
|
2049
1266
|
|
|
1267
|
+
/** A customer_balance intent the customer's funded cash balance pays: its ledger records what was applied, and the intent
|
|
1268
|
+
* succeeds with its charge. */
|
|
1269
|
+
export async function payFromCashBalance(ctx: HandlerContext, pi: Row, fields: Row, o: { customer: string; currency: string; available: number; amount: number }): Promise<Row> {
|
|
1270
|
+
await created(ctx, 'customer_cash_balance_transaction', { customer: o.customer }, { currency: o.currency, type: 'applied_to_payment', net_amount: -o.amount, ending_balance: o.available - o.amount, applied_to_payment: { payment_intent: String(pi.id) }, livemode: false });
|
|
1271
|
+
const charge = await mintCharge(ctx, String(pi.id), { ...pi, ...fields }, o.amount);
|
|
1272
|
+
return ctx.write(PI, String(pi.id), { ...fields, status: 'succeeded', next_action: null, amount_received: o.amount, last_payment_error: null, latest_charge: charge }, 'payment_intent.succeeded');
|
|
1273
|
+
}
|
|
1274
|
+
|
|
2050
1275
|
/** Whether a charge's funds go straight to the available balance: a bypass card as a raw number, a test name (pm_card_*
|
|
2051
1276
|
* or tok_*), or a stored PaymentMethod made from one (which records it as what follows its success, after-payment.ts);
|
|
2052
1277
|
* or a US bank account, stored or one of Stripe's test bank accounts named as one (pm_usBankAccount_*). */
|
|
@@ -2060,7 +1285,8 @@ export function bypassesPending(ctx: HandlerContext, card: string | undefined):
|
|
|
2060
1285
|
}
|
|
2061
1286
|
|
|
2062
1287
|
/** The connected account a request acts for, or undefined for the platform. */
|
|
2063
|
-
|
|
1288
|
+
// The platform's own account ID denotes the same balance as an omitted Stripe-Account header.
|
|
1289
|
+
export const actingAccount = (ctx: HandlerContext): string | undefined => { const id = ctx.call.request.headers.get('stripe-account'); return id && id !== PLATFORM_ACCOUNT_ID ? id : undefined; };
|
|
2064
1290
|
|
|
2065
1291
|
/** A ledger entry on an account's balance: the acting account's unless one is named (null names the platform). */
|
|
2066
1292
|
export async function write(ctx: HandlerContext, fields: Row, account: string | null | undefined = actingAccount(ctx)): Promise<string> {
|
|
@@ -2118,12 +1344,6 @@ export async function settleDispute(ctx: HandlerContext, disputeId: string, amou
|
|
|
2118
1344
|
});
|
|
2119
1345
|
}
|
|
2120
1346
|
|
|
2121
|
-
/** A won dispute's credit, at once: the disputed amount returned (docs.stripe.com/disputes/how-disputes-work). Where the
|
|
2122
|
-
* documentation stops and the twin decides: the dispute fee is not returned. */
|
|
2123
|
-
export async function settleDisputeWon(ctx: HandlerContext, disputeId: string, amount: number, currency: string): Promise<string> {
|
|
2124
|
-
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'adjustment', reporting_category: 'dispute_reversal', source: disputeId, available_on: Number(nowUnix(ctx.occurredAt)) });
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
1347
|
/** An account's ledger entries an automatic payout has not yet paid out: every entry on its payments balance that no
|
|
2128
1348
|
* automatic payout took (the automatic payouts' own debits excluded), as stored. */
|
|
2129
1349
|
export function unpaidEntries(ctx: HandlerContext, account: string | undefined): Row[] {
|
|
@@ -2156,27 +1376,12 @@ export async function settleTransfer(ctx: HandlerContext, transferId: string, am
|
|
|
2156
1376
|
return platform;
|
|
2157
1377
|
}
|
|
2158
1378
|
|
|
2159
|
-
/** A transfer reversal's two entries: back into the platform's balance, out of the destination's. */
|
|
2160
|
-
export async function settleTransferReversal(ctx: HandlerContext, reversalId: string, amount: number, currency: string, destination: string): Promise<string> {
|
|
2161
|
-
const platform = await write(ctx, { amount, currency, fee: 0, net: amount, type: 'transfer_refund', reporting_category: 'transfer_reversal', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, null);
|
|
2162
|
-
await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payment_refund', reporting_category: 'refund', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, destination);
|
|
2163
|
-
return platform;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
1379
|
/** A direct charge's application fee: out of the connected account's balance, into the platform's. */
|
|
2167
1380
|
export async function settleApplicationFee(ctx: HandlerContext, feeId: string, amount: number, currency: string, account: string): Promise<string> {
|
|
2168
1381
|
await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(nowUnix(ctx.occurredAt)) }, account);
|
|
2169
1382
|
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(nowUnix(ctx.occurredAt)) }, null);
|
|
2170
1383
|
}
|
|
2171
1384
|
|
|
2172
|
-
/** A top-up's credit, to the payments balance or, with `destination_balance=issuing`, to Issuing's
|
|
2173
|
-
* (docs.stripe.com/issuing/funding/balance). Where the documentation stops and the twin decides: a test-mode top-up
|
|
2174
|
-
* is available at once. */
|
|
2175
|
-
export async function settleTopup(ctx: HandlerContext, topupId: string, amount: number, currency: string, destination: string): Promise<string> {
|
|
2176
|
-
const issuing = destination === 'issuing';
|
|
2177
|
-
return write(ctx, { amount, currency, fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: topupId, available_on: Number(nowUnix(ctx.occurredAt)), balance_type: issuing ? 'issuing' : 'payments' });
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
1385
|
/** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
|
|
2181
1386
|
* moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
|
|
2182
1387
|
* sees it (asOf); the record is what lets Stripe's balance.available be sent once (./front.ts, the drain).
|
|
@@ -2233,96 +1438,23 @@ export function chargeBody(ctx: HandlerContext, c: Row): Row {
|
|
|
2233
1438
|
return { ...c, refunds: { object: 'list', data, has_more: false, total_count: data.length, url: `/v1/charges/${String(c.id)}/refunds` } };
|
|
2234
1439
|
}
|
|
2235
1440
|
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
const
|
|
2242
|
-
|
|
2243
|
-
...chargeDefaults(failedId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
|
|
2244
|
-
failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
|
|
2245
|
-
outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
|
|
2246
|
-
payment_method_details: paymentMethodDetails(ctx, params.payment_method ?? params.source ?? params.card) ?? null,
|
|
2247
|
-
}, { operation: 'charge.failed' });
|
|
2248
|
-
return send(ctx, cardError(decline, { charge: failedId }));
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
/** A charge's fraud report: "user_report … Assessments reported by you. If set, possible values of are `safe` and
|
|
2252
|
-
* `fraudulent`" (docs.stripe.com/api/charges/object, fraud_details). */
|
|
2253
|
-
export function fraudReport(ctx: HandlerContext, existing: Row, fd: Row): Row | Response {
|
|
2254
|
-
const report = String(fd.user_report);
|
|
2255
|
-
if (report !== 'safe' && report !== 'fraudulent') return fail(ctx, "Invalid fraud_details[user_report]: must be 'safe' or 'fraudulent'.", 400, 'parameter_invalid_string_enum');
|
|
2256
|
-
const prior = existing.fraud_details && typeof existing.fraud_details === 'object' ? (existing.fraud_details as Row) : {};
|
|
2257
|
-
return { user_report: report, stripe_report: prior.stripe_report ?? null };
|
|
1441
|
+
export const subMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such subscription: '${id}'`, 404, 'resource_missing');
|
|
1442
|
+
|
|
1443
|
+
/** A coupon that replaces the subscription's discount, or an empty one that clears it (docs.stripe.com/api/subscriptions/update#update_subscription-discounts). */
|
|
1444
|
+
export function replacedDiscounts(ctx: HandlerContext, couponId: string, sub: Row): Row[] | Response {
|
|
1445
|
+
if (couponId === '') return [];
|
|
1446
|
+
const coupon = ctx.get('coupon', couponId);
|
|
1447
|
+
return coupon ? [buildDiscount(coupon, String(sub.customer ?? ''), Number(nowUnix(ctx.occurredAt)), { subscription: String(sub.id) })] : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
2258
1448
|
}
|
|
2259
1449
|
|
|
2260
|
-
/** A
|
|
2261
|
-
*
|
|
2262
|
-
*
|
|
2263
|
-
*
|
|
2264
|
-
*
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
const id = String(ch.id);
|
|
2269
|
-
const full = Number(ch.amount) || 0;
|
|
2270
|
-
if (ch.refunded !== true) ctx.legal('charge', 'refunded', operationId, 'false', 'true', id);
|
|
2271
|
-
const refund = await created(ctx, 'refund', { ...given, charge: id, amount: full - (Number(ch.amount_refunded) || 0), currency: String(ch.currency ?? 'usd') }, {
|
|
2272
|
-
status: 'succeeded', metadata: {}, reason: null, receipt_number: null,
|
|
2273
|
-
balance_transaction: null, source_transfer_reversal: null, transfer_reversal: null, ...refundDestination(ch, 'reversal'),
|
|
2274
|
-
...(typeof ch.payment_intent === 'string' ? { payment_intent: ch.payment_intent } : {}),
|
|
2275
|
-
});
|
|
2276
|
-
await ctx.write('charge', id, { amount_refunded: full, refunded: true }, 'charge.refunded');
|
|
2277
|
-
return refund;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
/** A PaymentIntent's authorization released by its cancel: "For PaymentIntents with a `status` of `requires_capture`, the
|
|
2281
|
-
* remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel), and since basil
|
|
2282
|
-
* a cancellation makes no Refund: "`amount_captured` will be 0 instead of `nil` in payment cancellation flows",
|
|
2283
|
-
* "`amount_refunded` will no longer be updated by these actions", "`refunded` will no longer be `true` for payment
|
|
2284
|
-
* cancellation flows", and no charge.refunded is sent (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-
|
|
2285
|
-
* partial-capture-and-payment-cancellation-flow). The charge stays uncaptured; nothing is written as an event. */
|
|
2286
|
-
export async function cancelAuthorization(ctx: HandlerContext, ch: Row): Promise<Row> {
|
|
2287
|
-
return ctx.write('charge', String(ch.id), { amount_captured: 0 }, 'charge.authorization_released');
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
/** An authorized charge captured, by the charge's capture or its PaymentIntent's: what is captured credits the balance.
|
|
2291
|
-
* Written as charge.captured, "Occurs whenever a previously uncaptured charge is captured" (docs.stripe.com/api/events/types).
|
|
2292
|
-
* A partial capture releases the rest with no Refund and leaves amount_refunded and refunded as they were: "The following
|
|
2293
|
-
* flows no longer result in a `Refund` object created and linked to the payment: Partial capture", "`amount_refunded`
|
|
2294
|
-
* will no longer be updated by these actions", and "There will only be a single balance transaction for partial captures"
|
|
2295
|
-
* (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-partial-capture-and-payment-cancellation-flow).
|
|
2296
|
-
* The caller has asked the machine whether it may capture. */
|
|
2297
|
-
export async function captureAuthorization(ctx: HandlerContext, ch: Row, toCapture: number, _operationId: string): Promise<Row> {
|
|
2298
|
-
const id = String(ch.id);
|
|
2299
|
-
const bt = toCapture > 0 ? await settleCharge(ctx, id, toCapture, String(ch.currency ?? 'usd'), String(ch.source ?? ch.payment_method ?? '')) : null;
|
|
2300
|
-
return ctx.write('charge', id, { captured: true, amount_captured: toCapture, balance_transaction: bt }, 'charge.captured');
|
|
2301
|
-
}
|
|
2302
|
-
|
|
2303
|
-
export const chargeRefund = (ctx: HandlerContext): Row | Response => {
|
|
2304
|
-
if (!ctx.get('charge', at(ctx, 'charge'))) return chargeMissing(ctx, at(ctx, 'charge'));
|
|
2305
|
-
const r = ctx.get('refund', at(ctx, 'refund'));
|
|
2306
|
-
return r && r.charge === at(ctx, 'charge') ? r : fail(ctx, `No such refund: '${at(ctx, 'refund')}'`, 404, 'resource_missing');
|
|
2307
|
-
};
|
|
2308
|
-
|
|
2309
|
-
export const subMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such subscription: '${id}'`, 404, 'resource_missing');
|
|
2310
|
-
|
|
2311
|
-
/** A coupon that replaces the subscription's discount, or an empty one that clears it (docs.stripe.com/api/subscriptions/update#update_subscription-discounts). */
|
|
2312
|
-
export function replacedDiscounts(ctx: HandlerContext, couponId: string, sub: Row): Row[] | Response {
|
|
2313
|
-
if (couponId === '') return [];
|
|
2314
|
-
const coupon = ctx.get('coupon', couponId);
|
|
2315
|
-
return coupon ? [buildDiscount(coupon, String(sub.customer ?? ''), Number(nowUnix(ctx.occurredAt)), { subscription: String(sub.id) })] : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
|
-
/** A `trial_end` Stripe refuses: the parameter is `"now" | timestamp` (docs.stripe.com/api/subscriptions/update), and a
|
|
2319
|
-
* timestamp that is not in the future answers "Invalid timestamp: must be an integer Unix timestamp in the future."
|
|
2320
|
-
* (Stripe's own message, as its SDKs' users report it: github.com/stripe/stripe-dotnet/issues/805,
|
|
2321
|
-
* github.com/stripe/stripe-ruby/issues/682). Where the documentation stops and the twin decides: a value that is no
|
|
2322
|
-
* number at all, and one more than two years on, answer that same message. */
|
|
2323
|
-
export function trialEndRefused(ctx: HandlerContext, value: unknown, now: number): Response | undefined {
|
|
2324
|
-
const t = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : NaN;
|
|
2325
|
-
return value === undefined || value === 'now' || (Number.isInteger(t) && t > now && t <= now + MAX_TRIAL) ? undefined : invalidTrialEnd(ctx);
|
|
1450
|
+
/** A `trial_end` Stripe refuses: the parameter is `"now" | timestamp` (docs.stripe.com/api/subscriptions/update), and a
|
|
1451
|
+
* timestamp that is not in the future answers "Invalid timestamp: must be an integer Unix timestamp in the future."
|
|
1452
|
+
* (Stripe's own message, as its SDKs' users report it: github.com/stripe/stripe-dotnet/issues/805,
|
|
1453
|
+
* github.com/stripe/stripe-ruby/issues/682). Where the documentation stops and the twin decides: a value that is no
|
|
1454
|
+
* number at all, and one more than two years on, answer that same message. */
|
|
1455
|
+
export function trialEndRefused(ctx: HandlerContext, value: unknown, now: number): Response | undefined {
|
|
1456
|
+
const t = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : NaN;
|
|
1457
|
+
return value === undefined || value === 'now' || (Number.isInteger(t) && t > now && t <= now + MAX_TRIAL) ? undefined : invalidTrialEnd(ctx);
|
|
2326
1458
|
}
|
|
2327
1459
|
|
|
2328
1460
|
export function invalidTrialEnd(ctx: HandlerContext): Response {
|
|
@@ -2362,7 +1494,7 @@ export async function applyTrialEnd(ctx: HandlerContext, sub: Row, fields: Row,
|
|
|
2362
1494
|
const id = String(sub.id);
|
|
2363
1495
|
if (value !== 'now' || sub.status !== 'trialing') return value === 'now' ? undefined : movedTrial(sub, fields, Number(value), now);
|
|
2364
1496
|
// the period's invoice bills the items as this update leaves them, under its discount
|
|
2365
|
-
const next: Row = { ...sub, ...fields, items: ctx
|
|
1497
|
+
const next: Row = { ...sub, ...fields, items: resourceView(ctx, SUB, id)?.items ?? sub.items };
|
|
2366
1498
|
const { interval, interval_count } = resolveSubscriptionBillingInterval(((next.items as { data?: Row[] } | undefined)?.data) ?? [], finder(ctx));
|
|
2367
1499
|
const period = { start: now, end: addBillingInterval(now, interval, interval_count) };
|
|
2368
1500
|
const invoice = await draftSubscriptionInvoice(ctx, next, { ...period, reason: 'subscription_update' });
|
|
@@ -2500,7 +1632,7 @@ export async function writeItemChanges(ctx: HandlerContext, subId: string, entri
|
|
|
2500
1632
|
const metadata = entry.metadata && typeof entry.metadata === 'object' ? { metadata: entry.metadata } : {};
|
|
2501
1633
|
if (typeof entry.id === 'string' && entry.id) {
|
|
2502
1634
|
if (asBool(entry.deleted)) { await ctx.write('subscription_item', entry.id, { deleted: true }, 'subscription_item.delete'); continue; }
|
|
2503
|
-
await ctx.write('subscription_item', entry.id, { ...(priceRef ?
|
|
1635
|
+
await ctx.write('subscription_item', entry.id, { ...(priceRef ? { price: priceRef } : {}), ...quantity, ...metadata }, 'subscription_item.update');
|
|
2504
1636
|
} else {
|
|
2505
1637
|
await created(ctx, 'subscription_item', { subscription: subId, ...subscriptionItemPrice(priceRef, finder(ctx)), quantity: 1, ...quantity, ...metadata }, { livemode: false, metadata: {}, discounts: [], tax_rates: [], billing_thresholds: null });
|
|
2506
1638
|
}
|
|
@@ -2540,24 +1672,16 @@ export async function inlinePrice(ctx: HandlerContext, d: Row): Promise<string>
|
|
|
2540
1672
|
export async function storeItems(ctx: HandlerContext, subId: string, items: Row[]): Promise<void> {
|
|
2541
1673
|
for (const item of items) {
|
|
2542
1674
|
if (typeof item.id !== 'string' || ctx.get('subscription_item', item.id)) continue;
|
|
2543
|
-
|
|
1675
|
+
const { plan: _plan, price, ...fields } = item;
|
|
1676
|
+
await ctx.write('subscription_item', item.id, { ...fields, price: typeof price === 'object' && price ? (price as Row).id : price, subscription: subId }, 'subscription_item.create');
|
|
2544
1677
|
}
|
|
2545
1678
|
}
|
|
2546
1679
|
|
|
2547
1680
|
export async function syncItems(ctx: HandlerContext, subId: string): Promise<void> {
|
|
2548
1681
|
if (!subId) return;
|
|
2549
|
-
|
|
2550
|
-
await ctx.write(SUB, subId, { items: { object: 'list', data: items, has_more: false, total_count: items.length, url: `/v1/subscription_items?subscription=${subId}` } }, 'subscription.items_synced');
|
|
1682
|
+
await ctx.write(SUB, subId, {}, 'subscription.items_synced');
|
|
2551
1683
|
}
|
|
2552
1684
|
|
|
2553
|
-
/** A subscription item with its billing period: its subscription's (basil moved the period onto the items). */
|
|
2554
|
-
export const withPeriod = (ctx: HandlerContext, si: Row): Row => {
|
|
2555
|
-
const sub = ctx.get(SUB, String(si.subscription));
|
|
2556
|
-
return { ...si, current_period_start: si.current_period_start ?? sub?.current_period_start ?? null, current_period_end: si.current_period_end ?? sub?.current_period_end ?? null };
|
|
2557
|
-
};
|
|
2558
|
-
|
|
2559
|
-
export const subscriptionsItemMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such subscription_item: '${id}'`, 404, 'resource_missing');
|
|
2560
|
-
|
|
2561
1685
|
export const listMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such radar value list: '${id}'`, status, 'resource_missing');
|
|
2562
1686
|
|
|
2563
1687
|
/** A value list's list_items, "List of items contained within this value list" (the served spec), kept in step with
|
|
@@ -2567,25 +1691,9 @@ export async function radarSyncItems(ctx: HandlerContext, listId: string): Promi
|
|
|
2567
1691
|
await ctx.write(VL, listId, { list_items: { object: 'list', data, has_more: false, url: `/v1/radar/value_list_items?value_list=${listId}` } }, 'radar.value_list.items_synced');
|
|
2568
1692
|
}
|
|
2569
1693
|
|
|
2570
|
-
/** A requested move of a state field the machine declares, legal or refused; a value the machine
|
|
2571
|
-
* does not name is written as given, as the hand-written route did. */
|
|
2572
|
-
export function requestedMove(ctx: HandlerContext, resource: string, operationId: string, current: unknown, values: string[]): Response | undefined {
|
|
2573
|
-
const to = ctx.params.status;
|
|
2574
|
-
if (typeof to !== 'string' || !values.includes(to)) return undefined;
|
|
2575
|
-
const refused = ctx.legal(resource, 'status', operationId, current, to);
|
|
2576
|
-
return refused ? ctx.refuse(refused) : undefined;
|
|
2577
|
-
}
|
|
2578
|
-
|
|
2579
|
-
// ── authorizations ──
|
|
2580
|
-
|
|
2581
1694
|
/** An authorization as every egress carries it: its card embedded in full. */
|
|
2582
1695
|
export function withCard(ctx: HandlerContext, a: Row): Row {
|
|
2583
|
-
|
|
2584
|
-
if (typeof out.card === 'string') {
|
|
2585
|
-
const card = ctx.get(CARD, out.card);
|
|
2586
|
-
if (card) out.card = card;
|
|
2587
|
-
}
|
|
2588
|
-
return out;
|
|
1696
|
+
return ctx.expand(AUTH, a);
|
|
2589
1697
|
}
|
|
2590
1698
|
|
|
2591
1699
|
export const authMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such authorization: '${id}'`, 404, 'resource_missing');
|
|
@@ -2637,7 +1745,7 @@ export function issuingDecide(approved: boolean): Handler {
|
|
|
2637
1745
|
if (!auth) return authMissing(ctx, id);
|
|
2638
1746
|
const finalized = { status: 400, code: 'authorization_already_finalized', message: `This authorization has already been finalized (status ${String(auth.status)}).` };
|
|
2639
1747
|
// a webhook-approved authorization is decided, though still pending
|
|
2640
|
-
if (auth.approved === true) return ctx.refuse(finalized);
|
|
1748
|
+
if (auth.approved === true && auth.status === 'pending') return ctx.refuse(finalized);
|
|
2641
1749
|
const refused = ctx.legal(AUTH, 'status', approved ? 'PostIssuingAuthorizationsAuthorizationApprove' : 'PostIssuingAuthorizationsAuthorizationDecline', auth.status, undefined, id);
|
|
2642
1750
|
if (refused) return ctx.refuse(refused);
|
|
2643
1751
|
// a partial approval only for an amount-controllable presentment, and never a credit
|
|
@@ -2650,7 +1758,7 @@ export function issuingDecide(approved: boolean): Handler {
|
|
|
2650
1758
|
// (docs.stripe.com/issuing/purchases/authorizations, docs.stripe.com/issuing/funding/balance)
|
|
2651
1759
|
const hold = approved ? await holdFor(ctx, id, decidedAmount, currency) : null;
|
|
2652
1760
|
return ctx.reply(withCard(ctx, await ctx.write(AUTH, id, {
|
|
2653
|
-
|
|
1761
|
+
status: approved ? 'pending' : 'closed', approved, pending_request: null,
|
|
2654
1762
|
...(approved && ctx.params.amount !== undefined ? { amount: decidedAmount } : {}),
|
|
2655
1763
|
request_history: [...history, historyEntry({
|
|
2656
1764
|
amount: decidedAmount, currency, approved, reason: approved ? 'webhook_approved' : 'webhook_declined', reasonMessage: null,
|
|
@@ -2721,31 +1829,209 @@ export async function answeredByEndpoint(ctx: HandlerContext, outcome: { kind: '
|
|
|
2721
1829
|
return outcome.kind === 'declined' ? o.decline('webhook_declined', null) : o.decline('webhook_error', outcome.message);
|
|
2722
1830
|
}
|
|
2723
1831
|
|
|
2724
|
-
|
|
1832
|
+
/** Time's settling of held refunds, caught up to the World's clock: each refund held for want of balance, oldest first,
|
|
1833
|
+
* is made at the first moment the account's available balance covers it (the funds that come due then), its debit
|
|
1834
|
+
* written then, as `refund.updated`. Where the documentation stops and the twin decides: a held refund is made the
|
|
1835
|
+
* moment the balance covers it. */
|
|
1836
|
+
export async function settleHeldRefunds(ctx: HandlerContext): Promise<void> {
|
|
1837
|
+
const now = Number(nowUnix(ctx.occurredAt));
|
|
1838
|
+
const heldRefunds = ctx.rowsRaw('refund').filter((r) => r.status === 'pending' && r.pending_reason === 'insufficient_funds').sort((a, b) => Number(a.created) - Number(b.created));
|
|
1839
|
+
for (const r of heldRefunds) await settleOneHeldRefund(ctx, r, now);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** A pending refund can enter this surface only through the vendor-backed refund refresh. */
|
|
1843
|
+
export async function settleOneHeldRefund(ctx: HandlerContext, r: Row, now: number): Promise<void> {
|
|
1844
|
+
const account = typeof r._account === 'string' ? r._account : undefined;
|
|
1845
|
+
const currency = String(r.currency ?? 'usd');
|
|
1846
|
+
const amount = Number(r.amount) || 0;
|
|
1847
|
+
const when = [Number(r.created), ...fundsDueBetween(ctx, account, currency, Number(r.created), now)].find((t) => availableAt(ctx, account, currency, t) >= amount);
|
|
1848
|
+
if (when === undefined) return;
|
|
1849
|
+
const c = await at_(ctx)(when);
|
|
1850
|
+
c.legal('refund', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(r.id), 'vendor');
|
|
1851
|
+
const bt = await settleRefund(c, String(r.id), amount, currency, account ?? null);
|
|
1852
|
+
await c.write('refund', String(r.id), { status: 'succeeded', pending_reason: null, balance_transaction: bt }, 'refund.updated');
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
export const accountMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
|
|
1856
|
+
|
|
1857
|
+
/** The account, tombstones included where the hand-written routes looked past a deletion. */
|
|
1858
|
+
export const accountRow = (ctx: HandlerContext, id: string): Row | undefined => ctx.row('account', id, { withDeleted: true });
|
|
2725
1859
|
|
|
2726
|
-
|
|
1860
|
+
/** Stripe's review of a Custom account once the platform gives it something: with nothing left due, its requested
|
|
1861
|
+
* capabilities become active and it can take charges and receive payouts; with something due again (its bank account
|
|
1862
|
+
* removed), they stop. Where the documentation stops and the twin decides: test-mode verification is immediate, and
|
|
1863
|
+
* it owes what customRequirements lists. An Express or Standard account is onboarded on Stripe's own pages. */
|
|
1864
|
+
export async function connectReview(ctx: HandlerContext, id: string): Promise<void> {
|
|
1865
|
+
const account = resourceView(ctx, 'account', id);
|
|
1866
|
+
if (account && account.type === 'custom') await reviewCustom(ctx, id, account);
|
|
1867
|
+
}
|
|
2727
1868
|
|
|
2728
|
-
|
|
1869
|
+
/** The review of a Custom account (see review). */
|
|
1870
|
+
export async function reviewCustom(ctx: HandlerContext, id: string, account: Row): Promise<void> {
|
|
1871
|
+
const bank = ctx.rows('external_account').some((e) => e.account === id);
|
|
1872
|
+
const persons = ctx.rows('person').filter((p) => p.account === id);
|
|
1873
|
+
const { now: due, later } = customRequirements(account, bank, persons);
|
|
1874
|
+
// one account, one review: "If a connected account has both card_payments and transfers, and the status of either one
|
|
1875
|
+
// is inactive, then both capabilities are disabled" (docs.stripe.com/connect/account-capabilities)
|
|
1876
|
+
const ready = due.length === 0;
|
|
1877
|
+
const capabilities: Row = {};
|
|
1878
|
+
for (const [key, was] of Object.entries((account.capabilities as Row | undefined) ?? {})) {
|
|
1879
|
+
const to = ready ? 'active' : 'inactive';
|
|
1880
|
+
// Capabilities are entries of the account's map, not standalone stored subjects.
|
|
1881
|
+
capabilities[key] = to;
|
|
1882
|
+
}
|
|
1883
|
+
const requirements = { ...((account.requirements as Row | undefined) ?? {}), currently_due: due, eventually_due: [...due, ...later], past_due: [], disabled_reason: ready ? null : 'requirements.past_due', current_deadline: null };
|
|
1884
|
+
await ctx.write('account', id, { capabilities, requirements, charges_enabled: ready, payouts_enabled: ready, details_submitted: ready }, ready ? 'account.updated' : 'account.update');
|
|
1885
|
+
}
|
|
2729
1886
|
|
|
2730
|
-
|
|
1887
|
+
/** A transfer to an account whose transfers capability is not active. */
|
|
1888
|
+
export function transfersInactive(ctx: HandlerContext): Response {
|
|
1889
|
+
return ctx.refuse({ status: 400, code: 'insufficient_capabilities_for_transfer', param: 'destination', message: 'Your destination account needs to have at least one of the following capabilities enabled: transfers, crypto_transfers, legacy_payments' });
|
|
1890
|
+
}
|
|
2731
1891
|
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
const refused = ctx.legal(PD, 'status', operationId, d.status, undefined, id);
|
|
2738
|
-
if (refused) return ctx.refuse(refused);
|
|
2739
|
-
const status = action === 'activate' ? 'active' : action === 'deactivate' ? 'inactive' : 'rejected';
|
|
2740
|
-
// a rejection records its reasons: the reject helper's required rejection_reasons, "The reason(s) the
|
|
2741
|
-
// personalization design was rejected" (the served spec), per card_logo and carrier_text
|
|
2742
|
-
const given = ctx.params.rejection_reasons && typeof ctx.params.rejection_reasons === 'object' ? (ctx.params.rejection_reasons as Row) : undefined;
|
|
2743
|
-
if (action === 'reject' && !given) return fail(ctx, 'Missing required param: rejection_reasons.', 400, 'parameter_missing');
|
|
2744
|
-
const reasons = action === 'reject' ? { rejection_reasons: { card_logo: Array.isArray(given!.card_logo) ? given!.card_logo : [], carrier_text: Array.isArray(given!.carrier_text) ? given!.carrier_text : [] } } : {};
|
|
2745
|
-
return ctx.reply(await ctx.write(PD, id, { status, ...reasons }, `personalization_design.${action}`));
|
|
2746
|
-
};
|
|
1892
|
+
/** A transfer from a charge whose funds have settled: "With a source_transaction, the transfer request returns success
|
|
1893
|
+
* regardless of your available balance if the related charge hasn't settled yet" (docs.stripe.com/connect/separate-
|
|
1894
|
+
* charges-and-transfers), so a settled one comes out of the available balance. */
|
|
1895
|
+
export function settledSourceRefused(ctx: HandlerContext, amount: number, currency: string): Response | undefined {
|
|
1896
|
+
return refusePayout(ctx, amount, currency, undefined);
|
|
2747
1897
|
}
|
|
2748
1898
|
|
|
1899
|
+
/** An earlier version's payment_method_types refused: the first value that names no payment method type the
|
|
1900
|
+
* operation takes, in the intent's words or (a Checkout Session) the enum's. */
|
|
1901
|
+
export function unknownMethodType(ctx: HandlerContext, opId: string, given: unknown): Response | undefined {
|
|
1902
|
+
const values = Array.isArray(given) ? given : given && typeof given === 'object' ? Object.values(given) : [given];
|
|
1903
|
+
const checkout = opId === 'PostCheckoutSessions';
|
|
1904
|
+
const known = checkout ? CHECKOUT_METHOD_TYPES : PAYMENT_METHOD_TYPES;
|
|
1905
|
+
const i = values.findIndex((v) => !known.includes(String(v)));
|
|
1906
|
+
if (i < 0) return undefined;
|
|
1907
|
+
if (checkout) {
|
|
1908
|
+
const param = `payment_method_types[${i}]`;
|
|
1909
|
+
return ctx.refuse({ status: 400, param, message: `Invalid ${param}: must be one of ${oneOf(known)}` });
|
|
1910
|
+
}
|
|
1911
|
+
const doc = TYPES_DOC[opId.startsWith('PostSetupIntents') ? 'setup_intents' : 'payment_intents'];
|
|
1912
|
+
return ctx.refuse({ status: 400, param: 'payment_method_types', message: `The payment method type "${String(values[i])}" is invalid. See ${doc} for the full list of supported payment method types.` });
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/** payment_method_types refused where the caller's version removed it. */
|
|
1916
|
+
export const noLongerSupported = (ctx: HandlerContext, version: string): Response =>
|
|
1917
|
+
ctx.refuse({ status: 400, code: 'payment_method_types_no_longer_supported', param: 'payment_method_types', message: `payment_method_types is no longer supported in API version ${version}. Use allowed_payment_method_types, or dynamic payment methods, instead.` });
|
|
1918
|
+
|
|
1919
|
+
/** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
|
|
1920
|
+
export function refuseParameters(ctx: HandlerContext): Response | undefined {
|
|
1921
|
+
const op = OPS.get(ctx.call.operation.id);
|
|
1922
|
+
if (!op) return undefined;
|
|
1923
|
+
const pinned = ctx.call.request.headers.get('stripe-version');
|
|
1924
|
+
const params = ctx.params;
|
|
1925
|
+
if (pinned && pinned < SERVED_VERSION) {
|
|
1926
|
+
if (!TYPED.has(op.id)) return undefined;
|
|
1927
|
+
if (params.payment_method_types === undefined) return undefined;
|
|
1928
|
+
if (REMOVED_TYPES.has(op.id) && methodTypesRemoved(pinned)) return noLongerSupported(ctx, pinned);
|
|
1929
|
+
return unknownMethodType(ctx, op.id, params.payment_method_types);
|
|
1930
|
+
}
|
|
1931
|
+
const method = ctx.call.request.method.toUpperCase();
|
|
1932
|
+
const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
|
|
1933
|
+
if (REMOVED_TYPES.has(op.id) && params.payment_method_types !== undefined) return noLongerSupported(ctx, pinned ?? SERVED_VERSION);
|
|
1934
|
+
for (const name of Object.keys(params)) {
|
|
1935
|
+
if (!known.has(name)) return ctx.refuse({ status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
|
|
1936
|
+
}
|
|
1937
|
+
const required = method === 'GET' || method === 'DELETE' ? (op.query ?? []) : (op.body ?? []);
|
|
1938
|
+
for (const p of required) {
|
|
1939
|
+
if (p.required && params[p.name] === undefined) return ctx.refuse({ status: 400, code: 'parameter_missing', param: p.name, message: `Missing required param: ${p.name}.` });
|
|
1940
|
+
}
|
|
1941
|
+
return undefined;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** An answer in the shape of the API version the caller is served (./version.ts), a webhook endpoint's `secret` shown
|
|
1945
|
+
* only at its creation ("Only returned at creation", docs.stripe.com/api/webhook_endpoints/object). */
|
|
1946
|
+
export async function rendered(request: Request, res: Response): Promise<Response> {
|
|
1947
|
+
const creates = request.method === 'POST' && new URL(request.url).pathname.replace(/\/+$/, '') === '/v1/webhook_endpoints';
|
|
1948
|
+
const pinned = request.headers.get('stripe-version');
|
|
1949
|
+
if (!(res.headers.get('content-type') ?? '').includes('json')) return res;
|
|
1950
|
+
if (!servesCurrent(pinned) && creates) return res;
|
|
1951
|
+
const expand = await expandOf(request.clone());
|
|
1952
|
+
const body = await res.json();
|
|
1953
|
+
const answered = creates ? body : withoutEndpointSecret(body);
|
|
1954
|
+
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
/** Write a draft's line list and the totals derived from it. */
|
|
1960
|
+
export async function writeLines(ctx: HandlerContext, invoiceId: string, next: Row[]): Promise<Row> {
|
|
1961
|
+
const subtotal = sumLines(next);
|
|
1962
|
+
const lines = { object: 'list', data: next, has_more: false, total_count: next.length, url: `/v1/invoices/${invoiceId}/lines` };
|
|
1963
|
+
return ctx.write(INV, invoiceId, { lines, subtotal, total: subtotal, amount_due: subtotal, amount_remaining: subtotal }, 'invoice.update');
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
/** A customer's pending invoice item, made before the invoice, pulled onto it (docs.stripe.com/api/invoices/create#create_invoice-pending_invoice_items_behavior). */
|
|
1969
|
+
export async function pullPendingItem(ctx: HandlerContext, invoiceId: string, inv: Row, item: Row, now: number): Promise<void> {
|
|
1970
|
+
const current = ctx.get(INV, invoiceId) ?? inv;
|
|
1971
|
+
await writeLines(ctx, invoiceId, [...linesOf(current), {
|
|
1972
|
+
id: `il_${String(item.id)}`, object: 'line_item', type: 'invoiceitem', amount: Number(item.amount) || 0, currency: String(current.currency ?? item.currency ?? 'usd'),
|
|
1973
|
+
quantity: Number(item.quantity) || 1, proration: false, invoice_item: item.id, price: item.price ?? null,
|
|
1974
|
+
period: item.period ?? { start: now, end: now }, description: (item.description as string) ?? null,
|
|
1975
|
+
discountable: item.discountable ?? true, discounts: [], livemode: false, metadata: item.metadata ?? {}, tax_amounts: [], tax_rates: [],
|
|
1976
|
+
}]);
|
|
1977
|
+
await ctx.write('invoiceitem', String(item.id), { invoice: invoiceId }, 'invoiceitem.update');
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
/** The intent the path names, and the machine's refusal when this operation may not move it. */
|
|
1983
|
+
export function paymentIntentsLoad(ctx: HandlerContext, operationId: string): { pi: Record<string, unknown> } | { answer: Response } {
|
|
1984
|
+
const pi = ctx.id ? ctx.get(PI, ctx.id) : undefined;
|
|
1985
|
+
if (!pi) return { answer: ctx.notFound(PI, String(ctx.id)) };
|
|
1986
|
+
const refusal = ctx.legal(PI, 'status', operationId, pi.status);
|
|
1987
|
+
return refusal ? { answer: ctx.refuse(refusal) } : { pi };
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
/** A transfer reversal's two entries: back into the platform's balance, out of the destination's. */
|
|
1993
|
+
export async function settleTransferReversal(ctx: HandlerContext, reversalId: string, amount: number, currency: string, destination: string): Promise<string> {
|
|
1994
|
+
const platform = await write(ctx, { amount, currency, fee: 0, net: amount, type: 'transfer_refund', reporting_category: 'transfer_reversal', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, null);
|
|
1995
|
+
await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payment_refund', reporting_category: 'refund', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, destination);
|
|
1996
|
+
return platform;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
/** A capture=false charge refunded while uncaptured: its authorization is released by the refund, as it would be
|
|
2002
|
+
* "automatically refunded if uncaptured" (spec/openapi.json.gz, `capture_before`). The charge stays uncaptured and
|
|
2003
|
+
* becomes refunded in full, a Refund records the release, and no money moves: none was ever received. Written as
|
|
2004
|
+
* charge.refunded. Where the documentation stops and the twin decides: Stripe's basil change ("Partially capturing or
|
|
2005
|
+
* canceling payments no longer creates a Refund", docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-partial-
|
|
2006
|
+
* capture-and-payment-cancellation-flow) names partial capture and cancellation, not a refund asked for, so a refund
|
|
2007
|
+
* still makes one; its reason is null and it has no balance transaction. Answers the Refund. */
|
|
2008
|
+
export async function releaseAuthorization(ctx: HandlerContext, ch: Row, operationId: string, given: Row = {}): Promise<Row> {
|
|
2009
|
+
const id = String(ch.id);
|
|
2010
|
+
const full = Number(ch.amount) || 0;
|
|
2011
|
+
if (ch.refunded !== true) ctx.legal('charge', 'refunded', operationId, 'false', 'true', id);
|
|
2012
|
+
const refund = await created(ctx, 'refund', { ...given, charge: id, amount: full - (Number(ch.amount_refunded) || 0), currency: String(ch.currency ?? 'usd') }, {
|
|
2013
|
+
status: 'succeeded', metadata: {}, reason: null, receipt_number: null,
|
|
2014
|
+
balance_transaction: null, source_transfer_reversal: null, transfer_reversal: null, ...refundDestination(ch, 'reversal'),
|
|
2015
|
+
...(typeof ch.payment_intent === 'string' ? { payment_intent: ch.payment_intent } : {}),
|
|
2016
|
+
});
|
|
2017
|
+
await ctx.write('charge', id, { amount_refunded: full, refunded: true }, 'charge.refunded');
|
|
2018
|
+
return refund;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
/** A PaymentIntent's authorization released by its cancel: "For PaymentIntents with a `status` of `requires_capture`, the
|
|
2024
|
+
* remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel), and since basil
|
|
2025
|
+
* a cancellation makes no Refund: "`amount_captured` will be 0 instead of `nil` in payment cancellation flows",
|
|
2026
|
+
* "`amount_refunded` will no longer be updated by these actions", "`refunded` will no longer be `true` for payment
|
|
2027
|
+
* cancellation flows", and no charge.refunded is sent (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-
|
|
2028
|
+
* partial-capture-and-payment-cancellation-flow). The charge stays uncaptured; nothing is written as an event. */
|
|
2029
|
+
export async function cancelAuthorization(ctx: HandlerContext, ch: Row): Promise<Row> {
|
|
2030
|
+
return ctx.write('charge', String(ch.id), { amount_captured: 0 }, 'charge.authorization_released');
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
|
|
2749
2035
|
/**
|
|
2750
2036
|
* Create a refund and land it on its charge. An omitted amount is the whole remaining balance. The
|
|
2751
2037
|
* charge's write records `charge.refunded`, the event Stripe sends carrying the charge's new totals;
|
|
@@ -2756,6 +2042,8 @@ export function badReason(ctx: HandlerContext): Response {
|
|
|
2756
2042
|
return fail(ctx, `Invalid refund reason: must be one of ${[...REFUND_REASONS].join(', ')}.`, 400, 'parameter_invalid_string_enum');
|
|
2757
2043
|
}
|
|
2758
2044
|
|
|
2045
|
+
|
|
2046
|
+
|
|
2759
2047
|
/** A bank-transfer payment refunded with origin=customer_balance goes back to the cash balance
|
|
2760
2048
|
* (docs.stripe.com/payments/customer-balance/refunding). */
|
|
2761
2049
|
export async function backToCashBalance(ctx: HandlerContext, customer: string, currency: string, amount: number, refundId: string): Promise<void> {
|
|
@@ -2763,6 +2051,8 @@ export async function backToCashBalance(ctx: HandlerContext, customer: string, c
|
|
|
2763
2051
|
await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'refunded_from_payment', net_amount: amount, ending_balance: before + amount, refunded_from_payment: { refund: refundId }, livemode: false });
|
|
2764
2052
|
}
|
|
2765
2053
|
|
|
2054
|
+
|
|
2055
|
+
|
|
2766
2056
|
/** A capture=false charge's refund, which releases its whole authorization (see the header). */
|
|
2767
2057
|
export async function refundUncaptured(ctx: HandlerContext, charge: Row, amount: number, remaining: number, params: Row): Promise<Response> {
|
|
2768
2058
|
if (amount < remaining) return fail(ctx, `Charge ${String(charge.id)} is uncaptured: refunding it releases the whole authorization (${remaining}).`, 400, 'amount_too_small');
|
|
@@ -2770,6 +2060,8 @@ export async function refundUncaptured(ctx: HandlerContext, charge: Row, amount:
|
|
|
2770
2060
|
return ctx.reply(await releaseAuthorization(ctx, charge, ctx.call.operation.id, { ...(reason !== undefined ? { reason } : {}), ...(metadata !== undefined ? { metadata } : {}) }));
|
|
2771
2061
|
}
|
|
2772
2062
|
|
|
2063
|
+
|
|
2064
|
+
|
|
2773
2065
|
export async function refundCharge(ctx: HandlerContext, charge: Row, params: Row): Promise<Response> {
|
|
2774
2066
|
const chargeId = String(charge.id);
|
|
2775
2067
|
const total = Number(charge.amount) || 0;
|
|
@@ -2856,25 +2148,7 @@ export async function refundCharge(ctx: HandlerContext, charge: Row, params: Row
|
|
|
2856
2148
|
return ctx.reply(body);
|
|
2857
2149
|
}
|
|
2858
2150
|
|
|
2859
|
-
|
|
2860
|
-
* is made at the first moment the account's available balance covers it (the funds that come due then), its debit
|
|
2861
|
-
* written then, as `refund.updated`. Where the documentation stops and the twin decides: a held refund is made the
|
|
2862
|
-
* moment the balance covers it. */
|
|
2863
|
-
export async function settleHeldRefunds(ctx: HandlerContext): Promise<void> {
|
|
2864
|
-
const now = Number(nowUnix(ctx.occurredAt));
|
|
2865
|
-
const heldRefunds = ctx.rowsRaw('refund').filter((r) => r.status === 'pending' && r.pending_reason === 'insufficient_funds').sort((a, b) => Number(a.created) - Number(b.created));
|
|
2866
|
-
for (const r of heldRefunds) {
|
|
2867
|
-
const account = typeof r._account === 'string' ? r._account : undefined;
|
|
2868
|
-
const currency = String(r.currency ?? 'usd');
|
|
2869
|
-
const amount = Number(r.amount) || 0;
|
|
2870
|
-
const when = [Number(r.created), ...fundsDueBetween(ctx, account, currency, Number(r.created), now)].find((t) => availableAt(ctx, account, currency, t) >= amount);
|
|
2871
|
-
if (when === undefined) continue;
|
|
2872
|
-
const c = await at_(ctx)(when);
|
|
2873
|
-
c.legal('refund', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(r.id), 'vendor');
|
|
2874
|
-
const bt = await settleRefund(c, String(r.id), amount, currency, account ?? null);
|
|
2875
|
-
await c.write('refund', String(r.id), { status: 'succeeded', pending_reason: null, balance_transaction: bt }, 'refund.updated');
|
|
2876
|
-
}
|
|
2877
|
-
}
|
|
2151
|
+
|
|
2878
2152
|
|
|
2879
2153
|
/** The charge a refund aims at: named directly, or reached through its PaymentIntent. */
|
|
2880
2154
|
export function target(ctx: HandlerContext): Row | Response {
|
|
@@ -2889,324 +2163,18 @@ export function target(ctx: HandlerContext): Row | Response {
|
|
|
2889
2163
|
return ch ?? fail(ctx, `This PaymentIntent does not have a successful charge to refund. Its status is ${String(pi.status)}.`, 400, 'payment_intent_unexpected_state');
|
|
2890
2164
|
}
|
|
2891
2165
|
|
|
2892
|
-
export const refundMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such refund: '${id}'`, 404, 'resource_missing');
|
|
2893
|
-
|
|
2894
|
-
/** Each advancing clock whose advance has taken its time reaches its target, caught up before anything is answered:
|
|
2895
|
-
* its frozen_time moves, it is ready, and its customers' trials and periods that ended by then end
|
|
2896
|
-
* (./renewals.ts advanceBilling). The advance's own write is when it began (the kernel's updatedAt). */
|
|
2897
|
-
export async function finishClockAdvances(ctx: HandlerContext): Promise<void> {
|
|
2898
|
-
const now = Number(nowUnix(ctx.occurredAt));
|
|
2899
|
-
for (const clock of ctx.rowsRaw(CLOCK)) {
|
|
2900
|
-
if (clock.status !== 'advancing') continue;
|
|
2901
|
-
if (Date.parse(String(clock.updatedAt)) / 1000 + ADVANCE_SECONDS > now) continue;
|
|
2902
|
-
const id = String(clock.id);
|
|
2903
|
-
const target = Number((clock.status_details as { advancing?: { target_frozen_time?: number } } | undefined)?.advancing?.target_frozen_time);
|
|
2904
|
-
ctx.legal(CLOCK, 'status', ctx.call.operation.id, 'advancing', 'ready', id, 'vendor');
|
|
2905
|
-
await ctx.write(CLOCK, id, { frozen_time: target, status: 'ready', status_details: {} }, 'test_clock.ready');
|
|
2906
|
-
await advanceBilling(ctx, (sub) => typeof sub.customer === 'string' && ctx.get('customer', sub.customer)?.test_clock === id);
|
|
2907
|
-
}
|
|
2908
|
-
}
|
|
2909
|
-
|
|
2910
|
-
/** What the platform saved, or the settings a new platform starts with (OAuth off, no redirect URI). */
|
|
2911
|
-
export function connectOAuthSettings(ctx: Pick<HandlerContext, 'rowsRaw'>): ConnectOAuthSettings {
|
|
2912
|
-
const row = ctx.rowsRaw(SETTINGS).find((r) => r.id === PLATFORM_CLIENT_ID);
|
|
2913
|
-
return { oauth_enabled: row?.oauth_enabled === true, redirect_uris: Array.isArray(row?.redirect_uris) ? (row!.redirect_uris as string[]) : [] };
|
|
2914
|
-
}
|
|
2915
|
-
|
|
2916
|
-
export const accountMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
|
|
2917
|
-
|
|
2918
|
-
/** The account, tombstones included where the hand-written routes looked past a deletion. */
|
|
2919
|
-
export const accountRow = (ctx: HandlerContext, id: string): Row | undefined => ctx.row('account', id, { withDeleted: true });
|
|
2920
|
-
|
|
2921
|
-
/** Stripe's review of a Custom account once the platform gives it something: with nothing left due, its requested
|
|
2922
|
-
* capabilities become active and it can take charges and receive payouts; with something due again (its bank account
|
|
2923
|
-
* removed), they stop. Where the documentation stops and the twin decides: test-mode verification is immediate, and
|
|
2924
|
-
* it owes what customRequirements lists. An Express or Standard account is onboarded on Stripe's own pages. */
|
|
2925
|
-
export async function connectReview(ctx: HandlerContext, id: string): Promise<void> {
|
|
2926
|
-
const account = ctx.get('account', id);
|
|
2927
|
-
if (account && account.type === 'custom') await reviewCustom(ctx, id, account);
|
|
2928
|
-
}
|
|
2929
|
-
|
|
2930
|
-
/** The review of a Custom account (see review). */
|
|
2931
|
-
export async function reviewCustom(ctx: HandlerContext, id: string, account: Row): Promise<void> {
|
|
2932
|
-
const bank = ctx.rows('external_account').some((e) => e.account === id);
|
|
2933
|
-
const persons = ctx.rows('person').filter((p) => p.account === id);
|
|
2934
|
-
const { now: due, later } = customRequirements(account, bank, persons);
|
|
2935
|
-
// one account, one review: "If a connected account has both card_payments and transfers, and the status of either one
|
|
2936
|
-
// is inactive, then both capabilities are disabled" (docs.stripe.com/connect/account-capabilities)
|
|
2937
|
-
const ready = due.length === 0;
|
|
2938
|
-
const capabilities: Row = {};
|
|
2939
|
-
for (const [key, was] of Object.entries((account.capabilities as Row | undefined) ?? {})) {
|
|
2940
|
-
const to = ready ? 'active' : 'inactive';
|
|
2941
|
-
// Stripe's review moves each capability, as the machine declares it may
|
|
2942
|
-
if (was !== to) ctx.legal('capability', 'status', ctx.call.operation.id, String(was), to, key, 'vendor');
|
|
2943
|
-
capabilities[key] = to;
|
|
2944
|
-
}
|
|
2945
|
-
const requirements = { ...((account.requirements as Row | undefined) ?? {}), currently_due: due, eventually_due: [...due, ...later], past_due: [], disabled_reason: ready ? null : 'requirements.past_due', current_deadline: null };
|
|
2946
|
-
await ctx.write('account', id, { capabilities, requirements, charges_enabled: ready, payouts_enabled: ready, details_submitted: ready }, ready ? 'account.updated' : 'account.update');
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
// ── persons on an account ──
|
|
2950
|
-
|
|
2951
|
-
export const personMissing = (ctx: HandlerContext): Response => fail(ctx, `No such person: '${at(ctx, 'person')}'`, 404, 'resource_missing');
|
|
2952
|
-
|
|
2953
|
-
export const accountPerson = (ctx: HandlerContext): Row | undefined => {
|
|
2954
|
-
const p = ctx.get('person', at(ctx, 'person'));
|
|
2955
|
-
return p && p.account === at(ctx, 'account') ? p : undefined;
|
|
2956
|
-
};
|
|
2957
|
-
|
|
2958
|
-
// ── external accounts: a payout destination from a token or a bank_account hash ──
|
|
2959
|
-
|
|
2960
|
-
export const externalMissing = (ctx: HandlerContext): Response => fail(ctx, `No such external account: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
2961
|
-
|
|
2962
|
-
export const accountExternal = (ctx: HandlerContext): Row | undefined => {
|
|
2963
|
-
const e = ctx.get('external_account', at(ctx, 'id'));
|
|
2964
|
-
return e && e.account === at(ctx, 'account') ? e : undefined;
|
|
2965
|
-
};
|
|
2966
|
-
|
|
2967
|
-
/** Whether the platform may create, update or delete an account's external accounts: "A platform can create, update, or
|
|
2968
|
-
* delete external accounts only for connected accounts without access to either the full Stripe or Express Dashboard
|
|
2969
|
-
* and where the platform is responsible for negative balances" (docs.stripe.com/connect/payouts-bank-accounts); it can
|
|
2970
|
-
* still view an Express account's ("A platform can view the external accounts of connected accounts that don’t have
|
|
2971
|
-
* access to the full Stripe Dashboard"). Where the documentation stops and the twin decides: the refusal. Stripe's
|
|
2972
|
-
* pages give no error code for it; the twin answers 403, which the errors page gives as "The API key doesn’t have
|
|
2973
|
-
* permissions to perform the request" (docs.stripe.com/api/errors), with the twin's own wording. The create page says
|
|
2974
|
-
* it too: "You can only specify connected accounts where account.controller.requirement_collection is `application`"
|
|
2975
|
-
* (docs.stripe.com/api/external_account_bank_accounts/create). */
|
|
2976
|
-
export function refuseExternalWrite(ctx: HandlerContext, account: string): Response | undefined {
|
|
2977
|
-
const row = accountRow(ctx, account);
|
|
2978
|
-
const controller = (row?.controller as Row | undefined) ?? controllerOf(String(row?.type ?? 'standard'));
|
|
2979
|
-
const dashboard = String(((controller.stripe_dashboard as Row | undefined)?.type) ?? '');
|
|
2980
|
-
const losses = String(((controller.losses as Row | undefined)?.payments) ?? '');
|
|
2981
|
-
if (dashboard === 'none' && losses === 'application') return undefined;
|
|
2982
|
-
return ctx.refuse({ status: 403, message: `This application does not have the required permissions to manage the external accounts of account '${account}': they can only be managed through the account's ${dashboard === 'express' ? 'Express' : 'Stripe'} Dashboard.` });
|
|
2983
|
-
}
|
|
2984
|
-
|
|
2985
|
-
/** Update and delete by id, finding tombstones as the hand-written routes did, with Stripe's own operation names. */
|
|
2986
|
-
export function patch(resource: string, param: string, missing: string, op: string): Handler {
|
|
2987
|
-
return (ctx) => patchRow(ctx, resource, param, missing, op);
|
|
2988
|
-
}
|
|
2989
|
-
|
|
2990
|
-
export async function patchRow(ctx: HandlerContext, resource: string, param: string, missing: string, op: string): Promise<Response> {
|
|
2991
|
-
const id = at(ctx, param);
|
|
2992
|
-
if (!ctx.row(resource, id, { withDeleted: true })) return fail(ctx, `No such ${missing}: '${id}'`, 404, 'resource_missing');
|
|
2993
|
-
return ctx.reply(await ctx.write(resource, id, ctx.params, op));
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
// A reader's update takes its label and metadata only; it moves to another location by registering it
|
|
2997
|
-
// there again (https://docs.stripe.com/api/terminal/readers/update, docs.stripe.com/terminal/fleet/locations-and-zones)
|
|
2998
|
-
// (the parameter check refuses anything else, as Stripe does: ./params.ts)
|
|
2999
|
-
export const updateReader: Handler = patch(READER, 'reader', 'reader', 'terminal_reader.updated');
|
|
3000
|
-
|
|
3001
|
-
export function remove(resource: string, param: string, missing: string, op: string, object: string): Handler {
|
|
3002
|
-
return async (ctx) => {
|
|
3003
|
-
const id = at(ctx, param);
|
|
3004
|
-
if (!ctx.row(resource, id, { withDeleted: true })) return fail(ctx, `No such ${missing}: '${id}'`, 404, 'resource_missing');
|
|
3005
|
-
const row = ctx.get(resource, id);
|
|
3006
|
-
await ctx.write(resource, id, { deleted: true }, op);
|
|
3007
|
-
// a deleted reader still says what device it was
|
|
3008
|
-
return ctx.reply({ id, object, deleted: true, ...(object === 'terminal.reader' && row ? { device_type: row.device_type, serial_number: row.serial_number } : {}) });
|
|
3009
|
-
};
|
|
3010
|
-
}
|
|
3011
|
-
|
|
3012
|
-
export const readerMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such reader: '${id}'`, 404, 'resource_missing');
|
|
3013
|
-
|
|
3014
|
-
/** A card presented to a reader with no payment in progress. */
|
|
3015
|
-
export function nothingToPresent(ctx: HandlerContext): Response {
|
|
3016
|
-
return fail(ctx, 'This reader has no in-progress payment to present a payment method to.', 400, 'terminal_reader_invalid_state');
|
|
3017
|
-
}
|
|
3018
|
-
|
|
3019
|
-
/** Under manual capture the presented card only holds the amount (docs.stripe.com/terminal/payments/collect-card-payment#capture-payment):
|
|
3020
|
-
* the hold is an uncaptured charge, as an online authorization's is (authorizeCharge), which the intent's capture takes. */
|
|
3021
|
-
export async function holdForCapture(ctx: HandlerContext, piId: string, pi: Record<string, unknown>, pm: string, amount: number): Promise<void> {
|
|
3022
|
-
const charge = await authorizeCharge(ctx, piId, { ...pi, payment_method: pm }, amount);
|
|
3023
|
-
await ctx.write('payment_intent', piId, { status: 'requires_capture', payment_method: pm, amount_capturable: amount, latest_charge: charge }, 'payment_intent.amount_capturable_updated');
|
|
3024
|
-
}
|
|
3025
|
-
|
|
3026
|
-
/** Time's settling of top-ups, caught up to the World's clock: a pending top-up succeeds on its expected_availability_date,
|
|
3027
|
-
* its funds entered then in the balance it names. */
|
|
3028
|
-
export async function settleTopups(ctx: HandlerContext): Promise<void> {
|
|
3029
|
-
const now = Number(nowUnix(ctx.occurredAt));
|
|
3030
|
-
for (const t of ctx.rowsRaw('topup')) {
|
|
3031
|
-
if (t.status !== 'pending' || Number(t.expected_availability_date) > now) continue;
|
|
3032
|
-
const c = await at_(ctx)(Number(t.expected_availability_date));
|
|
3033
|
-
c.legal('topup', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(t.id), 'vendor');
|
|
3034
|
-
const bt = await settleTopup(c, String(t.id), Number(t.amount), String(t.currency), t.destination_balance === 'issuing' ? 'issuing' : 'payments');
|
|
3035
|
-
await c.write('topup', String(t.id), { status: 'succeeded', balance_transaction: bt }, 'topup.succeeded');
|
|
3036
|
-
}
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3039
|
-
export const disputesDisputeMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such dispute: '${id}'`, 404, 'resource_missing');
|
|
3040
|
-
|
|
3041
|
-
/** Evidence staged without submitting it (submit=false), or on a dispute already under review or decided. */
|
|
3042
|
-
export async function keepEvidence(ctx: HandlerContext, id: string, fields: Row): Promise<Response> {
|
|
3043
|
-
return ctx.reply(await ctx.write('dispute', id, fields, 'dispute.update'));
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
export const meterMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such meter: '${id}'`, 404, 'resource_missing');
|
|
3047
|
-
|
|
3048
|
-
export function activation(kind: 'deactivate' | 'reactivate'): Handler {
|
|
3049
|
-
return async (ctx) => {
|
|
3050
|
-
const id = at(ctx, 'id');
|
|
3051
|
-
const m = ctx.get(METER, id);
|
|
3052
|
-
if (!m) return meterMissing(ctx, id);
|
|
3053
|
-
const refused = ctx.legal(METER, 'status', kind === 'deactivate' ? 'PostBillingMetersIdDeactivate' : 'PostBillingMetersIdReactivate', m.status, undefined, id);
|
|
3054
|
-
if (refused) return ctx.refuse(refused);
|
|
3055
|
-
const fields = { ...(kind === 'deactivate' ? { status: 'inactive', status_transitions: { deactivated_at: nowUnix(ctx.occurredAt) } } : { status: 'active', status_transitions: { deactivated_at: null } }), updated: nowUnix(ctx.occurredAt) };
|
|
3056
|
-
return ctx.reply(await ctx.write(METER, id, fields, `billing_meter.${kind}`));
|
|
3057
|
-
};
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
export const grantMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such credit grant: '${id}'`, 404, 'resource_missing');
|
|
3061
2166
|
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
export function alertMove(kind: 'activate' | 'deactivate' | 'archive', operationId: string): Handler {
|
|
3072
|
-
return async (ctx) => {
|
|
3073
|
-
const id = at(ctx, 'id');
|
|
3074
|
-
const a = ctx.get(ALERT, id);
|
|
3075
|
-
if (!a) return fail(ctx, `No such alert: '${id}'`, 404, 'resource_missing');
|
|
3076
|
-
const refused = ctx.legal(ALERT, 'status', operationId, a.status, undefined, id);
|
|
3077
|
-
if (refused) return ctx.refuse(refused);
|
|
3078
|
-
const status = kind === 'activate' ? 'active' : kind === 'deactivate' ? 'inactive' : 'archived';
|
|
3079
|
-
return ctx.reply(await ctx.write(ALERT, id, { status }, `billing_alert.${kind}`));
|
|
2167
|
+
/** A pending invoice item contributes to a preview only on its customer's next invoice. */
|
|
2168
|
+
// source: spec:PostInvoicesCreatePreview "including subscription renewal charges, invoice item charges, etc."
|
|
2169
|
+
export function pendingPreviewItem(ii: Row, customer: string, currency: string, now: number): Row | undefined {
|
|
2170
|
+
if (ii.customer !== customer || ii.invoice) return undefined;
|
|
2171
|
+
return {
|
|
2172
|
+
id: `il_${String(ii.id)}`, object: 'line_item', type: 'invoiceitem', amount: Number(ii.amount) || 0, currency,
|
|
2173
|
+
quantity: Number(ii.quantity) || 1, proration: false, invoice_item: ii.id,
|
|
2174
|
+
period: ii.period ?? { start: now, end: now }, description: (ii.description as string) ?? null,
|
|
3080
2175
|
};
|
|
3081
2176
|
}
|
|
3082
2177
|
|
|
3083
|
-
export
|
|
3084
|
-
|
|
3085
|
-
/** A transfer to an account whose transfers capability is not active. */
|
|
3086
|
-
export function transfersInactive(ctx: HandlerContext): Response {
|
|
3087
|
-
return ctx.refuse({ status: 400, code: 'insufficient_capabilities_for_transfer', param: 'destination', message: 'Your destination account needs to have at least one of the following capabilities enabled: transfers, crypto_transfers, legacy_payments' });
|
|
3088
|
-
}
|
|
3089
|
-
|
|
3090
|
-
/** A transfer from a charge whose funds have settled: "With a source_transaction, the transfer request returns success
|
|
3091
|
-
* regardless of your available balance if the related charge hasn't settled yet" (docs.stripe.com/connect/separate-
|
|
3092
|
-
* charges-and-transfers), so a settled one comes out of the available balance. */
|
|
3093
|
-
export function settledSourceRefused(ctx: HandlerContext, amount: number, currency: string): Response | undefined {
|
|
3094
|
-
return refusePayout(ctx, amount, currency, undefined);
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
/** An application fee refunded to its whole through its own refunds endpoint: the fee machine's move, or its refusal. */
|
|
3098
|
-
export function feeFullyRefunded(ctx: HandlerContext, resource: string, flag: string, id: string): Response | undefined {
|
|
3099
|
-
const refused = ctx.legal(resource, flag, ctx.call.operation.id, 'false', 'true', id);
|
|
3100
|
-
return refused ? ctx.refuse(refused) : undefined;
|
|
3101
|
-
}
|
|
3102
|
-
|
|
3103
|
-
/** Take `amount` (the remainder by default) back out of a parent's total, as a child row appended to its list. */
|
|
3104
|
-
export async function giveBack(ctx: HandlerContext, o: {
|
|
3105
|
-
parent: Row; parentResource: string; child: string; childFields: (amount: number) => Row; childDefaults: Row;
|
|
3106
|
-
doneField: string; flag: string; listUrl: string; op: string; tooLarge: (remaining: number) => string;
|
|
3107
|
-
/** what the money movement itself writes, once the child exists */
|
|
3108
|
-
settle?: (child: Row, amount: number) => Promise<void>;
|
|
3109
|
-
}): Promise<Response> {
|
|
3110
|
-
const id = String(o.parent.id);
|
|
3111
|
-
const total = Number(o.parent.amount ?? 0);
|
|
3112
|
-
const already = Number(o.parent[o.doneField] ?? 0);
|
|
3113
|
-
const remaining = total - already;
|
|
3114
|
-
const amount = ctx.params.amount !== undefined ? Math.trunc(Number(ctx.params.amount) || 0) : remaining;
|
|
3115
|
-
if (amount <= 0 || amount > remaining) return fail(ctx, o.tooLarge(remaining), 400, 'amount_too_large');
|
|
3116
|
-
const done = already + amount;
|
|
3117
|
-
// a fee refunded in full is `refunded`: the application fee machine's move (a transfer's `reversed` is no state)
|
|
3118
|
-
const feeRefusal = o.parentResource === 'application_fee' && done >= total && o.parent[o.flag] !== true ? feeFullyRefunded(ctx, o.parentResource, o.flag, id) : undefined;
|
|
3119
|
-
if (feeRefusal) return feeRefusal;
|
|
3120
|
-
// the `metadata` a reversal or a fee refund is created with is its own ("Set of key-value pairs that you can attach to
|
|
3121
|
-
// an object", docs.stripe.com/api/transfer_reversals/create, docs.stripe.com/api/fee_refunds/create)
|
|
3122
|
-
const metadata = ctx.params.metadata && typeof ctx.params.metadata === 'object' ? { metadata: ctx.params.metadata } : {};
|
|
3123
|
-
const child = await created(ctx, o.child, { ...o.childFields(amount), ...metadata }, o.childDefaults);
|
|
3124
|
-
if (o.settle) await o.settle(child, amount);
|
|
3125
|
-
const existing = (o.parent[o.flag === 'reversed' ? 'reversals' : 'refunds'] as Row) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
|
|
3126
|
-
const data = [...((existing.data as unknown[]) ?? []), child];
|
|
3127
|
-
await ctx.write(o.parentResource, id, {
|
|
3128
|
-
[o.doneField]: done, [o.flag]: done >= total,
|
|
3129
|
-
[o.flag === 'reversed' ? 'reversals' : 'refunds']: { ...existing, data, total_count: data.length, url: o.listUrl },
|
|
3130
|
-
}, o.op);
|
|
3131
|
-
return ctx.reply(child);
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3134
|
-
export const feeMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such application fee: '${id}'`, 404, 'resource_missing');
|
|
3135
|
-
|
|
3136
|
-
/** An earlier version's payment_method_types refused: the first value that names no payment method type the
|
|
3137
|
-
* operation takes, in the intent's words or (a Checkout Session) the enum's. */
|
|
3138
|
-
export function unknownMethodType(ctx: HandlerContext, opId: string, given: unknown): Response | undefined {
|
|
3139
|
-
const values = Array.isArray(given) ? given : given && typeof given === 'object' ? Object.values(given) : [given];
|
|
3140
|
-
const checkout = opId === 'PostCheckoutSessions';
|
|
3141
|
-
const known = checkout ? CHECKOUT_METHOD_TYPES : PAYMENT_METHOD_TYPES;
|
|
3142
|
-
const i = values.findIndex((v) => !known.includes(String(v)));
|
|
3143
|
-
if (i < 0) return undefined;
|
|
3144
|
-
if (checkout) {
|
|
3145
|
-
const param = `payment_method_types[${i}]`;
|
|
3146
|
-
return ctx.refuse({ status: 400, param, message: `Invalid ${param}: must be one of ${oneOf(known)}` });
|
|
3147
|
-
}
|
|
3148
|
-
const doc = TYPES_DOC[opId.startsWith('PostSetupIntents') ? 'setup_intents' : 'payment_intents'];
|
|
3149
|
-
return ctx.refuse({ status: 400, param: 'payment_method_types', message: `The payment method type "${String(values[i])}" is invalid. See ${doc} for the full list of supported payment method types.` });
|
|
3150
|
-
}
|
|
3151
|
-
|
|
3152
|
-
/** payment_method_types refused where the caller's version removed it. */
|
|
3153
|
-
export const noLongerSupported = (ctx: HandlerContext, version: string): Response =>
|
|
3154
|
-
ctx.refuse({ status: 400, code: 'payment_method_types_no_longer_supported', param: 'payment_method_types', message: `payment_method_types is no longer supported in API version ${version}. Use allowed_payment_method_types, or dynamic payment methods, instead.` });
|
|
3155
|
-
|
|
3156
|
-
/** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
|
|
3157
|
-
export function refuseParameters(ctx: HandlerContext): Response | undefined {
|
|
3158
|
-
const op = OPS.get(ctx.call.operation.id);
|
|
3159
|
-
if (!op) return undefined;
|
|
3160
|
-
const pinned = ctx.call.request.headers.get('stripe-version');
|
|
3161
|
-
const params = ctx.params;
|
|
3162
|
-
if (pinned && pinned < SERVED_VERSION) {
|
|
3163
|
-
if (!TYPED.has(op.id)) return undefined;
|
|
3164
|
-
if (params.payment_method_types === undefined) return undefined;
|
|
3165
|
-
if (REMOVED_TYPES.has(op.id) && methodTypesRemoved(pinned)) return noLongerSupported(ctx, pinned);
|
|
3166
|
-
return unknownMethodType(ctx, op.id, params.payment_method_types);
|
|
3167
|
-
}
|
|
3168
|
-
const method = ctx.call.request.method.toUpperCase();
|
|
3169
|
-
const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
|
|
3170
|
-
if (REMOVED_TYPES.has(op.id) && params.payment_method_types !== undefined) return noLongerSupported(ctx, pinned ?? SERVED_VERSION);
|
|
3171
|
-
for (const name of Object.keys(params)) {
|
|
3172
|
-
if (!known.has(name)) return ctx.refuse({ status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
|
|
3173
|
-
}
|
|
3174
|
-
const required = method === 'GET' || method === 'DELETE' ? (op.query ?? []) : (op.body ?? []);
|
|
3175
|
-
for (const p of required) {
|
|
3176
|
-
if (p.required && params[p.name] === undefined) return ctx.refuse({ status: 400, code: 'parameter_missing', param: p.name, message: `Missing required param: ${p.name}.` });
|
|
3177
|
-
}
|
|
3178
|
-
return undefined;
|
|
3179
|
-
}
|
|
3180
|
-
|
|
3181
|
-
/** A file's links, kept in step with the links made to it, at the address Stripe lists them
|
|
3182
|
-
* (/v1/file_links?file=…, the File object's example). */
|
|
3183
|
-
export async function syncLinks(ctx: HandlerContext, fileId: string): Promise<void> {
|
|
3184
|
-
const data = newest(ctx, 'file_link').filter((l) => l.file === fileId);
|
|
3185
|
-
await ctx.write('file', fileId, { links: { object: 'list', data, has_more: false, url: `/v1/file_links?file=${fileId}` } }, 'file.links_synced');
|
|
3186
|
-
}
|
|
3187
|
-
|
|
3188
|
-
// ── calculations: each line's tax on top of its amount; no `created`, a tax_date instead ──
|
|
3189
|
-
|
|
3190
|
-
export const calcMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such tax calculation: '${id}'`, 404, 'resource_missing');
|
|
3191
|
-
|
|
3192
|
-
/** A page of the line items a stored object carries. */
|
|
3193
|
-
export function taxLinePage(ctx: HandlerContext, holder: Row): Response {
|
|
3194
|
-
const li = holder.line_items as { data?: Row[] } | undefined;
|
|
3195
|
-
const { page, hasMore } = paginate(Array.isArray(li?.data) ? li!.data! : [], ctx.params);
|
|
3196
|
-
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
3197
|
-
}
|
|
3198
|
-
|
|
3199
|
-
export const txMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such tax transaction: '${id}'`, status, 'resource_missing');
|
|
3200
|
-
|
|
3201
|
-
/** An answer in the shape of the API version the caller is served (./version.ts), a webhook endpoint's `secret` shown
|
|
3202
|
-
* only at its creation ("Only returned at creation", docs.stripe.com/api/webhook_endpoints/object). */
|
|
3203
|
-
export async function rendered(request: Request, res: Response): Promise<Response> {
|
|
3204
|
-
const creates = request.method === 'POST' && new URL(request.url).pathname.replace(/\/+$/, '') === '/v1/webhook_endpoints';
|
|
3205
|
-
const pinned = request.headers.get('stripe-version');
|
|
3206
|
-
if (!(res.headers.get('content-type') ?? '').includes('json')) return res;
|
|
3207
|
-
if (!servesCurrent(pinned) && creates) return res;
|
|
3208
|
-
const expand = await expandOf(request.clone());
|
|
3209
|
-
const body = await res.json();
|
|
3210
|
-
const answered = creates ? body : withoutEndpointSecret(body);
|
|
3211
|
-
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
2178
|
+
export function pendingPreviewLines(ctx: HandlerContext, customer: string, currency: string, now: number): Row[] {
|
|
2179
|
+
return newest(ctx, 'invoiceitem', 'date').flatMap(ii => { const line = pendingPreviewItem(ii, customer, currency, now); return line ? [line] : []; });
|
|
3212
2180
|
}
|