@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,605 @@
|
|
|
1
|
+
import { addBillingInterval, applyCouponDiscount, asBool, buildDiscount, buildSubscriptionItemsList, cardError, declineFor, mintClientSecret, priceAmount, resolveSubscriptionBillingInterval, resolveTrial, subscriptionCouponParam, subscriptionHasPrice, subscriptionItemEntries, subscriptionItemPrice, } from "../stripe-twin.js";
|
|
2
|
+
import { finalizedFields } from "./invoices.js";
|
|
3
|
+
import { mintCharge } from "./payment-intents.js";
|
|
4
|
+
import { advanceBilling, clockNow, collectSubscriptionInvoice, draftSubscriptionInvoice, itemQuantity, keptAfter, payerOf, spendOnceCoupon } from "./renewals.js";
|
|
5
|
+
import { at, created, deletedDiscount, expanded, fail, finder, list, newest, redeemCoupon, search, send, where } from "./shared.js";
|
|
6
|
+
const SUB = 'subscription';
|
|
7
|
+
const subMissing = (ctx, id) => fail(ctx, `No such subscription: '${id}'`, 404, 'resource_missing');
|
|
8
|
+
const create = async (ctx) => {
|
|
9
|
+
const params = ctx.params;
|
|
10
|
+
const customer = typeof params.customer === 'string' ? params.customer : '';
|
|
11
|
+
if (!customer)
|
|
12
|
+
return fail(ctx, 'Missing required param: customer.', 400, 'parameter_missing');
|
|
13
|
+
if (!ctx.get('customer', customer))
|
|
14
|
+
return fail(ctx, `No such customer: '${customer}'`, 404, 'resource_missing');
|
|
15
|
+
const now = clockNow(ctx, customer);
|
|
16
|
+
const badTrial = trialEndRefused(ctx, params.trial_end, now);
|
|
17
|
+
if (badTrial)
|
|
18
|
+
return badTrial;
|
|
19
|
+
// a coupon (or discounts[0][coupon]) must exist; it becomes the subscription's discount
|
|
20
|
+
const couponId = subscriptionCouponParam(params);
|
|
21
|
+
let discounts = [];
|
|
22
|
+
if (couponId) {
|
|
23
|
+
const coupon = ctx.get('coupon', couponId);
|
|
24
|
+
if (!coupon)
|
|
25
|
+
return fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
26
|
+
discounts = [buildDiscount(coupon, customer, now)];
|
|
27
|
+
}
|
|
28
|
+
// a trial is the current period; otherwise one billing interval of the first item's price. The
|
|
29
|
+
// period sits top-level: this twin's default API version predates basil moving it onto items.
|
|
30
|
+
const trial = resolveTrial(params, now);
|
|
31
|
+
const itemEntries = subscriptionItemEntries(params.items);
|
|
32
|
+
const { interval, interval_count } = resolveSubscriptionBillingInterval(itemEntries, finder(ctx));
|
|
33
|
+
const periodStart = trial ? trial.start : now;
|
|
34
|
+
const periodEnd = trial ? trial.end : addBillingInterval(now, interval, interval_count);
|
|
35
|
+
const subId = typeof params.id === 'string' && params.id ? params.id : ctx.mint(SUB);
|
|
36
|
+
const itemsList = buildSubscriptionItemsList(itemEntries, subId, now, finder(ctx));
|
|
37
|
+
const itemsData = itemsList.data;
|
|
38
|
+
const currency = typeof params.currency === 'string' && params.currency ? params.currency : 'usd';
|
|
39
|
+
// Stripe opens a subscription's first invoice at once (a trialing one has nothing due yet) and, by
|
|
40
|
+
// payment_behavior (docs.stripe.com/api/subscriptions/create#create_subscription-payment_behavior):
|
|
41
|
+
// allow_incomplete (the default) charges the default payment method now, and without one, or on a decline,
|
|
42
|
+
// leaves the subscription incomplete with the invoice open; error_if_incomplete refuses the create instead;
|
|
43
|
+
// default_incomplete never charges, leaving a PaymentIntent the frontend confirms.
|
|
44
|
+
const behavior = typeof params.payment_behavior === 'string' ? params.payment_behavior : 'allow_incomplete';
|
|
45
|
+
const needsFirstInvoice = !trial;
|
|
46
|
+
const holder = ctx.get('customer', customer) ?? {};
|
|
47
|
+
const payWith = behavior === 'default_incomplete' ? undefined
|
|
48
|
+
: (typeof params.default_payment_method === 'string' ? params.default_payment_method : undefined) ?? holder.invoice_settings?.default_payment_method ?? undefined;
|
|
49
|
+
const declined = payWith ? declineFor(finder(ctx), { payment_method: payWith }) : undefined;
|
|
50
|
+
let subtotal = 0;
|
|
51
|
+
for (const it of itemsData) {
|
|
52
|
+
const price = it.price && typeof it.price === 'object' ? it.price : undefined;
|
|
53
|
+
subtotal += priceAmount(price, itemQuantity(it));
|
|
54
|
+
}
|
|
55
|
+
const coupon = discounts[0]?.coupon;
|
|
56
|
+
const discountAmount = applyCouponDiscount(subtotal, coupon);
|
|
57
|
+
const invoiceTotal = Math.max(0, subtotal - discountAmount);
|
|
58
|
+
// a $0 first invoice is paid at once and needs no PaymentIntent
|
|
59
|
+
const isFree = invoiceTotal <= 0;
|
|
60
|
+
const pays = needsFirstInvoice && !isFree && !!payWith && !declined;
|
|
61
|
+
if (needsFirstInvoice && !isFree && !pays && behavior === 'error_if_incomplete')
|
|
62
|
+
return firstPaymentRefused(ctx, declined);
|
|
63
|
+
// minted first so the subscription names its latest_invoice in its own create (a trial's $0 invoice too)
|
|
64
|
+
const invoiceId = needsFirstInvoice ? ctx.mint('invoice') : null;
|
|
65
|
+
const trialInvoiceId = needsFirstInvoice ? null : ctx.mint('invoice');
|
|
66
|
+
const piId = needsFirstInvoice && !isFree ? ctx.mint('payment_intent') : null;
|
|
67
|
+
const { coupon: _c, discounts: _ds, trial_period_days: _tpd, trial_end: _te, items: _items, id: _id, ...rest } = params;
|
|
68
|
+
const sub = await created(ctx, SUB, { ...rest, id: subId }, {
|
|
69
|
+
status: trial ? 'trialing' : needsFirstInvoice && !isFree && !pays ? 'incomplete' : 'active',
|
|
70
|
+
livemode: false, currency, collection_method: 'charge_automatically',
|
|
71
|
+
// a trial's end is the billing anchor: "The `billing_cycle_anchor` will be updated to the `trial_end` value"
|
|
72
|
+
// (docs.stripe.com/api/subscriptions/create#create_subscription-trial_end)
|
|
73
|
+
cancel_at_period_end: false, start_date: now, billing_cycle_anchor: trial ? trial.end : now, metadata: {},
|
|
74
|
+
discounts: discounts.map((d) => ({ ...d, subscription: subId })), billing_schedules: [],
|
|
75
|
+
...(trial ? { trial_start: trial.start, trial_end: trial.end } : { trial_start: null, trial_end: null }),
|
|
76
|
+
current_period_start: periodStart, current_period_end: periodEnd,
|
|
77
|
+
items: itemsList,
|
|
78
|
+
latest_invoice: invoiceId ?? trialInvoiceId,
|
|
79
|
+
automatic_tax: { enabled: false, liability: null },
|
|
80
|
+
billing_mode: { type: 'classic' },
|
|
81
|
+
invoice_settings: { issuer: { type: 'self' } },
|
|
82
|
+
});
|
|
83
|
+
await storeItems(ctx, subId, itemsData);
|
|
84
|
+
if (discounts.length)
|
|
85
|
+
await redeemCoupon(ctx, couponId);
|
|
86
|
+
// a customer billed for the first time takes the subscription's currency
|
|
87
|
+
if (!holder.currency)
|
|
88
|
+
await ctx.write('customer', customer, { currency }, 'customer.update');
|
|
89
|
+
if (!invoiceId) {
|
|
90
|
+
// a trial's subscription still opens an invoice at once: "An immediate invoice is still created, but the amount is 0"
|
|
91
|
+
// (docs.stripe.com/billing/subscriptions/trials/free-trials); finalizing it spends a once coupon (renewals.ts)
|
|
92
|
+
const trialInvoice = await draftSubscriptionInvoice(ctx, ctx.get(SUB, subId) ?? sub, { start: periodStart, end: periodEnd, reason: 'subscription_create', trial: true, ...(trialInvoiceId ? { id: trialInvoiceId } : {}) });
|
|
93
|
+
await collectSubscriptionInvoice(ctx, trialInvoice, now, 'api');
|
|
94
|
+
return ctx.reply(expanded(ctx, SUB, ctx.get(SUB, subId) ?? sub));
|
|
95
|
+
}
|
|
96
|
+
// the default payment method pays the first invoice now: its charge (with its ledger entry) comes first
|
|
97
|
+
const chargeId = pays && piId ? await mintCharge(ctx, piId, { currency, customer, payment_method: payWith, invoice: invoiceId }, invoiceTotal) : null;
|
|
98
|
+
const invoiceLines = itemsData.map((it, i) => {
|
|
99
|
+
const price = it.price && typeof it.price === 'object' ? it.price : undefined;
|
|
100
|
+
const quantity = itemQuantity(it);
|
|
101
|
+
return {
|
|
102
|
+
id: `il_twin_${invoiceId}_${i + 1}`, object: 'line_item', type: 'subscription',
|
|
103
|
+
amount: priceAmount(price, quantity), currency, quantity, proration: false,
|
|
104
|
+
price: it.price ?? null, subscription: subId, invoice_item: null, subscription_item: it.id ?? null,
|
|
105
|
+
period: { start: periodStart, end: periodEnd }, description: null,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
await created(ctx, 'invoice', {
|
|
109
|
+
id: invoiceId,
|
|
110
|
+
...finalizedFields(ctx, { id: invoiceId, customer }, now),
|
|
111
|
+
status: isFree || pays ? 'paid' : 'open', livemode: false, currency, collection_method: 'charge_automatically',
|
|
112
|
+
auto_advance: !(isFree || pays), attempt_count: payWith && !isFree ? 1 : 0, attempted: !isFree && behavior !== 'default_incomplete',
|
|
113
|
+
customer, subscription: subId, billing_reason: 'subscription_create', charge: chargeId,
|
|
114
|
+
amount_due: invoiceTotal, amount_paid: isFree || pays ? invoiceTotal : 0, amount_remaining: isFree || pays ? 0 : invoiceTotal,
|
|
115
|
+
amount_overpaid: 0, amount_paid_off_stripe: 0, amount_shipping: 0,
|
|
116
|
+
subtotal, total: invoiceTotal, starting_balance: 0,
|
|
117
|
+
post_payment_credit_notes_amount: 0, pre_payment_credit_notes_amount: 0,
|
|
118
|
+
period_start: periodStart, period_end: periodEnd,
|
|
119
|
+
default_tax_rates: [], discounts: discounts[0] ? [discounts[0]] : [],
|
|
120
|
+
lines: { object: 'list', data: invoiceLines, has_more: false, total_count: invoiceLines.length, url: `/v1/invoices/${invoiceId}/lines` },
|
|
121
|
+
automatic_tax: { enabled: false, liability: null, status: null },
|
|
122
|
+
status_transitions: { finalized_at: now, marked_uncollectible_at: null, paid_at: isFree || pays ? now : null, voided_at: null },
|
|
123
|
+
issuer: { type: 'self' },
|
|
124
|
+
payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
|
|
125
|
+
payment_intent: piId,
|
|
126
|
+
}, {});
|
|
127
|
+
if (piId) {
|
|
128
|
+
// paid by the default payment method, or waiting on one (declined, absent, or collected client-side)
|
|
129
|
+
await created(ctx, 'payment_intent', { id: piId, amount: invoiceTotal, currency, customer, invoice: invoiceId, ...(payWith ? { payment_method: pays ? payWith : null } : {}) }, {
|
|
130
|
+
status: pays ? 'succeeded' : 'requires_payment_method', client_secret: mintClientSecret(piId), livemode: false,
|
|
131
|
+
capture_method: 'automatic', amount_capturable: 0, amount_received: pays ? invoiceTotal : 0, next_action: null,
|
|
132
|
+
latest_charge: chargeId,
|
|
133
|
+
last_payment_error: declined ? { type: 'card_error', code: declined.code, decline_code: declined.decline_code ?? null, message: declined.message, payment_method: { id: payWith } } : null,
|
|
134
|
+
automatic_payment_methods: null, payment_method_types: ['card', 'link'], payment_method_options: {},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// the first invoice is finalized: the `once` coupon it took is spent (renewals.ts spendOnceCoupon)
|
|
138
|
+
await spendOnceCoupon(ctx, ctx.get('invoice', invoiceId) ?? {});
|
|
139
|
+
return ctx.reply(expanded(ctx, SUB, ctx.get(SUB, subId) ?? sub));
|
|
140
|
+
};
|
|
141
|
+
const listSubscriptions = async (ctx) => {
|
|
142
|
+
await advanceBilling(ctx);
|
|
143
|
+
return list(ctx, SUB, where(ctx, newest(ctx, SUB), {
|
|
144
|
+
customer: (s, v) => s.customer === v,
|
|
145
|
+
status: (s, v) => (v === 'all' ? true : s.status === v),
|
|
146
|
+
price: (s, v) => subscriptionHasPrice(s, String(v)),
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
const searchSubscriptions = async (ctx) => {
|
|
150
|
+
await advanceBilling(ctx);
|
|
151
|
+
return search(ctx, SUB);
|
|
152
|
+
};
|
|
153
|
+
const retrieveSubscription = async (ctx) => {
|
|
154
|
+
const id = at(ctx, 'subscription_exposed_id');
|
|
155
|
+
await advanceBilling(ctx, (sub) => sub.id === id);
|
|
156
|
+
const sub = ctx.get(SUB, id);
|
|
157
|
+
return sub ? ctx.reply(expanded(ctx, SUB, sub)) : subMissing(ctx, id);
|
|
158
|
+
};
|
|
159
|
+
// pause_collection pauses billing (the subscription stays active; the paused event fires) and
|
|
160
|
+
// clearing it resumes; cancel_at_period_end schedules or withdraws a cancel; a coupon replaces the
|
|
161
|
+
// discount and an empty one clears it
|
|
162
|
+
/** pause_collection pauses billing (the paused event fires) and clearing it resumes
|
|
163
|
+
* (docs.stripe.com/billing/subscriptions/pause-payment); answers the event, or nothing for a value it does not take. */
|
|
164
|
+
function pauseCollection(pc, fields) {
|
|
165
|
+
const isClear = pc === '' || pc === null || (typeof pc === 'object' && pc !== null && Object.keys(pc).length === 0);
|
|
166
|
+
if (isClear) {
|
|
167
|
+
fields.pause_collection = null;
|
|
168
|
+
return 'customer.subscription.resumed';
|
|
169
|
+
}
|
|
170
|
+
if (!pc || typeof pc !== 'object')
|
|
171
|
+
return undefined;
|
|
172
|
+
const p = pc;
|
|
173
|
+
fields.pause_collection = { behavior: typeof p.behavior === 'string' ? p.behavior : 'void', resumes_at: p.resumes_at !== undefined ? Number(p.resumes_at) : null };
|
|
174
|
+
return 'customer.subscription.paused';
|
|
175
|
+
}
|
|
176
|
+
/** A coupon that replaces the subscription's discount, or an empty one that clears it (docs.stripe.com/api/subscriptions/update#update_subscription-discounts). */
|
|
177
|
+
function replacedDiscounts(ctx, couponId, sub) {
|
|
178
|
+
if (couponId === '')
|
|
179
|
+
return [];
|
|
180
|
+
const coupon = ctx.get('coupon', couponId);
|
|
181
|
+
return coupon ? [buildDiscount(coupon, String(sub.customer ?? ''), Number(ctx.now()), { subscription: String(sub.id) })] : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
|
|
182
|
+
}
|
|
183
|
+
/** The longest trial Stripe takes: `trial_end` "Can be at most two years from `billing_cycle_anchor`"
|
|
184
|
+
* (docs.stripe.com/api/subscriptions/update#update_subscription-trial_end); "The trial period must be 730 days (2 years)
|
|
185
|
+
* or less" (docs.stripe.com/billing/subscriptions/trials/free-trials). */
|
|
186
|
+
const MAX_TRIAL = 730 * 86400;
|
|
187
|
+
/** A `trial_end` Stripe refuses: the parameter is `"now" | timestamp` (docs.stripe.com/api/subscriptions/update), and a
|
|
188
|
+
* timestamp that is not in the future answers "Invalid timestamp: must be an integer Unix timestamp in the future."
|
|
189
|
+
* (Stripe's own message, as its SDKs' users report it: github.com/stripe/stripe-dotnet/issues/805,
|
|
190
|
+
* github.com/stripe/stripe-ruby/issues/682). Where the documentation stops and the twin decides: a value that is no
|
|
191
|
+
* number at all, and one more than two years on, answer that same message. */
|
|
192
|
+
export function trialEndRefused(ctx, value, now) {
|
|
193
|
+
const t = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : NaN;
|
|
194
|
+
return value === undefined || value === 'now' || (Number.isInteger(t) && t > now && t <= now + MAX_TRIAL) ? undefined : invalidTrialEnd(ctx);
|
|
195
|
+
}
|
|
196
|
+
function invalidTrialEnd(ctx) {
|
|
197
|
+
return ctx.refuse({ status: 400, param: 'trial_end', message: 'Invalid timestamp: must be an integer Unix timestamp in the future.' });
|
|
198
|
+
}
|
|
199
|
+
/** An update's `trial_end` on the subscription, into `fields`.
|
|
200
|
+
*
|
|
201
|
+
* `now` on a trialing subscription ends its trial at once: "The special value `now` can be provided to end the customer's
|
|
202
|
+
* trial immediately" (docs.stripe.com/api/subscriptions/update#update_subscription-trial_end; the trials guide's "To end a
|
|
203
|
+
* trial early … setting the `trial_end` value to … **now** to end immediately", docs.stripe.com/billing/subscriptions/
|
|
204
|
+
* trials/free-trials). The update resets the billing date and charges at once: "Switching prices does not normally change
|
|
205
|
+
* the billing date or generate an immediate charge unless … A trial starts or ends. In these cases, we … immediately
|
|
206
|
+
* charge the customer using the new price, and reset the billing date" (docs.stripe.com/api/subscriptions/update), and
|
|
207
|
+
* "Stripe immediately attempts payment when a subscription's billing cycle anchor is reset … When billing is performed
|
|
208
|
+
* immediately, but the required payment fails, the subscription change request succeeds and the subscription transitions
|
|
209
|
+
* to `past_due`" (docs.stripe.com/billing/subscriptions/upgrade-downgrade#immediate-payment). So a new period starts now,
|
|
210
|
+
* its invoice is made (`invoice.created`) and charged to the subscription's default payment method, else the
|
|
211
|
+
* customer's (paid: `invoice.paid`, the subscription `active`; declined or none: the invoice open, `past_due`), and the
|
|
212
|
+
* subscription's own write sends `customer.subscription.updated`. `payment_behavior` (the same page): `error_if_incomplete`
|
|
213
|
+
* answers 402 and leaves the subscription as it was, `default_incomplete` leaves the invoice open without an attempt.
|
|
214
|
+
*
|
|
215
|
+
* A future timestamp moves the trial's end, and the period and billing anchor with it: "The `billing_cycle_anchor` will be
|
|
216
|
+
* updated to the `trial_end` value" (the same parameter); on a subscription out of its trial it starts a new one ("You can
|
|
217
|
+
* add a new trial on a non-trialing subscription by updating the subscription while specifying trial_end", and for
|
|
218
|
+
* classic billing mode "the `trial_start` field remains set to the start of the first trial", docs.stripe.com/billing/
|
|
219
|
+
* subscriptions/trials/free-trials).
|
|
220
|
+
*
|
|
221
|
+
* Where the documentation stops and the twin decides: the invoice's billing_reason is `subscription_update` ("A
|
|
222
|
+
* subscription was updated", docs.stripe.com/api/invoices/object#invoice_object-billing_reason); `now` on a subscription
|
|
223
|
+
* that is not trialing has no trial to end and changes nothing; the credit Stripe prorates for the unused time when a new
|
|
224
|
+
* trial starts mid-period is not made (as `proration_behavior=none` would have it); `pending_if_incomplete` answers as
|
|
225
|
+
* `allow_incomplete` (no pending update is modelled). Answers a refusal, or the invoice to collect once the subscription is written. */
|
|
226
|
+
async function applyTrialEnd(ctx, sub, fields, now) {
|
|
227
|
+
const value = ctx.params.trial_end;
|
|
228
|
+
delete fields.trial_end;
|
|
229
|
+
const id = String(sub.id);
|
|
230
|
+
if (value !== 'now' || sub.status !== 'trialing')
|
|
231
|
+
return value === 'now' ? undefined : movedTrial(sub, fields, Number(value), now);
|
|
232
|
+
// the period's invoice bills the items as this update leaves them, under its discount
|
|
233
|
+
const next = { ...sub, ...fields, items: ctx.get(SUB, id)?.items ?? sub.items };
|
|
234
|
+
const { interval, interval_count } = resolveSubscriptionBillingInterval((next.items?.data) ?? [], finder(ctx));
|
|
235
|
+
const period = { start: now, end: addBillingInterval(now, interval, interval_count) };
|
|
236
|
+
const invoice = await draftSubscriptionInvoice(ctx, next, { ...period, reason: 'subscription_update' });
|
|
237
|
+
Object.assign(fields, { trial_end: now, billing_cycle_anchor: now, current_period_start: period.start, current_period_end: period.end, latest_invoice: invoice });
|
|
238
|
+
const payer = trialPayer(ctx, sub);
|
|
239
|
+
return { invoice, ...(payer ? { payer } : {}), attempt: ctx.params.payment_behavior !== 'default_incomplete' };
|
|
240
|
+
}
|
|
241
|
+
/** A trial moved to end at `end`, or a new one to `end` on a subscription out of its trial (applyTrialEnd). */
|
|
242
|
+
function movedTrial(sub, fields, end, now) {
|
|
243
|
+
Object.assign(fields, {
|
|
244
|
+
status: 'trialing', trial_end: end, trial_start: sub.trial_start ?? now, billing_cycle_anchor: end,
|
|
245
|
+
current_period_end: end, ...(sub.status !== 'trialing' ? { current_period_start: now } : {}),
|
|
246
|
+
});
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/** What ending the trial charges: the update's own default payment method, else the subscription's, else its customer's. */
|
|
250
|
+
function trialPayer(ctx, sub) {
|
|
251
|
+
const given = ctx.params.default_payment_method;
|
|
252
|
+
return typeof given === 'string' && given ? given : payerOf(ctx, sub);
|
|
253
|
+
}
|
|
254
|
+
/** What an update's `trial_end` refuses before anything is written: a new trial the machine does not allow from the
|
|
255
|
+
* subscription's status, or, under `error_if_incomplete`, ending a trial that cannot be paid at once ("If payment fails,
|
|
256
|
+
* return an HTTP `402` status code and don't update the subscription", docs.stripe.com/api/subscriptions/update). */
|
|
257
|
+
function trialEndBlocked(ctx, sub, discounts) {
|
|
258
|
+
if (ctx.params.trial_end !== 'now')
|
|
259
|
+
return newTrialRefused(ctx, sub);
|
|
260
|
+
if (sub.status !== 'trialing')
|
|
261
|
+
return undefined;
|
|
262
|
+
const charge = trialEndCharge(ctx, sub, discounts);
|
|
263
|
+
if (ctx.params.payment_behavior === 'error_if_incomplete' && charge.due > 0 && !charge.pays)
|
|
264
|
+
return firstPaymentRefused(ctx, charge.declined);
|
|
265
|
+
// the move ending the trial makes (paid or nothing due: active; else past_due) is the machine's, asked before
|
|
266
|
+
// anything is written
|
|
267
|
+
const refused = ctx.legal(SUB, 'status', ctx.call.operation.id, 'trialing', charge.due <= 0 || charge.pays ? 'active' : 'past_due', String(sub.id));
|
|
268
|
+
return refused ? ctx.refuse(refused) : undefined;
|
|
269
|
+
}
|
|
270
|
+
/** A price named by id, or given as an object. */
|
|
271
|
+
function priceRow(ctx, price) {
|
|
272
|
+
return typeof price === 'string' ? ctx.get('price', price) : price && typeof price === 'object' ? price : undefined;
|
|
273
|
+
}
|
|
274
|
+
/** One period of the subscription's items as an update's `items` would leave them, before any discount: an entry naming
|
|
275
|
+
* an item changes its price and quantity (a new price resets the quantity to 1 unless one is given) or deletes it, and
|
|
276
|
+
* one naming none adds an item (applyItemChanges). */
|
|
277
|
+
function dueAfter(ctx, sub, entries) {
|
|
278
|
+
const lines = new Map();
|
|
279
|
+
for (const it of (sub.items?.data) ?? [])
|
|
280
|
+
lines.set(String(it.id), { price: priceRow(ctx, it.price), quantity: itemQuantity(it) });
|
|
281
|
+
entries.forEach((e, i) => entryAfter(ctx, lines, e, i));
|
|
282
|
+
return [...lines.values()].reduce((sum, l) => sum + priceAmount(l.price, l.quantity), 0);
|
|
283
|
+
}
|
|
284
|
+
/** One entry of an update's `items` on the lines it would leave (dueAfter). */
|
|
285
|
+
function entryAfter(ctx, lines, e, i) {
|
|
286
|
+
// an inline price bills as the Price inlinePrice makes of it
|
|
287
|
+
const d = e.price_data && typeof e.price_data === 'object' ? e.price_data : undefined;
|
|
288
|
+
const inline = d ? (d.unit_amount_decimal !== undefined ? { unit_amount_decimal: String(d.unit_amount_decimal) } : { unit_amount: Math.trunc(Number(d.unit_amount) || 0) }) : undefined;
|
|
289
|
+
const price = typeof e.price === 'string' && e.price ? priceRow(ctx, e.price) : inline;
|
|
290
|
+
const named = typeof e.id === 'string' && e.id;
|
|
291
|
+
const key = named ? String(e.id) : `new_${i}`;
|
|
292
|
+
// only a named item is deleted; an entry naming none adds one, as applyItemChanges does
|
|
293
|
+
if (named && asBool(e.deleted)) {
|
|
294
|
+
lines.delete(key);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const was = lines.get(key);
|
|
298
|
+
const quantity = e.quantity !== undefined ? Math.max(0, Math.trunc(Number(e.quantity) || 0)) : price && was && price.id !== was.price?.id ? 1 : was?.quantity ?? 1;
|
|
299
|
+
lines.set(key, { price: price ?? was?.price, quantity });
|
|
300
|
+
}
|
|
301
|
+
/** A new trial on a subscription whose status the machine does not let start one. */
|
|
302
|
+
function newTrialRefused(ctx, sub) {
|
|
303
|
+
const refused = sub.status === 'trialing' ? undefined : ctx.legal(SUB, 'status', ctx.call.operation.id, sub.status, 'trialing', String(sub.id));
|
|
304
|
+
return refused ? ctx.refuse(refused) : undefined;
|
|
305
|
+
}
|
|
306
|
+
/** What ending the trial charges, judged before anything is written: the period of the items as this update leaves them
|
|
307
|
+
* (dueAfter) under the discount it leaves, and whether the payer pays it (an attempt made, a payer, no decline). */
|
|
308
|
+
function trialEndCharge(ctx, sub, discounts) {
|
|
309
|
+
const subtotal = dueAfter(ctx, sub, subscriptionItemEntries(ctx.params.items));
|
|
310
|
+
// the discount the subscription carries after this update takes this invoice (renewals.ts draftSubscriptionInvoice)
|
|
311
|
+
const discount = (discounts ?? sub.discounts ?? [])[0];
|
|
312
|
+
const coupon = discount && typeof discount === 'object' ? discount.coupon : undefined;
|
|
313
|
+
const due = subtotal - (coupon ? applyCouponDiscount(subtotal, coupon) : 0);
|
|
314
|
+
const payer = trialPayer(ctx, sub);
|
|
315
|
+
const attempt = ctx.params.payment_behavior !== 'default_incomplete';
|
|
316
|
+
const declined = payer && attempt ? declineFor(finder(ctx), { payment_method: payer }) : undefined;
|
|
317
|
+
return { due, pays: attempt && !!payer && !declined, ...(declined ? { declined } : {}) };
|
|
318
|
+
}
|
|
319
|
+
const update = async (ctx) => {
|
|
320
|
+
const id = at(ctx, 'subscription_exposed_id');
|
|
321
|
+
// a period that has already ended renews (or a trial that has, ends) before the update applies (renewals.ts)
|
|
322
|
+
await advanceBilling(ctx, (s) => s.id === id);
|
|
323
|
+
const sub = ctx.get(SUB, id);
|
|
324
|
+
if (!sub)
|
|
325
|
+
return subMissing(ctx, id);
|
|
326
|
+
const params = ctx.params;
|
|
327
|
+
const now = clockNow(ctx, sub.customer);
|
|
328
|
+
const badTrial = trialEndRefused(ctx, params.trial_end, now);
|
|
329
|
+
if (badTrial)
|
|
330
|
+
return badTrial;
|
|
331
|
+
// the update's instructions are no attributes of the subscription (docs.stripe.com/api/subscriptions/object lists none)
|
|
332
|
+
const { payment_behavior: _pb, proration_behavior: _prb, proration_date: _pd, trial_from_plan: _tfp, ...attributes } = params;
|
|
333
|
+
const fields = { ...attributes };
|
|
334
|
+
let op = 'subscription.update';
|
|
335
|
+
if ('pause_collection' in params)
|
|
336
|
+
op = pauseCollection(params.pause_collection, fields) ?? op;
|
|
337
|
+
const couponId = subscriptionCouponParam(params);
|
|
338
|
+
const discounts = couponId !== undefined ? replacedDiscounts(ctx, couponId, sub) : undefined;
|
|
339
|
+
if (discounts instanceof Response)
|
|
340
|
+
return discounts;
|
|
341
|
+
// `items` changes the subscription's items, never replaces the list with the request (applyItemChanges); every
|
|
342
|
+
// entry is checked before a trial's end is judged payable
|
|
343
|
+
const itemsRefused = 'items' in params ? itemChangesRefused(ctx, id, subscriptionItemEntries(params.items)) : undefined;
|
|
344
|
+
if (itemsRefused)
|
|
345
|
+
return itemsRefused;
|
|
346
|
+
const blocked = 'trial_end' in params ? trialEndBlocked(ctx, sub, discounts) : undefined;
|
|
347
|
+
if (blocked)
|
|
348
|
+
return blocked;
|
|
349
|
+
if ('items' in params) {
|
|
350
|
+
await applyItemChanges(ctx, id, subscriptionItemEntries(params.items));
|
|
351
|
+
delete fields.items;
|
|
352
|
+
}
|
|
353
|
+
if (discounts)
|
|
354
|
+
fields.discounts = discounts;
|
|
355
|
+
if (discounts && discounts.length)
|
|
356
|
+
await redeemCoupon(ctx, couponId);
|
|
357
|
+
delete fields.coupon;
|
|
358
|
+
// `trial_end`: the trial ended now (its period's invoice made, to collect below) or moved (applyTrialEnd)
|
|
359
|
+
const trial = 'trial_end' in params ? await applyTrialEnd(ctx, sub, fields, now) : undefined;
|
|
360
|
+
// scheduling a cancel at the period's end sets when it will be canceled (the period's end as this update leaves it),
|
|
361
|
+
// and withdrawing it clears that (docs.stripe.com/api/subscriptions/object#subscription_object-cancel_at); a
|
|
362
|
+
// scheduled cancel follows a period this update moves
|
|
363
|
+
if ('cancel_at_period_end' in params) {
|
|
364
|
+
fields.cancel_at_period_end = asBool(params.cancel_at_period_end);
|
|
365
|
+
Object.assign(fields, fields.cancel_at_period_end ? { cancel_at: fields.current_period_end ?? sub.current_period_end ?? null, canceled_at: Number(ctx.now()) } : { cancel_at: null, canceled_at: null });
|
|
366
|
+
}
|
|
367
|
+
else if (sub.cancel_at_period_end === true && fields.current_period_end !== undefined)
|
|
368
|
+
fields.cancel_at = fields.current_period_end;
|
|
369
|
+
if (trial?.invoice) {
|
|
370
|
+
// the trial's end is charged at once: paid (or nothing due), the subscription is active; not, past_due
|
|
371
|
+
// (applyTrialEnd; the move was asked in trialEndBlocked). Finalizing the invoice spends a `once` coupon, this
|
|
372
|
+
// update's own among them.
|
|
373
|
+
const pays = await collectSubscriptionInvoice(ctx, trial.invoice, now, 'api', { ...(trial.payer ? { payer: trial.payer } : {}), attempt: trial.attempt });
|
|
374
|
+
fields.status = pays ? 'active' : 'past_due';
|
|
375
|
+
if (fields.discounts !== undefined)
|
|
376
|
+
fields.discounts = keptAfter(fields.discounts, ctx.get('invoice', trial.invoice) ?? {});
|
|
377
|
+
}
|
|
378
|
+
return ctx.reply(await ctx.write(SUB, id, fields, op));
|
|
379
|
+
};
|
|
380
|
+
/** A new item named with neither `price` nor `price_data`: "One of `price` or `price_data` is required"
|
|
381
|
+
* (docs.stripe.com/api/subscriptions/update). */
|
|
382
|
+
function noItemPrice(ctx) {
|
|
383
|
+
return fail(ctx, 'Missing required param: items[price].', 400, 'parameter_missing');
|
|
384
|
+
}
|
|
385
|
+
/** A subscription asked to fail when its first invoice cannot be paid at once (payment_behavior=error_if_incomplete):
|
|
386
|
+
* the card's decline, or no payment method to charge (docs.stripe.com/api/subscriptions/create#create_subscription-payment_behavior). */
|
|
387
|
+
function firstPaymentRefused(ctx, declined) {
|
|
388
|
+
return declined ? send(ctx, cardError(declined)) : fail(ctx, 'This customer has no attached payment source or default payment method.', 400, 'resource_missing');
|
|
389
|
+
}
|
|
390
|
+
/** An update's `items`: "A list of up to 20 subscription items, each with an attached price"
|
|
391
|
+
* (docs.stripe.com/api/subscriptions/update): an entry naming an item's `id` changes that item (its price, quantity,
|
|
392
|
+
* metadata) or, with `deleted` ("A flag that, if set to `true`, will delete the specified item"), removes it; "If you
|
|
393
|
+
* omit `id`, the API adds a new subscription item rather than updating the existing one". Each takes `price` or
|
|
394
|
+
* `price_data`, "Data used to generate a new Price object inline. One of `price` or `price_data` is required", and
|
|
395
|
+
* "When changing a subscription item's price, `quantity` is set to 1 unless a `quantity` parameter is provided". An
|
|
396
|
+
* inline price is made as the manage-prices guide says: "By default, prices created with `price_data` are effectively
|
|
397
|
+
* archived (they're marked as `active=false`)" (docs.stripe.com/products-prices/manage-prices).
|
|
398
|
+
* Where the documentation stops and the twin decides: the update is all or nothing, every entry checked before any is
|
|
399
|
+
* written (Stripe answers one error for the request); the refusals are worded by the twin. */
|
|
400
|
+
async function applyItemChanges(ctx, subId, entries) {
|
|
401
|
+
// every entry is checked first: a refusal leaves the subscription as it was
|
|
402
|
+
const refused = itemChangesRefused(ctx, subId, entries);
|
|
403
|
+
if (refused)
|
|
404
|
+
return refused;
|
|
405
|
+
return writeItemChanges(ctx, subId, entries);
|
|
406
|
+
}
|
|
407
|
+
/** The refusal of an update's `items`, checked entry by entry before any is written (applyItemChanges). */
|
|
408
|
+
function itemChangesRefused(ctx, subId, entries) {
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
const priceRef = typeof entry.price === 'string' && entry.price ? entry.price : undefined;
|
|
411
|
+
if (priceRef && !ctx.get('price', priceRef))
|
|
412
|
+
return fail(ctx, `No such price: '${priceRef}'`, 400, 'resource_missing');
|
|
413
|
+
const inline = !priceRef && entry.price_data && typeof entry.price_data === 'object' ? entry.price_data : undefined;
|
|
414
|
+
if (inline) {
|
|
415
|
+
const refused = refuseInlinePrice(ctx, inline);
|
|
416
|
+
if (refused)
|
|
417
|
+
return refused;
|
|
418
|
+
}
|
|
419
|
+
if (typeof entry.id === 'string' && entry.id) {
|
|
420
|
+
const si = ctx.get('subscription_item', entry.id);
|
|
421
|
+
if (!si || si.subscription !== subId)
|
|
422
|
+
return fail(ctx, `No such subscription_item: '${entry.id}'`, 400, 'resource_missing');
|
|
423
|
+
}
|
|
424
|
+
else if (!priceRef && !inline)
|
|
425
|
+
return noItemPrice(ctx);
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
/** An update's checked `items`, written. */
|
|
430
|
+
async function writeItemChanges(ctx, subId, entries) {
|
|
431
|
+
for (const entry of entries) {
|
|
432
|
+
const inline = typeof entry.price === 'string' && entry.price ? undefined : entry.price_data && typeof entry.price_data === 'object' ? entry.price_data : undefined;
|
|
433
|
+
const priceRef = typeof entry.price === 'string' && entry.price ? entry.price : inline ? await inlinePrice(ctx, inline) : undefined;
|
|
434
|
+
// only a price that changes resets the quantity; one naming the item's own price leaves it
|
|
435
|
+
const was = typeof entry.id === 'string' && entry.id ? ctx.get('subscription_item', entry.id)?.price : undefined;
|
|
436
|
+
const wasId = typeof was === 'string' ? was : was && typeof was === 'object' ? String(was.id ?? '') : undefined;
|
|
437
|
+
const quantity = entry.quantity !== undefined ? { quantity: Math.max(0, Math.trunc(Number(entry.quantity) || 0)) } : priceRef && priceRef !== wasId ? { quantity: 1 } : {};
|
|
438
|
+
const metadata = entry.metadata && typeof entry.metadata === 'object' ? { metadata: entry.metadata } : {};
|
|
439
|
+
if (typeof entry.id === 'string' && entry.id) {
|
|
440
|
+
if (asBool(entry.deleted)) {
|
|
441
|
+
await ctx.write('subscription_item', entry.id, { deleted: true }, 'subscription_item.delete');
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
await ctx.write('subscription_item', entry.id, { ...(priceRef ? subscriptionItemPrice(priceRef, finder(ctx)) : {}), ...quantity, ...metadata }, 'subscription_item.update');
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
await created(ctx, 'subscription_item', { subscription: subId, ...subscriptionItemPrice(priceRef, finder(ctx)), quantity: 1, ...quantity, ...metadata }, { livemode: false, metadata: {}, discounts: [], tax_rates: [], billing_thresholds: null });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
await syncItems(ctx, subId);
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
/** A subscription item's `price_data` Stripe would refuse: its required `currency`, `product` (an existing one) and
|
|
454
|
+
* `recurring[interval]`, and "Only one of `unit_amount` and `unit_amount_decimal` can be set". */
|
|
455
|
+
function refuseInlinePrice(ctx, d) {
|
|
456
|
+
for (const name of ['currency', 'product'])
|
|
457
|
+
if (typeof d[name] !== 'string' || !d[name])
|
|
458
|
+
return fail(ctx, `Missing required param: items[price_data][${name}].`, 400, 'parameter_missing');
|
|
459
|
+
if (!ctx.get('product', String(d.product)))
|
|
460
|
+
return fail(ctx, `No such product: '${String(d.product)}'`, 400, 'resource_missing');
|
|
461
|
+
const interval = d.recurring?.interval;
|
|
462
|
+
if (!['day', 'week', 'month', 'year'].includes(String(interval)))
|
|
463
|
+
return fail(ctx, 'Missing required param: items[price_data][recurring][interval].', 400, 'parameter_missing');
|
|
464
|
+
if (d.unit_amount !== undefined && d.unit_amount_decimal !== undefined)
|
|
465
|
+
return fail(ctx, 'Only one of `unit_amount` and `unit_amount_decimal` can be set.', 400, 'parameter_invalid');
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
/** The inline Price a `price_data` generates: recurring, archived (active=false). Answers its id. */
|
|
469
|
+
async function inlinePrice(ctx, d) {
|
|
470
|
+
const r = d.recurring;
|
|
471
|
+
const decimal = d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0));
|
|
472
|
+
const price = await created(ctx, 'price', {
|
|
473
|
+
product: d.product, currency: String(d.currency).toLowerCase(),
|
|
474
|
+
// a decimal amount that is not a whole number of the smallest unit has no integer unit_amount (as semantics/plans.ts)
|
|
475
|
+
unit_amount: d.unit_amount_decimal !== undefined ? (Number.isInteger(Number(d.unit_amount_decimal)) ? Number(d.unit_amount_decimal) : null) : Math.trunc(Number(d.unit_amount) || 0), unit_amount_decimal: decimal,
|
|
476
|
+
recurring: { interval: r.interval, interval_count: Math.max(1, Math.trunc(Number(r.interval_count ?? 1)) || 1), usage_type: 'licensed', trial_period_days: null, meter: null },
|
|
477
|
+
tax_behavior: typeof d.tax_behavior === 'string' ? d.tax_behavior : 'unspecified',
|
|
478
|
+
}, { active: false, livemode: false, billing_scheme: 'per_unit', type: 'recurring', metadata: {}, lookup_key: null, nickname: null });
|
|
479
|
+
return String(price.id);
|
|
480
|
+
}
|
|
481
|
+
const deleteDiscount = async (ctx) => {
|
|
482
|
+
const id = at(ctx, 'subscription_exposed_id');
|
|
483
|
+
const sub = ctx.get(SUB, id);
|
|
484
|
+
if (!sub)
|
|
485
|
+
return subMissing(ctx, id);
|
|
486
|
+
const current = (sub.discounts ?? [])[0] ?? sub.discount;
|
|
487
|
+
if (!current)
|
|
488
|
+
return fail(ctx, `No active discount for subscription: '${id}'`, 404, 'resource_missing');
|
|
489
|
+
await ctx.write(SUB, id, { discounts: [] }, 'subscription.update');
|
|
490
|
+
return ctx.reply(deletedDiscount(typeof current === 'string' ? { id: current } : current, ctx.now()));
|
|
491
|
+
};
|
|
492
|
+
// canceling keeps the subscription, now canceled, stamped with when it was canceled and ended
|
|
493
|
+
// (https://docs.stripe.com/api/subscriptions/object#subscription_object-canceled_at, #subscription_object-ended_at)
|
|
494
|
+
const cancel = async (ctx) => {
|
|
495
|
+
const id = at(ctx, 'subscription_exposed_id');
|
|
496
|
+
const sub = ctx.get(SUB, id);
|
|
497
|
+
if (!sub)
|
|
498
|
+
return subMissing(ctx, id);
|
|
499
|
+
const refused = ctx.legal(SUB, 'status', 'DeleteSubscriptionsSubscriptionExposedId', sub.status, undefined, id);
|
|
500
|
+
if (refused)
|
|
501
|
+
return ctx.refuse(refused);
|
|
502
|
+
const now = ctx.now();
|
|
503
|
+
// why it was canceled: the request's `cancellation_details` ("Details about why this subscription was cancelled"), and
|
|
504
|
+
// the reason Stripe records for a cancel asked through the API, as the cancel page's example answers it:
|
|
505
|
+
// `"reason": "cancellation_requested"` (docs.stripe.com/api/subscriptions/cancel)
|
|
506
|
+
const given = ctx.params.cancellation_details && typeof ctx.params.cancellation_details === 'object' ? ctx.params.cancellation_details : {};
|
|
507
|
+
const cancellation_details = { comment: given.comment ?? null, feedback: given.feedback ?? null, reason: 'cancellation_requested' };
|
|
508
|
+
return ctx.reply(await ctx.write(SUB, id, { status: 'canceled', canceled_at: now, ended_at: now, cancellation_details }, 'subscription.cancel'));
|
|
509
|
+
};
|
|
510
|
+
// ── items: each write keeps the parent subscription's items.data in step, rebuilt from the live items ──
|
|
511
|
+
/** A new subscription's items, each its own subscription_item as Stripe keeps it (/v1/subscription_items/{id}). */
|
|
512
|
+
export async function storeItems(ctx, subId, items) {
|
|
513
|
+
for (const item of items) {
|
|
514
|
+
if (typeof item.id !== 'string' || ctx.get('subscription_item', item.id))
|
|
515
|
+
continue;
|
|
516
|
+
await ctx.write('subscription_item', item.id, { ...item, subscription: subId }, 'subscription_item.create');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function syncItems(ctx, subId) {
|
|
520
|
+
if (!subId)
|
|
521
|
+
return;
|
|
522
|
+
const items = ctx.rows('subscription_item').filter((r) => r.subscription === subId);
|
|
523
|
+
await ctx.write(SUB, subId, { items: { object: 'list', data: items, has_more: false, total_count: items.length, url: `/v1/subscription_items?subscription=${subId}` } }, 'subscription.items_synced');
|
|
524
|
+
}
|
|
525
|
+
/** A subscription item with its billing period: its subscription's (basil moved the period onto the items). */
|
|
526
|
+
const withPeriod = (ctx, si) => {
|
|
527
|
+
const sub = ctx.get(SUB, String(si.subscription));
|
|
528
|
+
return { ...si, current_period_start: si.current_period_start ?? sub?.current_period_start ?? null, current_period_end: si.current_period_end ?? sub?.current_period_end ?? null };
|
|
529
|
+
};
|
|
530
|
+
const createItem = async (ctx) => {
|
|
531
|
+
const params = ctx.params;
|
|
532
|
+
const subId = typeof params.subscription === 'string' ? params.subscription : '';
|
|
533
|
+
if (!subId)
|
|
534
|
+
return fail(ctx, 'Missing required param: subscription.', 400, 'parameter_missing');
|
|
535
|
+
if (!ctx.get(SUB, subId))
|
|
536
|
+
return subMissing(ctx, subId);
|
|
537
|
+
const priceRef = typeof params.price === 'string' ? params.price : '';
|
|
538
|
+
if (!priceRef && !(params.price_data && typeof params.price_data === 'object'))
|
|
539
|
+
return fail(ctx, 'Missing required param: price.', 400, 'parameter_missing');
|
|
540
|
+
if (priceRef && !ctx.get('price', priceRef))
|
|
541
|
+
return fail(ctx, `No such price: '${priceRef}'`, 400, 'resource_missing');
|
|
542
|
+
const quantity = params.quantity !== undefined ? Math.max(0, Math.trunc(Number(params.quantity) || 0)) : 1;
|
|
543
|
+
// "metadata: Set of key-value pairs that you can attach to an object" (docs.stripe.com/api/subscription_items/create)
|
|
544
|
+
const metadata = params.metadata && typeof params.metadata === 'object' ? { metadata: params.metadata } : {};
|
|
545
|
+
const body = await created(ctx, 'subscription_item', { subscription: subId, ...(priceRef ? subscriptionItemPrice(priceRef, finder(ctx)) : { price: null, plan: null }), quantity, ...metadata }, { livemode: false, metadata: {} });
|
|
546
|
+
await syncItems(ctx, subId);
|
|
547
|
+
return ctx.reply(withPeriod(ctx, body));
|
|
548
|
+
};
|
|
549
|
+
const listItems = async (ctx) => {
|
|
550
|
+
const subId = typeof ctx.params.subscription === 'string' ? ctx.params.subscription : '';
|
|
551
|
+
if (!subId)
|
|
552
|
+
return fail(ctx, 'Missing required param: subscription.', 400, 'parameter_missing');
|
|
553
|
+
if (!ctx.get(SUB, subId))
|
|
554
|
+
return subMissing(ctx, subId);
|
|
555
|
+
return list(ctx, 'subscription_item', newest(ctx, 'subscription_item').filter((si) => si.subscription === subId).map((si) => withPeriod(ctx, si)));
|
|
556
|
+
};
|
|
557
|
+
const ITEM_ATTRIBUTES = new Set(['billing_thresholds', 'discounts', 'metadata', 'price', 'quantity', 'tax_rates']);
|
|
558
|
+
const itemMissing = (ctx, id) => fail(ctx, `No such subscription_item: '${id}'`, 404, 'resource_missing');
|
|
559
|
+
const updateItem = async (ctx) => {
|
|
560
|
+
const id = at(ctx, 'item');
|
|
561
|
+
const si = ctx.get('subscription_item', id);
|
|
562
|
+
if (!si)
|
|
563
|
+
return itemMissing(ctx, id);
|
|
564
|
+
// only the item's own attributes are kept; the update's instructions (off_session, payment_behavior, price_data,
|
|
565
|
+
// proration_behavior, proration_date) are not attributes of the Subscription Item object
|
|
566
|
+
// (docs.stripe.com/api/subscription_items/object lists none of them)
|
|
567
|
+
const fields = Object.fromEntries(Object.entries(ctx.params).filter(([k]) => ITEM_ATTRIBUTES.has(k)));
|
|
568
|
+
if (ctx.params.quantity !== undefined)
|
|
569
|
+
fields.quantity = Math.max(0, Math.trunc(Number(ctx.params.quantity) || 0));
|
|
570
|
+
if (typeof ctx.params.price === 'string' && ctx.params.price && !ctx.get('price', ctx.params.price))
|
|
571
|
+
return fail(ctx, `No such price: '${ctx.params.price}'`, 400, 'resource_missing');
|
|
572
|
+
// the item carries its Price and plan, as the subscription's own items do (subscriptionItemPrice)
|
|
573
|
+
if (typeof ctx.params.price === 'string' && ctx.params.price)
|
|
574
|
+
Object.assign(fields, subscriptionItemPrice(ctx.params.price, finder(ctx)));
|
|
575
|
+
const out = await ctx.write('subscription_item', id, fields, 'subscription_item.update');
|
|
576
|
+
await syncItems(ctx, String(si.subscription));
|
|
577
|
+
return ctx.reply(withPeriod(ctx, out));
|
|
578
|
+
};
|
|
579
|
+
const retrieveItem = async (ctx) => {
|
|
580
|
+
const si = ctx.get('subscription_item', at(ctx, 'item'));
|
|
581
|
+
return si ? ctx.reply(withPeriod(ctx, si)) : itemMissing(ctx, at(ctx, 'item'));
|
|
582
|
+
};
|
|
583
|
+
const deleteItem = async (ctx) => {
|
|
584
|
+
const id = at(ctx, 'item');
|
|
585
|
+
const si = ctx.get('subscription_item', id);
|
|
586
|
+
if (!si)
|
|
587
|
+
return itemMissing(ctx, id);
|
|
588
|
+
await ctx.write('subscription_item', id, { deleted: true }, 'subscription_item.delete');
|
|
589
|
+
await syncItems(ctx, String(si.subscription));
|
|
590
|
+
return ctx.reply({ id, object: 'subscription_item', deleted: true });
|
|
591
|
+
};
|
|
592
|
+
export const subscriptions = {
|
|
593
|
+
PostSubscriptions: create,
|
|
594
|
+
GetSubscriptions: listSubscriptions,
|
|
595
|
+
GetSubscriptionsSearch: searchSubscriptions,
|
|
596
|
+
GetSubscriptionsSubscriptionExposedId: retrieveSubscription,
|
|
597
|
+
PostSubscriptionsSubscriptionExposedId: update,
|
|
598
|
+
DeleteSubscriptionsSubscriptionExposedIdDiscount: deleteDiscount,
|
|
599
|
+
DeleteSubscriptionsSubscriptionExposedId: cancel,
|
|
600
|
+
PostSubscriptionItems: createItem,
|
|
601
|
+
GetSubscriptionItems: listItems,
|
|
602
|
+
PostSubscriptionItemsItem: updateItem,
|
|
603
|
+
GetSubscriptionItemsItem: retrieveItem,
|
|
604
|
+
DeleteSubscriptionItemsItem: deleteItem,
|
|
605
|
+
};
|