@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,902 @@
|
|
|
1
|
+
// Billing: invoices (list, invoice page with the draft editor, create) and subscriptions (list, detail, create).
|
|
2
|
+
import { icon } from "./icons.js";
|
|
3
|
+
import { app, canRead, canWrite, indexAll, invalidateCache, navigate, now, registerPage, routeHash, setTitle } from "./store.js";
|
|
4
|
+
import { ToolError, action, badge, button, call, cardChip, confirmDialog, dateOnly, dateTime, el, field, humanize, idChip, input, intentStatus, invoiceStatus, key, link, money, moneyCell, openMenu, openModal, priceLabel, select, statusBadge, subscriptionStatus, text, textarea, toast } from "./ui.js";
|
|
5
|
+
import { alert, amountInput, amountWithStatus, applyError, chip, customerCell, customerPicker, dateCell, deniedPanel, empty, kv, listTools, load, metaBar, methodPicker, notSimulated, pageHeader, pageInner, pagedList, section, statTabs, submitModal, table, timeline } from "./widgets.js";
|
|
6
|
+
import { addressText, openCustomerForm, subscriptionAmount } from "./pages-customers.js";
|
|
7
|
+
|
|
8
|
+
const INVOICE_TABS = [
|
|
9
|
+
{ key: "all", label: "All invoices" },
|
|
10
|
+
{ key: "draft", label: "Draft" },
|
|
11
|
+
{ key: "open", label: "Outstanding" },
|
|
12
|
+
{ key: "past_due", label: "Past due" },
|
|
13
|
+
{ key: "paid", label: "Paid" },
|
|
14
|
+
{ key: "void", label: "Void" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function invoiceRows(invoices) {
|
|
18
|
+
return table({
|
|
19
|
+
select: { label: "invoices", rowLabel: (invoice) => `invoice ${invoice.number ?? invoice.id}` },
|
|
20
|
+
columns: [
|
|
21
|
+
{ label: "Amount", class: "nowrap", render: (invoice) => amountWithStatus(invoice.total, invoice.currency, statusBadge(invoiceStatus(invoice, now()))) },
|
|
22
|
+
{ label: "Invoice number", class: "nowrap", render: (invoice) => invoice.number ?? el("span", { class: "muted", text: "Draft" }) },
|
|
23
|
+
{ label: "Customer", class: "truncate", render: (invoice) => customerCell(typeof invoice.customer === "object" && invoice.customer !== null ? invoice.customer : { id: invoice.customer, name: invoice.customer_name, email: invoice.customer_email }) },
|
|
24
|
+
{ label: "Due", class: "nowrap muted", render: (invoice) => (invoice.due_date ? dateOnly(invoice.due_date, now()) : "—") },
|
|
25
|
+
{ label: "Created", class: "nowrap", render: (invoice) => dateCell(invoice.created) },
|
|
26
|
+
{ label: "", class: "actions", render: (invoice) => invoiceMenu(invoice) },
|
|
27
|
+
],
|
|
28
|
+
rows: invoices,
|
|
29
|
+
href: (invoice) => `#/invoices/${invoice.id}`,
|
|
30
|
+
onOpen: (invoice) => navigate(`#/invoices/${invoice.id}`),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function invoiceMenu(invoice) {
|
|
35
|
+
const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${invoice.number ?? invoice.id}`, class: "btn-icon-only" });
|
|
36
|
+
trigger.addEventListener("click", (event) => {
|
|
37
|
+
event.stopPropagation();
|
|
38
|
+
openMenu(trigger, [
|
|
39
|
+
{ label: "View invoice", icon: "external", onSelect: () => navigate(`#/invoices/${invoice.id}`) },
|
|
40
|
+
{ label: "Copy invoice ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(invoice.id).then(() => toast("Copied to clipboard")) },
|
|
41
|
+
{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${typeof invoice.customer === "object" ? invoice.customer.id : invoice.customer}`) },
|
|
42
|
+
], { align: "end" });
|
|
43
|
+
});
|
|
44
|
+
return trigger;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------------------------
|
|
48
|
+
// Invoices list
|
|
49
|
+
// ---------------------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
registerPage("invoices", async (host, route) => {
|
|
52
|
+
setTitle("Invoices");
|
|
53
|
+
const tab = route.params.tab ?? "all";
|
|
54
|
+
const customer = route.params.customer;
|
|
55
|
+
const actions = [];
|
|
56
|
+
if (canWrite("invoices")) actions.push(button("Create invoice", "primary", { icon: "plus", onClick: () => navigate(routeHash("invoices", "new", { customer })) }));
|
|
57
|
+
const header = pageHeader("Invoices", { actions });
|
|
58
|
+
const tabsHost = el("div");
|
|
59
|
+
const filters = el("div", { class: "filters" });
|
|
60
|
+
const listHost = el("div");
|
|
61
|
+
host.replaceChildren(pageInner(header, tabsHost, filters, listHost));
|
|
62
|
+
if (!canRead("invoices")) {
|
|
63
|
+
listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "invoices" }), "invoices"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const select_ = (next) => navigate(routeHash("invoices", undefined, { ...route.params, tab: next === "all" ? undefined : next }));
|
|
67
|
+
tabsHost.append(statTabs(INVOICE_TABS, tab, select_));
|
|
68
|
+
const clearCustomer = () => navigate(routeHash("invoices", undefined, { tab: tab === "all" ? undefined : tab }));
|
|
69
|
+
filters.append(
|
|
70
|
+
customer ? chip("Customer", { active: true, value: customer, onClick: clearCustomer, onClear: clearCustomer }) : chip("Customer", { onClick: () => notSimulated("Customer filter", "Open a customer and choose Invoices › View all to filter this list by that customer.") }),
|
|
71
|
+
chip("Created date", { onClick: () => notSimulated("Created date filter") }),
|
|
72
|
+
chip("Due date", { onClick: () => notSimulated("Due date filter") }),
|
|
73
|
+
chip("More filters", { onClick: () => notSimulated("More filters") }),
|
|
74
|
+
listTools(),
|
|
75
|
+
);
|
|
76
|
+
const args = { expand: ["data.customer"] };
|
|
77
|
+
if (customer) args.customer = customer;
|
|
78
|
+
if (tab === "past_due") {
|
|
79
|
+
args.status = "open";
|
|
80
|
+
args.due_date = { lt: now() };
|
|
81
|
+
} else if (tab !== "all") args.status = tab;
|
|
82
|
+
const list = pagedList({
|
|
83
|
+
operation: "invoices.list",
|
|
84
|
+
args,
|
|
85
|
+
limit: 20,
|
|
86
|
+
render: invoiceRows,
|
|
87
|
+
emptyNode: () => empty(tab === "all" ? "No invoices yet" : `No ${INVOICE_TABS.find((entry) => entry.key === tab)?.label.toLowerCase()} invoices`, "Invoices you create, or that subscriptions generate, appear here.", canWrite("invoices") && tab === "all" ? button("Create invoice", "primary", { icon: "plus", onClick: () => navigate("#/invoices/new") }) : undefined),
|
|
88
|
+
});
|
|
89
|
+
listHost.append(list.element);
|
|
90
|
+
// Counts for the tabs: one bounded index of the account's invoices.
|
|
91
|
+
try {
|
|
92
|
+
const index = await indexAll("invoices.list", customer ? { customer } : {});
|
|
93
|
+
const counts = Object.fromEntries(INVOICE_TABS.map((entry) => [entry.key, 0]));
|
|
94
|
+
for (const invoice of index.items) {
|
|
95
|
+
counts.all += 1;
|
|
96
|
+
const status = invoiceStatus(invoice, now()).key;
|
|
97
|
+
if (status === "past_due") counts.open += 1;
|
|
98
|
+
if (counts[status] !== undefined) counts[status] += 1;
|
|
99
|
+
}
|
|
100
|
+
tabsHost.replaceChildren(statTabs(INVOICE_TABS.map((entry) => ({ ...entry, count: `${counts[entry.key]}${index.complete ? "" : "+"}` })), tab, select_));
|
|
101
|
+
} catch {
|
|
102
|
+
/* the list itself already shows the error */
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------------------------
|
|
107
|
+
// Invoice page (draft editor / open / paid / void)
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
registerPage("invoice", async (host, route) => {
|
|
111
|
+
setTitle(route.id);
|
|
112
|
+
const inner = pageInner();
|
|
113
|
+
host.replaceChildren(inner);
|
|
114
|
+
await load(inner, async () => {
|
|
115
|
+
const invoice = await call("invoices.retrieve", { invoice: route.id, expand: ["customer", "default_payment_method", "payments.data.payment.payment_intent"] });
|
|
116
|
+
return renderInvoice(invoice);
|
|
117
|
+
}, { retry: () => navigate(location.hash) });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function renderInvoice(invoice) {
|
|
121
|
+
const status = invoiceStatus(invoice, now());
|
|
122
|
+
const customer = typeof invoice.customer === "object" && invoice.customer !== null ? invoice.customer : { id: invoice.customer, name: invoice.customer_name, email: invoice.customer_email };
|
|
123
|
+
const payment = invoice.payments?.data?.[0];
|
|
124
|
+
const intent = payment && typeof payment.payment.payment_intent === "object" ? payment.payment.payment_intent : undefined;
|
|
125
|
+
const actions = [];
|
|
126
|
+
const write = canWrite("invoices");
|
|
127
|
+
if (invoice.status === "draft" && write) {
|
|
128
|
+
actions.push(button("Add item", "secondary", { icon: "plus", onClick: () => openInvoiceItem(invoice, customer) }));
|
|
129
|
+
actions.push(button("Finalize invoice", "primary", { onClick: () => finalize(invoice) }));
|
|
130
|
+
}
|
|
131
|
+
if (invoice.status === "open" && write) {
|
|
132
|
+
actions.push(button("Charge customer", "primary", { onClick: () => openPay(invoice, customer) }));
|
|
133
|
+
}
|
|
134
|
+
const more = button(undefined, "secondary", { icon: "more", ariaLabel: "More actions", class: "btn-icon-only" });
|
|
135
|
+
more.addEventListener("click", () =>
|
|
136
|
+
openMenu(more, [
|
|
137
|
+
{ label: "Copy invoice ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(invoice.id).then(() => toast("Copied to clipboard")) },
|
|
138
|
+
{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${customer.id}`) },
|
|
139
|
+
...(intent ? [{ label: "View payment", icon: "payments", onSelect: () => navigate(`#/payments/${intent.id}`) }] : []),
|
|
140
|
+
"divider",
|
|
141
|
+
{ label: "Send invoice", icon: "external", disabled: true, description: "No e-mail is sent by this synthetic account" },
|
|
142
|
+
{ label: "Download PDF", icon: "receipt", disabled: true, description: "Not served" },
|
|
143
|
+
...(invoice.status === "open" && write ? [{ label: "Mark as paid out of band", icon: "check", onSelect: () => payOutOfBand(invoice) }, { label: "Void invoice", icon: "x-circle", danger: true, onSelect: () => voidInvoice(invoice) }] : []),
|
|
144
|
+
], { align: "end" }),
|
|
145
|
+
);
|
|
146
|
+
actions.push(more);
|
|
147
|
+
|
|
148
|
+
const title = el("div", { class: "detail-title" }, [el("h1", { class: "page-title" }, moneyCell(invoice.total, invoice.currency)), statusBadge(status)]);
|
|
149
|
+
const header = pageHeader(title, { kind: invoice.number ? `Invoice ${invoice.number}` : "Draft invoice", actions, breadcrumbs: [{ label: "Invoices", href: "#/invoices" }, { label: invoice.number ?? invoice.id }] });
|
|
150
|
+
|
|
151
|
+
const meta = metaBar([
|
|
152
|
+
["Customer", link(`#/customers/${customer.id}`, customer.name || customer.email || customer.id)],
|
|
153
|
+
["Billing method", invoice.collection_method === "send_invoice" ? "Send invoice" : "Charge automatically"],
|
|
154
|
+
invoice.due_date ? ["Due", dateOnly(invoice.due_date, now())] : undefined,
|
|
155
|
+
["Amount due", money(invoice.amount_due, invoice.currency)],
|
|
156
|
+
["Created", dateTime(invoice.created, now())],
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const banners = [];
|
|
160
|
+
if (invoice.status === "draft") banners.push(alert("Add items, then finalize the invoice to make it payable. Finalizing assigns the invoice number and creates its PaymentIntent.", "info", { title: "This invoice is a draft", iconName: "edit" }));
|
|
161
|
+
if (status.key === "past_due") banners.push(alert(`This invoice was due ${dateOnly(invoice.due_date, now())} and is still unpaid; the customer is marked delinquent.`, "warning", { title: "Past due" }));
|
|
162
|
+
if (invoice.status === "open" && invoice.attempt_count > 0) banners.push(alert(`${invoice.attempt_count} payment attempt${invoice.attempt_count === 1 ? "" : "s"} failed.${intent?.last_payment_error ? ` Last error: ${intent.last_payment_error.message}` : ""}`, "danger", { title: "Payment failed" }));
|
|
163
|
+
|
|
164
|
+
const lines = invoice.lines?.data ?? [];
|
|
165
|
+
const itemsTable = el("div", { class: "line-items" }, [
|
|
166
|
+
table({
|
|
167
|
+
columns: [
|
|
168
|
+
{ label: "Description", class: "truncate", render: (line) => el("span", { class: "customer-cell-text" }, [el("span", { text: line.description ?? "Item" }), el("span", { class: "customer-cell-sub", text: `${dateOnly(line.period.start, now())}${line.period.end !== line.period.start ? ` – ${dateOnly(line.period.end, now())}` : ""}` })]) },
|
|
169
|
+
{ label: "Qty", class: "num nowrap", render: (line) => String(line.quantity ?? 1) },
|
|
170
|
+
{ label: "Unit price", class: "num nowrap", render: (line) => money(line.pricing?.unit_amount_decimal !== undefined ? Number(line.pricing.unit_amount_decimal) : Math.round(line.amount / (line.quantity || 1)), line.currency) },
|
|
171
|
+
{ label: "Amount", class: "num nowrap", render: (line) => money(line.amount, line.currency) },
|
|
172
|
+
],
|
|
173
|
+
rows: lines,
|
|
174
|
+
stack: false,
|
|
175
|
+
emptyNode: el("div", { class: "table-note", text: "No items yet." }),
|
|
176
|
+
}),
|
|
177
|
+
el("div", { class: "totals" }, [
|
|
178
|
+
el("span", { class: "total-label", text: "Subtotal" }), el("span", { class: "money" }, text(money(invoice.subtotal, invoice.currency))),
|
|
179
|
+
el("span", { class: "total-label", text: "Total" }), el("span", { class: "money" }, text(money(invoice.total, invoice.currency))),
|
|
180
|
+
el("span", { class: "total-label total-strong", text: "Amount due" }), el("span", { class: "money total-strong" }, text(money(invoice.amount_due, invoice.currency))),
|
|
181
|
+
]),
|
|
182
|
+
]);
|
|
183
|
+
const itemsSection = section("Items", itemsTable, { count: lines.length, actions: invoice.status === "draft" && write ? [button("Add item", "secondary", { size: "sm", icon: "plus", onClick: () => openInvoiceItem(invoice, customer) })] : [] });
|
|
184
|
+
|
|
185
|
+
const summary = section("Summary", el("div", { class: "kv-cols" }, [
|
|
186
|
+
kv([
|
|
187
|
+
["Billed to", el("span", {}, [text(customer.name ?? "—"), invoice.customer_email ? el("span", { class: "muted", text: ` · ${invoice.customer_email}` }) : null])],
|
|
188
|
+
["Billing address", addressText(invoice.customer_address)],
|
|
189
|
+
["Currency", invoice.currency.toUpperCase()],
|
|
190
|
+
["Billing reason", humanize(invoice.billing_reason)],
|
|
191
|
+
["Subscription", invoice.parent?.subscription_details?.subscription ? link(`#/subscriptions/${invoice.parent.subscription_details.subscription}`, invoice.parent.subscription_details.subscription) : null],
|
|
192
|
+
]),
|
|
193
|
+
kv([
|
|
194
|
+
["Invoice number", invoice.number],
|
|
195
|
+
["ID", idChip(invoice.id)],
|
|
196
|
+
["Default payment method", typeof invoice.default_payment_method === "object" && invoice.default_payment_method !== null ? cardChip(invoice.default_payment_method) : null],
|
|
197
|
+
["Hosted invoice page", invoice.hosted_invoice_url ? el("span", { class: "muted", text: `${invoice.hosted_invoice_url} (synthetic, not served)` }) : null],
|
|
198
|
+
["Memo", invoice.description],
|
|
199
|
+
["Footer", invoice.footer],
|
|
200
|
+
]),
|
|
201
|
+
]));
|
|
202
|
+
|
|
203
|
+
const paymentsSection = payment ? section("Payments", table({
|
|
204
|
+
columns: [
|
|
205
|
+
{ label: "Amount", class: "nowrap", render: () => amountWithStatus(payment.amount_requested, payment.currency, badge(humanize(payment.status), payment.status === "paid" ? "green" : payment.status === "canceled" ? "gray" : "blue")) },
|
|
206
|
+
{ label: "PaymentIntent", render: () => (intent ? el("span", { class: "amount-status" }, [link(`#/payments/${intent.id}`, intent.id), statusBadge(intentStatus(intent, undefined))]) : el("code", { class: "id-code", text: payment.payment.payment_intent ?? "—" })) },
|
|
207
|
+
{ label: "Paid at", class: "nowrap muted", render: () => (payment.status_transitions?.paid_at ? dateTime(payment.status_transitions.paid_at, now()) : "—") },
|
|
208
|
+
],
|
|
209
|
+
rows: [payment],
|
|
210
|
+
href: intent ? () => `#/payments/${intent.id}` : undefined,
|
|
211
|
+
onOpen: intent ? () => navigate(`#/payments/${intent.id}`) : undefined,
|
|
212
|
+
})) : null;
|
|
213
|
+
|
|
214
|
+
const events = [];
|
|
215
|
+
const transitions = invoice.status_transitions ?? {};
|
|
216
|
+
if (transitions.paid_at) events.push({ title: invoice.paid_out_of_band ? "Marked as paid out of band" : "Invoice paid", at: transitions.paid_at, tone: "green", icon: "check-circle" });
|
|
217
|
+
if (transitions.voided_at) events.push({ title: "Invoice voided", at: transitions.voided_at, icon: "minus-circle" });
|
|
218
|
+
if (transitions.marked_uncollectible_at) events.push({ title: "Marked uncollectible", at: transitions.marked_uncollectible_at, tone: "red", icon: "x-circle" });
|
|
219
|
+
if (transitions.finalized_at) events.push({ title: "Invoice finalized", at: transitions.finalized_at, text: invoice.number ? `Number ${invoice.number} assigned` : undefined, tone: "blue", icon: "check-circle" });
|
|
220
|
+
events.push({ title: "Invoice created", at: invoice.created, text: humanize(invoice.billing_reason), icon: "dot" });
|
|
221
|
+
events.sort((left, right) => (right.at ?? 0) - (left.at ?? 0));
|
|
222
|
+
const historySection = section("History", timeline(events));
|
|
223
|
+
|
|
224
|
+
return [header, meta, ...banners, itemsSection, summary, paymentsSection, historySection].filter(Boolean);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function openInvoiceItem(invoice, customer) {
|
|
228
|
+
const mode = select([{ value: "price", label: "From the product catalog" }, { value: "adhoc", label: "One-off item" }], "price");
|
|
229
|
+
const priceSelect = select([{ value: "", label: "Loading prices…" }], "");
|
|
230
|
+
const quantity = input({ type: "number", value: "1", min: 1 });
|
|
231
|
+
const amount = amountInput({ currency: invoice.currency, currencies: [invoice.currency] });
|
|
232
|
+
amount.select.disabled = true;
|
|
233
|
+
const description = input({ placeholder: "What the customer is being billed for" });
|
|
234
|
+
const fields = { price: field("Price", priceSelect), quantity: field("Quantity", quantity), amount: field("Amount", amount), description: field("Description", description, { optional: true }) };
|
|
235
|
+
const toggle = () => {
|
|
236
|
+
fields.price.hidden = mode.value !== "price";
|
|
237
|
+
fields.amount.hidden = mode.value !== "adhoc";
|
|
238
|
+
fields.description.querySelector(".field-optional").hidden = mode.value === "adhoc";
|
|
239
|
+
};
|
|
240
|
+
mode.addEventListener("change", toggle);
|
|
241
|
+
toggle();
|
|
242
|
+
void (async () => {
|
|
243
|
+
try {
|
|
244
|
+
// Currency is filtered by the server and product names come expanded, so no second bounded index is needed.
|
|
245
|
+
const index = await indexAll("prices.list", { active: true, type: "one_time", currency: invoice.currency, expand: ["data.product"] });
|
|
246
|
+
const prices = index.items;
|
|
247
|
+
priceSelect.replaceChildren(el("option", { text: prices.length === 0 ? `No active one-time ${invoice.currency.toUpperCase()} prices` : "Choose a price", attrs: { value: "" } }));
|
|
248
|
+
for (const price of prices) {
|
|
249
|
+
const product = typeof price.product === "object" && price.product !== null ? price.product : undefined;
|
|
250
|
+
priceSelect.append(el("option", { text: `${product?.name ?? price.product} — ${priceLabel(price)}${price.nickname ? ` (${price.nickname})` : ""}`, attrs: { value: price.id } }));
|
|
251
|
+
}
|
|
252
|
+
if (!index.complete) priceSelect.append(el("option", { text: `Showing the first ${prices.length.toLocaleString("en-US")} prices; more exist (use a one-off item or the product page)`, attrs: { value: "", disabled: "" } }));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
priceSelect.replaceChildren(el("option", { text: "Could not load prices", attrs: { value: "" } }));
|
|
255
|
+
fields.price.setError(error.message);
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
const idempotencyKey = key();
|
|
259
|
+
openModal({
|
|
260
|
+
title: "Add an item",
|
|
261
|
+
body: [field("Item type", mode), fields.price, fields.amount, fields.quantity, fields.description],
|
|
262
|
+
actions: [
|
|
263
|
+
{ label: "Cancel" },
|
|
264
|
+
{
|
|
265
|
+
label: "Add item",
|
|
266
|
+
kind: "primary",
|
|
267
|
+
submit: true,
|
|
268
|
+
onClick: (api) => {
|
|
269
|
+
const args = { customer: customer.id, invoice: invoice.id, quantity: Number(quantity.value || 1) };
|
|
270
|
+
if (mode.value === "price") {
|
|
271
|
+
if (!priceSelect.value) {
|
|
272
|
+
fields.price.setError("Choose a price.");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
args.price = priceSelect.value;
|
|
276
|
+
} else {
|
|
277
|
+
const minor = amount.amount();
|
|
278
|
+
if (minor === undefined) {
|
|
279
|
+
fields.amount.setError("Enter a valid amount.");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
args.amount = minor;
|
|
283
|
+
args.currency = invoice.currency;
|
|
284
|
+
if (!description.value.trim()) {
|
|
285
|
+
fields.description.setError("Describe the item.");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (description.value.trim()) args.description = description.value.trim();
|
|
290
|
+
return submitModal(api, fields, () => call("invoice_items.create", args, idempotencyKey), () => {
|
|
291
|
+
toast("Item added");
|
|
292
|
+
invalidateCache();
|
|
293
|
+
navigate(location.hash);
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function finalize(invoice) {
|
|
302
|
+
const ok = await confirmDialog("Finalize invoice?", `The invoice becomes payable for ${money(invoice.total, invoice.currency)} and can no longer be edited.`, "Finalize");
|
|
303
|
+
if (!ok) return;
|
|
304
|
+
await action(async () => {
|
|
305
|
+
const finalized = await call("invoices.finalize", { invoice: invoice.id }, key());
|
|
306
|
+
toast(`Invoice ${finalized.number} finalized`);
|
|
307
|
+
invalidateCache();
|
|
308
|
+
navigate(location.hash);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function openPay(invoice, customer) {
|
|
313
|
+
const picker = methodPicker({ customer: customer.id, value: invoice.default_payment_method?.id ?? customer.invoice_settings?.default_payment_method ?? "" });
|
|
314
|
+
void picker.setCustomer(customer.id, customer.invoice_settings?.default_payment_method);
|
|
315
|
+
const fields = { payment_method: field("Payment method", picker) };
|
|
316
|
+
const idempotencyKey = key();
|
|
317
|
+
openModal({
|
|
318
|
+
title: "Charge customer",
|
|
319
|
+
describedBy: `Collect ${money(invoice.amount_due, invoice.currency)} now with one of the customer's cards or a test card.`,
|
|
320
|
+
body: [fields.payment_method],
|
|
321
|
+
actions: [
|
|
322
|
+
{ label: "Cancel" },
|
|
323
|
+
{
|
|
324
|
+
label: `Charge ${money(invoice.amount_due, invoice.currency)}`,
|
|
325
|
+
kind: "primary",
|
|
326
|
+
submit: true,
|
|
327
|
+
onClick: (api) => {
|
|
328
|
+
if (!picker.value) {
|
|
329
|
+
fields.payment_method.setError("Choose a payment method.");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
return submitModal(api, fields, () => call("invoices.pay", { invoice: invoice.id, payment_method: picker.value }, idempotencyKey), () => {
|
|
333
|
+
toast("Invoice paid");
|
|
334
|
+
invalidateCache();
|
|
335
|
+
navigate(location.hash);
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function payOutOfBand(invoice) {
|
|
344
|
+
const ok = await confirmDialog("Mark as paid out of band?", "The invoice is marked paid without collecting a payment (for example a bank transfer received elsewhere).", "Mark as paid");
|
|
345
|
+
if (!ok) return;
|
|
346
|
+
await action(async () => {
|
|
347
|
+
await call("invoices.pay", { invoice: invoice.id, paid_out_of_band: true }, key());
|
|
348
|
+
toast("Invoice marked as paid");
|
|
349
|
+
invalidateCache();
|
|
350
|
+
navigate(location.hash);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function voidInvoice(invoice) {
|
|
355
|
+
const ok = await confirmDialog("Void invoice?", "Voiding cancels the invoice and its payment; it cannot be undone.", "Void invoice", { danger: true });
|
|
356
|
+
if (!ok) return;
|
|
357
|
+
await action(async () => {
|
|
358
|
+
await call("invoices.void", { invoice: invoice.id }, key());
|
|
359
|
+
toast("Invoice voided");
|
|
360
|
+
invalidateCache();
|
|
361
|
+
navigate(location.hash);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------------------------
|
|
366
|
+
// Create invoice
|
|
367
|
+
// ---------------------------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
registerPage("invoice-new", async (host, route) => {
|
|
370
|
+
setTitle("Create an invoice");
|
|
371
|
+
if (!canWrite("invoices")) {
|
|
372
|
+
host.replaceChildren(pageInner(pageHeader("Create an invoice", { breadcrumbs: [{ label: "Invoices", href: "#/invoices" }] }), deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "invoices", level: "write" }), "invoices")));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
app.editing += 1;
|
|
376
|
+
const customer = customerPicker({ value: route.params.customer });
|
|
377
|
+
const collection = select([{ value: "charge_automatically", label: "Charge a payment method on file" }, { value: "send_invoice", label: "Send invoice — customer pays by the due date" }], "charge_automatically");
|
|
378
|
+
const days = input({ type: "number", value: "30", min: 1, max: 730 });
|
|
379
|
+
const currency = select(["usd", "eur", "gbp", "cad", "aud", "chf", "sek", "nok", "dkk", "jpy", "nzd", "sgd"].map((code) => ({ value: code, label: code.toUpperCase() })), app.context?.account?.default_currency ?? "usd");
|
|
380
|
+
const memo = textarea({ placeholder: "Thanks for your business", rows: 2 });
|
|
381
|
+
const pendingItems = el("input", { attrs: { type: "checkbox" } });
|
|
382
|
+
pendingItems.checked = true;
|
|
383
|
+
const fields = {
|
|
384
|
+
customer: field("Customer", el("div", { class: "inline-list" }, [customer, canWrite("customers") ? button("Add new customer", "link", { onClick: () => openCustomerForm(undefined, (record) => { customer.value = record.id; customer.customer = record; customer.control.value = record.name || record.email || record.id; }) }) : null])),
|
|
385
|
+
collection_method: field("Collection method", collection),
|
|
386
|
+
days_until_due: field("Days until due", days),
|
|
387
|
+
currency: field("Currency", currency),
|
|
388
|
+
description: field("Memo", memo, { optional: true }),
|
|
389
|
+
};
|
|
390
|
+
const toggle = () => {
|
|
391
|
+
fields.days_until_due.hidden = collection.value !== "send_invoice";
|
|
392
|
+
};
|
|
393
|
+
collection.addEventListener("change", toggle);
|
|
394
|
+
toggle();
|
|
395
|
+
const errorHost = el("div", { class: "form-error", attrs: { role: "alert" } });
|
|
396
|
+
errorHost.hidden = true;
|
|
397
|
+
const api = { setError: (message) => { errorHost.textContent = message ?? ""; errorHost.hidden = !message; } };
|
|
398
|
+
let idempotencyKey = key();
|
|
399
|
+
const submit = button("Create draft invoice", "primary", {
|
|
400
|
+
onClick: () =>
|
|
401
|
+
action(async () => {
|
|
402
|
+
for (const entry of Object.values(fields)) entry.setError(undefined);
|
|
403
|
+
errorHost.hidden = true;
|
|
404
|
+
if (!customer.value) {
|
|
405
|
+
fields.customer.setError("Choose a customer.");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const args = { customer: customer.value, collection_method: collection.value, currency: currency.value, pending_invoice_items_behavior: pendingItems.checked ? "include" : "exclude" };
|
|
409
|
+
if (collection.value === "send_invoice") args.days_until_due = Number(days.value || 30);
|
|
410
|
+
if (memo.value.trim()) args.description = memo.value.trim();
|
|
411
|
+
submit.classList.add("is-busy");
|
|
412
|
+
submit.disabled = true;
|
|
413
|
+
try {
|
|
414
|
+
const invoice = await call("invoices.create", args, idempotencyKey);
|
|
415
|
+
toast("Draft invoice created — add items, then finalize");
|
|
416
|
+
invalidateCache();
|
|
417
|
+
app.editing -= 1;
|
|
418
|
+
navigate(`#/invoices/${invoice.id}`);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
idempotencyKey = key();
|
|
421
|
+
applyError(error, fields, api);
|
|
422
|
+
} finally {
|
|
423
|
+
submit.classList.remove("is-busy");
|
|
424
|
+
submit.disabled = false;
|
|
425
|
+
}
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
const form = el("div", { class: "editor-form" }, [
|
|
429
|
+
fields.customer,
|
|
430
|
+
fields.collection_method,
|
|
431
|
+
fields.days_until_due,
|
|
432
|
+
fields.currency,
|
|
433
|
+
el("label", { class: "check-field" }, [pendingItems, el("span", { class: "check-label", text: "Include the customer's pending invoice items" }, [el("span", { class: "check-hint", text: "Invoice items created without an invoice are swept into this draft." })])]),
|
|
434
|
+
fields.description,
|
|
435
|
+
errorHost,
|
|
436
|
+
el("div", { class: "sticky-footer" }, [button("Cancel", "secondary", { onClick: () => { app.editing -= 1; navigate("#/invoices"); } }), submit]),
|
|
437
|
+
]);
|
|
438
|
+
const summary = el("div", { class: "editor-summary" }, [el("div", { class: "editor-summary-title", text: "How invoicing works here" }), el("div", { class: "muted", text: "The invoice starts as a draft. Add items from the catalog or one-off amounts, then finalize it. Finalizing assigns a number and a PaymentIntent; you can then charge a card, void it, or mark it paid out of band. No e-mail or PDF is produced." })]);
|
|
439
|
+
host.replaceChildren(pageInner(pageHeader("Create an invoice", { breadcrumbs: [{ label: "Invoices", href: "#/invoices" }, { label: "Create" }] }), el("div", { class: "editor" }, [form, summary])));
|
|
440
|
+
host.addEventListener("page-leave", () => { app.editing = Math.max(0, app.editing - 1); }, { once: true });
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------------------------
|
|
444
|
+
// Subscriptions list
|
|
445
|
+
// ---------------------------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
const SUBSCRIPTION_TABS = [
|
|
448
|
+
{ key: "all", label: "All" },
|
|
449
|
+
{ key: "active", label: "Active" },
|
|
450
|
+
{ key: "trialing", label: "Trialing" },
|
|
451
|
+
{ key: "past_due", label: "Past due" },
|
|
452
|
+
{ key: "incomplete", label: "Incomplete" },
|
|
453
|
+
{ key: "canceled", label: "Canceled" },
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
function subscriptionRows(subscriptions) {
|
|
457
|
+
return table({
|
|
458
|
+
select: { label: "subscriptions", rowLabel: (subscription) => `subscription ${subscription.id}` },
|
|
459
|
+
columns: [
|
|
460
|
+
{ label: "Customer", class: "truncate", render: (subscription) => customerCell(subscription.customer) },
|
|
461
|
+
{ label: "Status", class: "nowrap", render: (subscription) => statusBadge(subscriptionStatus(subscription, now())) },
|
|
462
|
+
{ label: "Product", class: "truncate", render: (subscription) => subscription.items.data.map((item) => `${item.price.nickname ?? item.price.id}${item.quantity > 1 ? ` × ${item.quantity}` : ""}`).join(", ") },
|
|
463
|
+
{ label: "Amount", class: "nowrap", render: (subscription) => subscriptionAmount(subscription) },
|
|
464
|
+
{ label: "Current period", class: "nowrap muted", render: (subscription) => `${dateOnly(subscription.items.data[0]?.current_period_start, now())} – ${dateOnly(subscription.items.data[0]?.current_period_end, now())}` },
|
|
465
|
+
{ label: "Created", class: "nowrap", render: (subscription) => dateCell(subscription.created) },
|
|
466
|
+
{ label: "", class: "actions", render: (subscription) => subscriptionMenu(subscription) },
|
|
467
|
+
],
|
|
468
|
+
rows: subscriptions,
|
|
469
|
+
href: (subscription) => `#/subscriptions/${subscription.id}`,
|
|
470
|
+
onOpen: (subscription) => navigate(`#/subscriptions/${subscription.id}`),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function subscriptionMenu(subscription) {
|
|
475
|
+
const trigger = button(undefined, "ghost", { icon: "more", size: "sm", ariaLabel: `Actions for ${subscription.id}`, class: "btn-icon-only" });
|
|
476
|
+
trigger.addEventListener("click", (event) => {
|
|
477
|
+
event.stopPropagation();
|
|
478
|
+
openMenu(trigger, [
|
|
479
|
+
{ label: "View subscription", icon: "external", onSelect: () => navigate(`#/subscriptions/${subscription.id}`) },
|
|
480
|
+
{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${typeof subscription.customer === "object" ? subscription.customer.id : subscription.customer}`) },
|
|
481
|
+
...(subscription.latest_invoice ? [{ label: "View latest invoice", icon: "invoices", onSelect: () => navigate(`#/invoices/${typeof subscription.latest_invoice === "object" ? subscription.latest_invoice.id : subscription.latest_invoice}`) }] : []),
|
|
482
|
+
...(canWrite("subscriptions") && subscription.status !== "canceled" ? ["divider", { label: "Cancel subscription", icon: "x-circle", danger: true, onSelect: () => openCancel(subscription) }] : []),
|
|
483
|
+
], { align: "end" });
|
|
484
|
+
});
|
|
485
|
+
return trigger;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
registerPage("subscriptions", async (host, route) => {
|
|
489
|
+
setTitle("Subscriptions");
|
|
490
|
+
const tab = route.params.tab ?? "all";
|
|
491
|
+
const actions = [];
|
|
492
|
+
if (canWrite("subscriptions")) actions.push(button("Create subscription", "primary", { icon: "plus", onClick: () => navigate("#/subscriptions/new") }));
|
|
493
|
+
const header = pageHeader("Subscriptions", { actions });
|
|
494
|
+
const tabsHost = el("div");
|
|
495
|
+
const filters = el("div", { class: "filters" }, [chip("Customer", { onClick: () => notSimulated("Customer filter") }), chip("Product", { onClick: () => notSimulated("Product filter") }), chip("Created date", { onClick: () => notSimulated("Created date filter") }), chip("More filters", { onClick: () => notSimulated("More filters") }), listTools()]);
|
|
496
|
+
const listHost = el("div");
|
|
497
|
+
host.replaceChildren(pageInner(header, tabsHost, filters, listHost));
|
|
498
|
+
if (!canRead("subscriptions")) {
|
|
499
|
+
listHost.append(deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "subscriptions" }), "subscriptions"));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const select_ = (next) => navigate(routeHash("subscriptions", undefined, { tab: next === "all" ? undefined : next }));
|
|
503
|
+
tabsHost.append(statTabs(SUBSCRIPTION_TABS, tab, select_));
|
|
504
|
+
const args = { expand: ["data.customer"], status: tab === "all" ? "all" : tab };
|
|
505
|
+
const list = pagedList({
|
|
506
|
+
operation: "subscriptions.list",
|
|
507
|
+
args,
|
|
508
|
+
limit: 20,
|
|
509
|
+
render: subscriptionRows,
|
|
510
|
+
emptyNode: () => empty(tab === "all" ? "No subscriptions yet" : `No ${SUBSCRIPTION_TABS.find((entry) => entry.key === tab)?.label.toLowerCase()} subscriptions`, "Subscriptions bill a customer for recurring prices.", canWrite("subscriptions") && tab === "all" ? button("Create subscription", "primary", { icon: "plus", onClick: () => navigate("#/subscriptions/new") }) : undefined),
|
|
511
|
+
});
|
|
512
|
+
listHost.append(list.element);
|
|
513
|
+
try {
|
|
514
|
+
const index = await indexAll("subscriptions.list", { status: "all" });
|
|
515
|
+
const counts = Object.fromEntries(SUBSCRIPTION_TABS.map((entry) => [entry.key, 0]));
|
|
516
|
+
for (const subscription of index.items) {
|
|
517
|
+
counts.all += 1;
|
|
518
|
+
if (counts[subscription.status] !== undefined) counts[subscription.status] += 1;
|
|
519
|
+
}
|
|
520
|
+
tabsHost.replaceChildren(statTabs(SUBSCRIPTION_TABS.map((entry) => ({ ...entry, count: `${counts[entry.key]}${index.complete ? "" : "+"}` })), tab, select_));
|
|
521
|
+
} catch {
|
|
522
|
+
/* list shows the error */
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------------------------
|
|
527
|
+
// Subscription detail
|
|
528
|
+
// ---------------------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
registerPage("subscription", async (host, route) => {
|
|
531
|
+
setTitle(route.id);
|
|
532
|
+
const inner = pageInner();
|
|
533
|
+
host.replaceChildren(inner);
|
|
534
|
+
await load(inner, async () => {
|
|
535
|
+
const subscription = await call("subscriptions.retrieve", { subscription: route.id, expand: ["customer", "latest_invoice", "default_payment_method"] });
|
|
536
|
+
return renderSubscription(subscription);
|
|
537
|
+
}, { retry: () => navigate(location.hash) });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
function renderSubscription(subscription) {
|
|
541
|
+
const status = subscriptionStatus(subscription, now());
|
|
542
|
+
const customer = typeof subscription.customer === "object" && subscription.customer !== null ? subscription.customer : { id: subscription.customer };
|
|
543
|
+
const latest = typeof subscription.latest_invoice === "object" && subscription.latest_invoice !== null ? subscription.latest_invoice : undefined;
|
|
544
|
+
const write = canWrite("subscriptions");
|
|
545
|
+
const active = subscription.status !== "canceled" && subscription.status !== "incomplete_expired";
|
|
546
|
+
const actions = [];
|
|
547
|
+
if (write && active) {
|
|
548
|
+
if (subscription.cancel_at_period_end) actions.push(button("Resume subscription", "primary", { onClick: () => resume(subscription) }));
|
|
549
|
+
actions.push(button("Update subscription", "secondary", { icon: "edit", onClick: () => openUpdate(subscription) }));
|
|
550
|
+
if (!subscription.cancel_at_period_end) actions.push(button("Cancel subscription", "secondary", { onClick: () => openCancel(subscription) }));
|
|
551
|
+
}
|
|
552
|
+
const more = button(undefined, "secondary", { icon: "more", ariaLabel: "More actions", class: "btn-icon-only" });
|
|
553
|
+
more.addEventListener("click", () =>
|
|
554
|
+
openMenu(more, [
|
|
555
|
+
{ label: "Copy subscription ID", icon: "copy", onSelect: () => navigator.clipboard?.writeText(subscription.id).then(() => toast("Copied to clipboard")) },
|
|
556
|
+
{ label: "View customer", icon: "customers", onSelect: () => navigate(`#/customers/${customer.id}`) },
|
|
557
|
+
...(latest ? [{ label: "View latest invoice", icon: "invoices", onSelect: () => navigate(`#/invoices/${latest.id}`) }] : []),
|
|
558
|
+
...(write && subscription.status === "trialing" ? ["divider", { label: "End trial now", icon: "check", onSelect: () => endTrial(subscription) }] : []),
|
|
559
|
+
...(write && subscription.cancel_at_period_end ? ["divider", { label: "Cancel immediately instead", icon: "x-circle", danger: true, onSelect: () => cancelNow(subscription) }] : []),
|
|
560
|
+
], { align: "end" }),
|
|
561
|
+
);
|
|
562
|
+
actions.push(more);
|
|
563
|
+
const names = subscription.items.data.map((item) => item.price.nickname ?? item.price.id).join(" + ");
|
|
564
|
+
const title = el("div", { class: "detail-title" }, [el("div", {}, [el("h1", { class: "page-title", text: customer.name || customer.email || customer.id }), el("div", { class: "page-subtitle", text: names })]), statusBadge(status)]);
|
|
565
|
+
const header = pageHeader(title, { kind: "Subscription", actions, breadcrumbs: [{ label: "Subscriptions", href: "#/subscriptions" }, { label: subscription.id }] });
|
|
566
|
+
const period = subscription.items.data[0];
|
|
567
|
+
const meta = metaBar([
|
|
568
|
+
["Started", dateTime(subscription.start_date, now())],
|
|
569
|
+
["Current period", `${dateOnly(period?.current_period_start, now())} – ${dateOnly(period?.current_period_end, now())}`],
|
|
570
|
+
subscription.trial_end && subscription.status === "trialing" ? ["Trial ends", dateOnly(subscription.trial_end, now())] : ["Next invoice", active && !subscription.cancel_at_period_end ? el("span", {}, [text(dateOnly(period?.current_period_end, now())), el("span", { class: "muted", text: " · not generated by this synthetic account" })]) : el("span", { class: "muted", text: "None" })],
|
|
571
|
+
["Billing method", subscription.collection_method === "send_invoice" ? `Send invoice (${subscription.days_until_due} days)` : typeof subscription.default_payment_method === "object" && subscription.default_payment_method !== null ? cardChip(subscription.default_payment_method) : "Customer's default payment method"],
|
|
572
|
+
]);
|
|
573
|
+
const banners = [];
|
|
574
|
+
if (subscription.status === "incomplete") banners.push(alert(el("span", {}, [text("The first invoice has not been paid. "), latest ? link(`#/invoices/${latest.id}`, "Pay the invoice") : null, text(" to activate the subscription, or cancel it.")]), "warning", { title: "Payment required" }));
|
|
575
|
+
if (subscription.status === "past_due") banners.push(alert("The latest invoice is unpaid. Collect payment on the invoice to keep the subscription active.", "warning", { title: "Past due" }));
|
|
576
|
+
if (subscription.cancel_at_period_end) banners.push(alert(`This subscription cancels on ${dateOnly(subscription.cancel_at, now())}. Resume it to keep billing.`, "neutral", { title: "Scheduled to cancel", iconName: "clock" }));
|
|
577
|
+
if (subscription.status === "canceled") banners.push(alert(`Canceled ${dateTime(subscription.canceled_at ?? subscription.ended_at, now())}${subscription.cancellation_details?.comment ? ` — ${subscription.cancellation_details.comment}` : ""}.`, "neutral", { title: "This subscription is canceled", iconName: "minus-circle" }));
|
|
578
|
+
|
|
579
|
+
const pricing = section("Pricing", el("div", { class: "line-items" }, [
|
|
580
|
+
table({
|
|
581
|
+
columns: [
|
|
582
|
+
{ label: "Product", render: (item) => el("span", { class: "customer-cell-text" }, [el("span", { text: item.price.nickname ?? item.price.id }), el("span", { class: "customer-cell-sub", text: item.price.product })]) },
|
|
583
|
+
{ label: "Qty", class: "num nowrap", render: (item) => String(item.quantity) },
|
|
584
|
+
{ label: "Unit price", class: "num nowrap", render: (item) => priceLabel(item.price) },
|
|
585
|
+
{ label: "Amount", class: "num nowrap", render: (item) => money(item.price.unit_amount * item.quantity, item.price.currency) },
|
|
586
|
+
],
|
|
587
|
+
rows: subscription.items.data,
|
|
588
|
+
stack: false,
|
|
589
|
+
}),
|
|
590
|
+
el("div", { class: "totals" }, [el("span", { class: "total-label total-strong", text: "Total per period" }), el("span", { class: "money total-strong" }, subscriptionAmount(subscription))]),
|
|
591
|
+
]), { actions: write && active ? [button("Update items", "secondary", { size: "sm", icon: "edit", onClick: () => openUpdate(subscription) })] : [] });
|
|
592
|
+
|
|
593
|
+
const details = section("Subscription details", el("div", { class: "kv-cols" }, [
|
|
594
|
+
kv([
|
|
595
|
+
["ID", idChip(subscription.id)],
|
|
596
|
+
["Customer", link(`#/customers/${customer.id}`, customer.name || customer.email || customer.id)],
|
|
597
|
+
["Created", dateTime(subscription.created, now())],
|
|
598
|
+
["Billing cycle anchor", dateTime(subscription.billing_cycle_anchor, now())],
|
|
599
|
+
["Description", subscription.description],
|
|
600
|
+
]),
|
|
601
|
+
kv([
|
|
602
|
+
["Latest invoice", latest ? el("span", { class: "amount-status" }, [link(`#/invoices/${latest.id}`, latest.number ?? latest.id), statusBadge(invoiceStatus(latest, now()))]) : subscription.latest_invoice ? link(`#/invoices/${subscription.latest_invoice}`, subscription.latest_invoice) : null],
|
|
603
|
+
["Trial", subscription.trial_start ? `${dateOnly(subscription.trial_start, now())} – ${dateOnly(subscription.trial_end, now())}` : "No trial"],
|
|
604
|
+
["Cancel at", subscription.cancel_at ? dateTime(subscription.cancel_at, now()) : null],
|
|
605
|
+
["Cancellation reason", subscription.cancellation_details?.reason ? humanize(subscription.cancellation_details.reason) : null],
|
|
606
|
+
["Collection method", humanize(subscription.collection_method)],
|
|
607
|
+
]),
|
|
608
|
+
]));
|
|
609
|
+
|
|
610
|
+
// Every invoice of the subscription, 20 per page with Previous/Next (cursor pagination, never one capped call).
|
|
611
|
+
const invoicesBody = !canRead("invoices") ? el("p", { class: "muted", text: "This key cannot read invoices." }) : pagedList({ operation: "invoices.list", args: { subscription: subscription.id }, limit: 20, emptyNode: () => el("p", { class: "muted", text: "No invoices for this subscription." }), render: (invoices) => table({
|
|
612
|
+
columns: [
|
|
613
|
+
{ label: "Amount", class: "nowrap", render: (invoice) => amountWithStatus(invoice.total, invoice.currency, statusBadge(invoiceStatus(invoice, now()))) },
|
|
614
|
+
{ label: "Invoice number", render: (invoice) => invoice.number ?? el("span", { class: "muted", text: "Draft" }) },
|
|
615
|
+
{ label: "Billing reason", render: (invoice) => humanize(invoice.billing_reason) },
|
|
616
|
+
{ label: "Created", class: "nowrap", render: (invoice) => dateCell(invoice.created) },
|
|
617
|
+
],
|
|
618
|
+
rows: invoices,
|
|
619
|
+
href: (invoice) => `#/invoices/${invoice.id}`,
|
|
620
|
+
onOpen: (invoice) => navigate(`#/invoices/${invoice.id}`),
|
|
621
|
+
}) }).element;
|
|
622
|
+
const invoicesSection = section("Invoices", invoicesBody);
|
|
623
|
+
|
|
624
|
+
const metadataRows = Object.entries(subscription.metadata ?? {});
|
|
625
|
+
const metadataSection = section("Metadata", metadataRows.length > 0 ? kv(metadataRows) : el("p", { class: "muted", text: "No metadata" }));
|
|
626
|
+
return [header, meta, ...banners, pricing, details, invoicesSection, metadataSection];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function resume(subscription) {
|
|
630
|
+
await action(async () => {
|
|
631
|
+
await call("subscriptions.update", { subscription: subscription.id, cancel_at_period_end: false }, key());
|
|
632
|
+
toast("Subscription resumed");
|
|
633
|
+
invalidateCache();
|
|
634
|
+
navigate(location.hash);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function endTrial(subscription) {
|
|
639
|
+
const ok = await confirmDialog("End trial now?", "The subscription becomes active immediately. This synthetic account does not generate the first paid invoice at that point.", "End trial");
|
|
640
|
+
if (!ok) return;
|
|
641
|
+
await action(async () => {
|
|
642
|
+
await call("subscriptions.update", { subscription: subscription.id, trial_end: "now" }, key());
|
|
643
|
+
toast("Trial ended");
|
|
644
|
+
invalidateCache();
|
|
645
|
+
navigate(location.hash);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function cancelNow(subscription) {
|
|
650
|
+
const ok = await confirmDialog("Cancel immediately?", "The subscription ends now; an open first invoice is voided.", "Cancel subscription", { danger: true });
|
|
651
|
+
if (!ok) return;
|
|
652
|
+
await action(async () => {
|
|
653
|
+
await call("subscriptions.cancel", { subscription: subscription.id }, key());
|
|
654
|
+
toast("Subscription canceled");
|
|
655
|
+
invalidateCache();
|
|
656
|
+
navigate(location.hash);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function openCancel(subscription) {
|
|
661
|
+
const when = el("div", { class: "radio-group" });
|
|
662
|
+
const nowRadio = el("input", { attrs: { type: "radio", name: "when", value: "now" } });
|
|
663
|
+
const endRadio = el("input", { attrs: { type: "radio", name: "when", value: "period_end" } });
|
|
664
|
+
endRadio.checked = true;
|
|
665
|
+
when.append(
|
|
666
|
+
el("label", { class: "radio-field" }, [endRadio, el("span", { class: "radio-labels" }, [el("span", { class: "radio-label", text: "At the end of the current period" }), el("span", { class: "radio-description", text: `The customer keeps access until ${dateOnly(subscription.items.data[0]?.current_period_end, now())}.` })])]),
|
|
667
|
+
el("label", { class: "radio-field" }, [nowRadio, el("span", { class: "radio-labels" }, [el("span", { class: "radio-label", text: "Immediately" }), el("span", { class: "radio-description", text: "Access ends now. An unpaid first invoice is voided." })])]),
|
|
668
|
+
);
|
|
669
|
+
const feedback = select([{ value: "", label: "Select feedback" }, { value: "too_expensive", label: "Too expensive" }, { value: "missing_features", label: "Missing features" }, { value: "switched_service", label: "Switched service" }, { value: "unused", label: "Unused" }, { value: "customer_service", label: "Customer service" }, { value: "too_complex", label: "Too complex" }, { value: "low_quality", label: "Low quality" }, { value: "other", label: "Other" }], "");
|
|
670
|
+
const comment = textarea({ placeholder: "Internal note", rows: 2 });
|
|
671
|
+
const fields = { when: field("Cancel", when), cancellation_details: field("Cancellation feedback", el("div", { class: "inline-list" }, [feedback, comment]), { optional: true }) };
|
|
672
|
+
const idempotencyKey = key();
|
|
673
|
+
openModal({
|
|
674
|
+
title: "Cancel subscription",
|
|
675
|
+
body: [fields.when, fields.cancellation_details],
|
|
676
|
+
actions: [
|
|
677
|
+
{ label: "Keep subscription" },
|
|
678
|
+
{
|
|
679
|
+
label: "Cancel subscription",
|
|
680
|
+
kind: "danger",
|
|
681
|
+
submit: true,
|
|
682
|
+
onClick: (api) =>
|
|
683
|
+
submitModal(api, fields, async () => {
|
|
684
|
+
const details = feedback.value || comment.value.trim() ? { cancellation_details: { ...(feedback.value ? { feedback: feedback.value } : {}), ...(comment.value.trim() ? { comment: comment.value.trim() } : {}) } } : {};
|
|
685
|
+
if (nowRadio.checked) {
|
|
686
|
+
if (details.cancellation_details) await call("subscriptions.update", { subscription: subscription.id, ...details }, key());
|
|
687
|
+
return call("subscriptions.cancel", { subscription: subscription.id }, idempotencyKey);
|
|
688
|
+
}
|
|
689
|
+
return call("subscriptions.update", { subscription: subscription.id, cancel_at_period_end: true, ...details }, idempotencyKey);
|
|
690
|
+
}, () => {
|
|
691
|
+
toast(nowRadio.checked ? "Subscription canceled" : "Subscription will cancel at period end");
|
|
692
|
+
invalidateCache();
|
|
693
|
+
navigate(location.hash);
|
|
694
|
+
}),
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Update items (quantity / price / remove / add) and description. */
|
|
701
|
+
function openUpdate(subscription) {
|
|
702
|
+
const rowsHost = el("div", { class: "inline-list" });
|
|
703
|
+
const rows = subscription.items.data.map((item) => ({ id: item.id, price: item.price.id, quantity: item.quantity, label: `${item.price.nickname ?? item.price.id} — ${priceLabel(item.price)}`, deleted: false }));
|
|
704
|
+
const added = [];
|
|
705
|
+
let priceOptions = [];
|
|
706
|
+
function draw() {
|
|
707
|
+
rowsHost.replaceChildren();
|
|
708
|
+
for (const row of rows) {
|
|
709
|
+
if (row.deleted) continue;
|
|
710
|
+
const quantity = input({ type: "number", value: String(row.quantity), min: 1 });
|
|
711
|
+
quantity.setAttribute("aria-label", "Quantity");
|
|
712
|
+
quantity.addEventListener("input", () => { row.quantity = Number(quantity.value || 1); });
|
|
713
|
+
const remove = button(undefined, "ghost", { icon: "trash", ariaLabel: "Remove item", class: "btn-icon-only", onClick: () => { row.deleted = true; draw(); } });
|
|
714
|
+
rowsHost.append(el("div", { class: "item-row" }, [el("span", { class: "truncate", text: row.label }), quantity, remove]));
|
|
715
|
+
}
|
|
716
|
+
for (const row of added) {
|
|
717
|
+
const priceSelect = select([{ value: "", label: "Choose a recurring price" }, ...priceOptions], row.price);
|
|
718
|
+
priceSelect.setAttribute("aria-label", "Recurring price");
|
|
719
|
+
priceSelect.addEventListener("change", () => { row.price = priceSelect.value; });
|
|
720
|
+
const quantity = input({ type: "number", value: String(row.quantity), min: 1 });
|
|
721
|
+
quantity.setAttribute("aria-label", "Quantity");
|
|
722
|
+
quantity.addEventListener("input", () => { row.quantity = Number(quantity.value || 1); });
|
|
723
|
+
const remove = button(undefined, "ghost", { icon: "trash", ariaLabel: "Remove item", class: "btn-icon-only", onClick: () => { added.splice(added.indexOf(row), 1); draw(); } });
|
|
724
|
+
rowsHost.append(el("div", { class: "item-row" }, [priceSelect, quantity, remove]));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
draw();
|
|
728
|
+
void loadRecurringPrices(subscription.currency).then((options) => { priceOptions = options; draw(); });
|
|
729
|
+
const description = input({ value: subscription.description ?? "", placeholder: "Shown on invoices" });
|
|
730
|
+
const fields = { items: field("Items", el("div", { class: "inline-list" }, [rowsHost, button("Add a product", "link", { icon: "plus", onClick: () => { added.push({ price: "", quantity: 1 }); draw(); } })])), description: field("Description", description, { optional: true }) };
|
|
731
|
+
const idempotencyKey = key();
|
|
732
|
+
openModal({
|
|
733
|
+
title: "Update subscription",
|
|
734
|
+
describedBy: "Changes apply to the current period. This synthetic account does not create proration invoice items.",
|
|
735
|
+
body: [fields.items, fields.description],
|
|
736
|
+
actions: [
|
|
737
|
+
{ label: "Cancel" },
|
|
738
|
+
{
|
|
739
|
+
label: "Update subscription",
|
|
740
|
+
kind: "primary",
|
|
741
|
+
submit: true,
|
|
742
|
+
onClick: (api) => {
|
|
743
|
+
const items = [];
|
|
744
|
+
for (const row of rows) {
|
|
745
|
+
if (row.deleted) items.push({ id: row.id, deleted: true });
|
|
746
|
+
else items.push({ id: row.id, quantity: row.quantity });
|
|
747
|
+
}
|
|
748
|
+
for (const row of added) {
|
|
749
|
+
if (!row.price) {
|
|
750
|
+
fields.items.setError("Choose a price for every added item.");
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
items.push({ price: row.price, quantity: row.quantity });
|
|
754
|
+
}
|
|
755
|
+
return submitModal(api, fields, () => call("subscriptions.update", { subscription: subscription.id, items, proration_behavior: "none", description: description.value.trim() || "" }, idempotencyKey), () => {
|
|
756
|
+
toast("Subscription updated");
|
|
757
|
+
invalidateCache();
|
|
758
|
+
navigate(location.hash);
|
|
759
|
+
});
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function loadRecurringPrices(currency) {
|
|
767
|
+
try {
|
|
768
|
+
const index = await indexAll("prices.list", { active: true, type: "recurring", expand: ["data.product"], ...(currency === undefined ? {} : { currency }) });
|
|
769
|
+
const options = index.items.map((price) => {
|
|
770
|
+
const product = typeof price.product === "object" && price.product !== null ? price.product : undefined;
|
|
771
|
+
return { value: price.id, label: `${product?.name ?? price.product} — ${priceLabel(price)}${price.nickname ? ` (${price.nickname})` : ""}`, price: { ...price, product: product?.id ?? price.product } };
|
|
772
|
+
});
|
|
773
|
+
// A bounded index never truncates silently: the last option says the list stops here.
|
|
774
|
+
if (!index.complete) options.push({ value: "", label: `Showing the first ${options.length.toLocaleString("en-US")} recurring prices; more exist`, disabled: true });
|
|
775
|
+
return options;
|
|
776
|
+
} catch {
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------------------------------------
|
|
782
|
+
// Create subscription
|
|
783
|
+
// ---------------------------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
registerPage("subscription-new", async (host, route) => {
|
|
786
|
+
setTitle("Create a subscription");
|
|
787
|
+
if (!canWrite("subscriptions")) {
|
|
788
|
+
host.replaceChildren(pageInner(pageHeader("Create a subscription", { breadcrumbs: [{ label: "Subscriptions", href: "#/subscriptions" }] }), deniedPanel(new ToolError("tool_error", "tool.PERMISSION_DENIED", "", { group: "subscriptions", level: "write" }), "subscriptions")));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
app.editing += 1;
|
|
792
|
+
const customer = customerPicker({ value: route.params.customer, onChange: (record) => void picker.setCustomer(record?.id, record?.invoice_settings?.default_payment_method) });
|
|
793
|
+
const items = [{ price: route.params.price ?? "", quantity: 1 }];
|
|
794
|
+
let priceOptions = [];
|
|
795
|
+
const rowsHost = el("div", { class: "inline-list" });
|
|
796
|
+
const summaryLines = el("div");
|
|
797
|
+
function draw() {
|
|
798
|
+
rowsHost.replaceChildren();
|
|
799
|
+
for (const row of items) {
|
|
800
|
+
const priceSelect = select([{ value: "", label: priceOptions.length === 0 ? "Loading prices…" : "Choose a recurring price" }, ...priceOptions], row.price);
|
|
801
|
+
priceSelect.setAttribute("aria-label", "Recurring price");
|
|
802
|
+
priceSelect.addEventListener("change", () => { row.price = priceSelect.value; drawSummary(); });
|
|
803
|
+
const quantity = input({ type: "number", value: String(row.quantity), min: 1 });
|
|
804
|
+
quantity.setAttribute("aria-label", "Quantity");
|
|
805
|
+
quantity.addEventListener("input", () => { row.quantity = Number(quantity.value || 1); drawSummary(); });
|
|
806
|
+
const remove = button(undefined, "ghost", { icon: "trash", ariaLabel: "Remove item", class: "btn-icon-only", disabled: items.length === 1, onClick: () => { items.splice(items.indexOf(row), 1); draw(); } });
|
|
807
|
+
rowsHost.append(el("div", { class: "item-row" }, [priceSelect, quantity, remove]));
|
|
808
|
+
}
|
|
809
|
+
drawSummary();
|
|
810
|
+
}
|
|
811
|
+
function drawSummary() {
|
|
812
|
+
summaryLines.replaceChildren();
|
|
813
|
+
let total = 0;
|
|
814
|
+
let currency;
|
|
815
|
+
for (const row of items) {
|
|
816
|
+
const option = priceOptions.find((entry) => entry.value === row.price && entry.price);
|
|
817
|
+
if (!option) continue;
|
|
818
|
+
currency = option.price.currency;
|
|
819
|
+
total += option.price.unit_amount * row.quantity;
|
|
820
|
+
summaryLines.append(el("div", { class: "balance-row" }, [el("span", { text: `${option.price.nickname ?? option.price.id} × ${row.quantity}` }), el("span", { text: money(option.price.unit_amount * row.quantity, option.price.currency) })]));
|
|
821
|
+
}
|
|
822
|
+
if (currency) summaryLines.append(el("div", { class: "balance-row" }, [el("strong", { text: "Total per period" }), el("strong", { text: money(total, currency) })]));
|
|
823
|
+
else summaryLines.append(el("div", { class: "muted", text: "Choose a price to see the total." }));
|
|
824
|
+
}
|
|
825
|
+
draw();
|
|
826
|
+
void loadRecurringPrices(undefined).then((options) => { priceOptions = options; draw(); });
|
|
827
|
+
const picker = methodPicker({ customer: route.params.customer, allowNone: true, noneLabel: "Use the customer's default payment method" });
|
|
828
|
+
const trialDays = input({ type: "number", placeholder: "0", min: 1, max: 730 });
|
|
829
|
+
const collection = select([{ value: "charge_automatically", label: "Charge automatically" }, { value: "send_invoice", label: "Send invoice" }], "charge_automatically");
|
|
830
|
+
const days = input({ type: "number", value: "30", min: 1, max: 730 });
|
|
831
|
+
const behavior = select([{ value: "default_incomplete", label: "Default incomplete — keep the subscription if the first payment fails" }, { value: "error_if_incomplete", label: "Error if incomplete — fail the request on a decline" }], "default_incomplete");
|
|
832
|
+
const description = input({ placeholder: "Shown on invoices" });
|
|
833
|
+
const fields = {
|
|
834
|
+
customer: field("Customer", el("div", { class: "inline-list" }, [customer, canWrite("customers") ? button("Add new customer", "link", { onClick: () => openCustomerForm(undefined, (record) => { customer.value = record.id; customer.customer = record; customer.control.value = record.name || record.email || record.id; void picker.setCustomer(record.id); }) }) : null])),
|
|
835
|
+
items: field("Pricing", el("div", { class: "inline-list" }, [rowsHost, button("Add another product", "link", { icon: "plus", onClick: () => { items.push({ price: "", quantity: 1 }); draw(); } })])),
|
|
836
|
+
trial_period_days: field("Free trial days", trialDays, { optional: true }),
|
|
837
|
+
collection_method: field("Collection method", collection),
|
|
838
|
+
days_until_due: field("Days until due", days),
|
|
839
|
+
default_payment_method: field("Payment method", picker),
|
|
840
|
+
payment_behavior: field("Payment behaviour", behavior),
|
|
841
|
+
description: field("Description", description, { optional: true }),
|
|
842
|
+
};
|
|
843
|
+
const toggle = () => {
|
|
844
|
+
fields.days_until_due.hidden = collection.value !== "send_invoice";
|
|
845
|
+
fields.default_payment_method.hidden = collection.value === "send_invoice";
|
|
846
|
+
};
|
|
847
|
+
collection.addEventListener("change", toggle);
|
|
848
|
+
toggle();
|
|
849
|
+
const errorHost = el("div", { class: "form-error", attrs: { role: "alert" } });
|
|
850
|
+
errorHost.hidden = true;
|
|
851
|
+
const api = { setError: (message) => { errorHost.textContent = message ?? ""; errorHost.hidden = !message; } };
|
|
852
|
+
let idempotencyKey = key();
|
|
853
|
+
const attachedTestCards = new Map();
|
|
854
|
+
const submit = button("Create subscription", "primary", {
|
|
855
|
+
onClick: () =>
|
|
856
|
+
action(async () => {
|
|
857
|
+
for (const entry of Object.values(fields)) entry.setError(undefined);
|
|
858
|
+
errorHost.hidden = true;
|
|
859
|
+
if (!customer.value) {
|
|
860
|
+
fields.customer.setError("Choose a customer.");
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (items.some((row) => !row.price)) {
|
|
864
|
+
fields.items.setError("Choose a price for every item.");
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const args = { customer: customer.value, items: items.map((row) => ({ price: row.price, quantity: row.quantity })), collection_method: collection.value, payment_behavior: behavior.value };
|
|
868
|
+
if (collection.value === "send_invoice") args.days_until_due = Number(days.value || 30);
|
|
869
|
+
else if (picker.value) {
|
|
870
|
+
if (picker.value.startsWith("pm_card_")) {
|
|
871
|
+
const cacheKey = `${customer.value}:${picker.value}`;
|
|
872
|
+
if (!attachedTestCards.has(cacheKey)) attachedTestCards.set(cacheKey, (await call("payment_methods.attach", { payment_method: picker.value, customer: customer.value }, key())).id);
|
|
873
|
+
args.default_payment_method = attachedTestCards.get(cacheKey);
|
|
874
|
+
} else args.default_payment_method = picker.value;
|
|
875
|
+
}
|
|
876
|
+
if (trialDays.value) args.trial_period_days = Number(trialDays.value);
|
|
877
|
+
if (description.value.trim()) args.description = description.value.trim();
|
|
878
|
+
submit.classList.add("is-busy");
|
|
879
|
+
submit.disabled = true;
|
|
880
|
+
try {
|
|
881
|
+
const subscription = await call("subscriptions.create", args, idempotencyKey);
|
|
882
|
+
toast(subscription.status === "incomplete" ? "Subscription created — the first payment is pending" : `Subscription ${subscription.status}`);
|
|
883
|
+
invalidateCache();
|
|
884
|
+
app.editing -= 1;
|
|
885
|
+
navigate(`#/subscriptions/${subscription.id}`);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
idempotencyKey = key();
|
|
888
|
+
if (error instanceof ToolError && error.is("CARD_DECLINED")) api.setError(`Card declined: ${error.message} (${humanize(error.details.decline_code ?? "generic_decline")}). Nothing was created; choose another card or use the default-incomplete behaviour.`);
|
|
889
|
+
else applyError(error, fields, api);
|
|
890
|
+
} finally {
|
|
891
|
+
submit.classList.remove("is-busy");
|
|
892
|
+
submit.disabled = false;
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
const form = el("div", { class: "editor-form" }, [fields.customer, fields.items, fields.trial_period_days, fields.collection_method, fields.days_until_due, fields.default_payment_method, fields.payment_behavior, fields.description, errorHost, el("div", { class: "sticky-footer" }, [button("Cancel", "secondary", { onClick: () => { app.editing -= 1; navigate("#/subscriptions"); } }), submit])]);
|
|
897
|
+
const summary = el("div", { class: "editor-summary" }, [el("div", { class: "editor-summary-title", text: "Summary" }), summaryLines, el("div", { class: "muted", text: "The first invoice is created and, when charging automatically, paid in the same request." })]);
|
|
898
|
+
host.replaceChildren(pageInner(pageHeader("Create a subscription", { breadcrumbs: [{ label: "Subscriptions", href: "#/subscriptions" }, { label: "Create" }] }), el("div", { class: "editor" }, [form, summary])));
|
|
899
|
+
if (route.params.customer) void picker.setCustomer(route.params.customer);
|
|
900
|
+
host.addEventListener("page-leave", () => { app.editing = Math.max(0, app.editing - 1); }, { once: true });
|
|
901
|
+
});
|
|
902
|
+
|