@volter/twin-stripe 0.1.2 → 2.0.0
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 +64 -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 +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -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 +214 -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 +93 -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 +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -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 +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -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 +138 -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 +78 -0
- package/dist/src/semantics/shared.js +192 -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-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 +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -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 +388 -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 +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -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 +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -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 +3740 -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 +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -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 +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -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 +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -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 +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -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 +215 -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-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 +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- 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 +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Credit note semantics. A credit note credits part of an invoice: pre_payment while the invoice
|
|
2
|
+
// is unpaid (it lowers what is owed), post_payment once paid. The invoice carries the sums of its
|
|
3
|
+
// issued credit notes, re-derived on every change so voiding one returns the invoice to where it
|
|
4
|
+
// was. The machine in ../manifest.ts says a credit note is voided once. The list is the derived core's.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { paginate } from '../stripe-twin.ts';
|
|
7
|
+
import { refundCharge } from './refunds.ts';
|
|
8
|
+
import { at, created, fail, path, type Row } from './shared.ts';
|
|
9
|
+
|
|
10
|
+
const CN = 'credit_note';
|
|
11
|
+
const noteMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such credit note: '${id}'`, 404, 'resource_missing');
|
|
12
|
+
|
|
13
|
+
/** The credit note the request describes (the `amount` form: one custom line), or Stripe's refusal. */
|
|
14
|
+
function build(ctx: SemanticsContext, id: string): Row | Response {
|
|
15
|
+
const params = ctx.params;
|
|
16
|
+
const invoiceId = typeof params.invoice === 'string' ? params.invoice : '';
|
|
17
|
+
if (!invoiceId) return fail(ctx, 'Missing required param: invoice.', 400, 'parameter_missing');
|
|
18
|
+
const invoice = ctx.get('invoice', invoiceId);
|
|
19
|
+
if (!invoice) return fail(ctx, `No such invoice: '${invoiceId}'`, 404, 'resource_missing');
|
|
20
|
+
if (params.amount === undefined) return fail(ctx, 'Missing required param: amount.', 400, 'parameter_missing');
|
|
21
|
+
const amount = Number(params.amount);
|
|
22
|
+
if (!Number.isInteger(amount) || amount <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
|
|
23
|
+
const vendorType = invoice.status === 'paid' ? 'post_payment' : 'pre_payment';
|
|
24
|
+
const outOfBand = Number(params.out_of_band_amount) || 0;
|
|
25
|
+
// a paid invoice's credit is paid back: "The sum of refunds, customer balance credits, and outside of Stripe credits
|
|
26
|
+
// must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create); the message's wording is the twin's
|
|
27
|
+
if (vendorType === 'post_payment') {
|
|
28
|
+
const split = (Number(params.refund_amount) || 0) + (Number(params.credit_amount) || 0) + outOfBand;
|
|
29
|
+
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');
|
|
30
|
+
}
|
|
31
|
+
const line = {
|
|
32
|
+
object: 'credit_note_line_item', id: `cnli_${id}_1`, type: 'custom_line_item',
|
|
33
|
+
amount, description: typeof params.memo === 'string' ? params.memo : null,
|
|
34
|
+
// a custom line credits no invoice line: invoice_line_item ("ID of the invoice line item being credited") is left
|
|
35
|
+
// out, the served spec not letting it be null
|
|
36
|
+
discount_amount: 0, discount_amounts: [], livemode: false,
|
|
37
|
+
pretax_credit_amounts: [], quantity: null, tax_rates: [], taxes: [],
|
|
38
|
+
unit_amount: null, unit_amount_decimal: null,
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
amount, amount_shipping: 0, created: ctx.now(), currency: typeof invoice.currency === 'string' ? invoice.currency : 'usd', customer: typeof invoice.customer === 'string' ? invoice.customer : null,
|
|
42
|
+
customer_balance_transaction: null,
|
|
43
|
+
// "The date when this credit note is in effect. Same as `created` unless overwritten" (the served spec)
|
|
44
|
+
discount_amount: 0, discount_amounts: [], effective_at: params.effective_at !== undefined ? Number(params.effective_at) : ctx.now(), invoice: invoiceId,
|
|
45
|
+
lines: { object: 'list', data: [line], has_more: false, total_count: 1, url: `/v1/credit_notes/${id}/lines` },
|
|
46
|
+
livemode: false, memo: params.memo ?? null, metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {},
|
|
47
|
+
number: `${invoiceId}-CN-01`, out_of_band_amount: outOfBand || null,
|
|
48
|
+
pdf: `https://files.twin.local/credit_notes/${id}.pdf`,
|
|
49
|
+
post_payment_amount: vendorType === 'post_payment' ? amount : 0,
|
|
50
|
+
pre_payment_amount: vendorType === 'pre_payment' ? amount : 0,
|
|
51
|
+
pretax_credit_amounts: [],
|
|
52
|
+
reason: typeof params.reason === 'string' ? params.reason : null,
|
|
53
|
+
refunds: [], shipping_cost: null, status: 'issued',
|
|
54
|
+
subtotal: amount, subtotal_excluding_tax: amount,
|
|
55
|
+
type: vendorType, total: amount, total_excluding_tax: amount, total_taxes: [],
|
|
56
|
+
voided_at: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The invoice's credit-note sums, from its issued credit notes; a pre-payment credit lowers what is owed. */
|
|
61
|
+
async function syncInvoice(ctx: SemanticsContext, invoiceId: string): Promise<void> {
|
|
62
|
+
const inv = ctx.get('invoice', invoiceId);
|
|
63
|
+
if (!inv) return;
|
|
64
|
+
let pre = 0;
|
|
65
|
+
let post = 0;
|
|
66
|
+
for (const cn of ctx.rows(CN)) {
|
|
67
|
+
if (cn.invoice !== invoiceId || cn.status === 'void') continue;
|
|
68
|
+
if (cn.type === 'post_payment') post += Number(cn.amount) || 0;
|
|
69
|
+
else pre += Number(cn.amount) || 0;
|
|
70
|
+
}
|
|
71
|
+
const total = Number(inv.total ?? inv.amount_due) || 0;
|
|
72
|
+
const paid = Number(inv.amount_paid) || 0;
|
|
73
|
+
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');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// the preview computes the credit note without keeping it
|
|
77
|
+
const preview: Semantics = async (ctx) => {
|
|
78
|
+
const id = 'cn_preview';
|
|
79
|
+
const cn = build(ctx, id);
|
|
80
|
+
return cn instanceof Response ? cn : ctx.reply({ object: CN, ...cn, id });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** What a post-payment credit pays back: "Refunds: create a new refund (using refund_amount)"; "Customer balance credit:
|
|
84
|
+
* credit the customer's balance (using credit_amount) which will be automatically applied to their next invoice when
|
|
85
|
+
* it's finalized" (docs.stripe.com/api/credit_notes/create). The refund is made on the invoice's charge as a refund
|
|
86
|
+
* call makes it (refunds.ts); the credit is a customer balance transaction of type credit_note. Where the documentation
|
|
87
|
+
* stops and the twin decides: the refund carries no reason of its own. */
|
|
88
|
+
async function payBack(ctx: SemanticsContext, id: string, cn: Row): Promise<Row | Response> {
|
|
89
|
+
const params = ctx.params;
|
|
90
|
+
const out: Row = {};
|
|
91
|
+
const refundAmount = Number(params.refund_amount) || 0;
|
|
92
|
+
if (refundAmount > 0) {
|
|
93
|
+
const invoice = ctx.get('invoice', String(cn.invoice));
|
|
94
|
+
const pi = typeof invoice?.payment_intent === 'string' ? ctx.get('payment_intent', invoice.payment_intent) : undefined;
|
|
95
|
+
const chargeId = typeof invoice?.charge === 'string' ? invoice.charge : typeof pi?.latest_charge === 'string' ? pi.latest_charge : undefined;
|
|
96
|
+
const charge = chargeId ? ctx.get('charge', chargeId) : undefined;
|
|
97
|
+
if (!charge) return fail(ctx, 'This invoice was not paid with a charge that can be refunded.', 400, 'invoice_no_charge_to_refund');
|
|
98
|
+
const answer = await refundCharge(ctx, charge, { amount: refundAmount });
|
|
99
|
+
if (answer.status >= 400) return answer;
|
|
100
|
+
const refund = (await answer.json()) as Row;
|
|
101
|
+
out.refunds = [{ amount_refunded: refundAmount, refund: refund.id, type: 'refund', payment_record_refund: null }];
|
|
102
|
+
}
|
|
103
|
+
const creditAmount = Number(params.credit_amount) || 0;
|
|
104
|
+
const customer = typeof cn.customer === 'string' ? cn.customer : undefined;
|
|
105
|
+
if (creditAmount > 0 && customer) {
|
|
106
|
+
const ending = (Number(ctx.get('customer', customer)?.balance) || 0) - creditAmount;
|
|
107
|
+
const txn = await created(ctx, 'customer_balance_transaction', { customer, amount: -creditAmount, currency: cn.currency, credit_note: id, invoice: cn.invoice }, {
|
|
108
|
+
ending_balance: ending, type: 'credit_note', livemode: false, metadata: {}, description: null, checkout_session: null,
|
|
109
|
+
});
|
|
110
|
+
await ctx.write('customer', customer, { balance: ending }, `customer.balance_adjusted:${String(txn.id)}`);
|
|
111
|
+
out.customer_balance_transaction = txn.id;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const create: Semantics = async (ctx) => {
|
|
117
|
+
const id = ctx.mint(CN);
|
|
118
|
+
const cn = build(ctx, id);
|
|
119
|
+
if (cn instanceof Response) return cn;
|
|
120
|
+
const paid = cn.type === 'post_payment' ? await payBack(ctx, id, cn) : {};
|
|
121
|
+
if (paid instanceof Response) return paid;
|
|
122
|
+
const body = await created(ctx, CN, { id, ...cn, ...paid }, {});
|
|
123
|
+
await syncInvoice(ctx, String(cn.invoice));
|
|
124
|
+
return ctx.reply(body);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const retrieve: Semantics = async (ctx) => {
|
|
128
|
+
const c = ctx.get(CN, at(ctx, 'id'));
|
|
129
|
+
return c ? ctx.reply(c) : noteMissing(ctx, at(ctx, 'id'));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const lines: Semantics = async (ctx) => {
|
|
133
|
+
const c = ctx.get(CN, at(ctx, 'credit_note'));
|
|
134
|
+
if (!c) return noteMissing(ctx, at(ctx, 'credit_note'));
|
|
135
|
+
const l = c.lines as { data?: Row[] } | undefined;
|
|
136
|
+
const { page, hasMore } = paginate(Array.isArray(l?.data) ? l!.data! : [], ctx.params);
|
|
137
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const voidNote: Semantics = async (ctx) => {
|
|
141
|
+
const id = at(ctx, 'id');
|
|
142
|
+
const c = ctx.get(CN, id);
|
|
143
|
+
if (!c) return noteMissing(ctx, id);
|
|
144
|
+
const refused = ctx.legal(CN, 'status', 'PostCreditNotesIdVoid', c.status, undefined, id);
|
|
145
|
+
if (refused) return ctx.refuse(refused);
|
|
146
|
+
const voided = await ctx.write(CN, id, { status: 'void', voided_at: ctx.now() }, 'credit_note.void');
|
|
147
|
+
if (typeof c.invoice === 'string') await syncInvoice(ctx, c.invoice);
|
|
148
|
+
return ctx.reply(voided);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// only the memo and metadata change
|
|
152
|
+
const update: Semantics = async (ctx) => {
|
|
153
|
+
const id = at(ctx, 'id');
|
|
154
|
+
if (!ctx.get(CN, id)) return noteMissing(ctx, id);
|
|
155
|
+
const patch: Row = {};
|
|
156
|
+
if (ctx.params.memo !== undefined) patch.memo = ctx.params.memo;
|
|
157
|
+
if (ctx.params.metadata !== undefined) patch.metadata = ctx.params.metadata;
|
|
158
|
+
return ctx.reply(await ctx.write(CN, id, patch, 'credit_note.update'));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const creditNotes: Record<string, Semantics> = {
|
|
162
|
+
GetCreditNotesPreview: preview,
|
|
163
|
+
PostCreditNotes: create,
|
|
164
|
+
GetCreditNotesId: retrieve,
|
|
165
|
+
GetCreditNotesCreditNoteLines: lines,
|
|
166
|
+
PostCreditNotesIdVoid: voidNote,
|
|
167
|
+
PostCreditNotesId: update,
|
|
168
|
+
};
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// Customer semantics: the customer and what hangs off it (tax ids, the credit-balance ledger, the
|
|
2
|
+
// cash balance, legacy sources, attached payment methods) and the top-level Source object. List,
|
|
3
|
+
// update and delete of a customer, retrieve of a Source and the flat tax-id list are the derived core's.
|
|
4
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { buildDiscount } from '../stripe-twin.ts';
|
|
6
|
+
import { invoicePrefix } from './invoices.ts';
|
|
7
|
+
import { mintCharge } from './payment-intents.ts';
|
|
8
|
+
import { attachable, attachPaymentMethod } from './payment-methods.ts';
|
|
9
|
+
import { at, created, deletedDiscount, expanded, fail, list, newest, redeemCoupon, search, where, type Row } from './shared.ts';
|
|
10
|
+
|
|
11
|
+
const customerMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such customer: '${id}'`, 404, 'resource_missing');
|
|
12
|
+
|
|
13
|
+
/** The discount a customer's `coupon` makes, a parameter of API versions before the served one. */
|
|
14
|
+
function customerDiscount(ctx: SemanticsContext, couponId: string): Row | Response {
|
|
15
|
+
const coupon = ctx.get('coupon', couponId);
|
|
16
|
+
return coupon ? buildDiscount(coupon, '', Number(ctx.now())) : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What a new customer answers before any invoice: its invoice_prefix and a next_invoice_sequence of 1, a zero balance,
|
|
20
|
+
* no metadata (docs.stripe.com/api/customers/object). */
|
|
21
|
+
export const newCustomer = (id: string): Row => ({ invoice_prefix: invoicePrefix(id), next_invoice_sequence: 1, balance: 0, metadata: {}, invoice_settings: {} });
|
|
22
|
+
|
|
23
|
+
const create: Semantics = async (ctx) => {
|
|
24
|
+
// a `coupon` on create materializes the discount object on the customer (the coupon must exist)
|
|
25
|
+
const couponId = typeof ctx.params.coupon === 'string' ? ctx.params.coupon : '';
|
|
26
|
+
const discount = couponId ? customerDiscount(ctx, couponId) : null;
|
|
27
|
+
if (discount instanceof Response) return discount;
|
|
28
|
+
// `payment_method` attaches that method to the new customer; it is not a field of the customer
|
|
29
|
+
// (docs.stripe.com/api/customers/create#create_customer-payment_method)
|
|
30
|
+
const pmId = typeof ctx.params.payment_method === 'string' ? ctx.params.payment_method : '';
|
|
31
|
+
if (pmId && !attachable(ctx, pmId)) return fail(ctx, `No such PaymentMethod: '${pmId}'`, 400, 'resource_missing');
|
|
32
|
+
const { coupon: _c, payment_method: _pm, ...rest } = ctx.params;
|
|
33
|
+
const id = ctx.mint('customer');
|
|
34
|
+
const customer = await created(ctx, 'customer', { ...rest, id }, { livemode: false, discount: discount ? { ...discount, customer: id } : null, ...newCustomer(id) });
|
|
35
|
+
if (discount) await redeemCoupon(ctx, couponId);
|
|
36
|
+
if (pmId) await attachPaymentMethod(ctx, pmId, String(customer.id));
|
|
37
|
+
return ctx.reply(customer);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const searchCustomers: Semantics = async (ctx) => search(ctx, 'customer');
|
|
41
|
+
|
|
42
|
+
// "If it’s for a deleted Customer, a subset of the customer’s information is returned, including a `deleted` property
|
|
43
|
+
// that’s set to true" (docs.stripe.com/api/customers/retrieve)
|
|
44
|
+
const retrieve: Semantics = async (ctx) => {
|
|
45
|
+
const id = at(ctx, 'customer');
|
|
46
|
+
const row = ctx.row('customer', id, { withDeleted: true });
|
|
47
|
+
if (!row) return customerMissing(ctx, id);
|
|
48
|
+
if (row.deleted === true) return ctx.reply({ id, object: 'customer', deleted: true });
|
|
49
|
+
return ctx.reply(expanded(ctx, 'customer', ctx.get('customer', id)!));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// the payment methods attached to a customer (customers.listPaymentMethods), optionally by `type`
|
|
53
|
+
const paymentMethods: Semantics = async (ctx) => {
|
|
54
|
+
const customer = at(ctx, 'customer');
|
|
55
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
56
|
+
const items = where(ctx, newest(ctx, 'payment_method').filter((m) => m.customer === customer), { type: (m, v) => m.type === v });
|
|
57
|
+
return list(ctx, 'payment_method', items);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const paymentMethod: Semantics = async (ctx) => {
|
|
61
|
+
const customer = at(ctx, 'customer');
|
|
62
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
63
|
+
const m = ctx.get('payment_method', at(ctx, 'payment_method'));
|
|
64
|
+
return m && m.customer === customer ? ctx.reply(m) : fail(ctx, `No such PaymentMethod: '${at(ctx, 'payment_method')}'`, 404, 'resource_missing');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// removing the customer's discount answers the deleted-discount stub
|
|
68
|
+
const deleteDiscount: Semantics = async (ctx) => {
|
|
69
|
+
const id = at(ctx, 'customer');
|
|
70
|
+
const customer = ctx.get('customer', id);
|
|
71
|
+
if (!customer) return customerMissing(ctx, id);
|
|
72
|
+
const discount = customer.discount as Row | null | undefined;
|
|
73
|
+
if (!discount) return fail(ctx, `No active discount for customer: '${id}'`, 404, 'resource_missing');
|
|
74
|
+
await ctx.write('customer', id, { discount: null }, 'customer.update');
|
|
75
|
+
return ctx.reply(deletedDiscount(discount, ctx.now()));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ── tax ids: a customer's, keyed under it; `type` and `value` are required ──
|
|
79
|
+
|
|
80
|
+
// An EU VAT number is its country's prefix and that country's format (the European Commission's VIES formats,
|
|
81
|
+
// ec.europa.eu/taxation_customs/vies); Stripe refuses one that does not fit as tax_id_invalid
|
|
82
|
+
// (docs.stripe.com/error-codes#tax-id-invalid). The message's wording is the twin's.
|
|
83
|
+
const EU_VAT: Record<string, RegExp> = {
|
|
84
|
+
AT: /^U\d{8}$/, BE: /^[01]\d{9}$/, BG: /^\d{9,10}$/, CY: /^\d{8}[A-Z]$/, CZ: /^\d{8,10}$/, DE: /^\d{9}$/, DK: /^\d{8}$/,
|
|
85
|
+
EE: /^\d{9}$/, EL: /^\d{9}$/, ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/, FI: /^\d{8}$/, FR: /^[A-Z0-9]{2}\d{9}$/, HR: /^\d{11}$/,
|
|
86
|
+
HU: /^\d{8}$/, IE: /^(\d{7}[A-W][A-I]?|\d[A-Z+*]\d{5}[A-W])$/, IT: /^\d{11}$/, LT: /^(\d{9}|\d{12})$/, LU: /^\d{8}$/,
|
|
87
|
+
LV: /^\d{11}$/, MT: /^\d{8}$/, NL: /^\d{9}B\d{2}$/, PL: /^\d{10}$/, PT: /^\d{9}$/, RO: /^\d{2,10}$/, SE: /^\d{12}$/,
|
|
88
|
+
SI: /^\d{8}$/, SK: /^\d{10}$/, XI: /^(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/,
|
|
89
|
+
};
|
|
90
|
+
const euVatCountry = (value: string): string | undefined => {
|
|
91
|
+
const v = value.replace(/[\s.-]/g, '').toUpperCase();
|
|
92
|
+
const prefix = v.slice(0, 2);
|
|
93
|
+
const format = EU_VAT[prefix];
|
|
94
|
+
return format && format.test(v.slice(2)) ? (prefix === 'EL' ? 'GR' : prefix) : undefined;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** The tax ID types Stripe checks against a government register: "Stripe automatically validates all Australian
|
|
98
|
+
* Business Numbers (ABNs)", "all European Value-Added-Tax (EU VAT) numbers" (VIES) and "all UK Value-Added-Tax (GB VAT)
|
|
99
|
+
* numbers" (HMRC); a verification's status "is pending, verified, unverified, or unavailable", and "When automatic
|
|
100
|
+
* validation is unavailable, you must manually verify the tax ID" (docs.stripe.com/billing/customer/tax-ids). Any other
|
|
101
|
+
* type answers unavailable; one of these waits pending. */
|
|
102
|
+
const AUTO_VALIDATED = new Set(['au_abn', 'eu_vat', 'gb_vat']);
|
|
103
|
+
|
|
104
|
+
/** A tax ID for its owner: the account itself, or one of its customers. */
|
|
105
|
+
async function makeTaxId(ctx: SemanticsContext, customer: string | undefined): Promise<Response> {
|
|
106
|
+
const { owner: _owner, ...params } = ctx.params;
|
|
107
|
+
if (params.type === undefined || params.type === '') return fail(ctx, 'Missing required param: type.', 400, 'parameter_missing');
|
|
108
|
+
if (params.value === undefined || params.value === '') return fail(ctx, 'Missing required param: value.', 400, 'parameter_missing');
|
|
109
|
+
const vatCountry = params.type === 'eu_vat' ? euVatCountry(String(params.value)) : undefined;
|
|
110
|
+
if (params.type === 'eu_vat' && !vatCountry) return ctx.refuse({ status: 400, code: 'tax_id_invalid', param: 'value', message: `Invalid value for eu_vat.` });
|
|
111
|
+
return ctx.reply(
|
|
112
|
+
await created(ctx, 'tax_id', { ...params, ...(customer ? { customer } : {}) }, {
|
|
113
|
+
livemode: false,
|
|
114
|
+
country: vatCountry ?? (typeof params.country === 'string' ? params.country : null),
|
|
115
|
+
owner: customer ? { type: 'customer', customer } : { type: 'self' },
|
|
116
|
+
verification: { status: AUTO_VALIDATED.has(String(params.type)) ? 'pending' : 'unavailable', verified_address: null, verified_name: null },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const createTaxId: Semantics = async (ctx) => {
|
|
122
|
+
const customer = at(ctx, 'customer');
|
|
123
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
124
|
+
return makeTaxId(ctx, customer);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The owner a top-level tax-ID call names: the account itself unless `owner[type]=customer` names a customer
|
|
128
|
+
* (docs.stripe.com/api/tax_ids/list: owner defaults to self). */
|
|
129
|
+
function ownerOf(ctx: SemanticsContext): { customer?: string } | Response {
|
|
130
|
+
const owner = (ctx.params.owner ?? {}) as Row;
|
|
131
|
+
const type = String(owner.type ?? 'self');
|
|
132
|
+
if (type === 'self') return {};
|
|
133
|
+
if (type !== 'customer') return fail(ctx, `Invalid owner[type]: must be one of self or customer.`, 400, 'parameter_invalid_string_enum');
|
|
134
|
+
const customer = typeof owner.customer === 'string' ? owner.customer : '';
|
|
135
|
+
if (!customer) return fail(ctx, 'Missing required param: owner[customer].', 400, 'parameter_missing');
|
|
136
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
137
|
+
return { customer };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const createOwnedTaxId: Semantics = async (ctx) => {
|
|
141
|
+
const owner = ownerOf(ctx);
|
|
142
|
+
return owner instanceof Response ? owner : makeTaxId(ctx, owner.customer);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const listOwnedTaxIds: Semantics = async (ctx) => {
|
|
146
|
+
const owner = ownerOf(ctx);
|
|
147
|
+
if (owner instanceof Response) return owner;
|
|
148
|
+
return list(ctx, 'tax_id', newest(ctx, 'tax_id').filter((t) => (owner.customer ? t.customer === owner.customer : !t.customer)));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const taxIds: Semantics = async (ctx) => {
|
|
152
|
+
const customer = at(ctx, 'customer');
|
|
153
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
154
|
+
return list(ctx, 'tax_id', newest(ctx, 'tax_id').filter((t) => t.customer === customer));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const taxId: Semantics = async (ctx) => {
|
|
158
|
+
const t = ctx.get('tax_id', at(ctx, 'id'));
|
|
159
|
+
return t && t.customer === at(ctx, 'customer') ? ctx.reply(t) : fail(ctx, `No such tax_id: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const deleteTaxId: Semantics = async (ctx) => {
|
|
163
|
+
const id = at(ctx, 'id');
|
|
164
|
+
const t = ctx.get('tax_id', id);
|
|
165
|
+
if (!t || t.customer !== at(ctx, 'customer')) return fail(ctx, `No such tax_id: '${id}'`, 404, 'resource_missing');
|
|
166
|
+
await ctx.write('tax_id', id, { deleted: true }, 'tax_id.delete');
|
|
167
|
+
return ctx.reply({ id, object: 'tax_id', deleted: true });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ── the credit-balance ledger: a negative amount is a credit; `ending_balance` is the running sum,
|
|
171
|
+
// mirrored onto the customer's `balance` ──
|
|
172
|
+
|
|
173
|
+
const createBalanceTransaction: Semantics = async (ctx) => {
|
|
174
|
+
const customer = at(ctx, 'customer');
|
|
175
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
176
|
+
const params = ctx.params;
|
|
177
|
+
if (params.amount === undefined) return fail(ctx, 'Missing required param: amount.', 400, 'parameter_missing');
|
|
178
|
+
const amount = Number(params.amount);
|
|
179
|
+
if (!Number.isInteger(amount)) return fail(ctx, 'Invalid integer: amount.', 400, 'parameter_invalid_integer');
|
|
180
|
+
if (params.currency === undefined || params.currency === '') return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
|
|
181
|
+
const prior = ctx.rows('customer_balance_transaction').filter((t) => t.customer === customer).reduce((s, t) => s + (Number(t.amount) || 0), 0);
|
|
182
|
+
const ending = prior + amount;
|
|
183
|
+
const body = await created(ctx, 'customer_balance_transaction', { ...params, customer, amount }, {
|
|
184
|
+
ending_balance: ending, type: 'adjustment', livemode: false, metadata: {},
|
|
185
|
+
description: params.description ?? null, credit_note: null, invoice: null, checkout_session: null,
|
|
186
|
+
});
|
|
187
|
+
// keyed by the new transaction so two adjustments at one instant are two writes, the last winning
|
|
188
|
+
await ctx.write('customer', customer, { balance: ending }, `customer.balance_adjusted:${String(body.id)}`);
|
|
189
|
+
return ctx.reply(body);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const balanceTransactions: Semantics = async (ctx) => {
|
|
193
|
+
const customer = at(ctx, 'customer');
|
|
194
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
195
|
+
return list(ctx, 'customer_balance_transaction', newest(ctx, 'customer_balance_transaction').filter((t) => t.customer === customer));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const balanceTransaction: Semantics = async (ctx) => {
|
|
199
|
+
const t = ctx.get('customer_balance_transaction', at(ctx, 'transaction'));
|
|
200
|
+
return t && t.customer === at(ctx, 'customer') ? ctx.reply(t) : fail(ctx, `No such balance transaction: '${at(ctx, 'transaction')}'`, 404, 'resource_missing');
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ── the cash balance: per-currency sums of the funding ledger, and the reconciliation setting ──
|
|
204
|
+
|
|
205
|
+
function cashBalance(ctx: SemanticsContext, customer: string): Row {
|
|
206
|
+
const byCurrency: Record<string, number> = {};
|
|
207
|
+
for (const t of ctx.rows('customer_cash_balance_transaction')) {
|
|
208
|
+
if (t.customer !== customer) continue;
|
|
209
|
+
const cur = String(t.currency ?? 'usd');
|
|
210
|
+
byCurrency[cur] = (byCurrency[cur] ?? 0) + (Number(t.net_amount) || 0);
|
|
211
|
+
}
|
|
212
|
+
const cust = ctx.row('customer', customer);
|
|
213
|
+
const mode = typeof cust?._cash_balance_mode === 'string' ? cust._cash_balance_mode : 'automatic';
|
|
214
|
+
return {
|
|
215
|
+
object: 'cash_balance', customer,
|
|
216
|
+
available: Object.keys(byCurrency).length ? byCurrency : null,
|
|
217
|
+
settings: { reconciliation_mode: mode, using_merchant_default: mode === 'merchant_default' },
|
|
218
|
+
livemode: false,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const getCashBalance: Semantics = async (ctx) => {
|
|
223
|
+
const customer = at(ctx, 'customer');
|
|
224
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
225
|
+
return ctx.reply(cashBalance(ctx, customer));
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const updateCashBalance: Semantics = async (ctx) => {
|
|
229
|
+
const customer = at(ctx, 'customer');
|
|
230
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
231
|
+
const settings = ctx.params.settings && typeof ctx.params.settings === 'object' ? (ctx.params.settings as Row) : {};
|
|
232
|
+
const mode = typeof settings.reconciliation_mode === 'string' ? settings.reconciliation_mode : 'automatic';
|
|
233
|
+
if (!['automatic', 'manual', 'merchant_default'].includes(mode)) return fail(ctx, 'Invalid settings[reconciliation_mode].', 400, 'parameter_invalid_string_enum');
|
|
234
|
+
await ctx.write('customer', customer, { _cash_balance_mode: mode }, 'cash_balance.updated');
|
|
235
|
+
return ctx.reply(cashBalance(ctx, customer));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const cashBalanceTransactions: Semantics = async (ctx) => {
|
|
239
|
+
const customer = at(ctx, 'customer');
|
|
240
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
241
|
+
return list(ctx, 'customer_cash_balance_transaction', newest(ctx, 'customer_cash_balance_transaction').filter((t) => t.customer === customer));
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Automatic reconciliation (docs.stripe.com/payments/customer-balance/reconciliation): funds that arrive pay the
|
|
245
|
+
* customer's open invoices that take bank transfers (payment_method_types customer_balance), the one whose number
|
|
246
|
+
* the transfer's reference names first, then the oldest. Where the documentation stops and the twin decides: an
|
|
247
|
+
* invoice is paid only when the balance covers all of what remains; a smaller balance waits for more funds. */
|
|
248
|
+
async function reconcile(ctx: SemanticsContext, customer: string, currency: string, reference: string | null): Promise<void> {
|
|
249
|
+
const takesTransfers = (inv: Row): boolean => ((inv.payment_settings as Row | undefined)?.payment_method_types as unknown[] | undefined)?.includes('customer_balance') === true;
|
|
250
|
+
const open = ctx.rows('invoice').filter((inv) => inv.customer === customer && inv.status === 'open' && String(inv.currency ?? 'usd') === currency && takesTransfers(inv))
|
|
251
|
+
.sort((a, b) => (reference && a.number === reference ? -1 : reference && b.number === reference ? 1 : Number(a.created) - Number(b.created)));
|
|
252
|
+
for (const inv of open) {
|
|
253
|
+
const available = Number(((cashBalance(ctx, customer).available as Row | null) ?? {})[currency] ?? 0);
|
|
254
|
+
const amount = Number(inv.amount_remaining ?? inv.amount_due) || 0;
|
|
255
|
+
if (amount <= 0 || available < amount) continue;
|
|
256
|
+
const id = String(inv.id);
|
|
257
|
+
if (ctx.legal('invoice', 'status', 'PostTestHelpersCustomersCustomerFundCashBalance', inv.status, 'paid', id)) continue;
|
|
258
|
+
// the payment the balance makes: the invoice's own PaymentIntent (every invoice finalized with an amount due has one,
|
|
259
|
+
// semantics/invoices.ts), paid from the cash balance, its charge, and the ledger's entry
|
|
260
|
+
const piId = String(inv.payment_intent);
|
|
261
|
+
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 });
|
|
262
|
+
const charge = await mintCharge(ctx, piId, { currency, customer, invoice: id }, amount);
|
|
263
|
+
await ctx.write('payment_intent', piId, { status: 'succeeded', amount_received: amount, latest_charge: charge }, 'payment_intent.succeeded');
|
|
264
|
+
await ctx.write('invoice', id, {
|
|
265
|
+
status: 'paid', paid: true, amount_paid: amount, amount_remaining: 0, payment_intent: piId, charge,
|
|
266
|
+
status_transitions: { ...((inv.status_transitions as Row | undefined) ?? {}), paid_at: ctx.now() },
|
|
267
|
+
}, 'invoice.paid');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// test mode's stand-in for a bank transfer arriving (docs.stripe.com/payments/customer-balance/funding#test):
|
|
272
|
+
// the customer's cash balance is funded by `amount`, and in automatic reconciliation the funds pay what waits on them
|
|
273
|
+
const fundCashBalance: Semantics = async (ctx) => {
|
|
274
|
+
const customer = at(ctx, 'customer');
|
|
275
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
276
|
+
const amount = Math.trunc(Number(ctx.params.amount) || 0);
|
|
277
|
+
if (amount <= 0) return fail(ctx, 'Missing required param: amount.', 400, 'parameter_missing');
|
|
278
|
+
const currency = typeof ctx.params.currency === 'string' ? ctx.params.currency : '';
|
|
279
|
+
if (!currency) return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
|
|
280
|
+
const before = Number(((cashBalance(ctx, customer).available as Row | null) ?? {})[currency] ?? 0);
|
|
281
|
+
const reference = typeof ctx.params.reference === 'string' ? ctx.params.reference : null;
|
|
282
|
+
const funded = await created(ctx, 'customer_cash_balance_transaction', { customer }, {
|
|
283
|
+
currency, type: 'funded', net_amount: amount, ending_balance: before + amount, livemode: false,
|
|
284
|
+
funded: { bank_transfer: { reference, type: 'us_bank_transfer', us_bank_transfer: { network: 'ach', sender_name: null } } },
|
|
285
|
+
});
|
|
286
|
+
if ((cashBalance(ctx, customer).settings as Row).reconciliation_mode !== 'manual') await reconcile(ctx, customer, currency, reference);
|
|
287
|
+
return ctx.reply(funded);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const cashBalanceTransaction: Semantics = async (ctx) => {
|
|
291
|
+
const t = ctx.get('customer_cash_balance_transaction', at(ctx, 'transaction'));
|
|
292
|
+
return t && t.customer === at(ctx, 'customer') ? ctx.reply(t) : fail(ctx, `No such cash balance transaction: '${at(ctx, 'transaction')}'`, 404, 'resource_missing');
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ── legacy sources: attaching a token mints a card with only the safe fields; the first becomes
|
|
296
|
+
// the customer's default_source, and detaching the default promotes the next ──
|
|
297
|
+
|
|
298
|
+
const attachSource: Semantics = async (ctx) => {
|
|
299
|
+
const customer = at(ctx, 'customer');
|
|
300
|
+
const c = ctx.get('customer', customer);
|
|
301
|
+
if (!c) return customerMissing(ctx, customer);
|
|
302
|
+
const src = typeof ctx.params.source === 'string' ? ctx.params.source : '';
|
|
303
|
+
if (!src) return fail(ctx, 'Missing required param: source.', 400, 'parameter_missing');
|
|
304
|
+
// a card token becomes the card it carries, the token spent: "you can only use tokens once" (the served spec's
|
|
305
|
+
// token.used); any other source reference keeps the twin's stand-in card
|
|
306
|
+
const token = src.startsWith('tok_') ? ctx.get('token', src) : undefined;
|
|
307
|
+
if (token?.used === true) return fail(ctx, `You cannot use a Stripe token more than once: ${src}.`, 400, 'token_already_used');
|
|
308
|
+
const tokenCard = token?.card && typeof token.card === 'object' ? (token.card as Row) : undefined;
|
|
309
|
+
const last4 = String(4242 + ctx.rows('source').length + 1).slice(-4);
|
|
310
|
+
const body = await created(ctx, 'source', { customer, ...(tokenCard ? { id: String(tokenCard.id) } : {}) }, tokenCard
|
|
311
|
+
? { ...tokenCard, customer, _source_token: src }
|
|
312
|
+
: { object: 'card', last4, brand: 'Visa', exp_month: 12, exp_year: 2030, funding: 'credit', country: 'US', metadata: {}, _source_token: src });
|
|
313
|
+
if (token) await ctx.write('token', src, { used: true }, 'token.used');
|
|
314
|
+
const existing = ctx.rows('source').filter((s) => s.customer === customer);
|
|
315
|
+
if (!c.default_source || existing.length === 1) await ctx.write('customer', customer, { default_source: body.id }, 'customer.updated');
|
|
316
|
+
return ctx.reply(body);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const sources: Semantics = async (ctx) => {
|
|
320
|
+
const customer = at(ctx, 'customer');
|
|
321
|
+
if (!ctx.get('customer', customer)) return customerMissing(ctx, customer);
|
|
322
|
+
const items = newest(ctx, 'source').filter((s) => s.customer === customer);
|
|
323
|
+
return list(ctx, 'source', typeof ctx.params.object === 'string' ? items.filter((s) => s.object === ctx.params.object) : items);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** The customer's source the path names, or undefined. */
|
|
327
|
+
const customerSource = (ctx: SemanticsContext): Row | undefined => {
|
|
328
|
+
const s = ctx.get('source', at(ctx, 'id'));
|
|
329
|
+
return s && s.customer === at(ctx, 'customer') ? s : undefined;
|
|
330
|
+
};
|
|
331
|
+
const sourceMissing = (ctx: SemanticsContext): Response => fail(ctx, `No such source: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
332
|
+
|
|
333
|
+
const source: Semantics = async (ctx) => {
|
|
334
|
+
const s = customerSource(ctx);
|
|
335
|
+
return s ? ctx.reply(s) : sourceMissing(ctx);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const updateSource: Semantics = async (ctx) => {
|
|
339
|
+
if (!customerSource(ctx)) return sourceMissing(ctx);
|
|
340
|
+
return ctx.reply(await ctx.write('source', at(ctx, 'id'), ctx.params, 'source.updated'));
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const detachSource: Semantics = async (ctx) => {
|
|
344
|
+
const customer = at(ctx, 'customer');
|
|
345
|
+
const id = at(ctx, 'id');
|
|
346
|
+
const c = ctx.get('customer', customer);
|
|
347
|
+
const s = customerSource(ctx);
|
|
348
|
+
if (!c || !s) return sourceMissing(ctx);
|
|
349
|
+
await ctx.write('source', id, { deleted: true, customer: null }, 'source.detached');
|
|
350
|
+
if (c.default_source === id) {
|
|
351
|
+
const remaining = ctx.rows('source').filter((x) => x.customer === customer && x.id !== id);
|
|
352
|
+
await ctx.write('customer', customer, { default_source: remaining[0]?.id ?? null }, 'customer.updated');
|
|
353
|
+
}
|
|
354
|
+
return ctx.reply({ id, object: s.object ?? 'card', deleted: true });
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// ── the top-level Source: `type` is required; a card is chargeable at once, anything else pending ──
|
|
358
|
+
|
|
359
|
+
const createSource: Semantics = async (ctx) => {
|
|
360
|
+
const params = ctx.params;
|
|
361
|
+
const srcType = typeof params.type === 'string' ? params.type : '';
|
|
362
|
+
if (!srcType) return fail(ctx, 'Missing required param: type.', 400, 'parameter_missing');
|
|
363
|
+
return ctx.reply(
|
|
364
|
+
await created(ctx, 'source', { customer: null }, {
|
|
365
|
+
object: 'source',
|
|
366
|
+
status: srcType === 'card' ? 'chargeable' : 'pending',
|
|
367
|
+
flow: srcType === 'card' ? 'none' : 'receiver',
|
|
368
|
+
usage: (params.usage as string) ?? 'reusable',
|
|
369
|
+
currency: typeof params.currency === 'string' ? params.currency : null,
|
|
370
|
+
livemode: false,
|
|
371
|
+
type: srcType,
|
|
372
|
+
client_secret: `src_client_secret_twin_${ctx.rows('source').length + 1}`,
|
|
373
|
+
owner: params.owner && typeof params.owner === 'object' ? params.owner : { name: null, email: null, address: null, phone: null, verified_name: null, verified_email: null, verified_address: null, verified_phone: null },
|
|
374
|
+
metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {},
|
|
375
|
+
statement_descriptor: null,
|
|
376
|
+
...(srcType === 'ach_credit_transfer' ? achCreditTransfer(ctx) : {}),
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/** An ACH credit transfer source's account for the customer to wire to, and its receiver flow, as the sources create
|
|
382
|
+
* page's example answers one (docs.stripe.com/api/sources/create): a test account number, "TEST BANK", routing
|
|
383
|
+
* 110000000 and SWIFT TSTEZ122; the receiver's address "routing-account" with nothing yet charged, received or
|
|
384
|
+
* returned, refund attributes by email and missing. Where the documentation stops and the twin decides: the account
|
|
385
|
+
* number is the source's own. */
|
|
386
|
+
function achCreditTransfer(ctx: SemanticsContext): Row {
|
|
387
|
+
const account = `test_${String(ctx.rows('source').length + 1).padStart(12, '0')}`;
|
|
388
|
+
return {
|
|
389
|
+
ach_credit_transfer: { account_number: account, bank_name: 'TEST BANK', fingerprint: `twin_acct_${account}`, refund_account_holder_name: null, refund_account_holder_type: null, refund_routing_number: null, routing_number: '110000000', swift_code: 'TSTEZ122' },
|
|
390
|
+
receiver: { address: `110000000-${account}`, amount_charged: 0, amount_received: 0, amount_returned: 0, refund_attributes_method: 'email', refund_attributes_status: 'missing' },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// a Source update changes only its metadata and owner
|
|
395
|
+
const updateTopSource: Semantics = async (ctx) => {
|
|
396
|
+
const id = at(ctx, 'source');
|
|
397
|
+
if (!ctx.get('source', id)) return fail(ctx, `No such source: '${id}'`, 404, 'resource_missing');
|
|
398
|
+
const patch: Row = {};
|
|
399
|
+
if (ctx.params.metadata !== undefined) patch.metadata = ctx.params.metadata;
|
|
400
|
+
if (ctx.params.owner !== undefined) patch.owner = ctx.params.owner;
|
|
401
|
+
return ctx.reply(await ctx.write('source', id, patch, 'source.updated'));
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
export const customers: Record<string, Semantics> = {
|
|
405
|
+
PostCustomers: create,
|
|
406
|
+
GetCustomersCustomer: retrieve,
|
|
407
|
+
GetCustomersSearch: searchCustomers,
|
|
408
|
+
GetCustomersCustomerPaymentMethods: paymentMethods,
|
|
409
|
+
GetCustomersCustomerPaymentMethodsPaymentMethod: paymentMethod,
|
|
410
|
+
DeleteCustomersCustomerDiscount: deleteDiscount,
|
|
411
|
+
PostCustomersCustomerTaxIds: createTaxId,
|
|
412
|
+
PostTaxIds: createOwnedTaxId,
|
|
413
|
+
GetTaxIds: listOwnedTaxIds,
|
|
414
|
+
GetCustomersCustomerTaxIds: taxIds,
|
|
415
|
+
GetCustomersCustomerTaxIdsId: taxId,
|
|
416
|
+
DeleteCustomersCustomerTaxIdsId: deleteTaxId,
|
|
417
|
+
PostCustomersCustomerBalanceTransactions: createBalanceTransaction,
|
|
418
|
+
GetCustomersCustomerBalanceTransactions: balanceTransactions,
|
|
419
|
+
GetCustomersCustomerBalanceTransactionsTransaction: balanceTransaction,
|
|
420
|
+
GetCustomersCustomerCashBalance: getCashBalance,
|
|
421
|
+
PostCustomersCustomerCashBalance: updateCashBalance,
|
|
422
|
+
GetCustomersCustomerCashBalanceTransactions: cashBalanceTransactions,
|
|
423
|
+
GetCustomersCustomerCashBalanceTransactionsTransaction: cashBalanceTransaction,
|
|
424
|
+
PostTestHelpersCustomersCustomerFundCashBalance: fundCashBalance,
|
|
425
|
+
PostCustomersCustomerSources: attachSource,
|
|
426
|
+
GetCustomersCustomerSources: sources,
|
|
427
|
+
GetCustomersCustomerSourcesId: source,
|
|
428
|
+
PostCustomersCustomerSourcesId: updateSource,
|
|
429
|
+
DeleteCustomersCustomerSourcesId: detachSource,
|
|
430
|
+
PostSources: createSource,
|
|
431
|
+
PostSourcesSource: updateTopSource,
|
|
432
|
+
};
|