@firedrill-tools/stripe 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +433 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-unavailable.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/refund-committed-lost.scenario.json +11 -0
- package/firedrill/stripe-api-unavailable.drill.json +463 -0
- package/firedrill/stripe-denied.drill.json +74 -0
- package/firedrill/stripe-large-pages.drill.json +153 -0
- package/firedrill/stripe-live-mode.drill.json +136 -0
- package/firedrill/stripe-mcp-aliases.drill.json +406 -0
- package/firedrill/stripe-no-permissions.drill.json +1143 -0
- package/firedrill/stripe-rate-limited.drill.json +616 -0
- package/firedrill/stripe-refund-committed-lost.drill.json +139 -0
- package/firedrill/stripe-rest-flow.drill.json +1401 -0
- package/firedrill/stripe-restricted-key.drill.json +171 -0
- package/firedrill/tools/stripe/app/assets/ATTRIBUTION.md +36 -0
- package/firedrill/tools/stripe/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/stripe/app/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/app.js +456 -0
- package/firedrill/tools/stripe/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/stripe/app/site/assets/stripe-s.svg +1 -0
- package/firedrill/tools/stripe/app/site/assets/stripe.svg +1 -0
- package/firedrill/tools/stripe/app/site/icons.js +90 -0
- package/firedrill/tools/stripe/app/site/index.html +137 -0
- package/firedrill/tools/stripe/app/site/pages-billing.js +902 -0
- package/firedrill/tools/stripe/app/site/pages-catalog.js +314 -0
- package/firedrill/tools/stripe/app/site/pages-customers.js +416 -0
- package/firedrill/tools/stripe/app/site/pages-home.js +373 -0
- package/firedrill/tools/stripe/app/site/pages-payments.js +502 -0
- package/firedrill/tools/stripe/app/site/store.js +99 -0
- package/firedrill/tools/stripe/app/site/styles.css +2512 -0
- package/firedrill/tools/stripe/app/site/ui.js +767 -0
- package/firedrill/tools/stripe/app/site/widgets.js +707 -0
- package/firedrill/tools/stripe/behavior.mjs +148 -0
- package/firedrill/tools/stripe/lib/cards.mjs +53 -0
- package/firedrill/tools/stripe/lib/form.mjs +204 -0
- package/firedrill/tools/stripe/lib/ids.mjs +85 -0
- package/firedrill/tools/stripe/lib/money.mjs +35 -0
- package/firedrill/tools/stripe/lib/objects.mjs +229 -0
- package/firedrill/tools/stripe/lib/periods.mjs +41 -0
- package/firedrill/tools/stripe/lib/size.mjs +55 -0
- package/firedrill/tools/stripe/lib/state.mjs +230 -0
- package/firedrill/tools/stripe/lib/validate.mjs +184 -0
- package/firedrill/tools/stripe/lib/wire.mjs +98 -0
- package/firedrill/tools/stripe/ops/billing.mjs +914 -0
- package/firedrill/tools/stripe/ops/catalog.mjs +203 -0
- package/firedrill/tools/stripe/ops/customers.mjs +241 -0
- package/firedrill/tools/stripe/ops/dashboard.mjs +29 -0
- package/firedrill/tools/stripe/ops/payments.mjs +608 -0
- package/firedrill/tools/stripe/stripe.tool.json +28833 -0
- package/firedrill/world.json +8527 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +7999 -0
- package/test/conformance.mjs +1133 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
// Invoice items, invoices and subscriptions. Invoices follow draft → open → paid | void; subscriptions create
|
|
2
|
+
// their first invoice and attempt the first payment in the same transaction. No renewal cycling, dunning or
|
|
3
|
+
// proration runs in this Tool (documented in the README).
|
|
4
|
+
|
|
5
|
+
import { nextId } from "../lib/ids.mjs";
|
|
6
|
+
import { decimalString, formatAmount } from "../lib/money.mjs";
|
|
7
|
+
import { applyExpand, makeView, renderInvoice, renderInvoiceItem, renderSubscription, validateExpand } from "../lib/objects.mjs";
|
|
8
|
+
import { DAY, addInterval } from "../lib/periods.mjs";
|
|
9
|
+
import { requireFits } from "../lib/size.mjs";
|
|
10
|
+
import { allRows, getRow, invalid, invalidState, matchesRange, paginate, parameterMissing, parameterUnknown, requirePermission, requirePermissions, requireRow, resourceMissing } from "../lib/state.mjs";
|
|
11
|
+
import { isPlainObject, mergeMetadata, optionalBoolean, optionalEnum, optionalInteger, optionalString, rejectMangled, requireCurrency, requireInteger, requireString } from "../lib/validate.mjs";
|
|
12
|
+
import { requireAttachedMethod, resolvePaymentMethod } from "./customers.mjs";
|
|
13
|
+
import { accountInfo, confirmAttempt, declinedFailure, newIntentRow } from "./payments.mjs";
|
|
14
|
+
|
|
15
|
+
const COLLECTION_METHODS = ["charge_automatically", "send_invoice"];
|
|
16
|
+
const INVOICE_STATUSES = ["draft", "open", "paid", "uncollectible", "void"];
|
|
17
|
+
const SUBSCRIPTION_STATUSES = ["incomplete", "incomplete_expired", "trialing", "active", "past_due", "canceled", "unpaid", "paused"];
|
|
18
|
+
const PAYMENT_BEHAVIORS = ["default_incomplete", "allow_incomplete", "error_if_incomplete"];
|
|
19
|
+
const PRORATION_BEHAVIORS = ["create_prorations", "none", "always_invoice", "none_implicit"];
|
|
20
|
+
const PENDING_BEHAVIORS = ["include", "exclude"];
|
|
21
|
+
const MAX_ITEMS = 20;
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------------------------
|
|
24
|
+
// Shared builders
|
|
25
|
+
// ---------------------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function customerSnapshot(customer) {
|
|
28
|
+
return {
|
|
29
|
+
customer_address: customer.address === null ? null : { ...customer.address },
|
|
30
|
+
customer_email: customer.email,
|
|
31
|
+
customer_name: customer.name,
|
|
32
|
+
customer_phone: customer.phone,
|
|
33
|
+
customer_shipping: customer.shipping === null ? null : { ...customer.shipping, address: { ...customer.shipping.address } },
|
|
34
|
+
customer_tax_exempt: customer.tax_exempt,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pricingFor(price) {
|
|
39
|
+
return { price_details: { price: price.id, product: price.product }, type: "price_details", unit_amount_decimal: price.unit_amount_decimal };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lineBase(context, view, invoiceId, fields) {
|
|
43
|
+
return {
|
|
44
|
+
id: nextId(context, "il"),
|
|
45
|
+
object: "line_item",
|
|
46
|
+
amount: fields.amount,
|
|
47
|
+
currency: fields.currency,
|
|
48
|
+
description: fields.description,
|
|
49
|
+
discount_amounts: [],
|
|
50
|
+
discountable: true,
|
|
51
|
+
discounts: [],
|
|
52
|
+
invoice: invoiceId,
|
|
53
|
+
livemode: view.livemode,
|
|
54
|
+
metadata: fields.metadata,
|
|
55
|
+
parent: fields.parent,
|
|
56
|
+
period: fields.period,
|
|
57
|
+
pretax_credit_amounts: [],
|
|
58
|
+
pricing: fields.pricing,
|
|
59
|
+
quantity: fields.quantity,
|
|
60
|
+
quantity_decimal: decimalString(fields.quantity),
|
|
61
|
+
subtotal: fields.amount,
|
|
62
|
+
taxes: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lineFromInvoiceItem(context, view, invoiceId, item) {
|
|
67
|
+
return lineBase(context, view, invoiceId, {
|
|
68
|
+
amount: item.amount,
|
|
69
|
+
currency: item.currency,
|
|
70
|
+
description: item.description,
|
|
71
|
+
metadata: { ...item.metadata },
|
|
72
|
+
parent: { type: "invoice_item_details", invoice_item_details: { invoice_item: item.id, proration: false, proration_details: { credited_items: null }, subscription: null } },
|
|
73
|
+
period: { ...item.period },
|
|
74
|
+
pricing: item.pricing === null ? null : { ...item.pricing, price_details: { ...item.pricing.price_details } },
|
|
75
|
+
quantity: item.quantity,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function lineFromSubscriptionItem(context, view, invoiceId, subscription, item, product, trial) {
|
|
80
|
+
const price = item.price;
|
|
81
|
+
const description = trial
|
|
82
|
+
? `Trial period for ${product === null ? price.product : product.name}`
|
|
83
|
+
: `${item.quantity} × ${product === null ? price.product : product.name} (at ${formatAmount(price.unit_amount, price.currency)} / ${price.recurring.interval_count === 1 ? price.recurring.interval : `${price.recurring.interval_count} ${price.recurring.interval}s`})`;
|
|
84
|
+
return lineBase(context, view, invoiceId, {
|
|
85
|
+
amount: trial ? 0 : price.unit_amount * item.quantity,
|
|
86
|
+
currency: price.currency,
|
|
87
|
+
description,
|
|
88
|
+
metadata: {},
|
|
89
|
+
parent: { type: "subscription_item_details", subscription_item_details: { invoice_item: null, proration: false, proration_details: { credited_items: null }, subscription: subscription.id, subscription_item: item.id } },
|
|
90
|
+
period: { start: item.current_period_start, end: item.current_period_end },
|
|
91
|
+
pricing: pricingFor(price),
|
|
92
|
+
quantity: item.quantity,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withTotals(invoice) {
|
|
97
|
+
const subtotal = invoice.lines.data.reduce((sum, line) => sum + line.amount, 0);
|
|
98
|
+
// Customer balance credit is never applied (README Limitations), so the amount due is the subtotal.
|
|
99
|
+
const amountDue = Math.max(0, subtotal);
|
|
100
|
+
return {
|
|
101
|
+
...invoice,
|
|
102
|
+
lines: { ...invoice.lines, total_count: invoice.lines.data.length },
|
|
103
|
+
subtotal,
|
|
104
|
+
subtotal_excluding_tax: subtotal,
|
|
105
|
+
total: subtotal,
|
|
106
|
+
total_excluding_tax: subtotal,
|
|
107
|
+
amount_due: amountDue,
|
|
108
|
+
amount_remaining: Math.max(0, amountDue - invoice.amount_paid),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function newInvoiceRow(context, view, id, customer, fields) {
|
|
113
|
+
const account = accountInfo(context);
|
|
114
|
+
return withTotals({
|
|
115
|
+
id,
|
|
116
|
+
object: "invoice",
|
|
117
|
+
account_country: account.country,
|
|
118
|
+
account_name: account.business_name,
|
|
119
|
+
account_tax_ids: null,
|
|
120
|
+
amount_due: 0,
|
|
121
|
+
amount_overpaid: 0,
|
|
122
|
+
amount_paid: 0,
|
|
123
|
+
amount_remaining: 0,
|
|
124
|
+
amount_shipping: 0,
|
|
125
|
+
application: null,
|
|
126
|
+
attempt_count: 0,
|
|
127
|
+
attempted: false,
|
|
128
|
+
auto_advance: fields.auto_advance,
|
|
129
|
+
automatic_tax: { disabled_reason: null, enabled: false, liability: null, provider: null, status: null },
|
|
130
|
+
automatically_finalizes_at: null,
|
|
131
|
+
billing_reason: fields.billing_reason,
|
|
132
|
+
collection_method: fields.collection_method,
|
|
133
|
+
created: view.now,
|
|
134
|
+
currency: fields.currency,
|
|
135
|
+
custom_fields: null,
|
|
136
|
+
customer: customer.id,
|
|
137
|
+
...customerSnapshot(customer),
|
|
138
|
+
customer_tax_ids: [],
|
|
139
|
+
default_payment_method: fields.default_payment_method,
|
|
140
|
+
default_source: null,
|
|
141
|
+
default_tax_rates: [],
|
|
142
|
+
description: fields.description,
|
|
143
|
+
discounts: [],
|
|
144
|
+
due_date: fields.due_date,
|
|
145
|
+
effective_at: null,
|
|
146
|
+
ending_balance: null,
|
|
147
|
+
footer: null,
|
|
148
|
+
from_invoice: null,
|
|
149
|
+
hosted_invoice_url: null,
|
|
150
|
+
invoice_pdf: null,
|
|
151
|
+
issuer: { type: "self" },
|
|
152
|
+
last_finalization_error: null,
|
|
153
|
+
latest_revision: null,
|
|
154
|
+
lines: { object: "list", data: fields.lines, has_more: false, total_count: fields.lines.length, url: `/v1/invoices/${id}/lines` },
|
|
155
|
+
livemode: view.livemode,
|
|
156
|
+
metadata: fields.metadata,
|
|
157
|
+
next_payment_attempt: null,
|
|
158
|
+
number: null,
|
|
159
|
+
on_behalf_of: null,
|
|
160
|
+
paid_out_of_band: false,
|
|
161
|
+
parent: fields.parent,
|
|
162
|
+
payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
|
|
163
|
+
payments: { object: "list", data: [], has_more: false, total_count: 0, url: `/v1/invoice_payments?invoice=${id}` },
|
|
164
|
+
period_end: fields.period_end,
|
|
165
|
+
period_start: fields.period_start,
|
|
166
|
+
post_payment_credit_notes_amount: 0,
|
|
167
|
+
pre_payment_credit_notes_amount: 0,
|
|
168
|
+
receipt_number: null,
|
|
169
|
+
rendering: null,
|
|
170
|
+
shipping_cost: null,
|
|
171
|
+
shipping_details: null,
|
|
172
|
+
starting_balance: 0,
|
|
173
|
+
statement_descriptor: null,
|
|
174
|
+
status: "draft",
|
|
175
|
+
status_transitions: { finalized_at: null, marked_uncollectible_at: null, paid_at: null, voided_at: null },
|
|
176
|
+
subtotal: 0,
|
|
177
|
+
subtotal_excluding_tax: 0,
|
|
178
|
+
test_clock: null,
|
|
179
|
+
total: 0,
|
|
180
|
+
total_discount_amounts: [],
|
|
181
|
+
total_excluding_tax: 0,
|
|
182
|
+
total_pretax_credit_amounts: [],
|
|
183
|
+
total_taxes: [],
|
|
184
|
+
transfer_data: null,
|
|
185
|
+
webhooks_delivered_at: null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function emitInvoicePaid(context, invoice) {
|
|
190
|
+
context.events.emit("invoice.paid", {
|
|
191
|
+
id: invoice.id,
|
|
192
|
+
number: invoice.number,
|
|
193
|
+
customer: invoice.customer,
|
|
194
|
+
amount_paid: invoice.amount_paid,
|
|
195
|
+
currency: invoice.currency,
|
|
196
|
+
subscription: invoice.parent === null ? null : invoice.parent.subscription_details.subscription,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function customerDefaultMethod(context, customer) {
|
|
201
|
+
const id = customer.invoice_settings.default_payment_method;
|
|
202
|
+
if (id === null) return null;
|
|
203
|
+
const row = getRow(context, "payment_methods", id);
|
|
204
|
+
return row !== null && row.customer === customer.id ? row : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Finalize a draft: assign the number, freeze the snapshot and either open it with a PaymentIntent (positive
|
|
209
|
+
* total) or mark it paid (zero total). Writes invoice, customer and (maybe) a PaymentIntent; returns them.
|
|
210
|
+
*/
|
|
211
|
+
function finalizeInvoice(context, view, draft, customer) {
|
|
212
|
+
const number = `${customer.invoice_prefix}-${String(customer.next_invoice_sequence).padStart(4, "0")}`;
|
|
213
|
+
const nextCustomer = { ...customer, next_invoice_sequence: customer.next_invoice_sequence + 1 };
|
|
214
|
+
context.state.put("customers", nextCustomer.id, nextCustomer);
|
|
215
|
+
let invoice = withTotals({
|
|
216
|
+
...draft,
|
|
217
|
+
...customerSnapshot(customer),
|
|
218
|
+
number,
|
|
219
|
+
effective_at: view.now,
|
|
220
|
+
hosted_invoice_url: `https://invoice.stripe.test/i/${draft.id}`,
|
|
221
|
+
invoice_pdf: `https://invoice.stripe.test/i/${draft.id}/pdf`,
|
|
222
|
+
webhooks_delivered_at: view.now,
|
|
223
|
+
status_transitions: { ...draft.status_transitions, finalized_at: view.now },
|
|
224
|
+
});
|
|
225
|
+
let intent = null;
|
|
226
|
+
if (invoice.total > 0) {
|
|
227
|
+
const method = invoice.default_payment_method ?? customer.invoice_settings.default_payment_method;
|
|
228
|
+
const intentId = nextId(context, "pi");
|
|
229
|
+
intent = newIntentRow(view, intentId, {
|
|
230
|
+
amount: invoice.amount_due,
|
|
231
|
+
currency: invoice.currency,
|
|
232
|
+
customer: customer.id,
|
|
233
|
+
payment_method: method,
|
|
234
|
+
description: `Invoice ${number}`,
|
|
235
|
+
metadata: {},
|
|
236
|
+
statement_descriptor: accountInfo(context).statement_descriptor,
|
|
237
|
+
invoice: invoice.id,
|
|
238
|
+
});
|
|
239
|
+
intent = { ...intent, status: "requires_payment_method" };
|
|
240
|
+
context.state.put("payment_intents", intentId, intent);
|
|
241
|
+
const payment = {
|
|
242
|
+
id: nextId(context, "inpay"),
|
|
243
|
+
object: "invoice_payment",
|
|
244
|
+
amount_paid: null,
|
|
245
|
+
amount_requested: invoice.amount_due,
|
|
246
|
+
created: view.now,
|
|
247
|
+
currency: invoice.currency,
|
|
248
|
+
invoice: invoice.id,
|
|
249
|
+
is_default: true,
|
|
250
|
+
livemode: view.livemode,
|
|
251
|
+
payment: { type: "payment_intent", payment_intent: intentId, charge: null },
|
|
252
|
+
status: "open",
|
|
253
|
+
status_transitions: { canceled_at: null, paid_at: null },
|
|
254
|
+
};
|
|
255
|
+
invoice = { ...invoice, status: "open", payments: { ...invoice.payments, data: [payment], total_count: 1 } };
|
|
256
|
+
} else {
|
|
257
|
+
invoice = { ...invoice, status: "paid", amount_paid: 0, amount_remaining: 0, status_transitions: { ...invoice.status_transitions, paid_at: view.now } };
|
|
258
|
+
}
|
|
259
|
+
context.state.put("invoices", invoice.id, invoice);
|
|
260
|
+
if (invoice.status === "paid") emitInvoicePaid(context, invoice);
|
|
261
|
+
return { invoice, customer: nextCustomer, intent };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Mark an open invoice paid after its PaymentIntent succeeded; returns the stored invoice. */
|
|
265
|
+
function settlePaid(context, view, invoice, result, outOfBand) {
|
|
266
|
+
const payment = invoice.payments.data[0];
|
|
267
|
+
const paidPayment = payment === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: { ...payment, status: "paid", amount_paid: invoice.amount_due, payment: { ...payment.payment, charge: result === null ? null : result.charge.id }, status_transitions: { ...payment.status_transitions, paid_at: view.now } };
|
|
270
|
+
const paid = withTotals({
|
|
271
|
+
...invoice,
|
|
272
|
+
status: "paid",
|
|
273
|
+
amount_paid: invoice.amount_due,
|
|
274
|
+
attempt_count: invoice.attempt_count + (outOfBand ? 0 : 1),
|
|
275
|
+
attempted: outOfBand ? invoice.attempted : true,
|
|
276
|
+
paid_out_of_band: outOfBand,
|
|
277
|
+
status_transitions: { ...invoice.status_transitions, paid_at: view.now },
|
|
278
|
+
payments: paidPayment === undefined ? invoice.payments : { ...invoice.payments, data: [paidPayment] },
|
|
279
|
+
});
|
|
280
|
+
context.state.put("invoices", paid.id, paid);
|
|
281
|
+
const customer = getRow(context, "customers", paid.customer);
|
|
282
|
+
if (customer !== null && customer.delinquent) context.state.put("customers", customer.id, { ...customer, delinquent: false });
|
|
283
|
+
emitInvoicePaid(context, paid);
|
|
284
|
+
return paid;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function cancelInvoiceIntent(context, view, invoice, reason) {
|
|
288
|
+
const payment = invoice.payments.data[0];
|
|
289
|
+
if (payment === undefined) return invoice;
|
|
290
|
+
const intent = getRow(context, "payment_intents", payment.payment.payment_intent);
|
|
291
|
+
if (intent !== null && !["succeeded", "canceled"].includes(intent.status)) {
|
|
292
|
+
context.state.put("payment_intents", intent.id, { ...intent, status: "canceled", canceled_at: view.now, cancellation_reason: reason, next_action: null, amount_capturable: 0 });
|
|
293
|
+
}
|
|
294
|
+
const canceled = { ...payment, status: "canceled", status_transitions: { ...payment.status_transitions, canceled_at: view.now } };
|
|
295
|
+
return { ...invoice, payments: { ...invoice.payments, data: [canceled] } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function voidInvoice(context, view, invoice) {
|
|
299
|
+
const voided = { ...cancelInvoiceIntent(context, view, invoice, "void_invoice"), status: "void", status_transitions: { ...invoice.status_transitions, voided_at: view.now } };
|
|
300
|
+
context.state.put("invoices", voided.id, voided);
|
|
301
|
+
return voided;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function dueDateInput(context, input, collection) {
|
|
305
|
+
const days = optionalInteger(context, input, "days_until_due", undefined, { min: 1, max: 730 });
|
|
306
|
+
const dueDate = optionalInteger(context, input, "due_date", undefined, { min: 0 });
|
|
307
|
+
if (collection === "charge_automatically") {
|
|
308
|
+
if (days !== undefined) return invalid(context, "days_until_due may only be set for invoices with collection_method=send_invoice.", "parameter_invalid", "days_until_due");
|
|
309
|
+
if (dueDate !== undefined) return invalid(context, "due_date may only be set for invoices with collection_method=send_invoice.", "parameter_invalid", "due_date");
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (days !== undefined && dueDate !== undefined) return invalid(context, "You may only specify one of these parameters: days_until_due, due_date.", "parameter_invalid", "due_date");
|
|
313
|
+
if (days === undefined && dueDate === undefined) return parameterMissing(context, "days_until_due");
|
|
314
|
+
return { days, dueDate };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------------------------
|
|
318
|
+
// Invoice items
|
|
319
|
+
// ---------------------------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function priceIdInput(context, input) {
|
|
322
|
+
if (input.pricing !== undefined) {
|
|
323
|
+
if (input.price !== undefined) return invalid(context, "You may only specify one of these parameters: price, pricing.", "parameter_invalid", "pricing");
|
|
324
|
+
if (!isPlainObject(input.pricing)) return invalid(context, "Invalid pricing: must be a hash.", "parameter_invalid", "pricing");
|
|
325
|
+
for (const key of Object.keys(input.pricing)) if (key !== "price") return parameterUnknown(context, `pricing[${key}]`);
|
|
326
|
+
if (typeof input.pricing.price !== "string" || input.pricing.price.length === 0) return parameterMissing(context, "pricing[price]");
|
|
327
|
+
return { id: input.pricing.price, param: "pricing[price]" };
|
|
328
|
+
}
|
|
329
|
+
if (input.price !== undefined) return { id: requireString(context, input, "price", 255), param: "price" };
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function invoiceItemsCreate(input, context) {
|
|
334
|
+
requirePermissions(context, [["invoices", "write"], ["customers", "read"], ["products", "read"]]);
|
|
335
|
+
const view = makeView(context);
|
|
336
|
+
const expand = validateExpand(context, input.expand, "invoiceitem");
|
|
337
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
338
|
+
const priceRef = priceIdInput(context, input);
|
|
339
|
+
const quantity = optionalInteger(context, input, "quantity", 1, { min: 1 });
|
|
340
|
+
let amount;
|
|
341
|
+
let currency;
|
|
342
|
+
let pricing = null;
|
|
343
|
+
if (priceRef !== undefined) {
|
|
344
|
+
if (input.amount !== undefined) return invalid(context, "You may only specify one of these parameters: amount, price.", "parameter_invalid", "amount");
|
|
345
|
+
const price = getRow(context, "prices", priceRef.id);
|
|
346
|
+
if (price === null) return resourceMissing(context, "prices", priceRef.id, priceRef.param);
|
|
347
|
+
if (price.type !== "one_time") return invalid(context, `The price ${price.id} is a recurring price; invoice items require a one-time price.`, "parameter_invalid", priceRef.param);
|
|
348
|
+
if (!price.active) return invalid(context, `The price ${price.id} is not active.`, "parameter_invalid", priceRef.param);
|
|
349
|
+
if (input.currency !== undefined && requireCurrency(context, input.currency) !== price.currency) {
|
|
350
|
+
return invalid(context, `The currency ${input.currency} does not match the price's currency ${price.currency}.`, "parameter_invalid", "currency");
|
|
351
|
+
}
|
|
352
|
+
amount = price.unit_amount * quantity;
|
|
353
|
+
currency = price.currency;
|
|
354
|
+
pricing = pricingFor(price);
|
|
355
|
+
} else {
|
|
356
|
+
amount = requireInteger(context, input, "amount", { min: -99_999_999, max: 99_999_999 });
|
|
357
|
+
currency = requireCurrency(context, input.currency);
|
|
358
|
+
}
|
|
359
|
+
const description = optionalString(context, input, "description", null, 5_000);
|
|
360
|
+
const metadata = mergeMetadata(context, input.metadata);
|
|
361
|
+
let period = { start: view.now, end: view.now };
|
|
362
|
+
if (input.period !== undefined) {
|
|
363
|
+
if (!isPlainObject(input.period)) return invalid(context, "Invalid period: must be a hash with start and end.", "parameter_invalid", "period");
|
|
364
|
+
for (const key of Object.keys(input.period)) if (key !== "start" && key !== "end") return parameterUnknown(context, `period[${key}]`);
|
|
365
|
+
period = { start: requireInteger(context, input.period, "start", { min: 0 }), end: requireInteger(context, input.period, "end", { min: 0 }) };
|
|
366
|
+
if (period.end < period.start) return invalid(context, "period[end] must not be before period[start].", "parameter_invalid", "period[end]");
|
|
367
|
+
}
|
|
368
|
+
let invoice = null;
|
|
369
|
+
if (input.invoice !== undefined && input.invoice !== "") {
|
|
370
|
+
invoice = requireRow(context, "invoices", input.invoice, "invoice");
|
|
371
|
+
if (invoice.status !== "draft") return invalidState(context, `Invoice ${invoice.id} is no longer editable; only draft invoices accept new items.`, "invoice_not_editable");
|
|
372
|
+
if (invoice.customer !== customer.id) return invalid(context, `Invoice ${invoice.id} belongs to customer ${invoice.customer}, not ${customer.id}.`, "parameter_invalid", "invoice");
|
|
373
|
+
if (invoice.currency !== currency) return invalid(context, `Invoice ${invoice.id} is denominated in ${invoice.currency}; items must use the same currency.`, "parameter_invalid", "currency");
|
|
374
|
+
}
|
|
375
|
+
const id = nextId(context, "ii");
|
|
376
|
+
const item = {
|
|
377
|
+
id,
|
|
378
|
+
object: "invoiceitem",
|
|
379
|
+
amount,
|
|
380
|
+
currency,
|
|
381
|
+
customer: customer.id,
|
|
382
|
+
date: view.now,
|
|
383
|
+
description: description ?? (pricing === null ? null : (getRow(context, "products", pricing.price_details.product)?.name ?? null)),
|
|
384
|
+
discountable: true,
|
|
385
|
+
discounts: [],
|
|
386
|
+
invoice: invoice === null ? null : invoice.id,
|
|
387
|
+
livemode: view.livemode,
|
|
388
|
+
metadata,
|
|
389
|
+
parent: null,
|
|
390
|
+
period,
|
|
391
|
+
pricing,
|
|
392
|
+
proration: false,
|
|
393
|
+
quantity,
|
|
394
|
+
test_clock: null,
|
|
395
|
+
};
|
|
396
|
+
context.state.put("invoice_items", id, item);
|
|
397
|
+
if (invoice !== null) {
|
|
398
|
+
const line = lineFromInvoiceItem(context, view, invoice.id, item);
|
|
399
|
+
const next = withTotals({ ...invoice, lines: { ...invoice.lines, data: [...invoice.lines.data, line] } });
|
|
400
|
+
// The invoice must stay retrievable in one response once this line is added (with its largest expansions).
|
|
401
|
+
requireFits(context, renderInvoice(view, next), `The invoice ${invoice.id}`);
|
|
402
|
+
context.state.put("invoices", next.id, next);
|
|
403
|
+
}
|
|
404
|
+
return applyExpand(view, renderInvoiceItem(view, item), expand, "invoiceitem");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------------------------
|
|
408
|
+
// Invoices
|
|
409
|
+
// ---------------------------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
export function invoicesCreate(input, context) {
|
|
412
|
+
requirePermissions(context, [["invoices", "write"], ["customers", "read"]]);
|
|
413
|
+
const view = makeView(context);
|
|
414
|
+
const expand = validateExpand(context, input.expand, "invoice");
|
|
415
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
416
|
+
const inferred = input.days_until_due !== undefined || input.due_date !== undefined ? "send_invoice" : "charge_automatically";
|
|
417
|
+
const collection = optionalEnum(context, input, "collection_method", COLLECTION_METHODS, inferred);
|
|
418
|
+
const due = dueDateInput(context, input, collection);
|
|
419
|
+
const pendingBehavior = optionalEnum(context, input, "pending_invoice_items_behavior", PENDING_BEHAVIORS, "include");
|
|
420
|
+
const defaultMethod = input.default_payment_method === undefined || input.default_payment_method === "" ? null : requireAttachedMethod(context, view, input.default_payment_method, customer, "default_payment_method").id;
|
|
421
|
+
const pending = allRows(context, "invoice_items")
|
|
422
|
+
.filter((item) => item.customer === customer.id && item.invoice === null)
|
|
423
|
+
.sort((left, right) => left.date - right.date || (left.id < right.id ? -1 : 1));
|
|
424
|
+
const currency = input.currency !== undefined ? requireCurrency(context, input.currency) : pending.length > 0 ? pending[0].currency : accountInfo(context).default_currency;
|
|
425
|
+
optionalEnum(context, input, "proration_behavior", PRORATION_BEHAVIORS, undefined);
|
|
426
|
+
const id = nextId(context, "in");
|
|
427
|
+
const lines = [];
|
|
428
|
+
if (pendingBehavior === "include") {
|
|
429
|
+
for (const item of pending) {
|
|
430
|
+
if (item.currency !== currency) continue;
|
|
431
|
+
lines.push(lineFromInvoiceItem(context, view, id, item));
|
|
432
|
+
context.state.put("invoice_items", item.id, { ...item, invoice: id });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const invoice = newInvoiceRow(context, view, id, customer, {
|
|
436
|
+
auto_advance: optionalBoolean(context, input, "auto_advance", false),
|
|
437
|
+
billing_reason: "manual",
|
|
438
|
+
collection_method: collection,
|
|
439
|
+
currency,
|
|
440
|
+
default_payment_method: defaultMethod,
|
|
441
|
+
description: optionalString(context, input, "description", null, 1_500),
|
|
442
|
+
due_date: due === null ? null : due.dueDate ?? view.now + due.days * DAY,
|
|
443
|
+
lines,
|
|
444
|
+
metadata: mergeMetadata(context, input.metadata),
|
|
445
|
+
parent: null,
|
|
446
|
+
period_start: view.now,
|
|
447
|
+
period_end: view.now,
|
|
448
|
+
});
|
|
449
|
+
context.state.put("invoices", id, invoice);
|
|
450
|
+
return applyExpand(view, renderInvoice(view, invoice), expand, "invoice");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function invoicesRetrieve(input, context) {
|
|
454
|
+
requirePermission(context, "invoices", "read");
|
|
455
|
+
const view = makeView(context);
|
|
456
|
+
const expand = validateExpand(context, input.expand, "invoice");
|
|
457
|
+
const invoice = requireRow(context, "invoices", input.invoice, "invoice");
|
|
458
|
+
return applyExpand(view, renderInvoice(view, invoice), expand, "invoice");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function invoicesList(input, context) {
|
|
462
|
+
requirePermission(context, "invoices", "read");
|
|
463
|
+
rejectMangled(context, input, ["customer", "subscription", "status", "collection_method"]);
|
|
464
|
+
const view = makeView(context);
|
|
465
|
+
const expand = validateExpand(context, input.expand, "invoice", true);
|
|
466
|
+
const customer = optionalString(context, input, "customer", undefined, 255);
|
|
467
|
+
const status = optionalEnum(context, input, "status", INVOICE_STATUSES, undefined);
|
|
468
|
+
const subscription = optionalString(context, input, "subscription", undefined, 255);
|
|
469
|
+
const collection = optionalEnum(context, input, "collection_method", COLLECTION_METHODS, undefined);
|
|
470
|
+
const rows = allRows(context, "invoices").filter(
|
|
471
|
+
(invoice) =>
|
|
472
|
+
(customer === undefined || invoice.customer === customer) &&
|
|
473
|
+
(status === undefined || invoice.status === status) &&
|
|
474
|
+
(subscription === undefined || (invoice.parent !== null && invoice.parent.subscription_details.subscription === subscription)) &&
|
|
475
|
+
(collection === undefined || invoice.collection_method === collection) &&
|
|
476
|
+
matchesRange(invoice.created, input.created, context, "created") &&
|
|
477
|
+
matchesRange(invoice.due_date, input.due_date, context, "due_date"),
|
|
478
|
+
);
|
|
479
|
+
return paginate(context, "invoices", rows, input, "/v1/invoices", (row) => applyExpand(view, renderInvoice(view, row), expand, "invoice"));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function invoicesFinalize(input, context) {
|
|
483
|
+
requirePermission(context, "invoices", "write");
|
|
484
|
+
const view = makeView(context);
|
|
485
|
+
const expand = validateExpand(context, input.expand, "invoice");
|
|
486
|
+
const draft = requireRow(context, "invoices", input.invoice, "invoice");
|
|
487
|
+
if (draft.status !== "draft") return invalidState(context, `This invoice is already finalized (status: ${draft.status}); only draft invoices can be finalized.`, "invoice_not_editable");
|
|
488
|
+
if (draft.lines.data.length === 0) return invalidState(context, `Nothing to invoice for customer ${draft.customer}: the invoice has no line items.`, "invoice_no_customer_line_items");
|
|
489
|
+
const autoAdvance = optionalBoolean(context, input, "auto_advance", draft.auto_advance);
|
|
490
|
+
const customer = requireRow(context, "customers", draft.customer, "customer");
|
|
491
|
+
const { invoice } = finalizeInvoice(context, view, { ...draft, auto_advance: autoAdvance }, customer);
|
|
492
|
+
return applyExpand(view, renderInvoice(view, invoice), expand, "invoice");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function invoicesPay(input, context) {
|
|
496
|
+
requirePermissions(context, [["invoices", "write"], ["payment_intents", "write"]]);
|
|
497
|
+
const view = makeView(context);
|
|
498
|
+
const expand = validateExpand(context, input.expand, "invoice");
|
|
499
|
+
const invoice = requireRow(context, "invoices", input.invoice, "invoice");
|
|
500
|
+
if (invoice.status === "draft") return invalidState(context, "You can only pay an invoice once it has been finalized. Finalize the draft with /v1/invoices/:id/finalize first.", "invoice_not_finalized");
|
|
501
|
+
if (invoice.status === "paid") return invalidState(context, `Invoice ${invoice.id} is already paid.`, "invoice_already_paid");
|
|
502
|
+
if (invoice.status === "void") return invalidState(context, `Invoice ${invoice.id} is void and cannot be paid.`, "invoice_void");
|
|
503
|
+
const outOfBand = optionalBoolean(context, input, "paid_out_of_band", false);
|
|
504
|
+
const customer = requireRow(context, "customers", invoice.customer, "customer");
|
|
505
|
+
if (outOfBand) {
|
|
506
|
+
if (input.payment_method !== undefined) return invalid(context, "You may not specify a payment_method when marking an invoice as paid out of band.", "parameter_invalid", "payment_method");
|
|
507
|
+
const canceled = cancelInvoiceIntent(context, view, invoice, "automatic");
|
|
508
|
+
const paid = settlePaid(context, view, canceled, null, true);
|
|
509
|
+
return applyExpand(view, renderInvoice(view, paid), expand, "invoice");
|
|
510
|
+
}
|
|
511
|
+
const payment = invoice.payments.data[0];
|
|
512
|
+
const intent = payment === undefined ? null : getRow(context, "payment_intents", payment.payment.payment_intent);
|
|
513
|
+
if (intent === null) return invalidState(context, `Invoice ${invoice.id} has no PaymentIntent to pay; it was not finalized by this Tool.`, "invoice_payment_intent_missing");
|
|
514
|
+
let resolved;
|
|
515
|
+
if (input.payment_method !== undefined && input.payment_method !== "") {
|
|
516
|
+
resolved = resolvePaymentMethod(context, view, input.payment_method);
|
|
517
|
+
if (resolved.row !== undefined && resolved.row.customer !== customer.id) {
|
|
518
|
+
return invalidState(context, `The payment method '${resolved.row.id}' is not attached to customer '${customer.id}'.`, "payment_method_unattached");
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
const stored = intent.payment_method === null ? null : getRow(context, "payment_methods", intent.payment_method);
|
|
522
|
+
const method = stored !== null && stored.customer === customer.id ? stored : customerDefaultMethod(context, customer);
|
|
523
|
+
if (method === null) {
|
|
524
|
+
return invalidState(context, `This customer has no attached payment source or default payment method. Pass payment_method to /v1/invoices/${invoice.id}/pay or attach one to the customer.`, "invoice_no_payment_method");
|
|
525
|
+
}
|
|
526
|
+
resolved = { row: method };
|
|
527
|
+
}
|
|
528
|
+
const result = confirmAttempt(context, view, { ...intent, customer: customer.id }, resolved);
|
|
529
|
+
if (result.status === "declined") return declinedFailure(context, view, result, { intentCreated: false });
|
|
530
|
+
if (result.status !== "succeeded") {
|
|
531
|
+
return invalidState(context, `The payment for invoice ${invoice.id} requires customer authentication that this synthetic account cannot complete; use a card that does not require 3D Secure.`, "invoice_payment_intent_requires_action");
|
|
532
|
+
}
|
|
533
|
+
const paid = settlePaid(context, view, invoice, result, false);
|
|
534
|
+
return applyExpand(view, renderInvoice(view, paid), expand, "invoice");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function invoicesVoid(input, context) {
|
|
538
|
+
requirePermission(context, "invoices", "write");
|
|
539
|
+
const view = makeView(context);
|
|
540
|
+
const expand = validateExpand(context, input.expand, "invoice");
|
|
541
|
+
const invoice = requireRow(context, "invoices", input.invoice, "invoice");
|
|
542
|
+
if (invoice.status === "draft") return invalidState(context, "Draft invoices cannot be voided; delete the draft instead.", "invoice_not_finalized");
|
|
543
|
+
if (invoice.status === "paid") return invalidState(context, `Invoice ${invoice.id} is already paid and cannot be voided; issue a refund instead.`, "invoice_already_paid");
|
|
544
|
+
if (invoice.status === "void") return invalidState(context, `Invoice ${invoice.id} is already void.`, "invoice_void");
|
|
545
|
+
const voided = voidInvoice(context, view, invoice);
|
|
546
|
+
return applyExpand(view, renderInvoice(view, voided), expand, "invoice");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------------------------
|
|
550
|
+
// Subscriptions
|
|
551
|
+
// ---------------------------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
function planMirror(price) {
|
|
554
|
+
return {
|
|
555
|
+
id: price.id,
|
|
556
|
+
object: "plan",
|
|
557
|
+
active: price.active,
|
|
558
|
+
amount: price.unit_amount,
|
|
559
|
+
amount_decimal: price.unit_amount_decimal,
|
|
560
|
+
billing_scheme: price.billing_scheme,
|
|
561
|
+
created: price.created,
|
|
562
|
+
currency: price.currency,
|
|
563
|
+
interval: price.recurring.interval,
|
|
564
|
+
interval_count: price.recurring.interval_count,
|
|
565
|
+
livemode: price.livemode,
|
|
566
|
+
metadata: { ...price.metadata },
|
|
567
|
+
meter: null,
|
|
568
|
+
nickname: price.nickname,
|
|
569
|
+
product: price.product,
|
|
570
|
+
tiers_mode: null,
|
|
571
|
+
transform_usage: null,
|
|
572
|
+
trial_period_days: price.recurring.trial_period_days,
|
|
573
|
+
usage_type: "licensed",
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function requireRecurringPrice(context, id, param, reference) {
|
|
578
|
+
if (typeof id !== "string" || id.length === 0) return parameterMissing(context, param);
|
|
579
|
+
const price = getRow(context, "prices", id);
|
|
580
|
+
if (price === null) return resourceMissing(context, "prices", id, param);
|
|
581
|
+
if (!price.active) return invalid(context, `The price specified is inactive. This field only accepts active prices.`, "parameter_invalid", param);
|
|
582
|
+
if (price.type !== "recurring" || price.recurring === null) return invalid(context, `The price ${price.id} is a one-time price; subscriptions require recurring prices.`, "parameter_invalid", param);
|
|
583
|
+
if (reference !== undefined) {
|
|
584
|
+
if (reference.currency !== price.currency) return invalid(context, `All prices of a subscription must share one currency (${reference.currency}); ${price.id} is in ${price.currency}.`, "parameter_invalid", param);
|
|
585
|
+
if (reference.recurring.interval !== price.recurring.interval || reference.recurring.interval_count !== price.recurring.interval_count) {
|
|
586
|
+
return invalid(context, `All prices of a subscription must share one billing interval (${reference.recurring.interval_count} ${reference.recurring.interval}); ${price.id} bills every ${price.recurring.interval_count} ${price.recurring.interval}.`, "parameter_invalid", param);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return price;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function itemInput(context, entry, index) {
|
|
593
|
+
if (!isPlainObject(entry)) return invalid(context, `Invalid items[${index}]: must be a hash.`, "parameter_invalid", `items[${index}]`);
|
|
594
|
+
for (const key of Object.keys(entry)) {
|
|
595
|
+
if (!["id", "price", "quantity", "deleted", "metadata"].includes(key)) return parameterUnknown(context, `items[${index}][${key}]`);
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
id: entry.id === undefined ? undefined : requireString(context, entry, "id", 255),
|
|
599
|
+
price: entry.price,
|
|
600
|
+
quantity: optionalInteger(context, entry, "quantity", undefined, { min: 1, max: 999_999 }),
|
|
601
|
+
deleted: optionalBoolean(context, entry, "deleted", false),
|
|
602
|
+
metadata: entry.metadata,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function newSubscriptionItem(context, view, subscription, price, quantity, metadata, period) {
|
|
607
|
+
return {
|
|
608
|
+
id: nextId(context, "si"),
|
|
609
|
+
object: "subscription_item",
|
|
610
|
+
billing_thresholds: null,
|
|
611
|
+
created: view.now,
|
|
612
|
+
current_period_end: period.end,
|
|
613
|
+
current_period_start: period.start,
|
|
614
|
+
discounts: [],
|
|
615
|
+
metadata,
|
|
616
|
+
plan: planMirror(price),
|
|
617
|
+
price: { ...price, metadata: { ...price.metadata }, recurring: { ...price.recurring } },
|
|
618
|
+
quantity,
|
|
619
|
+
subscription: subscription,
|
|
620
|
+
tax_rates: [],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function trialInput(context, input, now) {
|
|
625
|
+
const days = optionalInteger(context, input, "trial_period_days", undefined, { min: 1, max: 730 });
|
|
626
|
+
const end = input.trial_end;
|
|
627
|
+
if (days !== undefined && end !== undefined) return invalid(context, "You may only specify one of these parameters: trial_end, trial_period_days.", "parameter_invalid", "trial_end");
|
|
628
|
+
if (days !== undefined) return now + days * DAY;
|
|
629
|
+
if (end === undefined || end === "now") return null;
|
|
630
|
+
if (!Number.isInteger(end)) return invalid(context, `Invalid integer: ${String(end)}`, "parameter_invalid_integer", "trial_end");
|
|
631
|
+
if (end <= now) return invalid(context, "trial_end must be in the future, or the string 'now'.", "parameter_invalid", "trial_end");
|
|
632
|
+
return end;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function subscriptionsCreate(input, context) {
|
|
636
|
+
requirePermissions(context, [["subscriptions", "write"], ["customers", "read"], ["products", "read"], ["invoices", "write"], ["payment_intents", "write"]]);
|
|
637
|
+
const view = makeView(context);
|
|
638
|
+
const expand = validateExpand(context, input.expand, "subscription");
|
|
639
|
+
const customer = requireRow(context, "customers", input.customer, "customer");
|
|
640
|
+
if (!Array.isArray(input.items) || input.items.length === 0) return parameterMissing(context, "items");
|
|
641
|
+
if (input.items.length > MAX_ITEMS) return invalid(context, `A subscription can have at most ${MAX_ITEMS} items.`, "parameter_invalid", "items");
|
|
642
|
+
const entries = input.items.map((entry, index) => itemInput(context, entry, index));
|
|
643
|
+
const prices = [];
|
|
644
|
+
entries.forEach((entry, index) => {
|
|
645
|
+
if (entry.id !== undefined) return parameterUnknown(context, `items[${index}][id]`);
|
|
646
|
+
prices.push(requireRecurringPrice(context, entry.price, `items[${index}][price]`, prices[0]));
|
|
647
|
+
});
|
|
648
|
+
const collection = optionalEnum(context, input, "collection_method", COLLECTION_METHODS, "charge_automatically");
|
|
649
|
+
const due = dueDateInput(context, input, collection);
|
|
650
|
+
const behavior = optionalEnum(context, input, "payment_behavior", PAYMENT_BEHAVIORS, "default_incomplete");
|
|
651
|
+
optionalEnum(context, input, "proration_behavior", PRORATION_BEHAVIORS, undefined);
|
|
652
|
+
const defaultMethod = input.default_payment_method === undefined || input.default_payment_method === "" ? null : requireAttachedMethod(context, view, input.default_payment_method, customer, "default_payment_method");
|
|
653
|
+
const trialEnd = trialInput(context, input, view.now);
|
|
654
|
+
const anchor = optionalInteger(context, input, "billing_cycle_anchor", view.now, { min: view.now });
|
|
655
|
+
const cancelAtPeriodEnd = optionalBoolean(context, input, "cancel_at_period_end", false);
|
|
656
|
+
const cancelAt = optionalInteger(context, input, "cancel_at", null, { min: view.now });
|
|
657
|
+
const description = optionalString(context, input, "description", null, 500);
|
|
658
|
+
const metadata = mergeMetadata(context, input.metadata);
|
|
659
|
+
const trialing = trialEnd !== null;
|
|
660
|
+
const reference = prices[0];
|
|
661
|
+
const period = trialing ? { start: view.now, end: trialEnd } : { start: anchor, end: addInterval(anchor, reference.recurring.interval, reference.recurring.interval_count) };
|
|
662
|
+
const id = nextId(context, "sub");
|
|
663
|
+
const items = entries.map((entry, index) => newSubscriptionItem(context, view, id, prices[index], entry.quantity ?? 1, mergeMetadata(context, entry.metadata), period));
|
|
664
|
+
const invoiceId = nextId(context, "in");
|
|
665
|
+
const lines = items.map((item) => lineFromSubscriptionItem(context, view, invoiceId, { id }, item, getRow(context, "products", item.price.product), trialing));
|
|
666
|
+
const draft = newInvoiceRow(context, view, invoiceId, customer, {
|
|
667
|
+
auto_advance: collection === "charge_automatically",
|
|
668
|
+
billing_reason: "subscription_create",
|
|
669
|
+
collection_method: collection,
|
|
670
|
+
currency: reference.currency,
|
|
671
|
+
default_payment_method: defaultMethod === null ? null : defaultMethod.id,
|
|
672
|
+
description: null,
|
|
673
|
+
due_date: due === null ? null : due.dueDate ?? view.now + due.days * DAY,
|
|
674
|
+
lines,
|
|
675
|
+
metadata: {},
|
|
676
|
+
parent: { type: "subscription_details", subscription_details: { metadata: { ...metadata }, pause_collection: null, subscription: id } },
|
|
677
|
+
period_start: period.start,
|
|
678
|
+
period_end: period.end,
|
|
679
|
+
});
|
|
680
|
+
let { invoice, intent } = finalizeInvoice(context, view, draft, customer);
|
|
681
|
+
let status = trialing ? "trialing" : "active";
|
|
682
|
+
if (invoice.status === "open" && collection === "charge_automatically") {
|
|
683
|
+
const method = defaultMethod ?? customerDefaultMethod(context, customer);
|
|
684
|
+
if (method === null) {
|
|
685
|
+
if (behavior === "error_if_incomplete") {
|
|
686
|
+
return invalidState(context, "This customer has no attached payment source or default payment method. Please consider adding a default payment method or pass default_payment_method.", "customer_missing_payment_method");
|
|
687
|
+
}
|
|
688
|
+
status = "incomplete";
|
|
689
|
+
} else {
|
|
690
|
+
const result = confirmAttempt(context, view, intent, { row: method });
|
|
691
|
+
if (result.status === "succeeded") {
|
|
692
|
+
invoice = settlePaid(context, view, invoice, result, false);
|
|
693
|
+
intent = result.intent;
|
|
694
|
+
} else if (result.status === "declined") {
|
|
695
|
+
if (behavior === "error_if_incomplete") return declinedFailure(context, view, result, { intentCreated: true });
|
|
696
|
+
status = "incomplete";
|
|
697
|
+
invoice = withTotals({ ...invoice, attempt_count: 1, attempted: true });
|
|
698
|
+
context.state.put("invoices", invoice.id, invoice);
|
|
699
|
+
intent = result.intent;
|
|
700
|
+
} else {
|
|
701
|
+
status = "incomplete";
|
|
702
|
+
intent = result.intent;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const subscription = {
|
|
707
|
+
id,
|
|
708
|
+
object: "subscription",
|
|
709
|
+
application: null,
|
|
710
|
+
application_fee_percent: null,
|
|
711
|
+
automatic_tax: { disabled_reason: null, enabled: false, liability: null },
|
|
712
|
+
billing_cycle_anchor: period.start,
|
|
713
|
+
billing_cycle_anchor_config: null,
|
|
714
|
+
billing_mode: { type: "flexible", updated_at: view.now },
|
|
715
|
+
billing_schedules: [],
|
|
716
|
+
billing_thresholds: null,
|
|
717
|
+
cancel_at: cancelAtPeriodEnd ? period.end : cancelAt,
|
|
718
|
+
cancel_at_period_end: cancelAtPeriodEnd,
|
|
719
|
+
canceled_at: cancelAtPeriodEnd ? view.now : null,
|
|
720
|
+
cancellation_details: { comment: null, feedback: null, reason: cancelAtPeriodEnd ? "cancellation_requested" : null },
|
|
721
|
+
collection_method: collection,
|
|
722
|
+
created: view.now,
|
|
723
|
+
currency: reference.currency,
|
|
724
|
+
customer: customer.id,
|
|
725
|
+
days_until_due: due === null ? null : due.days ?? null,
|
|
726
|
+
default_payment_method: defaultMethod === null ? null : defaultMethod.id,
|
|
727
|
+
default_source: null,
|
|
728
|
+
default_tax_rates: [],
|
|
729
|
+
description,
|
|
730
|
+
discounts: [],
|
|
731
|
+
ended_at: null,
|
|
732
|
+
invoice_settings: { account_tax_ids: null, issuer: { type: "self" } },
|
|
733
|
+
items: { object: "list", data: items, has_more: false, total_count: items.length, url: `/v1/subscription_items?subscription=${id}` },
|
|
734
|
+
latest_invoice: invoice.id,
|
|
735
|
+
livemode: view.livemode,
|
|
736
|
+
metadata,
|
|
737
|
+
next_pending_invoice_item_invoice: null,
|
|
738
|
+
on_behalf_of: null,
|
|
739
|
+
pause_collection: null,
|
|
740
|
+
payment_settings: { payment_method_options: null, payment_method_types: null, save_default_payment_method: "off" },
|
|
741
|
+
pending_invoice_item_interval: null,
|
|
742
|
+
pending_setup_intent: null,
|
|
743
|
+
pending_update: null,
|
|
744
|
+
schedule: null,
|
|
745
|
+
start_date: view.now,
|
|
746
|
+
status,
|
|
747
|
+
test_clock: null,
|
|
748
|
+
transfer_data: null,
|
|
749
|
+
trial_end: trialEnd,
|
|
750
|
+
trial_settings: { end_behavior: { missing_payment_method: "create_invoice" } },
|
|
751
|
+
trial_start: trialing ? view.now : null,
|
|
752
|
+
};
|
|
753
|
+
context.state.put("subscriptions", id, subscription);
|
|
754
|
+
return applyExpand(view, renderSubscription(view, subscription), expand, "subscription");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function subscriptionsRetrieve(input, context) {
|
|
758
|
+
requirePermission(context, "subscriptions", "read");
|
|
759
|
+
const view = makeView(context);
|
|
760
|
+
const expand = validateExpand(context, input.expand, "subscription");
|
|
761
|
+
const subscription = requireRow(context, "subscriptions", input.subscription, "subscription");
|
|
762
|
+
return applyExpand(view, renderSubscription(view, subscription), expand, "subscription");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function subscriptionsList(input, context) {
|
|
766
|
+
requirePermission(context, "subscriptions", "read");
|
|
767
|
+
rejectMangled(context, input, ["customer", "price", "status", "collection_method"]);
|
|
768
|
+
const view = makeView(context);
|
|
769
|
+
const expand = validateExpand(context, input.expand, "subscription", true);
|
|
770
|
+
const customer = optionalString(context, input, "customer", undefined, 255);
|
|
771
|
+
const price = optionalString(context, input, "price", undefined, 255);
|
|
772
|
+
const status = optionalEnum(context, input, "status", [...SUBSCRIPTION_STATUSES, "all", "ended"], undefined);
|
|
773
|
+
const collection = optionalEnum(context, input, "collection_method", COLLECTION_METHODS, undefined);
|
|
774
|
+
const matchesStatus = (subscription) => {
|
|
775
|
+
if (status === undefined) return subscription.status !== "canceled" && subscription.status !== "incomplete_expired";
|
|
776
|
+
if (status === "all") return true;
|
|
777
|
+
if (status === "ended") return subscription.status === "canceled" || subscription.status === "incomplete_expired";
|
|
778
|
+
return subscription.status === status;
|
|
779
|
+
};
|
|
780
|
+
const rows = allRows(context, "subscriptions").filter(
|
|
781
|
+
(subscription) =>
|
|
782
|
+
(customer === undefined || subscription.customer === customer) &&
|
|
783
|
+
(price === undefined || subscription.items.data.some((item) => item.price.id === price)) &&
|
|
784
|
+
(collection === undefined || subscription.collection_method === collection) &&
|
|
785
|
+
matchesStatus(subscription) &&
|
|
786
|
+
matchesRange(subscription.created, input.created, context, "created") &&
|
|
787
|
+
matchesRange(subscription.items.data[0]?.current_period_start, input.current_period_start, context, "current_period_start") &&
|
|
788
|
+
matchesRange(subscription.items.data[0]?.current_period_end, input.current_period_end, context, "current_period_end"),
|
|
789
|
+
);
|
|
790
|
+
return paginate(context, "subscriptions", rows, input, "/v1/subscriptions", (row) => applyExpand(view, renderSubscription(view, row), expand, "subscription"));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function subscriptionsUpdate(input, context) {
|
|
794
|
+
requirePermissions(context, [["subscriptions", "write"], ["customers", "read"], ["products", "read"]]);
|
|
795
|
+
const view = makeView(context);
|
|
796
|
+
const expand = validateExpand(context, input.expand, "subscription");
|
|
797
|
+
const current = requireRow(context, "subscriptions", input.subscription, "subscription");
|
|
798
|
+
if (current.status === "canceled" || current.status === "incomplete_expired") {
|
|
799
|
+
return invalidState(context, "A canceled subscription can only update its cancellation_details.", "subscription_canceled");
|
|
800
|
+
}
|
|
801
|
+
const customer = requireRow(context, "customers", current.customer, "customer");
|
|
802
|
+
optionalEnum(context, input, "proration_behavior", PRORATION_BEHAVIORS, undefined);
|
|
803
|
+
let items = current.items.data.map((item) => ({ ...item }));
|
|
804
|
+
if (input.items !== undefined) {
|
|
805
|
+
if (!Array.isArray(input.items)) return invalid(context, "Invalid items: must be an array.", "parameter_invalid", "items");
|
|
806
|
+
if (input.items.length > MAX_ITEMS) return invalid(context, `A subscription can have at most ${MAX_ITEMS} items.`, "parameter_invalid", "items");
|
|
807
|
+
const period = { start: current.items.data[0].current_period_start, end: current.items.data[0].current_period_end };
|
|
808
|
+
input.items.forEach((raw, index) => {
|
|
809
|
+
const entry = itemInput(context, raw, index);
|
|
810
|
+
if (entry.id !== undefined) {
|
|
811
|
+
const position = items.findIndex((item) => item.id === entry.id);
|
|
812
|
+
if (position === -1) return resourceMissing(context, "subscription_items", entry.id, `items[${index}][id]`);
|
|
813
|
+
if (entry.deleted) {
|
|
814
|
+
items.splice(position, 1);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const item = items[position];
|
|
818
|
+
if (entry.price !== undefined && entry.price !== item.price.id) {
|
|
819
|
+
const others = items.filter((candidate) => candidate.id !== item.id);
|
|
820
|
+
const price = requireRecurringPrice(context, entry.price, `items[${index}][price]`, others[0]?.price);
|
|
821
|
+
items[position] = { ...item, price: { ...price, metadata: { ...price.metadata }, recurring: { ...price.recurring } }, plan: planMirror(price) };
|
|
822
|
+
}
|
|
823
|
+
if (entry.quantity !== undefined) items[position] = { ...items[position], quantity: entry.quantity };
|
|
824
|
+
if (entry.metadata !== undefined) items[position] = { ...items[position], metadata: mergeMetadata(context, entry.metadata, item.metadata) };
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (entry.deleted) return invalid(context, `items[${index}][deleted] requires items[${index}][id].`, "parameter_invalid", `items[${index}][deleted]`);
|
|
828
|
+
const price = requireRecurringPrice(context, entry.price, `items[${index}][price]`, items[0]?.price);
|
|
829
|
+
items.push(newSubscriptionItem(context, view, current.id, price, entry.quantity ?? 1, mergeMetadata(context, entry.metadata), period));
|
|
830
|
+
});
|
|
831
|
+
if (items.length === 0) return invalid(context, "A subscription must have at least one item; you cannot delete every item.", "parameter_invalid", "items");
|
|
832
|
+
}
|
|
833
|
+
const next = { ...current, items: { ...current.items, data: items, total_count: items.length } };
|
|
834
|
+
if (items[0] !== undefined) next.currency = items[0].price.currency;
|
|
835
|
+
const periodEnd = items[0]?.current_period_end ?? current.items.data[0].current_period_end;
|
|
836
|
+
const cancelAtPeriodEnd = optionalBoolean(context, input, "cancel_at_period_end", undefined);
|
|
837
|
+
if (cancelAtPeriodEnd === true) {
|
|
838
|
+
next.cancel_at_period_end = true;
|
|
839
|
+
next.cancel_at = periodEnd;
|
|
840
|
+
next.canceled_at = view.now;
|
|
841
|
+
next.cancellation_details = { ...next.cancellation_details, reason: "cancellation_requested" };
|
|
842
|
+
} else if (cancelAtPeriodEnd === false) {
|
|
843
|
+
next.cancel_at_period_end = false;
|
|
844
|
+
next.cancel_at = null;
|
|
845
|
+
next.canceled_at = null;
|
|
846
|
+
next.cancellation_details = { ...next.cancellation_details, reason: null };
|
|
847
|
+
}
|
|
848
|
+
if (input.cancel_at !== undefined) {
|
|
849
|
+
if (input.cancel_at === "" || input.cancel_at === null) {
|
|
850
|
+
next.cancel_at = null;
|
|
851
|
+
next.cancel_at_period_end = false;
|
|
852
|
+
} else {
|
|
853
|
+
next.cancel_at = optionalInteger(context, input, "cancel_at", null, { min: view.now });
|
|
854
|
+
next.cancel_at_period_end = false;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (input.default_payment_method !== undefined) {
|
|
858
|
+
next.default_payment_method = input.default_payment_method === "" || input.default_payment_method === null ? null : requireAttachedMethod(context, view, input.default_payment_method, customer, "default_payment_method").id;
|
|
859
|
+
}
|
|
860
|
+
next.description = optionalString(context, input, "description", current.description, 500);
|
|
861
|
+
next.metadata = mergeMetadata(context, input.metadata, current.metadata);
|
|
862
|
+
if (input.collection_method !== undefined || input.days_until_due !== undefined) {
|
|
863
|
+
const collection = optionalEnum(context, input, "collection_method", COLLECTION_METHODS, current.collection_method);
|
|
864
|
+
const due = dueDateInput(context, { days_until_due: input.days_until_due ?? (collection === "send_invoice" ? current.days_until_due ?? undefined : undefined) }, collection);
|
|
865
|
+
next.collection_method = collection;
|
|
866
|
+
next.days_until_due = due === null ? null : due.days ?? null;
|
|
867
|
+
}
|
|
868
|
+
if (input.trial_end !== undefined) {
|
|
869
|
+
if (input.trial_end === "now") {
|
|
870
|
+
next.trial_end = current.trial_end === null ? null : view.now;
|
|
871
|
+
if (current.status === "trialing") next.status = "active";
|
|
872
|
+
} else {
|
|
873
|
+
const end = trialInput(context, { trial_end: input.trial_end }, view.now);
|
|
874
|
+
next.trial_end = end;
|
|
875
|
+
next.trial_start = current.trial_start ?? view.now;
|
|
876
|
+
if (current.status === "active") next.status = "trialing";
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (input.cancellation_details !== undefined) {
|
|
880
|
+
if (!isPlainObject(input.cancellation_details)) return invalid(context, "Invalid cancellation_details: must be a hash.", "parameter_invalid", "cancellation_details");
|
|
881
|
+
for (const key of Object.keys(input.cancellation_details)) if (key !== "comment" && key !== "feedback") return parameterUnknown(context, `cancellation_details[${key}]`);
|
|
882
|
+
next.cancellation_details = {
|
|
883
|
+
...next.cancellation_details,
|
|
884
|
+
comment: optionalString(context, input.cancellation_details, "comment", next.cancellation_details.comment, 1_000),
|
|
885
|
+
feedback: optionalEnum(context, input.cancellation_details, "feedback", ["customer_service", "low_quality", "missing_features", "other", "switched_service", "too_complex", "too_expensive", "unused"], next.cancellation_details.feedback),
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
context.state.put("subscriptions", next.id, next);
|
|
889
|
+
return applyExpand(view, renderSubscription(view, next), expand, "subscription");
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export function subscriptionsCancel(input, context) {
|
|
893
|
+
requirePermission(context, "subscriptions", "write");
|
|
894
|
+
const view = makeView(context);
|
|
895
|
+
const expand = validateExpand(context, input.expand, "subscription");
|
|
896
|
+
const current = requireRow(context, "subscriptions", input.subscription, "subscription");
|
|
897
|
+
if (current.status === "canceled") return resourceMissing(context, "subscriptions", current.id, "subscription");
|
|
898
|
+
if (current.status === "incomplete" && current.latest_invoice !== null) {
|
|
899
|
+
const invoice = getRow(context, "invoices", current.latest_invoice);
|
|
900
|
+
if (invoice !== null && invoice.status === "open") voidInvoice(context, view, invoice);
|
|
901
|
+
}
|
|
902
|
+
const next = {
|
|
903
|
+
...current,
|
|
904
|
+
status: "canceled",
|
|
905
|
+
canceled_at: view.now,
|
|
906
|
+
ended_at: view.now,
|
|
907
|
+
cancel_at: null,
|
|
908
|
+
cancel_at_period_end: false,
|
|
909
|
+
cancellation_details: { ...current.cancellation_details, reason: "cancellation_requested" },
|
|
910
|
+
};
|
|
911
|
+
context.state.put("subscriptions", next.id, next);
|
|
912
|
+
context.events.emit("customer.subscription.deleted", { id: next.id, customer: next.customer, canceled_at: view.now });
|
|
913
|
+
return applyExpand(view, renderSubscription(view, next), expand, "subscription");
|
|
914
|
+
}
|