@volter/twin-stripe 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +75 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1070 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +255 -0
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +216 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +99 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +209 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +316 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +493 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +545 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +575 -0
- package/dist/src/semantics/ledger.d.ts +59 -0
- package/dist/src/semantics/ledger.js +200 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +140 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +82 -0
- package/dist/src/semantics/shared.js +203 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5695 -0
- package/dist/src/stripe-conformance.d.ts +43 -0
- package/dist/src/stripe-conformance.js +105 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +392 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +393 -0
- package/dist/src/stripe-shared.d.ts +109 -0
- package/dist/src/stripe-shared.js +276 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1232 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +12 -0
- package/dist/src/stripe-version.js +287 -0
- package/dist/test-fixtures/stripe-known-deviations.json +110 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3813 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +34 -10
- package/src/manifest.ts +1102 -0
- package/src/screens/checkout.tsx +267 -0
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +220 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +118 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +220 -0
- package/src/semantics/checkout.ts +310 -0
- package/src/semantics/connect.ts +487 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +545 -0
- package/src/semantics/issuing.ts +590 -0
- package/src/semantics/ledger.ts +253 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +145 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +226 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +2258 -380
- package/src/stripe-conformance.ts +19 -7
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +15 -8
- package/src/stripe-events.ts +102 -40
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +318 -38
- package/src/stripe-shared.ts +297 -0
- package/src/stripe-twin.ts +434 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +281 -0
- package/test-fixtures/stripe-known-deviations.json +8 -8
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/test-fixtures/stripe-schemas.json +85 -12
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// What Stripe does itself once a payment succeeds, which no API call asks for:
|
|
2
|
+
// - a test card that is disputed opens a dispute on its charge (docs.stripe.com/testing#disputes: 4000000000000259
|
|
3
|
+
// "charge succeeds, then disputed as fraudulent", 4000000000002685 disputed as product not received,
|
|
4
|
+
// 4000000000001976 an inquiry), and Stripe debits the disputed
|
|
5
|
+
// amount and its dispute fee from the balance (docs.stripe.com/disputes/how-disputes-work);
|
|
6
|
+
// - a test card Radar scores as elevated risk opens a review of its charge (docs.stripe.com/testing#fraud-prevention:
|
|
7
|
+
// 4000000000009235, "elevated risk", which Radar places in review; docs.stripe.com/radar/reviews);
|
|
8
|
+
// - a charge a platform makes with `application_fee_amount` earns the platform an application fee, from the connected
|
|
9
|
+
// account it pays (a destination charge's `transfer_data.destination`, or the account a direct charge is made on;
|
|
10
|
+
// docs.stripe.com/connect/destination-charges, docs.stripe.com/connect/direct-charges);
|
|
11
|
+
// - a bank account verified by micro-deposits for a payment or a setup carries a mandate, the customer's acceptance
|
|
12
|
+
// of debits (docs.stripe.com/payments/ach-direct-debit/accept-a-payment, docs.stripe.com/api/mandates).
|
|
13
|
+
//
|
|
14
|
+
// Where the documentation stops and the twin decides: evidence is due seven days after a dispute opens (Stripe's
|
|
15
|
+
// deadline depends on the card network); the dispute fee is 1500 cents in any currency (Stripe's US fee is $15); an
|
|
16
|
+
// elevated-risk review's risk score is 67 (Radar's elevated band); the application is `ca_twin`.
|
|
17
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
18
|
+
import { disputeEvidence } from '../stripe-twin.ts';
|
|
19
|
+
import { actingAccount, settleApplicationFee, settleDispute, settleTransfer } from './ledger.ts';
|
|
20
|
+
import { BYPASS_PENDING_CARDS } from './test-cards.ts';
|
|
21
|
+
import { created, type Row } from './shared.ts';
|
|
22
|
+
|
|
23
|
+
type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review' | 'available';
|
|
24
|
+
const DAY = 86_400;
|
|
25
|
+
|
|
26
|
+
/** The test cards whose success Stripe follows with its own act, by number and by their documented test names. */
|
|
27
|
+
export const AFTER_SUCCESS_CARDS: Record<string, { brand: string; number: string; outcome: Outcome }> = {
|
|
28
|
+
pm_card_createDispute: { brand: 'visa', number: '4000000000000259', outcome: 'dispute' },
|
|
29
|
+
pm_card_createDisputeProductNotReceived: { brand: 'visa', number: '4000000000002685', outcome: 'dispute_not_received' },
|
|
30
|
+
pm_card_createDisputeInquiry: { brand: 'visa', number: '4000000000001976', outcome: 'inquiry' },
|
|
31
|
+
pm_card_riskLevelElevated: { brand: 'visa', number: '4000000000009235', outcome: 'review' },
|
|
32
|
+
// its funds go straight to the available balance (ledger.ts reads it; no act follows here)
|
|
33
|
+
...Object.fromEntries(Object.entries(BYPASS_PENDING_CARDS).map(([name, c]) => [name, { ...c, outcome: 'available' as const }])),
|
|
34
|
+
};
|
|
35
|
+
const BY_NUMBER: Record<string, Outcome> = Object.fromEntries(Object.values(AFTER_SUCCESS_CARDS).map((c) => [c.number, c.outcome]));
|
|
36
|
+
|
|
37
|
+
/** What Stripe does after a card succeeds: from a raw number, a test name (pm_card_* or tok_*), or a stored
|
|
38
|
+
* PaymentMethod that recorded it when it was made. */
|
|
39
|
+
export function afterSuccessOf(ctx: SemanticsContext, ref: unknown): Outcome | undefined {
|
|
40
|
+
// a card given as a token (card[token], payment_method_data[card][token]) is the card the token names
|
|
41
|
+
if (ref && typeof ref === 'object' && typeof (ref as Row).token === 'string') return afterSuccessOf(ctx, (ref as Row).token);
|
|
42
|
+
if (ref && typeof ref === 'object') return BY_NUMBER[String((ref as Row).number ?? '').replace(/\D/g, '')];
|
|
43
|
+
if (typeof ref !== 'string' || !ref) return undefined;
|
|
44
|
+
const stored = ctx.row('payment_method', ref);
|
|
45
|
+
if (stored) return (stored._afterSuccess as Outcome | undefined) ?? undefined;
|
|
46
|
+
return AFTER_SUCCESS_CARDS[ref.replace(/^tok_/, 'pm_card_')]?.outcome ?? BY_NUMBER[ref.replace(/\D/g, '')];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stripe's acts on a charge that just succeeded: a dispute or a review its card brings, and the platform's fee. */
|
|
50
|
+
export async function afterCharge(ctx: SemanticsContext, chargeId: string, charge: { amount: number; currency: string; payment_intent?: string | undefined; application_fee_amount?: unknown; destination?: unknown }, ref: unknown): Promise<void> {
|
|
51
|
+
const outcome = afterSuccessOf(ctx, ref);
|
|
52
|
+
const pi = charge.payment_intent ? { payment_intent: charge.payment_intent } : { payment_intent: null };
|
|
53
|
+
const now = Number(ctx.now());
|
|
54
|
+
if (outcome === 'dispute' || outcome === 'dispute_not_received' || outcome === 'inquiry') {
|
|
55
|
+
const dispute = await created(ctx, 'dispute', { charge: chargeId, ...pi, amount: charge.amount, currency: charge.currency }, {
|
|
56
|
+
reason: outcome === 'dispute_not_received' ? 'product_not_received' : 'fraudulent', status: outcome === 'inquiry' ? 'warning_needs_response' : 'needs_response', is_charge_refundable: false,
|
|
57
|
+
evidence: disputeEvidence(), evidence_details: { due_by: now + 7 * DAY, has_evidence: false, past_due: false, submission_count: 0, enhanced_eligibility: {} },
|
|
58
|
+
balance_transactions: [], livemode: false, metadata: {}, enhanced_eligibility_types: [],
|
|
59
|
+
});
|
|
60
|
+
// an inquiry moves no money; a dispute takes the amount and its fee until it is decided
|
|
61
|
+
if (outcome !== 'inquiry') {
|
|
62
|
+
const bt = await settleDispute(ctx, String(dispute.id), charge.amount, charge.currency);
|
|
63
|
+
await ctx.write('dispute', String(dispute.id), { balance_transactions: [ctx.get('balance_transaction', bt) ?? bt] }, 'dispute.funds_withdrawn');
|
|
64
|
+
}
|
|
65
|
+
await ctx.write('charge', chargeId, { disputed: true }, 'charge.dispute.created');
|
|
66
|
+
}
|
|
67
|
+
if (outcome === 'review') await openReview(ctx, chargeId, pi);
|
|
68
|
+
const feeAmount = Math.trunc(Number(charge.application_fee_amount) || 0);
|
|
69
|
+
const destination = typeof charge.destination === 'string' && charge.destination ? charge.destination : undefined;
|
|
70
|
+
const direct = actingAccount(ctx);
|
|
71
|
+
const account = destination ?? direct;
|
|
72
|
+
// a destination charge transfers what it collected, less the platform's fee, available when the charge's funds are
|
|
73
|
+
if (destination) {
|
|
74
|
+
const amount = charge.amount - feeAmount;
|
|
75
|
+
const chargeBt = ctx.get('charge', chargeId)?.balance_transaction;
|
|
76
|
+
const availableOn = Number(ctx.get('balance_transaction', String(chargeBt))?.available_on ?? ctx.now());
|
|
77
|
+
const tr = await created(ctx, 'transfer', { amount, currency: charge.currency, destination }, {
|
|
78
|
+
amount_reversed: 0, balance_transaction: null, livemode: false, metadata: {}, reversed: false, source_type: 'card', source_transaction: chargeId,
|
|
79
|
+
reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: '' },
|
|
80
|
+
});
|
|
81
|
+
const bt = await settleTransfer(ctx, String(tr.id), amount, charge.currency, destination, availableOn);
|
|
82
|
+
await ctx.write('transfer', String(tr.id), { balance_transaction: bt, destination_payment: `py_${String(tr.id).replace(/^tr_/, '')}`, reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/transfers/${String(tr.id)}/reversals` } }, 'transfer.created');
|
|
83
|
+
await ctx.write('charge', chargeId, { transfer: tr.id }, 'charge.updated');
|
|
84
|
+
}
|
|
85
|
+
if (feeAmount > 0 && account) {
|
|
86
|
+
const fee = await created(ctx, 'application_fee', { account, amount: feeAmount, charge: chargeId, currency: charge.currency }, {
|
|
87
|
+
amount_refunded: 0, application: 'ca_twin', balance_transaction: null, originating_transaction: null, refunded: false, livemode: false,
|
|
88
|
+
});
|
|
89
|
+
// a direct charge's fee moves from the connected account to the platform (a destination charge kept it back)
|
|
90
|
+
const bt = !destination && direct ? await settleApplicationFee(ctx, String(fee.id), feeAmount, charge.currency, direct) : null;
|
|
91
|
+
await ctx.write('application_fee', String(fee.id), { balance_transaction: bt, refunds: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.created');
|
|
92
|
+
await ctx.write('charge', chargeId, { application_fee: fee.id, application_fee_amount: feeAmount }, 'charge.updated');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Radar places an elevated-risk card's charge in review (docs.stripe.com/radar/reviews). */
|
|
97
|
+
async function openReview(ctx: SemanticsContext, chargeId: string, pi: { payment_intent: string | null }): Promise<void> {
|
|
98
|
+
const review = await created(ctx, 'review', { charge: chargeId, ...pi }, {
|
|
99
|
+
open: true, opened_reason: 'rule', reason: 'rule', closed_reason: null, billing_zip: null, ip_address: null, ip_address_location: null, session: null, livemode: false,
|
|
100
|
+
});
|
|
101
|
+
const existing = (ctx.get('charge', chargeId)?.outcome as Row | undefined) ?? {};
|
|
102
|
+
await ctx.write('charge', chargeId, {
|
|
103
|
+
review: review.id,
|
|
104
|
+
outcome: { ...existing, type: 'manual_review', risk_level: 'elevated', risk_score: 67, seller_message: 'Stripe evaluated this payment as having elevated risk, and placed it in review.' },
|
|
105
|
+
}, 'review.opened');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The mandate a verified bank account carries for a payment (single use) or a setup (multi use). */
|
|
109
|
+
export async function mintMandate(ctx: SemanticsContext, paymentMethod: unknown, use: 'single_use' | 'multi_use', amount?: number, currency?: string): Promise<string> {
|
|
110
|
+
const now = Number(ctx.now());
|
|
111
|
+
const mandate = await created(ctx, 'mandate', { payment_method: typeof paymentMethod === 'string' ? paymentMethod : null }, {
|
|
112
|
+
status: 'active', type: use, livemode: false,
|
|
113
|
+
customer_acceptance: { type: 'online', accepted_at: now, online: { ip_address: '127.0.0.1', user_agent: 'twin' } },
|
|
114
|
+
payment_method_details: { type: 'us_bank_account', us_bank_account: { collection_method: 'paper' } },
|
|
115
|
+
...(use === 'single_use' ? { single_use: { amount: amount ?? 0, currency: currency ?? 'usd' } } : { multi_use: {} }),
|
|
116
|
+
}, { timeField: '_created' });
|
|
117
|
+
return String(mandate.id);
|
|
118
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Apps secret-store semantics: a secret is named within a scope (the account, or one user); setting
|
|
2
|
+
// the same name in the same scope overwrites it. The scope is kept as a bookkeeping key.
|
|
3
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
4
|
+
import { created, fail, list, newest, type Row, kept } from './shared.ts';
|
|
5
|
+
|
|
6
|
+
const SECRET = 'apps.secret';
|
|
7
|
+
|
|
8
|
+
function scopeKeyOf(ctx: SemanticsContext): string {
|
|
9
|
+
const scope = ctx.params.scope && typeof ctx.params.scope === 'object' ? (ctx.params.scope as Row) : undefined;
|
|
10
|
+
const scopeType = scope && typeof scope.type === 'string' ? scope.type : 'account';
|
|
11
|
+
return scopeType === 'user' ? `user:${String(scope!.user)}` : 'account';
|
|
12
|
+
}
|
|
13
|
+
const named = (ctx: SemanticsContext, name: string): Row | undefined => ctx.rows(SECRET).find((s) => s.name === name && kept(ctx, SECRET, s, '_scope_key') === scopeKeyOf(ctx));
|
|
14
|
+
|
|
15
|
+
const set: Semantics = async (ctx) => {
|
|
16
|
+
const params = ctx.params;
|
|
17
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
18
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
19
|
+
const scope = params.scope && typeof params.scope === 'object' ? (params.scope as Row) : undefined;
|
|
20
|
+
const scopeType = scope && typeof scope.type === 'string' ? scope.type : '';
|
|
21
|
+
if (scopeType !== 'account' && scopeType !== 'user') return fail(ctx, 'Invalid scope[type]: must be account or user.', 400, 'parameter_invalid_string_enum');
|
|
22
|
+
if (scopeType === 'user' && typeof scope!.user !== 'string') return fail(ctx, 'Missing required param: scope[user].', 400, 'parameter_missing');
|
|
23
|
+
if (params.payload === undefined) return fail(ctx, 'Missing required param: payload.', 400, 'parameter_missing');
|
|
24
|
+
const expiresAt = params.expires_at !== undefined ? Math.trunc(Number(params.expires_at)) : null;
|
|
25
|
+
const existing = named(ctx, name);
|
|
26
|
+
if (existing) return ctx.reply(await ctx.write(SECRET, String(existing.id), { payload: params.payload, expires_at: expiresAt, _updated: ctx.now() }, 'apps_secret.update'));
|
|
27
|
+
return ctx.reply(
|
|
28
|
+
await created(ctx, SECRET, { name }, {
|
|
29
|
+
livemode: false, deleted: false, expires_at: expiresAt, payload: params.payload,
|
|
30
|
+
scope: scopeType === 'user' ? { type: 'user', user: scope!.user } : { type: 'account' },
|
|
31
|
+
_scope_key: scopeKeyOf(ctx),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const find: Semantics = async (ctx) => {
|
|
37
|
+
const name = typeof ctx.params.name === 'string' ? ctx.params.name : '';
|
|
38
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
39
|
+
const s = named(ctx, name);
|
|
40
|
+
return s ? ctx.reply(s) : fail(ctx, `No such secret: '${name}'`, 404, 'resource_missing');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const remove: Semantics = async (ctx) => {
|
|
44
|
+
const name = typeof ctx.params.name === 'string' ? ctx.params.name : '';
|
|
45
|
+
if (!name) return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
46
|
+
const s = named(ctx, name);
|
|
47
|
+
if (!s) return fail(ctx, `No such secret: '${name}'`, 404, 'resource_missing');
|
|
48
|
+
return ctx.reply(await ctx.write(SECRET, String(s.id), { deleted: true }, 'apps_secret.delete'));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const listSecrets: Semantics = async (ctx) => list(ctx, SECRET, newest(ctx, SECRET).filter((s) => kept(ctx, SECRET, s, '_scope_key') === scopeKeyOf(ctx)));
|
|
52
|
+
|
|
53
|
+
export const appsSecrets: Record<string, Semantics> = {
|
|
54
|
+
PostAppsSecrets: set,
|
|
55
|
+
GetAppsSecretsFind: find,
|
|
56
|
+
PostAppsSecretsDelete: remove,
|
|
57
|
+
GetAppsSecrets: listSecrets,
|
|
58
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Balance and payout semantics. The balance is computed from the balance-transaction ledger (./ledger.ts), never
|
|
2
|
+
// stored: funds past their available_on are available, the rest pending, and Issuing funds their own section. The
|
|
3
|
+
// balance, the payouts and the ledger are the acting account's: the platform's, or a connected account's under the
|
|
4
|
+
// Stripe-Account header. A payout comes out of what that account has available and goes to its bank account, which a
|
|
5
|
+
// connected account must have (docs.stripe.com/connect/payouts-connected-accounts). A pending payout can be canceled,
|
|
6
|
+
// which returns its money; a connected account's paid payout can be reversed, which debits its bank account back
|
|
7
|
+
// into its balance (docs.stripe.com/api/payouts/reverse). The machine in ../manifest.ts says how a payout's status
|
|
8
|
+
// moves.
|
|
9
|
+
//
|
|
10
|
+
// Stripe also pays each account out on its own schedule (settings.payouts.schedule: daily by default, weekly or monthly
|
|
11
|
+
// on an anchor, or manual): an automatic payout takes what has become available since the last one, and the ledger
|
|
12
|
+
// lists the entries it paid (docs.stripe.com/payouts#payout-schedule, docs.stripe.com/connect/manage-payout-schedule).
|
|
13
|
+
// A connected account is paid out once it can be (payouts_enabled, with a bank account).
|
|
14
|
+
//
|
|
15
|
+
// Where the documentation stops and the twin decides: a payout arrives two days after it is made (Stripe's standard
|
|
16
|
+
// US schedule counts business days) and reads paid from then on; an automatic payout is made at midnight UTC on the
|
|
17
|
+
// first scheduled day on or after its funds become available, only when what it would pay is positive.
|
|
18
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
19
|
+
import { validateMoney } from '../stripe-twin.ts';
|
|
20
|
+
import { accountSettings, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
|
|
21
|
+
import { actingAccount, balanceOf, refusePayout, settleAutomaticPayout, settlePayout, unpaidEntries } from './ledger.ts';
|
|
22
|
+
import { at, at_, created, fail, inRange, list, newest, send, where, type Row, kept } from './shared.ts';
|
|
23
|
+
|
|
24
|
+
const account = actingAccount;
|
|
25
|
+
const DAY = 86_400;
|
|
26
|
+
const payoutMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such payout: '${id}'`, 404, 'resource_missing');
|
|
27
|
+
|
|
28
|
+
/** The acting account's Balance object (docs.stripe.com/api/balance/balance_object). */
|
|
29
|
+
export function balanceBody(ctx: SemanticsContext): Row {
|
|
30
|
+
const { available, pending, issuing } = balanceOf(ctx);
|
|
31
|
+
const toArr = (m: Map<string, number>) => {
|
|
32
|
+
const out = [...m.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } }));
|
|
33
|
+
return out.length ? out : [{ amount: 0, currency: 'usd', source_types: { card: 0 } }];
|
|
34
|
+
};
|
|
35
|
+
// a platform's balance holds its connected accounts' reserve, "Funds held due to negative balances on connected accounts
|
|
36
|
+
// where account.controller.requirement_collection is `application`" (docs.stripe.com/api/balance/balance_object, whose
|
|
37
|
+
// example answers `[{"amount": 0, "currency": "usd"}]`); the twin models no such reserve, so it is zero in each currency
|
|
38
|
+
const reserved = account(ctx) ? {} : { connect_reserved: toArr(available).map((b) => ({ amount: 0, currency: b.currency })) };
|
|
39
|
+
return {
|
|
40
|
+
object: 'balance', available: toArr(available), pending: toArr(pending), ...reserved, livemode: false,
|
|
41
|
+
...(issuing.size ? { issuing: { available: [...issuing.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } })) } } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const balance: Semantics = async (ctx) => ctx.reply(balanceBody(ctx));
|
|
46
|
+
|
|
47
|
+
/** Time's arrivals, written: each of the acting account's payouts whose arrival date has come moves pending → paid
|
|
48
|
+
* (the clock's move, as asOf reads it), written as `payout.paid`, the event Stripe sends for it. Answers their ids. */
|
|
49
|
+
export async function payDuePayouts(ctx: SemanticsContext): Promise<string[]> {
|
|
50
|
+
const acct = account(ctx);
|
|
51
|
+
const now = Number(ctx.now());
|
|
52
|
+
const paid: string[] = [];
|
|
53
|
+
for (const p of newest(ctx, 'payout')) {
|
|
54
|
+
if ((acct ? kept(ctx, 'payout', p, '_account') !== acct : !!kept(ctx, 'payout', p, '_account')) || p.status !== 'pending' || Number(p.arrival_date) > now) continue;
|
|
55
|
+
if (ctx.legal('payout', 'status', ctx.call.operation.id, 'pending', 'paid', String(p.id), 'time')) continue;
|
|
56
|
+
await ctx.write('payout', String(p.id), { status: 'paid' }, 'payout.paid');
|
|
57
|
+
paid.push(String(p.id));
|
|
58
|
+
}
|
|
59
|
+
return paid;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where a connected account's payout goes: "ID of the bank account or card the payout is sent to" (served spec,
|
|
63
|
+
* payout.destination), its default external account for the currency, "When multiple accounts are available for a given
|
|
64
|
+
* currency, Stripe uses the one set as `default_for_currency`" (docs.stripe.com/connect/payouts-bank-accounts), the
|
|
65
|
+
* newest such. Where the documentation stops and the twin decides: the platform's own bank account is not modelled, so
|
|
66
|
+
* its payouts name none. */
|
|
67
|
+
function payoutBank(ctx: SemanticsContext, account: string, currency: string): string | null {
|
|
68
|
+
const banks = newest(ctx, 'external_account').filter((e) => e.account === account && String(e.currency ?? currency) === currency);
|
|
69
|
+
return String((banks.find((e) => e.default_for_currency === true) ?? banks[0])?.id ?? '') || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const payoutDefaults = (ctx: SemanticsContext): Row => ({
|
|
73
|
+
method: 'standard', type: 'bank_account', source_type: 'card', automatic: false,
|
|
74
|
+
reconciliation_status: 'not_applicable', arrival_date: Number(ctx.now()) + 2 * DAY, livemode: false, metadata: {},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** A payout as the clock reads it: paid once its arrival date has come, the clock's move, asked of the
|
|
78
|
+
* machine as a write asks it. */
|
|
79
|
+
function asOf(ctx: SemanticsContext, p: Row): Row {
|
|
80
|
+
if (p.status !== 'pending' || Number(p.arrival_date) > Number(ctx.now())) return p;
|
|
81
|
+
ctx.legal('payout', 'status', ctx.call.operation.id, 'pending', 'paid', String(p.id), 'time');
|
|
82
|
+
return { ...p, status: 'paid' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A connected account's payout in a currency none of its bank accounts takes. */
|
|
86
|
+
function noExternalAccount(ctx: SemanticsContext, currency: string): Response {
|
|
87
|
+
return fail(ctx, `Sorry, you don't have any external accounts in that currency (${currency}).`, 400);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const createPayout: Semantics = async (ctx) => {
|
|
91
|
+
const bad = validateMoney(ctx.params);
|
|
92
|
+
if (bad) return send(ctx, bad);
|
|
93
|
+
const acct = account(ctx);
|
|
94
|
+
if (acct && !ctx.get('account', acct)) return fail(ctx, `No such account: '${acct}'`, 400, 'account_invalid');
|
|
95
|
+
const amount = Math.trunc(Number(ctx.params.amount) || 0);
|
|
96
|
+
const currency = String(ctx.params.currency ?? 'usd');
|
|
97
|
+
// a connected account is paid out to its own bank account
|
|
98
|
+
if (acct && !ctx.rows('external_account').some((e) => e.account === acct && (e.currency ?? currency) === currency)) return noExternalAccount(ctx, currency);
|
|
99
|
+
const refused = refusePayout(ctx, amount, currency);
|
|
100
|
+
if (refused) return refused;
|
|
101
|
+
const id = ctx.mint('payout');
|
|
102
|
+
const bt = await settlePayout(ctx, id, -amount, currency);
|
|
103
|
+
return ctx.reply(await created(ctx, 'payout', { ...ctx.params, id }, { status: 'pending', ...payoutDefaults(ctx), balance_transaction: bt, destination: acct ? payoutBank(ctx, acct, currency) : null, ...(acct ? { _account: acct } : {}) }));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const listPayouts: Semantics = async (ctx) => {
|
|
107
|
+
const acct = account(ctx);
|
|
108
|
+
const scoped = newest(ctx, 'payout').filter((p) => (acct ? kept(ctx, 'payout', p, '_account') === acct : !kept(ctx, 'payout', p, '_account'))).map((p) => asOf(ctx, p));
|
|
109
|
+
return list(ctx, 'payout', where(ctx, scoped, { status: (p, v) => p.status === v, created: (p, v) => inRange(p.created, v), arrival_date: (p, v) => inRange(p.arrival_date, v) }));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
113
|
+
/** The first scheduled payout time on or after `from`: midnight UTC of a day the schedule pays out on. */
|
|
114
|
+
function payoutDay(from: number, schedule: Row): number {
|
|
115
|
+
const first = Math.ceil(from / DAY) * DAY;
|
|
116
|
+
const pays = (day: number): boolean => {
|
|
117
|
+
const d = new Date(day * 1000);
|
|
118
|
+
if (schedule.interval === 'weekly') return DAYS[d.getUTCDay()] === String(schedule.weekly_anchor ?? 'monday');
|
|
119
|
+
if (schedule.interval === 'monthly') return d.getUTCDate() === Math.min(Number(schedule.monthly_anchor ?? 1), new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate());
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
return Array.from({ length: 62 }, (_, i) => first + i * DAY).find(pays) ?? first;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Time's payouts, caught up to the World's clock: each account on an automatic schedule is paid out, on each of its
|
|
126
|
+
* scheduled days that has come, what became available by then. */
|
|
127
|
+
export async function advancePayouts(ctx: SemanticsContext): Promise<void> {
|
|
128
|
+
const now = Number(ctx.now());
|
|
129
|
+
const platform = ctx.get('account', PLATFORM_ACCOUNT_ID);
|
|
130
|
+
const accounts: Array<{ id: string | undefined; settings: unknown }> = [{ id: undefined, settings: platform?.settings }];
|
|
131
|
+
for (const a of ctx.rows('account')) {
|
|
132
|
+
if (a.id === PLATFORM_ACCOUNT_ID || a.payouts_enabled !== true) continue;
|
|
133
|
+
if (!ctx.rows('external_account').some((e) => e.account === a.id)) continue;
|
|
134
|
+
accounts.push({ id: String(a.id), settings: a.settings });
|
|
135
|
+
}
|
|
136
|
+
for (const acct of accounts) {
|
|
137
|
+
const schedule = ((accountSettings(acct.settings, (acct.settings as Row | undefined) ?? undefined).payouts as Row).schedule ?? {}) as Row;
|
|
138
|
+
if (schedule.interval === 'manual') continue;
|
|
139
|
+
const unpaid = unpaidEntries(ctx, acct.id);
|
|
140
|
+
const days = [...new Set(unpaid.map((t) => payoutDay(Number(t.available_on) || 0, schedule)))].filter((d) => d <= now).sort((x, y) => x - y);
|
|
141
|
+
const paid = new Set<unknown>();
|
|
142
|
+
for (const day of days) {
|
|
143
|
+
const due = unpaid.filter((t) => !paid.has(t.id) && (Number(t.available_on) || 0) <= day);
|
|
144
|
+
const byCurrency = new Map<string, Row[]>();
|
|
145
|
+
for (const t of due) byCurrency.set(String(t.currency ?? 'usd'), [...(byCurrency.get(String(t.currency ?? 'usd')) ?? []), t]);
|
|
146
|
+
for (const [currency, entries] of byCurrency) {
|
|
147
|
+
const amount = entries.reduce((n, t) => n + (Number(t.net) || 0), 0);
|
|
148
|
+
// nothing to pay yet: what came due is carried to the next payout
|
|
149
|
+
if (amount <= 0) continue;
|
|
150
|
+
// made on its day, as Stripe makes it, whenever the request that catches it up comes
|
|
151
|
+
const c = await at_(ctx)(day);
|
|
152
|
+
const id = c.mint('payout');
|
|
153
|
+
const bt = await settleAutomaticPayout(c, id, amount, currency, acct.id, day, entries);
|
|
154
|
+
await created(c, 'payout', { id, amount, currency }, {
|
|
155
|
+
status: 'pending', ...payoutDefaults(c), arrival_date: day + 2 * DAY, automatic: true, balance_transaction: bt, destination: acct.id ? payoutBank(c, String(acct.id), currency) : null,
|
|
156
|
+
description: 'STRIPE PAYOUT', ...(acct.id ? { _account: acct.id } : {}),
|
|
157
|
+
});
|
|
158
|
+
for (const t of entries) paid.add(t.id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const retrievePayout: Semantics = async (ctx) => {
|
|
165
|
+
const p = ctx.get('payout', at(ctx, 'payout'));
|
|
166
|
+
return p ? ctx.reply(asOf(ctx, p)) : payoutMissing(ctx, at(ctx, 'payout'));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const cancelPayout: Semantics = async (ctx) => {
|
|
170
|
+
const id = at(ctx, 'payout');
|
|
171
|
+
const po = ctx.get('payout', id);
|
|
172
|
+
if (!po) return payoutMissing(ctx, id);
|
|
173
|
+
const refused = ctx.legal('payout', 'status', 'PostPayoutsPayoutCancel', asOf(ctx, po).status, undefined, id);
|
|
174
|
+
if (refused) return ctx.refuse(refused);
|
|
175
|
+
// the money comes back to the balance it left
|
|
176
|
+
const owner = kept(ctx, 'payout', po, '_account');
|
|
177
|
+
await settlePayout(ctx, id, Number(po.amount) || 0, String(po.currency ?? 'usd'), typeof owner === 'string' ? owner : null);
|
|
178
|
+
return ctx.reply(await ctx.write('payout', id, { status: 'canceled' }, 'payout.cancel'));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// a reversal is itself a payout in the other direction, back into the connected account's balance, as the reverse page's
|
|
182
|
+
// example answers it: a negative amount, pending until it arrives, carrying the request's metadata
|
|
183
|
+
// (docs.stripe.com/api/payouts/reverse); the original stays paid and names it
|
|
184
|
+
const reversePayout: Semantics = async (ctx) => {
|
|
185
|
+
const id = at(ctx, 'payout');
|
|
186
|
+
const po = ctx.get('payout', id);
|
|
187
|
+
if (!po) return payoutMissing(ctx, id);
|
|
188
|
+
const owner = kept(ctx, 'payout', po, '_account');
|
|
189
|
+
if (typeof owner !== 'string') return fail(ctx, "Payout reversals are only supported for payouts to connected accounts' bank accounts.", 400);
|
|
190
|
+
const status = String(asOf(ctx, po).status);
|
|
191
|
+
if (po.reversed_by || status !== 'paid') return ctx.refuse({ status: 400, code: 'payout_reversal_not_allowed', message: po.reversed_by ? 'This payout has already been reversed.' : `This payout cannot be reversed because it has a status of ${status}. A pending payout can be canceled instead.` });
|
|
192
|
+
const reversalId = ctx.mint('payout');
|
|
193
|
+
const bt = await settlePayout(ctx, reversalId, Number(po.amount) || 0, String(po.currency ?? 'usd'), owner);
|
|
194
|
+
const metadata = ctx.params.metadata && typeof ctx.params.metadata === 'object' ? { metadata: ctx.params.metadata } : {};
|
|
195
|
+
const reversal = await created(ctx, 'payout', { id: reversalId, amount: -(Number(po.amount) || 0), currency: po.currency, ...metadata }, {
|
|
196
|
+
status: 'pending', ...payoutDefaults(ctx), balance_transaction: bt, original_payout: id, destination: po.destination ?? null, _account: owner,
|
|
197
|
+
});
|
|
198
|
+
await ctx.write('payout', id, { reversed_by: reversal.id }, 'payout.reversed');
|
|
199
|
+
return ctx.reply(reversal);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const balances: Record<string, Semantics> = {
|
|
203
|
+
GetBalance: balance,
|
|
204
|
+
PostPayouts: createPayout,
|
|
205
|
+
GetPayouts: listPayouts,
|
|
206
|
+
GetPayoutsPayout: retrievePayout,
|
|
207
|
+
PostPayoutsPayoutCancel: cancelPayout,
|
|
208
|
+
PostPayoutsPayoutReverse: reversePayout,
|
|
209
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Billing semantics: meters (how usage events aggregate), the meter events that report usage and
|
|
2
|
+
// the summaries that aggregate them, prepaid credit grants and the balance they leave, and usage
|
|
3
|
+
// alerts. The machines in ../manifest.ts say how a meter and an alert move. Lists and retrieves
|
|
4
|
+
// with no scoping of their own are the derived core's.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { at, created, fail, list, newest, path, where, type Row } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
const METER = 'billing.meter';
|
|
9
|
+
const meterMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such meter: '${id}'`, 404, 'resource_missing');
|
|
10
|
+
const objectOf = (v: unknown): Row => (v && typeof v === 'object' ? (v as Row) : {});
|
|
11
|
+
|
|
12
|
+
// a meter needs a display name, the event name it counts, and how it aggregates (sum or count)
|
|
13
|
+
const createMeter: Semantics = async (ctx) => {
|
|
14
|
+
const params = ctx.params;
|
|
15
|
+
const displayName = typeof params.display_name === 'string' ? params.display_name : '';
|
|
16
|
+
if (!displayName) return fail(ctx, 'Missing required param: display_name.', 400, 'parameter_missing');
|
|
17
|
+
if (!((params.event_name as string) ?? '')) return fail(ctx, 'Missing required param: event_name.', 400, 'parameter_missing');
|
|
18
|
+
const formula = objectOf(params.default_aggregation).formula;
|
|
19
|
+
if (typeof formula !== 'string' || !formula) return fail(ctx, 'Missing required param: default_aggregation[formula] (sum or count).', 400, 'parameter_missing');
|
|
20
|
+
return ctx.reply(
|
|
21
|
+
await created(ctx, METER, params, {
|
|
22
|
+
status: 'active', livemode: false,
|
|
23
|
+
customer_mapping: (params.customer_mapping as object) ?? { event_payload_key: 'stripe_customer_id', type: 'by_id' },
|
|
24
|
+
event_time_window: (params.event_time_window as string) ?? null,
|
|
25
|
+
value_settings: (params.value_settings as object) ?? { event_payload_key: 'value' },
|
|
26
|
+
status_transitions: { deactivated_at: null }, updated: ctx.now(),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function activation(kind: 'deactivate' | 'reactivate'): Semantics {
|
|
32
|
+
return async (ctx) => {
|
|
33
|
+
const id = at(ctx, 'id');
|
|
34
|
+
const m = ctx.get(METER, id);
|
|
35
|
+
if (!m) return meterMissing(ctx, id);
|
|
36
|
+
const refused = ctx.legal(METER, 'status', kind === 'deactivate' ? 'PostBillingMetersIdDeactivate' : 'PostBillingMetersIdReactivate', m.status, undefined, id);
|
|
37
|
+
if (refused) return ctx.refuse(refused);
|
|
38
|
+
const fields = { ...(kind === 'deactivate' ? { status: 'inactive', status_transitions: { deactivated_at: ctx.now() } } : { status: 'active', status_transitions: { deactivated_at: null } }), updated: ctx.now() };
|
|
39
|
+
return ctx.reply(await ctx.write(METER, id, fields, `billing_meter.${kind}`));
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// a meter event has no id of its own on Stripe (it carries an `identifier`); its object is billing.meter_event
|
|
44
|
+
const reportUsage: Semantics = async (ctx) => {
|
|
45
|
+
const params = ctx.params;
|
|
46
|
+
if (typeof params.event_name !== 'string' || !params.event_name) return fail(ctx, 'Missing required param: event_name.', 400, 'parameter_missing');
|
|
47
|
+
const payload = objectOf(params.payload);
|
|
48
|
+
const body = await created(ctx, 'billing.meter_event', { ...params }, {
|
|
49
|
+
livemode: false, timestamp: params.timestamp !== undefined ? Math.trunc(Number(params.timestamp)) : ctx.now(),
|
|
50
|
+
identifier: payload.identifier ?? null, payload,
|
|
51
|
+
});
|
|
52
|
+
return ctx.reply({ ...body, object: 'billing.meter_event' });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// the meter's events for one customer in [start_time, end_time), aggregated by its formula
|
|
56
|
+
const eventSummaries: Semantics = async (ctx) => {
|
|
57
|
+
const id = at(ctx, 'id');
|
|
58
|
+
const m = ctx.get(METER, id);
|
|
59
|
+
if (!m) return meterMissing(ctx, id);
|
|
60
|
+
const params = ctx.params;
|
|
61
|
+
const customer = typeof params.customer === 'string' ? params.customer : '';
|
|
62
|
+
if (!customer) return fail(ctx, 'Missing required param: customer.', 400, 'parameter_missing');
|
|
63
|
+
if (params.start_time === undefined) return fail(ctx, 'Missing required param: start_time.', 400, 'parameter_missing');
|
|
64
|
+
if (params.end_time === undefined) return fail(ctx, 'Missing required param: end_time.', 400, 'parameter_missing');
|
|
65
|
+
const startTime = Math.trunc(Number(params.start_time) || 0);
|
|
66
|
+
const endTime = Math.trunc(Number(params.end_time) || 0);
|
|
67
|
+
// "Must be aligned with minute boundaries" (start_time, end_time); "For hourly granularity, start and end times must
|
|
68
|
+
// align with hour boundaries … For daily granularity, … with UTC day boundaries (00:00 UTC)" (value_grouping_window;
|
|
69
|
+
// the served spec). Where the documentation stops and the twin decides: Stripe documents no error for it, so the
|
|
70
|
+
// twin answers a 400 naming the parameter and the boundary.
|
|
71
|
+
const window = params.value_grouping_window === 'hour' ? 3600 : params.value_grouping_window === 'day' ? 86_400 : 60;
|
|
72
|
+
const unit = window === 3600 ? 'hour' : window === 86_400 ? 'UTC day' : 'minute';
|
|
73
|
+
for (const [name, t] of [['start_time', startTime], ['end_time', endTime]] as const) {
|
|
74
|
+
if (t % window !== 0) return fail(ctx, `Invalid ${name}: ${t} is not aligned with ${unit} boundaries.`, 400);
|
|
75
|
+
}
|
|
76
|
+
const mapKey = typeof objectOf(m.customer_mapping).event_payload_key === 'string' ? String(objectOf(m.customer_mapping).event_payload_key) : 'stripe_customer_id';
|
|
77
|
+
const valueKey = typeof objectOf(m.value_settings).event_payload_key === 'string' ? String(objectOf(m.value_settings).event_payload_key) : 'value';
|
|
78
|
+
const formula = (objectOf(m.default_aggregation).formula as string) ?? 'sum';
|
|
79
|
+
let aggregate = 0;
|
|
80
|
+
for (const ev of ctx.rowsRaw('billing.meter_event', { withDeleted: true })) {
|
|
81
|
+
if (ev.event_name !== String(m.event_name ?? '')) continue;
|
|
82
|
+
const ts = Number(ev.timestamp) || 0;
|
|
83
|
+
if (ts < startTime || ts >= endTime) continue;
|
|
84
|
+
const payload = objectOf(ev.payload);
|
|
85
|
+
if (String(payload[mapKey] ?? '') !== customer) continue;
|
|
86
|
+
aggregate += formula === 'count' ? 1 : Number(payload[valueKey]) || 0;
|
|
87
|
+
}
|
|
88
|
+
return ctx.reply({
|
|
89
|
+
object: 'list', url: path(ctx), has_more: false,
|
|
90
|
+
data: [{ id: `mtrusg_twin_${id}_${customer}`, object: 'billing.meter_event_summary', meter: id, aggregated_value: aggregate, start_time: startTime, end_time: endTime, livemode: false }],
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ── credit grants: a customer's prepaid credit, paid or promotional, in a monetary amount ──
|
|
95
|
+
|
|
96
|
+
const GRANT = 'billing.credit_grant';
|
|
97
|
+
const grantMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such credit grant: '${id}'`, 404, 'resource_missing');
|
|
98
|
+
|
|
99
|
+
const createGrant: Semantics = async (ctx) => {
|
|
100
|
+
const params = ctx.params;
|
|
101
|
+
const customer = typeof params.customer === 'string' ? params.customer : '';
|
|
102
|
+
if (!customer) return fail(ctx, 'Missing required param: customer.', 400, 'parameter_missing');
|
|
103
|
+
if (!ctx.row('customer', customer, { withDeleted: true })) return fail(ctx, `No such customer: '${customer}'`, 404, 'resource_missing');
|
|
104
|
+
const category = typeof params.category === 'string' ? params.category : '';
|
|
105
|
+
if (category !== 'paid' && category !== 'promotional') return fail(ctx, 'Invalid category: must be paid or promotional.', 400, 'parameter_invalid_string_enum');
|
|
106
|
+
const monetary = params.amount && typeof params.amount === 'object' ? objectOf((params.amount as Row).monetary) : undefined;
|
|
107
|
+
if (!monetary || monetary.value === undefined || monetary.currency === undefined) return fail(ctx, 'Missing required param: amount[monetary][value] and amount[monetary][currency].', 400, 'parameter_missing');
|
|
108
|
+
return ctx.reply(
|
|
109
|
+
await created(ctx, GRANT, { customer }, {
|
|
110
|
+
category, livemode: false, name: params.name ?? null,
|
|
111
|
+
amount: { type: 'monetary', monetary: { currency: String(monetary.currency), value: Math.trunc(Number(monetary.value) || 0) } },
|
|
112
|
+
applicability_config: params.applicability_config && typeof params.applicability_config === 'object' ? params.applicability_config : { scope: { price_type: 'metered' } },
|
|
113
|
+
effective_at: params.effective_at !== undefined ? Math.trunc(Number(params.effective_at)) : ctx.now(),
|
|
114
|
+
expires_at: params.expires_at !== undefined ? Math.trunc(Number(params.expires_at)) : null,
|
|
115
|
+
priority: params.priority !== undefined ? Math.trunc(Number(params.priority)) : 50,
|
|
116
|
+
voided_at: null, metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {}, updated: ctx.now(),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// expiring or voiding a grant stamps the instant; it no longer counts toward the balance
|
|
122
|
+
function endGrant(kind: 'expire' | 'void'): Semantics {
|
|
123
|
+
return async (ctx) => {
|
|
124
|
+
const id = at(ctx, 'id');
|
|
125
|
+
if (!ctx.get(GRANT, id)) return grantMissing(ctx, id);
|
|
126
|
+
return ctx.reply(await ctx.write(GRANT, id, { ...(kind === 'void' ? { voided_at: ctx.now() } : { expires_at: ctx.now() }), updated: ctx.now() }, `credit_grant.${kind}`));
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// only the expiry and metadata change
|
|
131
|
+
const updateGrant: Semantics = async (ctx) => {
|
|
132
|
+
const id = at(ctx, 'id');
|
|
133
|
+
if (!ctx.get(GRANT, id)) return grantMissing(ctx, id);
|
|
134
|
+
const patch: Row = { updated: ctx.now() };
|
|
135
|
+
if (ctx.params.expires_at !== undefined) patch.expires_at = Math.trunc(Number(ctx.params.expires_at));
|
|
136
|
+
if (ctx.params.metadata !== undefined) patch.metadata = ctx.params.metadata;
|
|
137
|
+
return ctx.reply(await ctx.write(GRANT, id, patch, 'credit_grant.update'));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// the customer's live (unvoided, unexpired) monetary grants, summed per currency
|
|
141
|
+
const creditBalance: Semantics = async (ctx) => {
|
|
142
|
+
const customer = typeof ctx.params.customer === 'string' ? ctx.params.customer : '';
|
|
143
|
+
if (!customer) return fail(ctx, 'Missing required param: customer.', 400, 'parameter_missing');
|
|
144
|
+
if (!ctx.row('customer', customer, { withDeleted: true })) return fail(ctx, `No such customer: '${customer}'`, 404, 'resource_missing');
|
|
145
|
+
const now = Number(ctx.now());
|
|
146
|
+
const byCurrency: Record<string, number> = {};
|
|
147
|
+
for (const g of ctx.rows(GRANT)) {
|
|
148
|
+
if (g.customer !== customer || g.voided_at != null) continue;
|
|
149
|
+
if (typeof g.expires_at === 'number' && g.expires_at <= now) continue;
|
|
150
|
+
const monetary = objectOf(objectOf(g.amount).monetary);
|
|
151
|
+
const cur = String(monetary.currency ?? 'usd');
|
|
152
|
+
byCurrency[cur] = (byCurrency[cur] ?? 0) + (Number(monetary.value) || 0);
|
|
153
|
+
}
|
|
154
|
+
const balances = Object.entries(byCurrency).map(([currency, value]) => ({
|
|
155
|
+
available_balance: { type: 'monetary', monetary: { currency, value } },
|
|
156
|
+
ledger_balance: { type: 'monetary', monetary: { currency, value } },
|
|
157
|
+
}));
|
|
158
|
+
return ctx.reply({ object: 'billing.credit_balance_summary', customer, balances, livemode: false });
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ── alerts: a usage threshold on a meter ──
|
|
162
|
+
|
|
163
|
+
const ALERT = 'billing.alert';
|
|
164
|
+
|
|
165
|
+
const createAlert: Semantics = async (ctx) => {
|
|
166
|
+
const params = ctx.params;
|
|
167
|
+
if (params.alert_type !== 'usage_threshold') return fail(ctx, 'Invalid alert_type: must be usage_threshold.', 400, 'parameter_invalid_string_enum');
|
|
168
|
+
if (typeof params.title !== 'string' || !params.title) return fail(ctx, 'Missing required param: title.', 400, 'parameter_missing');
|
|
169
|
+
const ut = params.usage_threshold && typeof params.usage_threshold === 'object' ? (params.usage_threshold as Row) : undefined;
|
|
170
|
+
if (!ut || ut.gte === undefined || typeof ut.meter !== 'string') return fail(ctx, 'Missing required param: usage_threshold[gte] and usage_threshold[meter].', 400, 'parameter_missing');
|
|
171
|
+
// the spec requires recurrence too (one_time: the alert fires once)
|
|
172
|
+
if (ut.recurrence === undefined || ut.recurrence === '') return fail(ctx, 'Missing required param: usage_threshold[recurrence].', 400, 'parameter_missing');
|
|
173
|
+
if (!ctx.get(METER, ut.meter)) return fail(ctx, `No such meter: '${ut.meter}'`, 400, 'resource_missing');
|
|
174
|
+
return ctx.reply(
|
|
175
|
+
await created(ctx, ALERT, {}, {
|
|
176
|
+
alert_type: 'usage_threshold', livemode: false, status: 'active', title: params.title,
|
|
177
|
+
usage_threshold: { gte: Math.trunc(Number(ut.gte) || 0), meter: ut.meter, recurrence: String(ut.recurrence), filters: null },
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const listAlerts: Semantics = async (ctx) =>
|
|
183
|
+
list(ctx, ALERT, where(ctx, newest(ctx, ALERT), {
|
|
184
|
+
alert_type: (a, v) => a.alert_type === v,
|
|
185
|
+
meter: (a, v) => objectOf(a.usage_threshold).meter === v,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
function alertMove(kind: 'activate' | 'deactivate' | 'archive', operationId: string): Semantics {
|
|
189
|
+
return async (ctx) => {
|
|
190
|
+
const id = at(ctx, 'id');
|
|
191
|
+
const a = ctx.get(ALERT, id);
|
|
192
|
+
if (!a) return fail(ctx, `No such alert: '${id}'`, 404, 'resource_missing');
|
|
193
|
+
const refused = ctx.legal(ALERT, 'status', operationId, a.status, undefined, id);
|
|
194
|
+
if (refused) return ctx.refuse(refused);
|
|
195
|
+
const status = kind === 'activate' ? 'active' : kind === 'deactivate' ? 'inactive' : 'archived';
|
|
196
|
+
return ctx.reply(await ctx.write(ALERT, id, { status }, `billing_alert.${kind}`));
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const billing: Record<string, Semantics> = {
|
|
201
|
+
PostBillingMeters: createMeter,
|
|
202
|
+
PostBillingMetersIdDeactivate: activation('deactivate'),
|
|
203
|
+
PostBillingMetersIdReactivate: activation('reactivate'),
|
|
204
|
+
PostBillingMeterEvents: reportUsage,
|
|
205
|
+
GetBillingMetersIdEventSummaries: eventSummaries,
|
|
206
|
+
PostBillingCreditGrants: createGrant,
|
|
207
|
+
PostBillingCreditGrantsIdExpire: endGrant('expire'),
|
|
208
|
+
PostBillingCreditGrantsIdVoid: endGrant('void'),
|
|
209
|
+
PostBillingCreditGrantsId: updateGrant,
|
|
210
|
+
GetBillingCreditBalanceSummary: creditBalance,
|
|
211
|
+
PostBillingAlerts: createAlert,
|
|
212
|
+
GetBillingAlerts: listAlerts,
|
|
213
|
+
PostBillingAlertsIdActivate: alertMove('activate', 'PostBillingAlertsIdActivate'),
|
|
214
|
+
PostBillingAlertsIdDeactivate: alertMove('deactivate', 'PostBillingAlertsIdDeactivate'),
|
|
215
|
+
PostBillingAlertsIdArchive: alertMove('archive', 'PostBillingAlertsIdArchive'),
|
|
216
|
+
};
|