@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,154 @@
|
|
|
1
|
+
// Product and Price semantics, with the entitlement features granted to a product. Retrieve of a
|
|
2
|
+
// product and the product and price updates (archiving and restoring by `active`, the machines in
|
|
3
|
+
// ../manifest.ts) are the derived core's.
|
|
4
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { asBool } from '../stripe-twin.ts';
|
|
6
|
+
import { at, created, fail, kept, list, newest, where, type Row } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
const productMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such product: '${id}'`, 404, 'resource_missing');
|
|
9
|
+
|
|
10
|
+
const createProduct: Semantics = async (ctx) =>
|
|
11
|
+
ctx.reply(await created(ctx, 'product', ctx.params, { active: true, livemode: false, images: [], marketing_features: [], metadata: {}, updated: ctx.now() }));
|
|
12
|
+
|
|
13
|
+
const listProducts: Semantics = async (ctx) =>
|
|
14
|
+
list(ctx, 'product', where(ctx, newest(ctx, 'product'), {
|
|
15
|
+
active: (p, v) => asBool(p.active) === asBool(v),
|
|
16
|
+
ids: (p, v) => (Array.isArray(v) ? v.map(String).includes(String(p.id)) : String(p.id) === String(v)),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// a product with prices cannot be deleted; Stripe says to deactivate it instead
|
|
20
|
+
/** A product deleted while it has prices: "Deleting a product is only possible if it has no prices associated with it"
|
|
21
|
+
* (docs.stripe.com/api/products/delete). */
|
|
22
|
+
function pricedProductRefused(ctx: SemanticsContext): Response {
|
|
23
|
+
return fail(ctx, 'You cannot delete the product because it has one or more prices. Deactivate the product instead.', 400, 'resource_already_exists');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deleteProduct: Semantics = async (ctx) => {
|
|
27
|
+
const id = at(ctx, 'id');
|
|
28
|
+
if (!ctx.get('product', id)) return productMissing(ctx, id);
|
|
29
|
+
if (ctx.rows('price').some((p) => p.product === id)) return pricedProductRefused(ctx);
|
|
30
|
+
await ctx.write('product', id, { deleted: true }, 'product.delete');
|
|
31
|
+
return ctx.reply({ id, object: 'product', deleted: true });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── features: an entitlements feature granted to a product, at most once ──
|
|
35
|
+
|
|
36
|
+
/** A feature attached to a product that already has it. */
|
|
37
|
+
function alreadyAttached(ctx: SemanticsContext): Response {
|
|
38
|
+
return fail(ctx, 'This feature is already attached to the product.', 400, 'resource_already_exists');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const attachFeature: Semantics = async (ctx) => {
|
|
42
|
+
const product = at(ctx, 'product');
|
|
43
|
+
if (!ctx.get('product', product)) return productMissing(ctx, product);
|
|
44
|
+
const feature = typeof ctx.params.entitlement_feature === 'string' ? ctx.params.entitlement_feature : '';
|
|
45
|
+
if (!feature) return fail(ctx, 'Missing required param: entitlement_feature.', 400, 'parameter_missing');
|
|
46
|
+
const feat = ctx.get('entitlements_feature', feature);
|
|
47
|
+
if (!feat) return fail(ctx, `No such feature: '${feature}'`, 400, 'resource_missing');
|
|
48
|
+
if (ctx.rowsRaw('product_feature').some((pf) => pf._product === product && pf._feature === feature)) return alreadyAttached(ctx);
|
|
49
|
+
// Stripe answers the feature itself as the product feature's entitlement_feature (docs.stripe.com/api/product-feature/object)
|
|
50
|
+
return ctx.reply(
|
|
51
|
+
await created(ctx, 'product_feature', { _product: product, _feature: feature }, {
|
|
52
|
+
livemode: false,
|
|
53
|
+
entitlement_feature: { id: feature, object: 'entitlements.feature', name: feat.name, lookup_key: feat.lookup_key, active: feat.active, metadata: feat.metadata ?? {}, livemode: false },
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const features: Semantics = async (ctx) => {
|
|
59
|
+
const product = at(ctx, 'product');
|
|
60
|
+
if (!ctx.get('product', product)) return productMissing(ctx, product);
|
|
61
|
+
return list(ctx, 'product_feature', newest(ctx, 'product_feature').filter((pf) => kept(ctx, 'product_feature', pf, '_product') === product));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const productFeature = (ctx: SemanticsContext): Row | undefined => {
|
|
65
|
+
const pf = ctx.get('product_feature', at(ctx, 'id'));
|
|
66
|
+
// the product a feature is attached to is its address, not a field Stripe answers
|
|
67
|
+
return pf && kept(ctx, 'product_feature', pf, '_product') === at(ctx, 'product') ? pf : undefined;
|
|
68
|
+
};
|
|
69
|
+
const featureMissing = (ctx: SemanticsContext): Response => fail(ctx, `No such product feature: '${at(ctx, 'id')}'`, 404, 'resource_missing');
|
|
70
|
+
|
|
71
|
+
const feature: Semantics = async (ctx) => {
|
|
72
|
+
const pf = productFeature(ctx);
|
|
73
|
+
return pf ? ctx.reply(pf) : featureMissing(ctx);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const detachFeature: Semantics = async (ctx) => {
|
|
77
|
+
if (!productFeature(ctx)) return featureMissing(ctx);
|
|
78
|
+
const id = at(ctx, 'id');
|
|
79
|
+
// when it came off the product: a subscription keeps it until its next period (semantics/entitlements.ts)
|
|
80
|
+
await ctx.write('product_feature', id, { deleted: true, _removed_at: Number(ctx.now()) }, 'product_feature.delete');
|
|
81
|
+
return ctx.reply({ id, object: 'product_feature', deleted: true });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── prices ──
|
|
85
|
+
|
|
86
|
+
/** A tiered price: tiers_mode and tiers, each tier with up_to and a unit or flat amount; no single unit_amount, and
|
|
87
|
+
* the last tier's up_to null (∞) (docs.stripe.com/products-prices/pricing-models#tiered-pricing). */
|
|
88
|
+
async function tieredPrice(ctx: SemanticsContext, params: Row): Promise<Response> {
|
|
89
|
+
const mode = typeof params.tiers_mode === 'string' ? params.tiers_mode : '';
|
|
90
|
+
if (mode !== 'graduated' && mode !== 'volume') return fail(ctx, 'Missing required param: tiers_mode (graduated or volume).', 400, 'parameter_missing');
|
|
91
|
+
const rawTiers = Array.isArray(params.tiers) ? (params.tiers as Row[]) : [];
|
|
92
|
+
if (rawTiers.length === 0) return fail(ctx, 'Missing required param: tiers.', 400, 'parameter_missing');
|
|
93
|
+
const tiers = rawTiers.map((t) => {
|
|
94
|
+
const upToRaw = t.up_to;
|
|
95
|
+
return {
|
|
96
|
+
up_to: upToRaw === 'inf' || upToRaw === undefined || upToRaw === null ? null : Math.trunc(Number(upToRaw)),
|
|
97
|
+
unit_amount: t.unit_amount !== undefined ? Math.trunc(Number(t.unit_amount)) : null,
|
|
98
|
+
unit_amount_decimal: t.unit_amount !== undefined ? String(Math.trunc(Number(t.unit_amount))) : null,
|
|
99
|
+
flat_amount: t.flat_amount !== undefined ? Math.trunc(Number(t.flat_amount)) : null,
|
|
100
|
+
flat_amount_decimal: t.flat_amount !== undefined ? String(Math.trunc(Number(t.flat_amount))) : null,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
if (tiers.some((t) => t.unit_amount === null && t.flat_amount === null)) return fail(ctx, 'Each tier must specify a unit_amount or flat_amount.', 400, 'parameter_invalid_empty');
|
|
104
|
+
const { tiers: _t, ...rest } = params;
|
|
105
|
+
return ctx.reply(await created(ctx, 'price', { ...rest, billing_scheme: 'tiered', tiers_mode: mode, tiers, unit_amount: null }, { active: true, livemode: false, type: rest.recurring ? 'recurring' : 'one_time', metadata: {} }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const createPrice: Semantics = async (ctx) => {
|
|
109
|
+
const params = ctx.params;
|
|
110
|
+
// the product must exist
|
|
111
|
+
const product = typeof params.product === 'string' ? params.product : '';
|
|
112
|
+
if (!product || !ctx.get('product', product)) return fail(ctx, `No such product: '${product}'`, 400, 'resource_missing');
|
|
113
|
+
// a tiered price needs tiers_mode and tiers, each tier with up_to and a unit or flat amount; it
|
|
114
|
+
// has no single unit_amount, and its last tier's up_to is null (∞)
|
|
115
|
+
const scheme = typeof params.billing_scheme === 'string' ? params.billing_scheme : 'per_unit';
|
|
116
|
+
if (scheme === 'tiered') return tieredPrice(ctx, params);
|
|
117
|
+
// a price that recurs is billed by subscriptions; any other is paid once. Its recurring block answers what the create
|
|
118
|
+
// left out as Stripe does: `usage_type` "Defaults to `licensed`", and the create example's answer
|
|
119
|
+
// (docs.stripe.com/api/prices/create) carries `"interval_count": 1, "trial_period_days": null`; a meter only a metered
|
|
120
|
+
// price names
|
|
121
|
+
if (params.recurring && typeof params.recurring === 'object') {
|
|
122
|
+
const r = params.recurring as Row;
|
|
123
|
+
params.recurring = { ...r, interval_count: r.interval_count !== undefined ? Math.trunc(Number(r.interval_count)) : 1, usage_type: r.usage_type ?? 'licensed', trial_period_days: r.trial_period_days !== undefined ? Math.trunc(Number(r.trial_period_days)) : null, meter: r.meter ?? null };
|
|
124
|
+
}
|
|
125
|
+
return ctx.reply(await created(ctx, 'price', params, { active: true, livemode: false, billing_scheme: 'per_unit', type: params.recurring ? 'recurring' : 'one_time', metadata: {} }));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// `type` is not stored (it collides with the tree's discriminator), so `recurring` stands for it
|
|
129
|
+
const listPrices: Semantics = async (ctx) =>
|
|
130
|
+
list(ctx, 'price', where(ctx, newest(ctx, 'price'), {
|
|
131
|
+
active: (p, v) => asBool(p.active) === asBool(v),
|
|
132
|
+
product: (p, v) => p.product === v,
|
|
133
|
+
currency: (p, v) => p.currency === v,
|
|
134
|
+
type: (p, v) => (v === 'recurring' ? !!p.recurring : !p.recurring),
|
|
135
|
+
lookup_keys: (p, v) => (Array.isArray(v) ? v.map(String) : [String(v)]).includes(String(p.lookup_key)),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const retrievePrice: Semantics = async (ctx) => {
|
|
139
|
+
const p = ctx.get('price', at(ctx, 'price'));
|
|
140
|
+
return p ? ctx.reply(p) : fail(ctx, `No such price: '${at(ctx, 'price')}'`, 404, 'resource_missing');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const products: Record<string, Semantics> = {
|
|
144
|
+
PostProducts: createProduct,
|
|
145
|
+
GetProducts: listProducts,
|
|
146
|
+
DeleteProductsId: deleteProduct,
|
|
147
|
+
PostProductsProductFeatures: attachFeature,
|
|
148
|
+
GetProductsProductFeatures: features,
|
|
149
|
+
GetProductsProductFeaturesId: feature,
|
|
150
|
+
DeleteProductsProductFeaturesId: detachFeature,
|
|
151
|
+
PostPrices: createPrice,
|
|
152
|
+
GetPrices: listPrices,
|
|
153
|
+
GetPricesPrice: retrievePrice,
|
|
154
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Radar semantics: reviews of payments Radar held (approving closes one, the machine in
|
|
2
|
+
// ../manifest.ts) and the value lists custom rules reference, with their items. Review list and
|
|
3
|
+
// retrieve, value-list list and retrieve, and value-list-item retrieve are the derived core's.
|
|
4
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { at, created, fail, list, newest } from './shared.ts';
|
|
6
|
+
|
|
7
|
+
const approve: Semantics = async (ctx) => {
|
|
8
|
+
const id = at(ctx, 'review');
|
|
9
|
+
const r = ctx.get('review', id);
|
|
10
|
+
if (!r) return fail(ctx, `No such review: '${id}'`, 404, 'resource_missing');
|
|
11
|
+
const refused = ctx.legal('review', 'open', 'PostReviewsReviewApprove', r.open === true, undefined, id);
|
|
12
|
+
if (refused) return ctx.refuse(refused);
|
|
13
|
+
return ctx.reply(await ctx.write('review', id, { open: false, closed_reason: 'approved', reason: 'approved' }, 'review.closed'));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const VL = 'radar.value_list';
|
|
17
|
+
const VLI = 'radar.value_list_item';
|
|
18
|
+
const listMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such radar value list: '${id}'`, status, 'resource_missing');
|
|
19
|
+
|
|
20
|
+
const createList: Semantics = async (ctx) => {
|
|
21
|
+
const params = ctx.params;
|
|
22
|
+
if (params.alias === undefined || params.alias === '') return fail(ctx, 'Missing required param: alias.', 400, 'parameter_missing');
|
|
23
|
+
if (params.name === undefined || params.name === '') return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
|
|
24
|
+
const made = await created(ctx, VL, params, {
|
|
25
|
+
item_type: typeof params.item_type === 'string' ? params.item_type : 'string', livemode: false, metadata: {},
|
|
26
|
+
created_by: 'twin', list_items: { object: 'list', data: [], has_more: false, url: '' },
|
|
27
|
+
});
|
|
28
|
+
await syncItems(ctx, String(made.id));
|
|
29
|
+
return ctx.reply(ctx.get(VL, String(made.id))!);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A value list's list_items, "List of items contained within this value list" (the served spec), kept in step with
|
|
33
|
+
* its items, at the address the fixture names (/v1/radar/value_list_items?value_list=…). */
|
|
34
|
+
async function syncItems(ctx: SemanticsContext, listId: string): Promise<void> {
|
|
35
|
+
const data = newest(ctx, VLI).filter((i) => i.value_list === listId);
|
|
36
|
+
await ctx.write(VL, listId, { list_items: { object: 'list', data, has_more: false, url: `/v1/radar/value_list_items?value_list=${listId}` } }, 'radar.value_list.items_synced');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const updateList: Semantics = async (ctx) => {
|
|
40
|
+
const id = at(ctx, 'value_list');
|
|
41
|
+
if (!ctx.row(VL, id, { withDeleted: true })) return listMissing(ctx, id);
|
|
42
|
+
return ctx.reply(await ctx.write(VL, id, ctx.params, 'radar.value_list.updated'));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const deleteList: Semantics = async (ctx) => {
|
|
46
|
+
const id = at(ctx, 'value_list');
|
|
47
|
+
if (!ctx.row(VL, id, { withDeleted: true })) return listMissing(ctx, id);
|
|
48
|
+
await ctx.write(VL, id, { deleted: true }, 'radar.value_list.deleted');
|
|
49
|
+
return ctx.reply({ id, object: 'radar.value_list', deleted: true });
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const addItem: Semantics = async (ctx) => {
|
|
53
|
+
const listId = typeof ctx.params.value_list === 'string' ? ctx.params.value_list : '';
|
|
54
|
+
if (!listId) return fail(ctx, 'Missing required param: value_list.', 400, 'parameter_missing');
|
|
55
|
+
if (!ctx.row(VL, listId, { withDeleted: true })) return listMissing(ctx, listId, 400);
|
|
56
|
+
if (ctx.params.value === undefined || ctx.params.value === '') return fail(ctx, 'Missing required param: value.', 400, 'parameter_missing');
|
|
57
|
+
const item = await created(ctx, VLI, { value_list: listId, value: ctx.params.value }, { livemode: false, created_by: 'twin' });
|
|
58
|
+
await syncItems(ctx, listId);
|
|
59
|
+
return ctx.reply(item);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const items: Semantics = async (ctx) => {
|
|
63
|
+
const listId = typeof ctx.params.value_list === 'string' ? ctx.params.value_list : '';
|
|
64
|
+
if (!listId) return fail(ctx, 'Missing required param: value_list.', 400, 'parameter_missing');
|
|
65
|
+
return list(ctx, VLI, newest(ctx, VLI).filter((i) => i.value_list === listId));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const removeItem: Semantics = async (ctx) => {
|
|
69
|
+
const id = at(ctx, 'item');
|
|
70
|
+
const was = ctx.row(VLI, id, { withDeleted: true });
|
|
71
|
+
if (!was) return fail(ctx, `No such radar value list item: '${id}'`, 404, 'resource_missing');
|
|
72
|
+
await ctx.write(VLI, id, { deleted: true }, 'radar.value_list_item.deleted');
|
|
73
|
+
if (typeof was.value_list === 'string' && ctx.get(VL, was.value_list)) await syncItems(ctx, was.value_list);
|
|
74
|
+
return ctx.reply({ id, object: 'radar.value_list_item', deleted: true });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const radar: Record<string, Semantics> = {
|
|
78
|
+
PostReviewsReviewApprove: approve,
|
|
79
|
+
PostRadarValueLists: createList,
|
|
80
|
+
PostRadarValueListsValueList: updateList,
|
|
81
|
+
DeleteRadarValueListsValueList: deleteList,
|
|
82
|
+
PostRadarValueListItems: addItem,
|
|
83
|
+
GetRadarValueListItems: items,
|
|
84
|
+
DeleteRadarValueListItemsItem: removeItem,
|
|
85
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Refund semantics. A refund always names what it took money out of and lands there: a refund
|
|
2
|
+
// naming nothing, an id nobody minted, or more than the charge has left is refused as Stripe
|
|
3
|
+
// refuses it. The machine in ../manifest.ts says only a refund waiting on the customer
|
|
4
|
+
// (requires_action) may be canceled. List and update are the derived core's.
|
|
5
|
+
//
|
|
6
|
+
// Refunding a destination charge can take back the connected account's share (reverse_transfer: a reversal of the
|
|
7
|
+
// charge's transfer, in proportion to the refund) and the platform's (refund_application_fee: a refund of the
|
|
8
|
+
// application fee, in proportion) (docs.stripe.com/api/refunds/create#create_refund-reverse_transfer,
|
|
9
|
+
// docs.stripe.com/connect/destination-charges#issue-refunds). Where the documentation stops and the twin decides: the
|
|
10
|
+
// twin's destination charge transfers the amount less the fee (the fee is kept back rather than collected from the
|
|
11
|
+
// connected account), so a fee refund moves no money of its own; the reversal and the refund settle both shares.
|
|
12
|
+
//
|
|
13
|
+
// A payment made by bank transfer (from the customer's cash balance) is refunded to the customer's bank account: Stripe
|
|
14
|
+
// emails the customer (instructions_email, else the customer's email) for their bank details and the refund waits in
|
|
15
|
+
// requires_action, moving no money, until they answer; only then can it still be canceled. With origin=customer_balance
|
|
16
|
+
// it goes back to the cash balance at once (docs.stripe.com/payments/customer-balance/refunding,
|
|
17
|
+
// docs.stripe.com/api/refunds/cancel). Where the documentation stops and the twin decides: the customer never answers
|
|
18
|
+
// the email in the World, and its link expires 30 days on.
|
|
19
|
+
//
|
|
20
|
+
// An uncaptured charge holds an authorization, not money. A PaymentIntent's is not refunded: "the charge attached to the
|
|
21
|
+
// PaymentIntent remains uncaptured and can't be refunded directly. You must cancel the PaymentIntent"
|
|
22
|
+
// (docs.stripe.com/refunds), which releases it (charges.ts cancelAuthorization). A charge made with capture=false is
|
|
23
|
+
// released by its refund, as its authorization would be "automatically refunded if uncaptured" (spec/openapi.json.gz,
|
|
24
|
+
// `capture_before`): refunded in full, no balance moved, and its capture refused afterwards. Where the documentation
|
|
25
|
+
// stops and the twin decides: the refusal's code (payment_intent_unexpected_state) and wording, and that a refund of a
|
|
26
|
+
// capture=false charge releases the whole authorization, so an `amount` short of it is refused.
|
|
27
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
28
|
+
import { releaseAuthorization } from './charges.ts';
|
|
29
|
+
import { actingAccount, availableAt, fundsDueBetween, settleRefund, settleTransferReversal } from './ledger.ts';
|
|
30
|
+
import { asBool } from '../stripe-twin.ts';
|
|
31
|
+
import { at, at_, created, fail, newest, refundDestination, type Row } from './shared.ts';
|
|
32
|
+
|
|
33
|
+
// the reasons a caller may give; `expired_uncaptured_charge` is Stripe's own
|
|
34
|
+
const REFUND_REASONS = new Set(['duplicate', 'fraudulent', 'requested_by_customer']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a refund and land it on its charge. An omitted amount is the whole remaining balance. The
|
|
38
|
+
* charge's write records `charge.refunded`, the event Stripe sends carrying the charge's new totals;
|
|
39
|
+
* `refunded` is true only at the full amount.
|
|
40
|
+
*/
|
|
41
|
+
/** A reason Stripe does not take (docs.stripe.com/api/refunds/create#create_refund-reason). */
|
|
42
|
+
function badReason(ctx: SemanticsContext): Response {
|
|
43
|
+
return fail(ctx, `Invalid refund reason: must be one of ${[...REFUND_REASONS].join(', ')}.`, 400, 'parameter_invalid_string_enum');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A bank-transfer payment refunded with origin=customer_balance goes back to the cash balance
|
|
47
|
+
* (docs.stripe.com/payments/customer-balance/refunding). */
|
|
48
|
+
async function backToCashBalance(ctx: SemanticsContext, customer: string, currency: string, amount: number, refundId: string): Promise<void> {
|
|
49
|
+
const before = ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0);
|
|
50
|
+
await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'refunded_from_payment', net_amount: amount, ending_balance: before + amount, refunded_from_payment: { refund: refundId }, livemode: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A capture=false charge's refund, which releases its whole authorization (see the header). */
|
|
54
|
+
async function refundUncaptured(ctx: SemanticsContext, charge: Row, amount: number, remaining: number, params: Row): Promise<Response> {
|
|
55
|
+
if (amount < remaining) return fail(ctx, `Charge ${String(charge.id)} is uncaptured: refunding it releases the whole authorization (${remaining}).`, 400, 'amount_too_small');
|
|
56
|
+
const { reason, metadata } = params;
|
|
57
|
+
return ctx.reply(await releaseAuthorization(ctx, charge, ctx.call.operation.id, { ...(reason !== undefined ? { reason } : {}), ...(metadata !== undefined ? { metadata } : {}) }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function refundCharge(ctx: SemanticsContext, charge: Row, params: Row): Promise<Response> {
|
|
61
|
+
const chargeId = String(charge.id);
|
|
62
|
+
const total = Number(charge.amount) || 0;
|
|
63
|
+
const already = Number(charge.amount_refunded) || 0;
|
|
64
|
+
const remaining = Math.max(0, total - already);
|
|
65
|
+
const amount = params.amount === undefined ? remaining : Math.trunc(Number(params.amount));
|
|
66
|
+
if (!Number.isFinite(amount) || amount <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
|
|
67
|
+
if (amount > remaining) return fail(ctx, `Refund amount (${amount}) is greater than unrefunded amount on charge (${remaining}).`, 400, 'amount_too_large');
|
|
68
|
+
if (params.reason !== undefined && !REFUND_REASONS.has(String(params.reason))) return badReason(ctx);
|
|
69
|
+
// an authorization is released, never refunded out of the balance: a PaymentIntent's by cancelling the intent, a
|
|
70
|
+
// capture=false charge's by its refund (refundUncaptured)
|
|
71
|
+
if (charge.captured === false && typeof charge.payment_intent === 'string') return fail(ctx, `Charge ${chargeId} is uncaptured and can't be refunded directly; cancel its PaymentIntent (${charge.payment_intent}) to release the authorization.`, 400, 'payment_intent_unexpected_state');
|
|
72
|
+
if (charge.captured === false) return refundUncaptured(ctx, charge, amount, remaining, params);
|
|
73
|
+
const refunded = already + amount;
|
|
74
|
+
// the refund that takes the rest refunds the charge: the charge machine's move under this operation
|
|
75
|
+
if (refunded >= total && charge.refunded !== true) {
|
|
76
|
+
const refused = ctx.legal('charge', 'refunded', ctx.call.operation.id, 'false', 'true', chargeId);
|
|
77
|
+
if (refused) return ctx.refuse(refused);
|
|
78
|
+
}
|
|
79
|
+
// a refund has no `livemode` field; it debits the balance at once
|
|
80
|
+
const refundId = ctx.mint('refund');
|
|
81
|
+
const currency = String(charge.currency ?? 'usd');
|
|
82
|
+
const pi = typeof charge.payment_intent === 'string' ? ctx.get('payment_intent', charge.payment_intent) : undefined;
|
|
83
|
+
const byTransfer = ((pi?.payment_method_types as unknown[] | undefined) ?? []).includes('customer_balance');
|
|
84
|
+
const toCashBalance = byTransfer && params.origin === 'customer_balance';
|
|
85
|
+
const awaitsBank = byTransfer && !toCashBalance;
|
|
86
|
+
const now = Number(ctx.now());
|
|
87
|
+
const customer = typeof charge.customer === 'string' ? charge.customer : typeof pi?.customer === 'string' ? pi.customer : undefined;
|
|
88
|
+
const emailTo = typeof params.instructions_email === 'string' ? params.instructions_email : (customer ? ctx.get('customer', customer)?.email : undefined) ?? null;
|
|
89
|
+
// "Refunds use your available Stripe balance (not including pending amounts). If your available balance doesn't
|
|
90
|
+
// cover the amount of the refund, Stripe holds the refund as pending for card transactions ... until your Stripe
|
|
91
|
+
// balance becomes sufficient" (docs.stripe.com/refunds): the refund waits, moving no money, until the balance covers
|
|
92
|
+
// it (settleHeldRefunds). Where the documentation stops and the twin decides: a refund of another payment method
|
|
93
|
+
// short of balance is not modelled (Stripe fails it); the twin makes it as a card's.
|
|
94
|
+
const account = actingAccount(ctx);
|
|
95
|
+
const isCard = (charge.payment_method_details as Row | null | undefined)?.type === 'card';
|
|
96
|
+
const held = !awaitsBank && !toCashBalance && isCard && availableAt(ctx, account, currency, now) < amount;
|
|
97
|
+
if (held) ctx.legal('refund', 'status', ctx.call.operation.id, 'succeeded', 'pending', refundId);
|
|
98
|
+
const bt = awaitsBank || held ? null : await settleRefund(ctx, refundId, amount, currency);
|
|
99
|
+
if (toCashBalance && customer) await backToCashBalance(ctx, customer, currency, amount, refundId);
|
|
100
|
+
const { reverse_transfer: reverseTransfer, refund_application_fee: refundFee, instructions_email: _email, origin: _origin, ...stored } = params;
|
|
101
|
+
const share = (whole: number): number => Math.round((whole * amount) / (total || 1));
|
|
102
|
+
let transferReversal: string | null = null;
|
|
103
|
+
const transfer = typeof charge.transfer === 'string' ? ctx.get('transfer', charge.transfer) : undefined;
|
|
104
|
+
if (reverseTransfer !== undefined && asBool(reverseTransfer) && transfer) {
|
|
105
|
+
const back = Math.min(share(Number(transfer.amount) || 0), (Number(transfer.amount) || 0) - (Number(transfer.amount_reversed) || 0));
|
|
106
|
+
const trr = await created(ctx, 'transfer_reversal', { amount: back, currency, transfer: transfer.id }, { balance_transaction: null, destination_payment_refund: null, source_refund: refundId, metadata: {} });
|
|
107
|
+
const trrBt = await settleTransferReversal(ctx, String(trr.id), back, currency, String(transfer.destination));
|
|
108
|
+
const reversal = await ctx.write('transfer_reversal', String(trr.id), { balance_transaction: trrBt }, 'transfer_reversal.updated');
|
|
109
|
+
const reversed = (Number(transfer.amount_reversed) || 0) + back;
|
|
110
|
+
const list = (transfer.reversals as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
|
|
111
|
+
const data = [...(((list.data as unknown[]) ?? [])), reversal];
|
|
112
|
+
await ctx.write('transfer', String(transfer.id), { amount_reversed: reversed, reversed: reversed >= (Number(transfer.amount) || 0), reversals: { ...list, data, total_count: data.length, url: `/v1/transfers/${String(transfer.id)}/reversals` } }, 'transfer.reversed');
|
|
113
|
+
transferReversal = String(trr.id);
|
|
114
|
+
}
|
|
115
|
+
const fee = typeof charge.application_fee === 'string' ? ctx.get('application_fee', charge.application_fee) : undefined;
|
|
116
|
+
if (refundFee !== undefined && asBool(refundFee) && fee) {
|
|
117
|
+
const feeTotal = Number(fee.amount) || 0;
|
|
118
|
+
const back = Math.min(share(feeTotal), feeTotal - (Number(fee.amount_refunded) || 0));
|
|
119
|
+
const done = (Number(fee.amount_refunded) || 0) + back;
|
|
120
|
+
if (!(done >= feeTotal && fee.refunded !== true && ctx.legal('application_fee', 'refunded', ctx.call.operation.id, 'false', 'true', String(fee.id)))) {
|
|
121
|
+
const fr = await created(ctx, 'fee_refund', { amount: back, currency, fee: fee.id }, { balance_transaction: null, metadata: {} });
|
|
122
|
+
const list = (fee.refunds as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
|
|
123
|
+
const data = [...(((list.data as unknown[]) ?? [])), fr];
|
|
124
|
+
await ctx.write('application_fee', String(fee.id), { amount_refunded: done, refunded: done >= feeTotal, refunds: { ...list, data, total_count: data.length, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.refunded');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const body = await created(ctx, 'refund', {
|
|
128
|
+
...stored, id: refundId, charge: chargeId, amount, currency, transfer_reversal: transferReversal,
|
|
129
|
+
...(typeof charge.payment_intent === 'string' ? { payment_intent: charge.payment_intent } : {}),
|
|
130
|
+
}, {
|
|
131
|
+
status: awaitsBank ? 'requires_action' : held ? 'pending' : 'succeeded', metadata: {}, reason: null, receipt_number: null,
|
|
132
|
+
...(held ? { pending_reason: 'insufficient_funds', _account: account ?? null } : {}),
|
|
133
|
+
balance_transaction: bt, source_transfer_reversal: null,
|
|
134
|
+
// a bank transfer's refund goes to the customer's bank (a us_bank_transfer) or back to its cash balance
|
|
135
|
+
// (customer_cash_balance): "If this is a `us_bank_transfer` refund, this hash contains the transaction specific details",
|
|
136
|
+
// "If this is a `customer_cash_balance` refund ..." (docs.stripe.com/api/refunds/object, destination_details)
|
|
137
|
+
...(byTransfer
|
|
138
|
+
? { destination_details: toCashBalance ? { type: 'customer_cash_balance', customer_cash_balance: {} } : { type: 'us_bank_transfer', us_bank_transfer: { reference: null, reference_status: null } } }
|
|
139
|
+
: refundDestination(charge, 'refund')),
|
|
140
|
+
...(awaitsBank ? { next_action: { type: 'display_details', display_details: { email_sent: { email_sent_at: now, email_sent_to: emailTo }, expires_at: now + 30 * 86_400 } } } : {}),
|
|
141
|
+
});
|
|
142
|
+
await ctx.write('charge', chargeId, { amount_refunded: refunded, refunded: refunded >= total }, 'charge.refunded');
|
|
143
|
+
return ctx.reply(body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Time's settling of held refunds, caught up to the World's clock: each refund held for want of balance, oldest first,
|
|
147
|
+
* is made at the first moment the account's available balance covers it (the funds that come due then), its debit
|
|
148
|
+
* written then, as `refund.updated`. Where the documentation stops and the twin decides: a held refund is made the
|
|
149
|
+
* moment the balance covers it. */
|
|
150
|
+
export async function settleHeldRefunds(ctx: SemanticsContext): Promise<void> {
|
|
151
|
+
const now = Number(ctx.now());
|
|
152
|
+
const heldRefunds = ctx.rowsRaw('refund').filter((r) => r.status === 'pending' && r.pending_reason === 'insufficient_funds').sort((a, b) => Number(a.created) - Number(b.created));
|
|
153
|
+
for (const r of heldRefunds) {
|
|
154
|
+
const account = typeof r._account === 'string' ? r._account : undefined;
|
|
155
|
+
const currency = String(r.currency ?? 'usd');
|
|
156
|
+
const amount = Number(r.amount) || 0;
|
|
157
|
+
const when = [Number(r.created), ...fundsDueBetween(ctx, account, currency, Number(r.created), now)].find((t) => availableAt(ctx, account, currency, t) >= amount);
|
|
158
|
+
if (when === undefined) continue;
|
|
159
|
+
const c = await at_(ctx)(when);
|
|
160
|
+
c.legal('refund', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(r.id), 'vendor');
|
|
161
|
+
const bt = await settleRefund(c, String(r.id), amount, currency, account ?? null);
|
|
162
|
+
await c.write('refund', String(r.id), { status: 'succeeded', pending_reason: null, balance_transaction: bt }, 'refund.updated');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The charge a refund aims at: named directly, or reached through its PaymentIntent. */
|
|
167
|
+
function target(ctx: SemanticsContext): Row | Response {
|
|
168
|
+
const chargeId = typeof ctx.params.charge === 'string' ? ctx.params.charge : '';
|
|
169
|
+
const piId = typeof ctx.params.payment_intent === 'string' ? ctx.params.payment_intent : '';
|
|
170
|
+
if (!chargeId && !piId) return fail(ctx, 'One of `charge` or `payment_intent` is required.', 400, 'parameter_missing');
|
|
171
|
+
if (chargeId) return ctx.get('charge', chargeId) ?? fail(ctx, `No such charge: '${chargeId}'`, 404, 'resource_missing');
|
|
172
|
+
const pi = ctx.get('payment_intent', piId);
|
|
173
|
+
if (!pi) return fail(ctx, `No such payment_intent: '${piId}'`, 404, 'resource_missing');
|
|
174
|
+
const latest = typeof pi.latest_charge === 'string' ? pi.latest_charge : undefined;
|
|
175
|
+
const ch = (latest ? ctx.get('charge', latest) : undefined) ?? newest(ctx, 'charge').find((c) => c.payment_intent === piId);
|
|
176
|
+
return ch ?? fail(ctx, `This PaymentIntent does not have a successful charge to refund. Its status is ${String(pi.status)}.`, 400, 'payment_intent_unexpected_state');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const create: Semantics = async (ctx) => {
|
|
180
|
+
const ch = target(ctx);
|
|
181
|
+
return ch instanceof Response ? ch : refundCharge(ctx, ch, ctx.params);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const refundMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such refund: '${id}'`, 404, 'resource_missing');
|
|
185
|
+
|
|
186
|
+
const retrieve: Semantics = async (ctx) => {
|
|
187
|
+
const r = ctx.get('refund', at(ctx, 'refund'));
|
|
188
|
+
return r ? ctx.reply(r) : refundMissing(ctx, at(ctx, 'refund'));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// a canceled pending refund gives its money back to the charge: pending funds were already taken out
|
|
192
|
+
const cancel: Semantics = async (ctx) => {
|
|
193
|
+
const id = at(ctx, 'refund');
|
|
194
|
+
const r = ctx.get('refund', id);
|
|
195
|
+
if (!r) return refundMissing(ctx, id);
|
|
196
|
+
const refused = ctx.legal('refund', 'status', 'PostRefundsRefundCancel', r.status, undefined, id);
|
|
197
|
+
if (refused) return ctx.refuse(refused);
|
|
198
|
+
// next_action says what a refund "needs to continue processing" "If the refund has a status of requires_action"
|
|
199
|
+
// (docs.stripe.com/api/refunds/object): a canceled one needs nothing
|
|
200
|
+
const canceled = await ctx.write('refund', id, { status: 'canceled', next_action: null }, 'refund.updated');
|
|
201
|
+
const chargeId = typeof r.charge === 'string' ? r.charge : '';
|
|
202
|
+
const ch = chargeId ? ctx.get('charge', chargeId) : undefined;
|
|
203
|
+
if (ch) {
|
|
204
|
+
const total = Number(ch.amount) || 0;
|
|
205
|
+
const back = Math.max(0, (Number(ch.amount_refunded) || 0) - (Number(r.amount) || 0));
|
|
206
|
+
const refunded = back > 0 && back >= total;
|
|
207
|
+
// a charge the canceled refund had fully refunded is no longer refunded
|
|
208
|
+
if (refunded !== (ch.refunded === true)) ctx.legal('charge', 'refunded', 'PostRefundsRefundCancel', String(ch.refunded === true), String(refunded), chargeId);
|
|
209
|
+
await ctx.write('charge', chargeId, { amount_refunded: back, refunded }, 'charge.update');
|
|
210
|
+
}
|
|
211
|
+
return ctx.reply(canceled);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const refunds: Record<string, Semantics> = {
|
|
215
|
+
PostRefunds: create,
|
|
216
|
+
GetRefundsRefund: retrieve,
|
|
217
|
+
PostRefundsRefundCancel: cancel,
|
|
218
|
+
};
|