@volter/twin-stripe 0.1.1 → 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,220 @@
|
|
|
1
|
+
// Stripe Tax semantics: tax rates, the account's tax settings (a singleton), calculations,
|
|
2
|
+
// registrations, and the transactions that commit a calculation or reverse one. A calculation's
|
|
3
|
+
// tax comes from one flat default rate so its amounts are stable (Stripe derives it from the
|
|
4
|
+
// customer's jurisdiction and the account's registrations). Rate, calculation, registration and
|
|
5
|
+
// transaction retrieves and the registration list are the derived core's.
|
|
6
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { asBool, lineItemEntries, paginate } from '../stripe-twin.ts';
|
|
8
|
+
import { at, created, fail, list, newest, path, where, type Row } from './shared.ts';
|
|
9
|
+
|
|
10
|
+
const TWIN_TAX_RATE_PCT = 10;
|
|
11
|
+
|
|
12
|
+
const createRate: Semantics = async (ctx) => {
|
|
13
|
+
const params = ctx.params;
|
|
14
|
+
if (params.display_name === undefined || params.display_name === '') return fail(ctx, 'Missing required param: display_name.', 400, 'parameter_missing');
|
|
15
|
+
if (params.percentage === undefined) return fail(ctx, 'Missing required param: percentage.', 400, 'parameter_missing');
|
|
16
|
+
if (params.inclusive === undefined) return fail(ctx, 'Missing required param: inclusive.', 400, 'parameter_missing');
|
|
17
|
+
return ctx.reply(
|
|
18
|
+
await created(ctx, 'tax_rate', { ...params, inclusive: asBool(params.inclusive), percentage: Number(params.percentage) }, {
|
|
19
|
+
active: true, livemode: false, metadata: {},
|
|
20
|
+
country: null, state: null, jurisdiction: typeof params.jurisdiction === 'string' ? params.jurisdiction : null,
|
|
21
|
+
jurisdiction_level: null, description: params.description ?? null, display_name: params.display_name,
|
|
22
|
+
tax_type: null,
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const listRates: Semantics = async (ctx) =>
|
|
28
|
+
list(ctx, 'tax_rate', where(ctx, newest(ctx, 'tax_rate'), {
|
|
29
|
+
active: (t, v) => asBool(t.active) === asBool(v),
|
|
30
|
+
inclusive: (t, v) => asBool(t.inclusive) === asBool(v),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// percentage and inclusive are fixed once created
|
|
34
|
+
const updateRate: Semantics = async (ctx) => {
|
|
35
|
+
const id = at(ctx, 'tax_rate');
|
|
36
|
+
if (!ctx.get('tax_rate', id)) return ctx.notFound('tax_rate', id);
|
|
37
|
+
const { percentage: _p, inclusive: _i, ...rest } = ctx.params;
|
|
38
|
+
return ctx.reply(await ctx.write('tax_rate', id, { ...rest, ...('active' in rest ? { active: asBool(rest.active) } : {}) }, 'tax_rate.update'));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ── settings: active once a head office is set; the answer has no id ──
|
|
42
|
+
|
|
43
|
+
// the defaults' provider: "The tax calculation provider this account uses. Defaults to `stripe` when not using a
|
|
44
|
+
// third-party provider" (the served spec's tax settings defaults); the head office's address answers each line, the
|
|
45
|
+
// lines not given null (the served spec's address: each "(string, nullable)"; the settings page's example answers
|
|
46
|
+
// line2 null)
|
|
47
|
+
const ADDRESS_LINES = ['city', 'country', 'line1', 'line2', 'postal_code', 'state'];
|
|
48
|
+
function settingsBody(stored: Row | undefined): Row {
|
|
49
|
+
const given = (stored?.head_office ?? null) as Row | null;
|
|
50
|
+
const address = given && typeof given.address === 'object' && given.address ? (given.address as Row) : undefined;
|
|
51
|
+
const headOffice = given ? { ...given, ...(address ? { address: Object.fromEntries(ADDRESS_LINES.map((k) => [k, address[k] ?? null])) } : {}) } : null;
|
|
52
|
+
const active = !!headOffice;
|
|
53
|
+
return {
|
|
54
|
+
object: 'tax.settings', livemode: false,
|
|
55
|
+
defaults: { provider: 'stripe', ...((stored?.defaults as Row) ?? { tax_behavior: null, tax_code: null }) }, head_office: headOffice,
|
|
56
|
+
status: active ? 'active' : 'pending',
|
|
57
|
+
status_details: active ? { active: {} } : { pending: { missing_fields: ['head_office'] } },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const getSettings: Semantics = async (ctx) => ctx.reply(settingsBody(ctx.rows('tax.settings')[0]));
|
|
62
|
+
|
|
63
|
+
const updateSettings: Semantics = async (ctx) => {
|
|
64
|
+
const existing = ctx.rows('tax.settings')[0];
|
|
65
|
+
const cur = existing ?? {};
|
|
66
|
+
const defaults = ctx.params.defaults && typeof ctx.params.defaults === 'object' ? (ctx.params.defaults as Row) : {};
|
|
67
|
+
const curDefaults = (cur.defaults as Row) ?? {};
|
|
68
|
+
const headOffice = ctx.params.head_office && typeof ctx.params.head_office === 'object' ? (ctx.params.head_office as Row) : undefined;
|
|
69
|
+
const fields = {
|
|
70
|
+
defaults: {
|
|
71
|
+
tax_behavior: typeof defaults.tax_behavior === 'string' ? defaults.tax_behavior : (curDefaults.tax_behavior ?? null),
|
|
72
|
+
tax_code: typeof defaults.tax_code === 'string' ? defaults.tax_code : (curDefaults.tax_code ?? null),
|
|
73
|
+
},
|
|
74
|
+
head_office: headOffice ?? cur.head_office ?? null,
|
|
75
|
+
livemode: false,
|
|
76
|
+
};
|
|
77
|
+
const body = await ctx.write('tax.settings', existing ? String(existing.id) : 'taxset_twin_1', fields, existing ? 'tax_settings.update' : 'tax_settings.create');
|
|
78
|
+
return ctx.reply(settingsBody(body));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ── calculations: each line's tax on top of its amount; no `created`, a tax_date instead ──
|
|
82
|
+
|
|
83
|
+
const calcMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such tax calculation: '${id}'`, 404, 'resource_missing');
|
|
84
|
+
|
|
85
|
+
const calculate: Semantics = async (ctx) => {
|
|
86
|
+
const params = ctx.params;
|
|
87
|
+
if (params.currency === undefined || params.currency === '') return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
|
|
88
|
+
const entries = lineItemEntries(params);
|
|
89
|
+
if (entries.length === 0) return fail(ctx, 'Missing required param: line_items.', 400, 'parameter_missing');
|
|
90
|
+
const now = Number(ctx.now());
|
|
91
|
+
const id = ctx.mint('tax.calculation');
|
|
92
|
+
const items = entries.map((e, i) => {
|
|
93
|
+
const amount = Math.max(0, Math.trunc(Number(e.amount) || 0));
|
|
94
|
+
return {
|
|
95
|
+
object: 'tax.calculation_line_item', id: `tax_li_${id}_${i + 1}`,
|
|
96
|
+
amount, amount_tax: Math.round((amount * TWIN_TAX_RATE_PCT) / 100), reference: typeof e.reference === 'string' ? e.reference : null,
|
|
97
|
+
tax_behavior: 'exclusive', tax_code: typeof e.tax_code === 'string' ? e.tax_code : 'txcd_99999999',
|
|
98
|
+
quantity: e.quantity === undefined ? 1 : Math.max(1, Math.trunc(Number(e.quantity) || 1)), livemode: false,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const amountSubtotal = items.reduce((s, it) => s + it.amount, 0);
|
|
102
|
+
const taxExclusive = items.reduce((s, it) => s + it.amount_tax, 0);
|
|
103
|
+
const cd = params.customer_details && typeof params.customer_details === 'object' ? (params.customer_details as Row) : {};
|
|
104
|
+
return ctx.reply(
|
|
105
|
+
await created(ctx, 'tax.calculation', {
|
|
106
|
+
id, currency: String(params.currency), amount_total: amountSubtotal + taxExclusive, tax_amount_exclusive: taxExclusive, tax_amount_inclusive: 0,
|
|
107
|
+
customer: typeof params.customer === 'string' ? params.customer : null,
|
|
108
|
+
customer_details: {
|
|
109
|
+
address: cd.address ?? null, address_source: cd.address_source ?? 'shipping', ip_address: cd.ip_address ?? null,
|
|
110
|
+
tax_ids: Array.isArray(cd.tax_ids) ? cd.tax_ids : [], taxability_override: cd.taxability_override ?? 'none',
|
|
111
|
+
},
|
|
112
|
+
line_items: { object: 'list', data: items, has_more: false, url: `/v1/tax/calculations/${id}/line_items` },
|
|
113
|
+
shipping_cost: null, tax_date: now,
|
|
114
|
+
tax_breakdown: taxExclusive > 0 ? [{
|
|
115
|
+
amount: taxExclusive, inclusive: false, taxability_reason: 'standard_rated', taxable_amount: amountSubtotal,
|
|
116
|
+
tax_rate_details: { country: null, percentage_decimal: String(TWIN_TAX_RATE_PCT), state: null, tax_type: 'sales_tax' },
|
|
117
|
+
}] : [],
|
|
118
|
+
expires_at: now + 90 * 24 * 3600, livemode: false,
|
|
119
|
+
}, {}, { timeField: 'tax_date' }),
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** A page of the line items a stored object carries. */
|
|
124
|
+
function linePage(ctx: SemanticsContext, holder: Row): Response {
|
|
125
|
+
const li = holder.line_items as { data?: Row[] } | undefined;
|
|
126
|
+
const { page, hasMore } = paginate(Array.isArray(li?.data) ? li!.data! : [], ctx.params);
|
|
127
|
+
return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const calculationLines: Semantics = async (ctx) => {
|
|
131
|
+
const c = ctx.get('tax.calculation', at(ctx, 'calculation'));
|
|
132
|
+
return c ? linePage(ctx, c) : calcMissing(ctx, at(ctx, 'calculation'));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── registrations ──
|
|
136
|
+
|
|
137
|
+
const register: Semantics = async (ctx) => {
|
|
138
|
+
const params = ctx.params;
|
|
139
|
+
if (params.country === undefined || params.country === '') return fail(ctx, 'Missing required param: country.', 400, 'parameter_missing');
|
|
140
|
+
if (params.active_from === undefined) return fail(ctx, 'Missing required param: active_from.', 400, 'parameter_missing');
|
|
141
|
+
const now = Number(ctx.now());
|
|
142
|
+
const activeFrom = params.active_from === 'now' ? now : Number(params.active_from) || now;
|
|
143
|
+
return ctx.reply(
|
|
144
|
+
await created(ctx, 'tax.registration', { ...params, active_from: activeFrom }, {
|
|
145
|
+
status: 'active', expires_at: null, livemode: false,
|
|
146
|
+
country_options: params.country_options && typeof params.country_options === 'object' ? params.country_options : {},
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ── transactions: committing a calculation, or reversing a transaction with negated amounts ──
|
|
152
|
+
|
|
153
|
+
const TX = 'tax.transaction';
|
|
154
|
+
const txMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such tax transaction: '${id}'`, status, 'resource_missing');
|
|
155
|
+
const txLine = (id: string, i: number, l: Row, sign: 1 | -1, reversal: Row | null, type: string): Row => ({
|
|
156
|
+
object: 'tax.transaction_line_item', id: `tax_li_${id}_${i + 1}`,
|
|
157
|
+
amount: sign === 1 ? l.amount : -(Number(l.amount) || 0), amount_tax: sign === 1 ? l.amount_tax : -(Number(l.amount_tax) || 0),
|
|
158
|
+
reference: l.reference ?? null, tax_behavior: l.tax_behavior ?? 'exclusive', tax_code: l.tax_code ?? 'txcd_99999999',
|
|
159
|
+
quantity: l.quantity ?? 1, reversal, type, livemode: false, metadata: {},
|
|
160
|
+
});
|
|
161
|
+
const linesOf = (holder: Row): Row[] => (holder.line_items as { data?: Row[] } | undefined)?.data ?? [];
|
|
162
|
+
|
|
163
|
+
const fromCalculation: Semantics = async (ctx) => {
|
|
164
|
+
const calcId = typeof ctx.params.calculation === 'string' ? ctx.params.calculation : '';
|
|
165
|
+
if (!calcId) return fail(ctx, 'Missing required param: calculation.', 400, 'parameter_missing');
|
|
166
|
+
if (ctx.params.reference === undefined || ctx.params.reference === '') return fail(ctx, 'Missing required param: reference.', 400, 'parameter_missing');
|
|
167
|
+
const calc = ctx.get('tax.calculation', calcId);
|
|
168
|
+
if (!calc) return fail(ctx, `No such tax calculation: '${calcId}'`, 400, 'resource_missing');
|
|
169
|
+
const id = ctx.mint(TX);
|
|
170
|
+
const now = ctx.now();
|
|
171
|
+
return ctx.reply(
|
|
172
|
+
await created(ctx, TX, {
|
|
173
|
+
id, _stripe_type: 'transaction', reference: ctx.params.reference, currency: calc.currency,
|
|
174
|
+
customer: calc.customer ?? null, customer_details: calc.customer_details ?? {},
|
|
175
|
+
tax_date: now, livemode: false, posted_at: now,
|
|
176
|
+
line_items: { object: 'list', data: linesOf(calc).map((l, i) => txLine(id, i, l, 1, null, 'transaction')), has_more: false, url: `/v1/tax/transactions/${id}/line_items` },
|
|
177
|
+
shipping_cost: calc.shipping_cost ?? null, reversal: null, calculation: calcId, metadata: {},
|
|
178
|
+
}, {}),
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const reversal: Semantics = async (ctx) => {
|
|
183
|
+
const original = typeof ctx.params.original_transaction === 'string' ? ctx.params.original_transaction : '';
|
|
184
|
+
if (!original) return fail(ctx, 'Missing required param: original_transaction.', 400, 'parameter_missing');
|
|
185
|
+
if (ctx.params.reference === undefined || ctx.params.reference === '') return fail(ctx, 'Missing required param: reference.', 400, 'parameter_missing');
|
|
186
|
+
const mode = typeof ctx.params.mode === 'string' ? ctx.params.mode : '';
|
|
187
|
+
if (mode !== 'full' && mode !== 'partial') return fail(ctx, 'Missing required param: mode (full or partial).', 400, 'parameter_missing');
|
|
188
|
+
const orig = ctx.get(TX, original);
|
|
189
|
+
if (!orig) return txMissing(ctx, original, 400);
|
|
190
|
+
const id = ctx.mint(TX);
|
|
191
|
+
const now = ctx.now();
|
|
192
|
+
return ctx.reply(
|
|
193
|
+
await created(ctx, TX, {
|
|
194
|
+
id, _stripe_type: 'reversal', reference: ctx.params.reference, currency: orig.currency,
|
|
195
|
+
customer: orig.customer ?? null, customer_details: orig.customer_details ?? {},
|
|
196
|
+
tax_date: now, livemode: false, posted_at: now,
|
|
197
|
+
line_items: { object: 'list', data: linesOf(orig).map((l, i) => txLine(id, i, l, -1, { original_line_item: l.id }, 'reversal')), has_more: false, url: `/v1/tax/transactions/${id}/line_items` },
|
|
198
|
+
shipping_cost: null, reversal: { original_transaction: original, mode }, metadata: {},
|
|
199
|
+
}, {}),
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const transactionLines: Semantics = async (ctx) => {
|
|
204
|
+
const t = ctx.get(TX, at(ctx, 'transaction'));
|
|
205
|
+
return t ? linePage(ctx, t) : txMissing(ctx, at(ctx, 'transaction'));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const tax: Record<string, Semantics> = {
|
|
209
|
+
PostTaxRates: createRate,
|
|
210
|
+
GetTaxRates: listRates,
|
|
211
|
+
PostTaxRatesTaxRate: updateRate,
|
|
212
|
+
GetTaxSettings: getSettings,
|
|
213
|
+
PostTaxSettings: updateSettings,
|
|
214
|
+
PostTaxCalculations: calculate,
|
|
215
|
+
GetTaxCalculationsCalculationLineItems: calculationLines,
|
|
216
|
+
PostTaxRegistrations: register,
|
|
217
|
+
PostTaxTransactionsCreateFromCalculation: fromCalculation,
|
|
218
|
+
PostTaxTransactionsCreateReversal: reversal,
|
|
219
|
+
GetTaxTransactionsTransactionLineItems: transactionLines,
|
|
220
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Terminal and top-up semantics: in-person locations, readers (handed a PaymentIntent to process)
|
|
2
|
+
// and their configurations, the reader SDK's connection tokens, and top-ups that fund the balance
|
|
3
|
+
// (a test top-up succeeds at once; only a pending one cancels, the machine in ../manifest.ts). In test mode a
|
|
4
|
+
// simulated reader is presented a card by a test helper: the PaymentIntent the reader is processing is paid with a
|
|
5
|
+
// card_present payment method (succeeded, or requires_capture under manual capture) and the reader's action succeeds
|
|
6
|
+
// (docs.stripe.com/terminal/references/testing#simulated-test-cards,
|
|
7
|
+
// docs.stripe.com/api/terminal/readers/present_payment_method). Where the documentation stops and the twin decides:
|
|
8
|
+
// the presented card is the simulated reader's default Visa, and the reader answers at once.
|
|
9
|
+
// Lists and retrieves are the derived core's.
|
|
10
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
11
|
+
import { validateMoney } from '../stripe-twin.ts';
|
|
12
|
+
import { settleTopup } from './ledger.ts';
|
|
13
|
+
import { authorizeCharge, mintCharge } from './payment-intents.ts';
|
|
14
|
+
import { at, at_, created, fail, send } from './shared.ts';
|
|
15
|
+
|
|
16
|
+
const LOC = 'terminal.location';
|
|
17
|
+
const READER = 'terminal.reader';
|
|
18
|
+
const CONF = 'terminal.configuration';
|
|
19
|
+
|
|
20
|
+
/** Update and delete by id, finding tombstones as the hand-written routes did, with Stripe's own operation names. */
|
|
21
|
+
function patch(resource: string, param: string, missing: string, op: string): Semantics {
|
|
22
|
+
return (ctx) => patchRow(ctx, resource, param, missing, op);
|
|
23
|
+
}
|
|
24
|
+
async function patchRow(ctx: SemanticsContext, resource: string, param: string, missing: string, op: string): Promise<Response> {
|
|
25
|
+
const id = at(ctx, param);
|
|
26
|
+
if (!ctx.row(resource, id, { withDeleted: true })) return fail(ctx, `No such ${missing}: '${id}'`, 404, 'resource_missing');
|
|
27
|
+
return ctx.reply(await ctx.write(resource, id, ctx.params, op));
|
|
28
|
+
}
|
|
29
|
+
// A reader's update takes its label and metadata only; it moves to another location by registering it
|
|
30
|
+
// there again (https://docs.stripe.com/api/terminal/readers/update, docs.stripe.com/terminal/fleet/locations-and-zones)
|
|
31
|
+
// (the parameter check refuses anything else, as Stripe does: ../stripe-params.ts)
|
|
32
|
+
const updateReader: Semantics = patch(READER, 'reader', 'reader', 'terminal_reader.updated');
|
|
33
|
+
|
|
34
|
+
function remove(resource: string, param: string, missing: string, op: string, object: string): Semantics {
|
|
35
|
+
return async (ctx) => {
|
|
36
|
+
const id = at(ctx, param);
|
|
37
|
+
if (!ctx.row(resource, id, { withDeleted: true })) return fail(ctx, `No such ${missing}: '${id}'`, 404, 'resource_missing');
|
|
38
|
+
const row = ctx.get(resource, id);
|
|
39
|
+
await ctx.write(resource, id, { deleted: true }, op);
|
|
40
|
+
// a deleted reader still says what device it was
|
|
41
|
+
return ctx.reply({ id, object, deleted: true, ...(object === 'terminal.reader' && row ? { device_type: row.device_type, serial_number: row.serial_number } : {}) });
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const createLocation: Semantics = async (ctx) => {
|
|
46
|
+
if (ctx.params.display_name === undefined || ctx.params.display_name === '') return fail(ctx, 'Missing required param: display_name.', 400, 'parameter_missing');
|
|
47
|
+
const addr = ctx.params.address && typeof ctx.params.address === 'object' ? (ctx.params.address as Record<string, unknown>) : undefined;
|
|
48
|
+
if (!addr || addr.country === undefined || addr.country === '') return fail(ctx, 'Missing required param: address[country].', 400, 'parameter_missing');
|
|
49
|
+
return ctx.reply(await created(ctx, LOC, ctx.params, { livemode: false, metadata: {} }));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// a test registration code (simulated-wpe) registers a simulated reader, online
|
|
53
|
+
const createReader: Semantics = async (ctx) => {
|
|
54
|
+
if (ctx.params.registration_code === undefined || ctx.params.registration_code === '') return fail(ctx, 'Missing required param: registration_code.', 400, 'parameter_missing');
|
|
55
|
+
return ctx.reply(
|
|
56
|
+
await created(ctx, READER, ctx.params, {
|
|
57
|
+
status: 'online', device_type: 'simulated_wisepos_e', device_sw_version: null, ip_address: '0.0.0.0',
|
|
58
|
+
serial_number: `STR-${ctx.rowsRaw(READER, { withDeleted: true }).length + 1}`, action: null,
|
|
59
|
+
livemode: false, metadata: {}, label: ctx.params.label ?? 'Twin Reader',
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const readerMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such reader: '${id}'`, 404, 'resource_missing');
|
|
65
|
+
|
|
66
|
+
// handing the reader a PaymentIntent puts it in an in-progress process_payment_intent action
|
|
67
|
+
const processPaymentIntent: Semantics = async (ctx) => {
|
|
68
|
+
const id = at(ctx, 'reader');
|
|
69
|
+
if (!ctx.get(READER, id)) return readerMissing(ctx, id);
|
|
70
|
+
const pi = typeof ctx.params.payment_intent === 'string' ? ctx.params.payment_intent : '';
|
|
71
|
+
if (!pi) return fail(ctx, 'Missing required param: payment_intent.', 400, 'parameter_missing');
|
|
72
|
+
if (!ctx.get('payment_intent', pi)) return fail(ctx, `No such payment_intent: '${pi}'`, 400, 'resource_missing');
|
|
73
|
+
const action = { type: 'process_payment_intent', status: 'in_progress', failure_code: null, failure_message: null, process_payment_intent: { payment_intent: pi } };
|
|
74
|
+
return ctx.reply(await ctx.write(READER, id, { action }, 'terminal_reader.updated'));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A card presented to a reader with no payment in progress. */
|
|
78
|
+
function nothingToPresent(ctx: SemanticsContext): Response {
|
|
79
|
+
return fail(ctx, 'This reader has no in-progress payment to present a payment method to.', 400, 'terminal_reader_invalid_state');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Under manual capture the presented card only holds the amount (docs.stripe.com/terminal/payments/collect-card-payment#capture-payment):
|
|
83
|
+
* the hold is an uncaptured charge, as an online authorization's is (authorizeCharge), which the intent's capture takes. */
|
|
84
|
+
async function holdForCapture(ctx: SemanticsContext, piId: string, pi: Record<string, unknown>, pm: string, amount: number): Promise<void> {
|
|
85
|
+
const charge = await authorizeCharge(ctx, piId, { ...pi, payment_method: pm }, amount);
|
|
86
|
+
await ctx.write('payment_intent', piId, { status: 'requires_capture', payment_method: pm, amount_capturable: amount, latest_charge: charge }, 'payment_intent.amount_capturable_updated');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const presentPaymentMethod: Semantics = async (ctx) => {
|
|
90
|
+
const id = at(ctx, 'reader');
|
|
91
|
+
const reader = ctx.get(READER, id);
|
|
92
|
+
if (!reader) return readerMissing(ctx, id);
|
|
93
|
+
const action = reader.action as Record<string, unknown> | null | undefined;
|
|
94
|
+
const piId = String(((action?.process_payment_intent as Record<string, unknown> | undefined)?.payment_intent) ?? '');
|
|
95
|
+
if (!action || action.type !== 'process_payment_intent' || action.status !== 'in_progress' || !piId) return nothingToPresent(ctx);
|
|
96
|
+
const pi = ctx.get('payment_intent', piId);
|
|
97
|
+
if (!pi) return fail(ctx, `No such payment_intent: '${piId}'`, 400, 'resource_missing');
|
|
98
|
+
const manual = pi.capture_method === 'manual' || pi.capture_method === 'manual_preferred';
|
|
99
|
+
const to = manual ? 'requires_capture' : 'succeeded';
|
|
100
|
+
const refused = ctx.legal('payment_intent', 'status', 'PostTestHelpersTerminalReadersReaderPresentPaymentMethod', pi.status, to, piId);
|
|
101
|
+
if (refused) return ctx.refuse(refused);
|
|
102
|
+
const pm = await created(ctx, 'payment_method', {}, {
|
|
103
|
+
type: 'card_present', customer: null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null },
|
|
104
|
+
card_present: { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030, funding: 'credit', country: 'US', read_method: 'contactless_emv', networks: { available: ['visa'], preferred: null } },
|
|
105
|
+
});
|
|
106
|
+
const amount = Number(pi.amount) || 0;
|
|
107
|
+
if (manual) await holdForCapture(ctx, piId, pi, String(pm.id), amount);
|
|
108
|
+
else {
|
|
109
|
+
const charge = await mintCharge(ctx, piId, { ...pi, payment_method: pm.id }, amount);
|
|
110
|
+
await ctx.write('payment_intent', piId, { status: 'succeeded', payment_method: pm.id, amount_received: amount, latest_charge: charge }, 'payment_intent.succeeded');
|
|
111
|
+
}
|
|
112
|
+
return ctx.reply(await ctx.write(READER, id, { action: { ...action, status: 'succeeded' } }, 'terminal_reader.action_succeeded'));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const cancelAction: Semantics = async (ctx) => {
|
|
116
|
+
const id = at(ctx, 'reader');
|
|
117
|
+
if (!ctx.get(READER, id)) return readerMissing(ctx, id);
|
|
118
|
+
return ctx.reply(await ctx.write(READER, id, { action: null }, 'terminal_reader.updated'));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// a short-lived secret for the reader SDK, answered and not kept
|
|
122
|
+
const connectionToken: Semantics = async (ctx) => {
|
|
123
|
+
const loc = typeof ctx.params.location === 'string' ? ctx.params.location : undefined;
|
|
124
|
+
return ctx.reply({ object: 'terminal.connection_token', secret: `pst_test_twin_${ctx.rowsRaw(READER, { withDeleted: true }).length + 1}`, ...(loc ? { location: loc } : {}) });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const createConfiguration: Semantics = async (ctx) =>
|
|
128
|
+
ctx.reply(await created(ctx, CONF, ctx.params, { is_account_default: false, livemode: false, tipping: null, name: ctx.params.name ?? null }));
|
|
129
|
+
|
|
130
|
+
// ── top-ups ──
|
|
131
|
+
|
|
132
|
+
// a top-up adds money to the balance it names, through the ledger, once its funds arrive: "USA (USD) ACH Debit Transfer
|
|
133
|
+
// 5 days" (docs.stripe.com/connect/top-ups), and to the Issuing balance alike: "Top-ups debit your bank account. Your
|
|
134
|
+
// top-ups can take up to 5 business days to become available. While they're pending, they won't be added to your
|
|
135
|
+
// Issuing balance" (docs.stripe.com/issuing/funding/balance). It is pending until its expected_availability_date, then
|
|
136
|
+
// settled by settleTopups. Where the documentation stops and the twin decides: every top-up takes exactly five days,
|
|
137
|
+
// whatever its currency and balance, and succeeds.
|
|
138
|
+
const TOPUP_DAYS = 5;
|
|
139
|
+
const createTopup: Semantics = async (ctx) => {
|
|
140
|
+
const bad = validateMoney(ctx.params);
|
|
141
|
+
if (bad) return send(ctx, bad);
|
|
142
|
+
const now = Number(ctx.now());
|
|
143
|
+
return ctx.reply(await created(ctx, 'topup', ctx.params, {
|
|
144
|
+
status: 'pending', expected_availability_date: now + TOPUP_DAYS * 86_400, livemode: false,
|
|
145
|
+
metadata: {}, source: null, statement_descriptor: ctx.params.statement_descriptor ?? null,
|
|
146
|
+
balance_transaction: null, failure_code: null, failure_message: null,
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Time's settling of top-ups, caught up to the World's clock: a pending top-up succeeds on its expected_availability_date,
|
|
151
|
+
* its funds entered then in the balance it names. */
|
|
152
|
+
export async function settleTopups(ctx: SemanticsContext): Promise<void> {
|
|
153
|
+
const now = Number(ctx.now());
|
|
154
|
+
for (const t of ctx.rowsRaw('topup')) {
|
|
155
|
+
if (t.status !== 'pending' || Number(t.expected_availability_date) > now) continue;
|
|
156
|
+
const c = await at_(ctx)(Number(t.expected_availability_date));
|
|
157
|
+
c.legal('topup', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(t.id), 'vendor');
|
|
158
|
+
const bt = await settleTopup(c, String(t.id), Number(t.amount), String(t.currency), t.destination_balance === 'issuing' ? 'issuing' : 'payments');
|
|
159
|
+
await c.write('topup', String(t.id), { status: 'succeeded', balance_transaction: bt }, 'topup.succeeded');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cancelTopup: Semantics = async (ctx) => {
|
|
164
|
+
const id = at(ctx, 'topup');
|
|
165
|
+
const t = ctx.get('topup', id);
|
|
166
|
+
if (!t) return fail(ctx, `No such topup: '${id}'`, 404, 'resource_missing');
|
|
167
|
+
const refused = ctx.legal('topup', 'status', 'PostTopupsTopupCancel', t.status, undefined, id);
|
|
168
|
+
if (refused) return ctx.refuse(refused);
|
|
169
|
+
return ctx.reply(await ctx.write('topup', id, { status: 'canceled' }, 'topup.canceled'));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const updateTopup: Semantics = async (ctx) => {
|
|
173
|
+
const id = at(ctx, 'topup');
|
|
174
|
+
if (!ctx.get('topup', id)) return fail(ctx, `No such topup: '${id}'`, 404, 'resource_missing');
|
|
175
|
+
return ctx.reply(await ctx.write('topup', id, ctx.params, 'topup.updated'));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const terminal: Record<string, Semantics> = {
|
|
179
|
+
PostTerminalLocations: createLocation,
|
|
180
|
+
PostTerminalLocationsLocation: patch(LOC, 'location', 'location', 'terminal_location.updated'),
|
|
181
|
+
DeleteTerminalLocationsLocation: remove(LOC, 'location', 'location', 'terminal_location.deleted', 'terminal.location'),
|
|
182
|
+
PostTerminalReaders: createReader,
|
|
183
|
+
PostTerminalReadersReader: updateReader,
|
|
184
|
+
DeleteTerminalReadersReader: remove(READER, 'reader', 'reader', 'terminal_reader.deleted', 'terminal.reader'),
|
|
185
|
+
PostTerminalReadersReaderProcessPaymentIntent: processPaymentIntent,
|
|
186
|
+
PostTerminalReadersReaderCancelAction: cancelAction,
|
|
187
|
+
PostTestHelpersTerminalReadersReaderPresentPaymentMethod: presentPaymentMethod,
|
|
188
|
+
PostTerminalConnectionTokens: connectionToken,
|
|
189
|
+
PostTerminalConfigurations: createConfiguration,
|
|
190
|
+
PostTerminalConfigurationsConfiguration: patch(CONF, 'configuration', 'configuration', 'terminal_configuration.updated'),
|
|
191
|
+
DeleteTerminalConfigurationsConfiguration: remove(CONF, 'configuration', 'configuration', 'terminal_configuration.deleted', 'terminal.configuration'),
|
|
192
|
+
PostTopups: createTopup,
|
|
193
|
+
PostTopupsTopupCancel: cancelTopup,
|
|
194
|
+
PostTopupsTopup: updateTopup,
|
|
195
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Test clock semantics. A test clock is Stripe's own door for a transition no API verb makes: the
|
|
2
|
+
// passage of time. Advancing it moves the subscriptions of the clock's customers as a real clock tick does:
|
|
3
|
+
// trials that ended end, and periods that ended renew (./renewals.ts advanceBilling). List, retrieve and delete are
|
|
4
|
+
// the derived core's.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { at, created, fail } from './shared.ts';
|
|
7
|
+
import { advanceBilling } from './renewals.ts';
|
|
8
|
+
|
|
9
|
+
const CLOCK = 'test_helpers.test_clock';
|
|
10
|
+
|
|
11
|
+
const create: Semantics = async (ctx) => {
|
|
12
|
+
if (ctx.params.frozen_time === undefined) return fail(ctx, 'Missing required param: frozen_time.', 400, 'parameter_missing');
|
|
13
|
+
const frozen = Math.trunc(Number(ctx.params.frozen_time) || 0);
|
|
14
|
+
// "Simulations are automatically deleted 30 days after you create them" (docs.stripe.com/billing/testing/test-clocks/
|
|
15
|
+
// api-advanced-usage): from its creation on the account's clock, not its frozen time
|
|
16
|
+
return ctx.reply(await created(ctx, CLOCK, { name: ctx.params.name ?? null }, { frozen_time: frozen, status: 'ready', status_details: {}, livemode: false, deletes_after: Number(ctx.now()) + 30 * 24 * 3600 }));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A test clock deleted with what lives on it: "Deleting the simulation also deletes the test customers associated
|
|
20
|
+
* with the clock and cancels their subscriptions" (docs.stripe.com/billing/testing/test-clocks/api-advanced-usage). */
|
|
21
|
+
const remove: Semantics = async (ctx) => {
|
|
22
|
+
const id = at(ctx, 'test_clock');
|
|
23
|
+
if (!ctx.get(CLOCK, id)) return fail(ctx, `No such test clock: '${id}'`, 404, 'resource_missing');
|
|
24
|
+
const now = Number(ctx.now());
|
|
25
|
+
for (const c of ctx.rows('customer').filter((c) => c.test_clock === id)) {
|
|
26
|
+
for (const sub of ctx.rows('subscription').filter((s) => s.customer === c.id && s.status !== 'canceled')) {
|
|
27
|
+
ctx.legal('subscription', 'status', 'DeleteTestHelpersTestClocksTestClock', String(sub.status), 'canceled', String(sub.id));
|
|
28
|
+
await ctx.write('subscription', String(sub.id), { status: 'canceled', canceled_at: now, ended_at: now }, 'subscription.cancel');
|
|
29
|
+
}
|
|
30
|
+
await ctx.write('customer', String(c.id), { deleted: true }, 'customer.deleted');
|
|
31
|
+
}
|
|
32
|
+
await ctx.write(CLOCK, id, { deleted: true }, 'test_clock.deleted');
|
|
33
|
+
return ctx.reply({ id, object: 'test_helpers.test_clock', deleted: true });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const advance: Semantics = async (ctx) => {
|
|
37
|
+
const id = at(ctx, 'test_clock');
|
|
38
|
+
const clock = ctx.get(CLOCK, id);
|
|
39
|
+
if (!clock) return fail(ctx, `No such test clock: '${id}'`, 404, 'resource_missing');
|
|
40
|
+
if (ctx.params.frozen_time === undefined) return fail(ctx, 'Missing required param: frozen_time.', 400, 'parameter_missing');
|
|
41
|
+
const to = Math.trunc(Number(ctx.params.frozen_time) || 0);
|
|
42
|
+
if (to <= Number(clock.frozen_time)) return fail(ctx, 'frozen_time must be after the current clock time.', 400, 'parameter_invalid');
|
|
43
|
+
const refused = ctx.legal(CLOCK, 'status', 'PostTestHelpersTestClocksTestClockAdvance', clock.status, 'advancing', id);
|
|
44
|
+
if (refused) return ctx.refuse(refused);
|
|
45
|
+
// "After a successful API request or Dashboard operation, it takes a few seconds to advance to the specified time"
|
|
46
|
+
// (docs.stripe.com/billing/testing/test-clocks/api-advanced-usage): the advance answers advancing toward its target,
|
|
47
|
+
// and finishClockAdvances completes it ADVANCE_SECONDS of World time later; an advance asked meanwhile is refused
|
|
48
|
+
// test_clock_not_ready (the machine's refusal).
|
|
49
|
+
return ctx.reply(await ctx.write(CLOCK, id, { status: 'advancing', status_details: { advancing: { target_frozen_time: to } } }, 'test_clock.advancing'));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** How long an advance takes: "it takes a few seconds to advance to the specified time"
|
|
53
|
+
* (docs.stripe.com/billing/testing/test-clocks/api-advanced-usage). Where the documentation stops and the twin decides:
|
|
54
|
+
* five seconds of World time from the advance. */
|
|
55
|
+
const ADVANCE_SECONDS = 5;
|
|
56
|
+
|
|
57
|
+
/** Each advancing clock whose advance has taken its time reaches its target, caught up before anything is answered:
|
|
58
|
+
* its frozen_time moves, it is ready, and its customers' trials and periods that ended by then end
|
|
59
|
+
* (./renewals.ts advanceBilling). The advance's own write is when it began (the kernel's updatedAt). */
|
|
60
|
+
export async function finishClockAdvances(ctx: SemanticsContext): Promise<void> {
|
|
61
|
+
const now = Number(ctx.now());
|
|
62
|
+
for (const clock of ctx.rowsRaw(CLOCK)) {
|
|
63
|
+
if (clock.status !== 'advancing') continue;
|
|
64
|
+
if (Date.parse(String(clock.updatedAt)) / 1000 + ADVANCE_SECONDS > now) continue;
|
|
65
|
+
const id = String(clock.id);
|
|
66
|
+
const target = Number((clock.status_details as { advancing?: { target_frozen_time?: number } } | undefined)?.advancing?.target_frozen_time);
|
|
67
|
+
ctx.legal(CLOCK, 'status', ctx.call.operation.id, 'advancing', 'ready', id, 'vendor');
|
|
68
|
+
await ctx.write(CLOCK, id, { frozen_time: target, status: 'ready', status_details: {} }, 'test_clock.ready');
|
|
69
|
+
await advanceBilling(ctx, (sub) => typeof sub.customer === 'string' && ctx.get('customer', sub.customer)?.test_clock === id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const testClocks: Record<string, Semantics> = {
|
|
74
|
+
PostTestHelpersTestClocks: create,
|
|
75
|
+
PostTestHelpersTestClocksTestClockAdvance: advance,
|
|
76
|
+
DeleteTestHelpersTestClocksTestClock: remove,
|
|
77
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Token semantics: the older integrations' single-use tokens for a card, a bank account or PII. A
|
|
2
|
+
// token carries only the safe fields of its instrument, never the card or account number. Retrieve
|
|
3
|
+
// is the derived core's.
|
|
4
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { paymentMethodSubObject } from '../stripe-twin.ts';
|
|
6
|
+
import { created, fail, type Row } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
/** The Card object a card token carries, as the served spec's `card` describes it: its brand as a name ("Card brand.
|
|
9
|
+
* Can be `American Express`, ... `MasterCard`, `UnionPay`, `Visa`, or `Unknown`"), the holder's name and address as
|
|
10
|
+
* given ("Cardholder name"), and the CVC's check when one was given ("If a CVC was provided, results of the check:
|
|
11
|
+
* pass, fail, unavailable, or unchecked"). Where the documentation stops and the twin decides: a test card's CVC passes. */
|
|
12
|
+
const BRAND_NAMES: Record<string, string> = { amex: 'American Express', cartes_bancaires: 'Cartes Bancaires', diners: 'Diners Club', discover: 'Discover', eftpos_au: 'Eftpos Australia', girocard: 'Girocard', jcb: 'JCB', mastercard: 'MasterCard', unionpay: 'UnionPay', visa: 'Visa' };
|
|
13
|
+
export function legacyCard(id: string, card: Row): Row {
|
|
14
|
+
const pm = paymentMethodSubObject('card', { card }).card as Row;
|
|
15
|
+
const given = (k: string): unknown => (typeof card[k] === 'string' && card[k] !== '' ? card[k] : null);
|
|
16
|
+
return {
|
|
17
|
+
id, object: 'card', brand: BRAND_NAMES[String(pm.brand)] ?? 'Unknown', last4: pm.last4, exp_month: pm.exp_month, exp_year: pm.exp_year,
|
|
18
|
+
funding: pm.funding ?? 'credit', country: pm.country ?? null, fingerprint: pm.fingerprint ?? null, name: given('name'),
|
|
19
|
+
address_line1: given('address_line1'), address_line2: given('address_line2'), address_city: given('address_city'), address_state: given('address_state'),
|
|
20
|
+
address_zip: given('address_zip'), address_country: given('address_country'), address_line1_check: null, address_zip_check: null,
|
|
21
|
+
cvc_check: card.cvc !== undefined && card.cvc !== '' ? 'pass' : null, dynamic_last4: null, tokenization_method: null, metadata: {}, customer: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A token asked for none of what a token holds. */
|
|
26
|
+
function noTokenSource(ctx: SemanticsContext): Response {
|
|
27
|
+
return fail(ctx, 'You must provide one of the following parameters: card, bank_account, pii.', 400, 'parameter_missing');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A bank account token's account, as the legacy BankAccount object holds it. */
|
|
31
|
+
function bankAccountOf(ctx: SemanticsContext, ba: Row): Row {
|
|
32
|
+
const acct = typeof ba.account_number === 'string' ? ba.account_number.replace(/\D/g, '') : '';
|
|
33
|
+
return {
|
|
34
|
+
id: ctx.mint('external_account'), object: 'bank_account', account_holder_name: ba.account_holder_name ?? null,
|
|
35
|
+
account_holder_type: ba.account_holder_type ?? null, bank_name: 'STRIPE TEST BANK',
|
|
36
|
+
country: ba.country ?? 'US', currency: ba.currency ?? 'usd', fingerprint: 'twin_ba_fp',
|
|
37
|
+
last4: acct ? acct.slice(-4) : '6789', routing_number: ba.routing_number ?? '110000000', status: 'new',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const create: Semantics = async (ctx) => {
|
|
42
|
+
const params = ctx.params;
|
|
43
|
+
const isObject = (v: unknown): v is Row => !!v && typeof v === 'object';
|
|
44
|
+
const type = isObject(params.card) ? 'card' : isObject(params.bank_account) ? 'bank_account' : isObject(params.pii) ? 'pii' : undefined;
|
|
45
|
+
if (!type) return noTokenSource(ctx);
|
|
46
|
+
const sub: Row = type === 'card'
|
|
47
|
+
? { card: legacyCard(ctx.mint('payment_method').replace('pm_', 'card_'), params.card as Row) }
|
|
48
|
+
: type === 'bank_account' ? { bank_account: bankAccountOf(ctx, params.bank_account as Row) } : {};
|
|
49
|
+
return ctx.reply(await created(ctx, 'token', { type }, { ...sub, client_ip: null, used: false, livemode: false }));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const tokens: Record<string, Semantics> = { PostTokens: create };
|