@fleetless/contracts 6.2.0 → 6.3.0-next.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/artifacts/openapi.json +4322 -1887
  3. package/artifacts/routes.json +326 -2
  4. package/artifacts/schema/billing-address.schema.json +49 -0
  5. package/artifacts/schema/billing-cancel-request.schema.json +12 -0
  6. package/artifacts/schema/billing-change-request.schema.json +60 -0
  7. package/artifacts/schema/billing-change-response.schema.json +765 -0
  8. package/artifacts/schema/billing-details-update.schema.json +25 -0
  9. package/artifacts/schema/billing-details.schema.json +176 -0
  10. package/artifacts/schema/billing-invoice.schema.json +76 -0
  11. package/artifacts/schema/billing-view.schema.json +671 -0
  12. package/artifacts/schema/checkout-request.schema.json +252 -0
  13. package/artifacts/schema/checkout-response.schema.json +22 -0
  14. package/artifacts/schema/checkout-status.schema.json +49 -0
  15. package/artifacts/schema/payment-method-change-request.schema.json +19 -0
  16. package/artifacts/schema/payment-method.schema.json +80 -0
  17. package/artifacts/schema/payment-provider-unavailable-details.schema.json +29 -0
  18. package/artifacts/schema/vat-id-check-request.schema.json +22 -0
  19. package/artifacts/schema/vat-id-check-response.schema.json +36 -0
  20. package/dist/billing.d.ts +778 -0
  21. package/dist/billing.js +441 -0
  22. package/dist/errors.d.ts +17 -5
  23. package/dist/errors.js +31 -0
  24. package/dist/index.d.ts +4 -2
  25. package/dist/index.js +5 -1
  26. package/dist/plans.d.ts +9 -9
  27. package/dist/realtime.d.ts +2 -2
  28. package/dist/rest.d.ts +3 -3
  29. package/dist/routes.d.ts +1 -1
  30. package/dist/routes.js +181 -4
  31. package/package.json +1 -1
@@ -0,0 +1,441 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ import { ADDONS, PLANS, PLAN_ORDER, addonKey, orgAddons, planCurrency, planId, } from './plans.js';
4
+ /**
5
+ * Countries, VAT and the money math for billing through Mollie
6
+ * (fleetless/fleetless#104).
7
+ *
8
+ * Every price here reads from `plans.ts`'s `PLANS` / `ADDONS` catalogue,
9
+ * never a cent amount of its own, so a price change in one place is a price
10
+ * change everywhere. Money is integer cents throughout; the one rounding a
11
+ * proration or a VAT amount is allowed is `Math.round`, applied exactly
12
+ * once (`prorateCents`, `vatCents`, and `changeNetCents`'s single
13
+ * proration).
14
+ */
15
+ /** ISO 3166-1 alpha-2, upper case. Greece is `GR` here; VIES calls it `EL`. */
16
+ export const countryCode = z.string().regex(/^[A-Z]{2}$/);
17
+ /** The 27 EU member states, as the billing country. Greece is `GR`. */
18
+ export const EU_COUNTRIES = [
19
+ 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV',
20
+ 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
21
+ ];
22
+ /** The seller: Dehne Robotik GmbH. Fleetless never charges another EU country's VAT. */
23
+ export const SELLER_COUNTRY = 'DE';
24
+ /** Germany's VAT rate, in whole percent. */
25
+ export const DE_VAT_RATE_PERCENT = 19;
26
+ /** Whether `country` is one of the 27 EU member states. */
27
+ export function isEuCountry(country) {
28
+ return EU_COUNTRIES.includes(country);
29
+ }
30
+ /** EUR in the EU, USD elsewhere. */
31
+ export function currencyForCountry(country) {
32
+ return isEuCountry(country) ? 'eur' : 'usd';
33
+ }
34
+ /** Who is paying: a company (may hold a VAT ID) or a person (a full name, never a VAT ID). */
35
+ export const payerKind = z.enum(['company', 'person']);
36
+ /** The billing cycle: monthly, or yearly at twelve months for 15 % off (#103). */
37
+ export const billingCycle = z.enum(['monthly', 'yearly']);
38
+ /**
39
+ * How a charge is taxed: `de_standard` (German VAT), `reverse_charge` (0 %,
40
+ * an EU company whose VAT ID VIES confirms) or `outside_eu` (0 %, no
41
+ * reverse-charge note).
42
+ */
43
+ export const vatTreatment = z.enum(['de_standard', 'reverse_charge', 'outside_eu']);
44
+ /**
45
+ * Why `vatFor` refused a payer: `eu_person` (a person outside Germany, in
46
+ * another EU country), `vat_id_required` (a company in another EU country
47
+ * gave no VAT ID) or `vat_id_invalid` (VIES said the given VAT ID is
48
+ * invalid).
49
+ */
50
+ export const payerRefusalRule = z.enum(['eu_person', 'vat_id_required', 'vat_id_invalid']);
51
+ /**
52
+ * Who may pay, and the VAT they pay. `vatIdValid`: `null` = no VAT ID
53
+ * given; `true` = VIES valid **or unverified** (VIES unreachable is
54
+ * accepted); `false` = VIES said invalid.
55
+ *
56
+ * Germany is unconditional — company or person, VAT ID optional, 19 %
57
+ * regardless of whether a given ID checks out. A German VAT ID's validity
58
+ * never gates checkout, only the renewal sweep later (dunning);
59
+ * `vatFor` only ever prices the charge in hand.
60
+ */
61
+ export function vatFor(input) {
62
+ const { kind, country, vatIdValid } = input;
63
+ if (country === SELLER_COUNTRY)
64
+ return { allowed: true, treatment: 'de_standard', rate_percent: DE_VAT_RATE_PERCENT };
65
+ if (isEuCountry(country)) {
66
+ if (kind === 'person')
67
+ return { allowed: false, rule: 'eu_person' };
68
+ if (vatIdValid === true)
69
+ return { allowed: true, treatment: 'reverse_charge', rate_percent: 0 };
70
+ if (vatIdValid === false)
71
+ return { allowed: false, rule: 'vat_id_invalid' };
72
+ return { allowed: false, rule: 'vat_id_required' };
73
+ }
74
+ return { allowed: true, treatment: 'outside_eu', rate_percent: 0 };
75
+ }
76
+ /** VIES's member-state code: `EL` for `GR`, the country otherwise. */
77
+ export function viesCountry(country) {
78
+ return country === 'GR' ? 'EL' : country;
79
+ }
80
+ /**
81
+ * Upper-case, without spaces, dots and dashes, and without a leading
82
+ * country prefix: the VIES code (`EL` for Greece) or, for Greece only, the
83
+ * address code `GR` as well, since a payer is as likely to type that. `null` for an
84
+ * empty string (trimmed).
85
+ */
86
+ export function normalizeVatId(country, raw) {
87
+ const trimmed = raw.trim();
88
+ if (trimmed === '')
89
+ return null;
90
+ let cleaned = trimmed.toUpperCase().replace(/[\s.-]/g, '');
91
+ const vies = viesCountry(country);
92
+ const prefixes = country === 'GR' ? [vies, country] : [vies];
93
+ for (const prefix of prefixes) {
94
+ if (cleaned.startsWith(prefix)) {
95
+ cleaned = cleaned.slice(prefix.length);
96
+ break;
97
+ }
98
+ }
99
+ return vies + cleaned;
100
+ }
101
+ function priceKeyFor(cycle, currency) {
102
+ return `${currency}_${cycle === 'monthly' ? 'month' : 'year'}`;
103
+ }
104
+ function planPriceCentsFor(plan, cycle, currency) {
105
+ const prices = PLANS[plan].prices;
106
+ if (prices === null)
107
+ throw new Error(`billing: ${plan} has no catalogue price — sold by contract, never self-service`);
108
+ return prices[priceKeyFor(cycle, currency)];
109
+ }
110
+ /**
111
+ * Net price of one period: the plan plus each add-on × units, for the
112
+ * cycle and currency. Add-ons count only on Pro — `plans.ts`'s own rule for
113
+ * which plans may buy them at all (`planFeature.addons`). Throws for
114
+ * `enterprise`: its price is `null`, sold by contract, and self-service
115
+ * never charges it.
116
+ */
117
+ export function periodNetCents(input) {
118
+ const { plan, addons, cycle, currency } = input;
119
+ const key = priceKeyFor(cycle, currency);
120
+ let total = planPriceCentsFor(plan, cycle, currency);
121
+ if (plan === 'pro') {
122
+ for (const addon of addonKey.options) {
123
+ const units = addons[addon];
124
+ if (units > 0)
125
+ total += ADDONS[addon].prices[key] * units;
126
+ }
127
+ }
128
+ return total;
129
+ }
130
+ /** `Math.round(priceCents * remainingDays / periodDays)` — the one rounding every proration in this module uses. */
131
+ export function prorateCents(priceCents, remainingDays, periodDays) {
132
+ return Math.round((priceCents * remainingDays) / periodDays);
133
+ }
134
+ /** `Math.round(netCents * ratePercent / 100)`. */
135
+ export function vatCents(netCents, ratePercent) {
136
+ return Math.round((netCents * ratePercent) / 100);
137
+ }
138
+ /** Net, VAT (`vatCents`) and gross for one charge. */
139
+ export function chargeAmounts(netCents, ratePercent) {
140
+ const vat = vatCents(netCents, ratePercent);
141
+ return { net_cents: netCents, vat_cents: vat, gross_cents: netCents + vat };
142
+ }
143
+ /** Milliseconds in a day. */
144
+ export const DAY_MS = 86_400_000;
145
+ /** Whole days between `start` and `end` (`Math.round`). */
146
+ export function periodDays(start, end) {
147
+ return Math.round((end.getTime() - start.getTime()) / DAY_MS);
148
+ }
149
+ /** Days left until `end`, as of `now` (`Math.ceil`), clamped to `[0, days]`. */
150
+ export function remainingDays(now, end, days) {
151
+ const left = Math.ceil((end.getTime() - now.getTime()) / DAY_MS);
152
+ return Math.min(Math.max(left, 0), days);
153
+ }
154
+ /**
155
+ * The net amount charged NOW for moving `from` → `to` within the period
156
+ * — increases are charged at once, decreases wait for the period's end,
157
+ * nothing is credited. Three branches, in this order:
158
+ *
159
+ * 1. **Monthly → yearly**: the full yearly price of `to` (plan plus
160
+ * add-ons, Pro only) minus the unused share of `from`'s monthly price,
161
+ * floored at 0. The period restarts today with a new anchor, so this
162
+ * is the only branch that prices `to` and `from` on different cycles.
163
+ * 2. **Yearly → monthly**: the reverse is a cycle *decrease* — charged 0
164
+ * now, stored as `next_cycle` and applied at the period's end, like
165
+ * every other decrease.
166
+ * 3. **Same cycle**: the increase only — the plan difference when `to` is
167
+ * the higher plan (`PLAN_ORDER`), plus each add-on's added units × its
168
+ * unit price, summed and prorated once over the remaining days. A plan
169
+ * decrease and fewer add-ons contribute 0; add-ons count only when
170
+ * `to.plan` is `pro`, and units held on a plan other than Pro count as
171
+ * none.
172
+ */
173
+ export function changeNetCents(input) {
174
+ const { from, to, currency, periodStart, periodEnd, now } = input;
175
+ const days = periodDays(periodStart, periodEnd);
176
+ const remaining = remainingDays(now, periodEnd, days);
177
+ if (from.cycle === 'monthly' && to.cycle === 'yearly') {
178
+ const yearlyTo = periodNetCents({ plan: to.plan, addons: to.addons, cycle: 'yearly', currency });
179
+ const unusedFromShare = prorateCents(periodNetCents({ plan: from.plan, addons: from.addons, cycle: 'monthly', currency }), remaining, days);
180
+ return Math.max(0, yearlyTo - unusedFromShare);
181
+ }
182
+ if (from.cycle === 'yearly' && to.cycle === 'monthly')
183
+ return 0;
184
+ const planIncrease = PLAN_ORDER.indexOf(to.plan) > PLAN_ORDER.indexOf(from.plan)
185
+ ? planPriceCentsFor(to.plan, to.cycle, currency) - planPriceCentsFor(from.plan, to.cycle, currency)
186
+ : 0;
187
+ let addonIncrease = 0;
188
+ if (to.plan === 'pro') {
189
+ const fromAddons = from.plan === 'pro' ? from.addons : null;
190
+ const key = priceKeyFor(to.cycle, currency);
191
+ for (const addon of addonKey.options) {
192
+ const base = fromAddons ? fromAddons[addon] : 0;
193
+ const added = Math.max(0, to.addons[addon] - base);
194
+ if (added > 0)
195
+ addonIncrease += added * ADDONS[addon].prices[key];
196
+ }
197
+ }
198
+ return prorateCents(planIncrease + addonIncrease, remaining, days);
199
+ }
200
+ /**
201
+ * The first charge of a checkout: one full period of `plan` + `addons`.
202
+ * One line for the plan, then one line per add-on actually bought
203
+ * (quantity 0 is omitted, not a zero-amount line).
204
+ */
205
+ export function checkoutQuote(input) {
206
+ const { plan, addons, cycle, currency, rate_percent } = input;
207
+ const key = priceKeyFor(cycle, currency);
208
+ const lines = [];
209
+ const planNet = planPriceCentsFor(plan, cycle, currency);
210
+ lines.push({ item: 'plan', quantity: 1, net_cents: planNet, gross_cents: chargeAmounts(planNet, rate_percent).gross_cents });
211
+ if (plan === 'pro') {
212
+ for (const addon of addonKey.options) {
213
+ const quantity = addons[addon];
214
+ if (quantity > 0) {
215
+ const net = ADDONS[addon].prices[key] * quantity;
216
+ lines.push({ item: addon, quantity, net_cents: net, gross_cents: chargeAmounts(net, rate_percent).gross_cents });
217
+ }
218
+ }
219
+ }
220
+ const netCents = lines.reduce((sum, line) => sum + line.net_cents, 0);
221
+ return { ...chargeAmounts(netCents, rate_percent), lines, rate_percent, currency, cycle };
222
+ }
223
+ /** Dunning after a failed renewal: retries on these days after the charge's due date. */
224
+ export const BILLING_RETRY_DAYS = [3, 7];
225
+ /** The day, after the charge's due date, the org is locked when still unpaid. */
226
+ export const BILLING_LOCK_DAY = 14;
227
+ /*
228
+ * ---------------------------------------------------------------------------
229
+ * Billing shapes: checkout, the billing account, invoices and the owner's
230
+ * self-service changes (2026-10-04, fleetless/fleetless#104, I-2).
231
+ *
232
+ * The wire shapes for `src/routes.ts`'s `billing` section
233
+ * (`GET /api/billing`, `POST /api/billing/*`). Money is the same
234
+ * integer-cents discipline as everything above; every price still comes
235
+ * from `plans.ts`'s catalogue through `periodNetCents` / `changeNetCents` —
236
+ * nothing here invents a cent amount of its own.
237
+ * ---------------------------------------------------------------------------
238
+ */
239
+ /** A billing address. `country` gates VAT (`vatFor`) and currency (`currencyForCountry`). */
240
+ export const billingAddress = z.object({
241
+ line1: z.string().trim().min(1).max(200).meta({ description: 'Street and number, or the first address line.' }),
242
+ line2: z.string().trim().max(200).nullable().meta({ description: 'A second address line, or `null` when there is none.' }),
243
+ postal_code: z.string().trim().min(1).max(20).meta({ description: 'Postal or ZIP code.' }),
244
+ city: z.string().trim().min(1).max(100).meta({ description: 'City or town.' }),
245
+ country: countryCode.meta({ description: "The billing country. Decides VAT (`vatFor`) and currency (`currencyForCountry`)." }),
246
+ }).strict();
247
+ /**
248
+ * Who is paying: a `company` (may hold a VAT
249
+ * ID, checked through VIES) or a `person` (full name, never a VAT ID — only
250
+ * a company can be VAT-registered). The discriminant decides which other
251
+ * fields exist at all, so a person cannot even send `vat_id` or
252
+ * `company_name` — there is no field for `.strict()` to refuse, the shape
253
+ * itself has none.
254
+ */
255
+ export const billingDetails = z.discriminatedUnion('kind', [
256
+ z.object({
257
+ kind: z.literal('company').meta({ description: 'Billed as a company.' }),
258
+ company_name: z.string().trim().min(1).max(200).meta({ description: "The company's legal name, printed on the invoice." }),
259
+ vat_id: z.string().trim().max(20).nullable().meta({
260
+ description: "The company's VAT ID, or `null` for none. Required, and must check out through VIES, for a company outside Germany.",
261
+ }),
262
+ address: billingAddress.meta({ description: 'The billing address.' }),
263
+ invoice_email: z.email().meta({ description: 'Where invoices and billing mail are sent.' }),
264
+ }).strict().meta({ description: "A company: name and an optional VAT ID, never 'full name'." }),
265
+ z.object({
266
+ kind: z.literal('person').meta({ description: 'Billed as a person.' }),
267
+ full_name: z.string().trim().min(1).max(200).meta({ description: "The person's full name, printed on the invoice." }),
268
+ address: billingAddress.meta({ description: 'The billing address.' }),
269
+ invoice_email: z.email().meta({ description: 'Where invoices and billing mail are sent.' }),
270
+ }).strict().meta({ description: 'A person: a full name, never a VAT ID — only a company can be VAT-registered.' }),
271
+ ]);
272
+ /**
273
+ * `POST /api/billing/checkout`'s body: the plan and cycle to buy — Basic is
274
+ * free and never checked out — optional add-ons (Pro only; named on `plus`,
275
+ * or without `planFeature.addons`, is `400 validation_error`), the payer,
276
+ * and the terms and withdrawal confirmations: `accept_terms` is
277
+ * always required; `accept_withdrawal` is required for a person and refused
278
+ * for a company, which has no consumer right of withdrawal to confirm.
279
+ */
280
+ export const checkoutRequest = z.object({
281
+ plan: z.enum(['plus', 'pro']).meta({ description: 'The plan to buy.' }),
282
+ cycle: billingCycle.meta({ description: 'Monthly, or yearly at 15 % off.' }),
283
+ addons: orgAddons.partial().strict().optional().meta({
284
+ description: 'Add-on counts to buy alongside the plan. Pro only; named on `plus`, or without the feature, `400 validation_error`.',
285
+ }),
286
+ billing: billingDetails.meta({ description: 'Who is paying, and where the invoice goes.' }),
287
+ accept_terms: z.literal(true).meta({ description: "Confirms Fleetless's terms of service. Always required." }),
288
+ accept_withdrawal: z.literal(true).optional().meta({
289
+ description: "Confirms the plan starts at once and the 14-day right of withdrawal ends with it. Required for a person; a company sending it is `400 validation_error` — it has no withdrawal right to confirm.",
290
+ }),
291
+ }).strict();
292
+ /** `POST /api/billing/checkout`'s answer: where to send the caller's browser. */
293
+ export const checkoutResponse = z.object({
294
+ checkout_id: z.uuid().meta({ description: 'Identifies this checkout: polled by `GET /api/billing/checkout/:id` and carried on the return URL.' }),
295
+ checkout_url: z.url().meta({ description: "Mollie's hosted checkout page. The caller's browser is sent here." }),
296
+ });
297
+ /** `GET /api/billing/checkout/:id`'s answer: the return page's poll. */
298
+ export const checkoutStatus = z.object({
299
+ checkout_id: z.uuid().meta({ description: 'The checkout this status is for.' }),
300
+ status: z.enum(['pending', 'paid', 'failed', 'canceled', 'expired']).meta({
301
+ description: "Mollie's payment status, as `reconcilePayment` last read it.",
302
+ }),
303
+ purpose: z.enum(['upgrade', 'payment_method', 'invoice']).meta({
304
+ description: 'What this checkout paid for: a plan upgrade, a payment-method change, or an open invoice.',
305
+ }),
306
+ plan: planId.meta({ description: "The org's plan after applying — unchanged unless `purpose` is `upgrade` and `status` is `paid`." }),
307
+ });
308
+ /** VIES's answer to a VAT-ID check: `unverified` when VIES could not be reached in time. */
309
+ export const vatIdStatus = z.enum(['valid', 'unverified', 'invalid']);
310
+ /** `POST /api/billing/vat-id/check`'s body: the VAT-ID-on-blur check the checkout and `PATCH /api/billing/details` both use. */
311
+ export const vatIdCheckRequest = z.object({
312
+ country: countryCode.meta({ description: "The VAT ID's country." }),
313
+ vat_id: z.string().trim().min(1).max(20).meta({ description: 'The VAT ID as typed; normalized before the VIES lookup (`normalizeVatId`).' }),
314
+ }).strict();
315
+ /** `POST /api/billing/vat-id/check`'s answer. */
316
+ export const vatIdCheckResponse = z.object({
317
+ status: vatIdStatus.meta({ description: "VIES's answer." }),
318
+ vat_id: z.string().meta({ description: 'The normalized VAT ID that was checked.' }),
319
+ name: z.string().nullable().meta({ description: 'The registered holder, when VIES named one; `null` otherwise.' }),
320
+ });
321
+ /**
322
+ * `POST /api/billing/change`'s body: the **absolute** target state, never a
323
+ * delta — the route compares it with the org's current state
324
+ * and splits the difference into what is charged now and what is only
325
+ * scheduled. At least one of `plan`, `cycle` or `addons` must be named; the
326
+ * `.refine()` below says so in prose rather than in the JSON Schema this
327
+ * exports as, the same discipline `auditQuery`'s pair already follows —
328
+ * `.refine()` has no JSON Schema rendering.
329
+ */
330
+ export const billingChangeRequest = z.object({
331
+ plan: z.enum(['plus', 'pro']).optional().meta({ description: 'The target plan. Omitted leaves the plan as it is.' }),
332
+ cycle: billingCycle.optional().meta({ description: 'The target cycle. Omitted leaves the cycle as it is.' }),
333
+ addons: orgAddons.partial().strict().optional().meta({
334
+ description: 'Absolute add-on counts to end up with, not a delta. Omitted leaves add-ons as they are.',
335
+ }),
336
+ }).strict().refine((r) => r.plan !== undefined || r.cycle !== undefined || r.addons !== undefined, { message: 'Name a plan, a cycle or add-ons.' });
337
+ /** `POST /api/billing/cancel`'s body — optional, hence `requestOptional` on the route entry. */
338
+ export const billingCancelRequest = z.object({
339
+ reason: z.string().trim().max(500).optional().meta({ description: 'An optional free-text reason. Shown to nobody but Fleetless.' }),
340
+ }).strict();
341
+ /** `PATCH /api/billing/details`'s body: the invoice email and the VAT ID, the two fields an owner edits after checkout. */
342
+ export const billingDetailsUpdate = z.object({
343
+ invoice_email: z.email().optional().meta({ description: 'Replaces the invoice email. Omitted leaves it as it is.' }),
344
+ vat_id: z.string().trim().max(20).nullable().optional().meta({
345
+ description: 'Replaces the VAT ID; `null` clears it. Omitted leaves it as it is. A new ID is re-checked through VIES.',
346
+ }),
347
+ }).strict();
348
+ /** `POST /api/billing/payment-method`'s body. */
349
+ export const paymentMethodChangeRequest = z.object({
350
+ method: z.enum(['card', 'paypal', 'sepa']).meta({
351
+ description: "The new mandate's method. `sepa` needs an open invoice to charge a real amount against; without one it is `400 validation_error`.",
352
+ }),
353
+ }).strict();
354
+ /** The billing account's own status, distinct from the org's plan: `billing_accounts` holds no plan, currency or period of its own. */
355
+ export const billingAccountStatus = z.enum(['pending', 'active', 'past_due', 'canceled']);
356
+ /** The payment method on file, read from the active Mollie mandate — never a card or bank number, only its display fields. */
357
+ export const paymentMethod = z.discriminatedUnion('kind', [
358
+ z.object({
359
+ kind: z.literal('card').meta({ description: 'A card mandate.' }),
360
+ brand: z.string().meta({ description: "The card network, as Mollie's mandate reports it, e.g. 'Visa'." }),
361
+ last4: z.string().regex(/^\d{4}$/).meta({ description: 'The last four digits of the card.' }),
362
+ expires: z.string().regex(/^\d{2}\/\d{2}$/).meta({ description: "Expiry as Mollie's mandate shows it, 'MM/YY'." }),
363
+ }),
364
+ z.object({
365
+ kind: z.literal('sepa').meta({ description: 'A SEPA direct-debit mandate.' }),
366
+ holder: z.string().meta({ description: "The account holder's name on the mandate." }),
367
+ iban_last4: z.string().regex(/^[0-9A-Z]{4}$/).meta({ description: 'The last four characters of the IBAN.' }),
368
+ }),
369
+ z.object({
370
+ kind: z.literal('paypal').meta({ description: 'A PayPal mandate.' }),
371
+ account: z.string().meta({ description: "The PayPal account Mollie's mandate names." }),
372
+ }),
373
+ ]);
374
+ /** An invoice's status: `open` until paid, or `uncollectible` after a fallback to Basic with an unpaid balance. */
375
+ export const invoiceStatus = z.enum(['open', 'paid', 'uncollectible']);
376
+ /** One invoice, rendered by the cloud: the number is gapless, `FL-<year>-<seq>`. */
377
+ export const billingInvoice = z.object({
378
+ id: z.uuid().meta({ description: "The invoice's id." }),
379
+ number: z.string().regex(/^FL-\d{4}-\d{4,}$/).meta({
380
+ description: 'The invoice number: `FL-<year>-<seq>`, `seq` zero-padded to four digits, gapless per calendar year in `Europe/Berlin`.',
381
+ }),
382
+ issued_at: z.iso.datetime().meta({ description: 'When the invoice was issued.' }),
383
+ status: invoiceStatus.meta({ description: 'This invoice\'s own status.' }),
384
+ currency: planCurrency.meta({ description: "The org's billing currency." }),
385
+ net_cents: z.number().int().nonnegative().meta({ description: 'The charge, excluding VAT, in integer cents.' }),
386
+ vat_rate_percent: z.number().int().min(0).max(100).meta({ description: 'The VAT rate applied, in whole percent.' }),
387
+ vat_cents: z.number().int().nonnegative().meta({ description: 'VAT, in integer cents (`vatCents`).' }),
388
+ gross_cents: z.number().int().nonnegative().meta({ description: 'Net plus VAT, in integer cents.' }),
389
+ });
390
+ /**
391
+ * The org's billing account: the payer, the VAT treatment, the cycle and
392
+ * period, what is scheduled for the period's end and dunning
393
+ *, when a charge is overdue. `orgs.plan`, `orgs.currency` and
394
+ * `orgs.period_ends_at` stay the single source of truth for the plan itself
395
+ * (#103) — nothing here duplicates them.
396
+ */
397
+ export const billingAccount = z.object({
398
+ payer: billingDetails.meta({ description: 'Who is paying, and the invoice address and email.' }),
399
+ vat_id_status: vatIdStatus.nullable().meta({ description: "The payer's VAT ID check, or `null` when no VAT ID was given." }),
400
+ vat: z.object({
401
+ treatment: vatTreatment.meta({ description: 'How the account is taxed.' }),
402
+ rate_percent: z.number().int().meta({ description: 'The VAT rate, in whole percent.' }),
403
+ }).meta({ description: 'The VAT treatment and rate this account charges at (`vatFor`).' }),
404
+ currency: planCurrency.meta({ description: "The org's billing currency, fixed at the first payment." }),
405
+ cycle: billingCycle.meta({ description: 'The current billing cycle.' }),
406
+ status: billingAccountStatus.meta({ description: "The account's own status." }),
407
+ period_starts_at: z.iso.datetime().nullable().meta({ description: "The current period's start. `null` before the first payment." }),
408
+ period_ends_at: z.iso.datetime().nullable().meta({ description: "The current period's end. `null` before the first payment." }),
409
+ next_charge: z.object({
410
+ at: z.iso.datetime().meta({ description: 'When the next charge is due.' }),
411
+ net_cents: z.number().int().meta({ description: 'The next charge, excluding VAT, in integer cents.' }),
412
+ vat_cents: z.number().int().meta({ description: 'VAT on the next charge, in integer cents.' }),
413
+ gross_cents: z.number().int().meta({ description: 'The next charge including VAT, in integer cents.' }),
414
+ }).nullable().meta({ description: '`null` while a cancel is pending or nothing else renews.' }),
415
+ scheduled: z.object({
416
+ cycle: billingCycle.nullable().meta({ description: "A cycle change queued for the period's end, or `null`." }),
417
+ addons: orgAddons.nullable().meta({ description: "Add-on counts queued for the period's end, or `null`." }),
418
+ }).meta({ description: "What takes effect at the period's end." }),
419
+ dunning: z.object({
420
+ invoice_id: z.uuid().meta({ description: 'The open invoice dunning is chasing.' }),
421
+ gross_cents: z.number().int().meta({ description: 'The amount owed, in integer cents.' }),
422
+ due_at: z.iso.datetime().meta({ description: "The charge's due date; retries and the lock count from here." }),
423
+ next_retry_at: z.iso.datetime().nullable().meta({ description: 'The next retry, or `null` once retries are exhausted.' }),
424
+ lock_at: z.iso.datetime().meta({ description: 'When the org is locked if still unpaid (`BILLING_LOCK_DAY`).' }),
425
+ failure: z.string().nullable().meta({
426
+ description: "Mollie's own reason, e.g. 'card_expired'; 'vat_id_invalid' when VIES turned definitive.",
427
+ }),
428
+ }).nullable().meta({ description: '`null` while nothing is overdue.' }),
429
+ });
430
+ /** `GET /api/billing`'s answer, and what every other billing route hands back after a change. */
431
+ export const billingView = z.object({
432
+ available: z.boolean().meta({ description: 'Whether this cloud takes payments at all — `false` when no Mollie key is configured.' }),
433
+ account: billingAccount.nullable().meta({ description: '`null` before the org has ever checked out.' }),
434
+ payment_method: paymentMethod.nullable().meta({ description: 'The payment method on file, or `null`.' }),
435
+ invoices: z.array(billingInvoice).meta({ description: 'Newest first, at most 24.' }),
436
+ });
437
+ /** `POST /api/billing/change`'s answer: the billing view afterwards, and what was charged right now, if anything. */
438
+ export const billingChangeResponse = z.object({
439
+ billing: billingView.meta({ description: 'The billing view after the change.' }),
440
+ charged: billingInvoice.nullable().meta({ description: 'The invoice charged now, or `null` when the change was only scheduled for the period\'s end.' }),
441
+ });
package/dist/errors.d.ts CHANGED
@@ -88,8 +88,8 @@ export type InvalidCodeDetails = z.infer<typeof invalidCodeDetails>;
88
88
  export declare const planLimitDetails: z.ZodObject<{
89
89
  limit: z.ZodEnum<{
90
90
  apps: "apps";
91
- robots: "robots";
92
91
  seats: "seats";
92
+ robots: "robots";
93
93
  app_users: "app_users";
94
94
  live_video_ms_per_month: "live_video_ms_per_month";
95
95
  asset_bytes_per_robot: "asset_bytes_per_robot";
@@ -111,8 +111,8 @@ export declare const planLimitDetails: z.ZodObject<{
111
111
  }>>;
112
112
  addon: z.ZodNullable<z.ZodEnum<{
113
113
  apps: "apps";
114
- robots: "robots";
115
114
  seats: "seats";
115
+ robots: "robots";
116
116
  app_user_packs: "app_user_packs";
117
117
  live_video_packs: "live_video_packs";
118
118
  }>>;
@@ -142,8 +142,8 @@ export type PlanLimitDetails = z.infer<typeof planLimitDetails>;
142
142
  export declare const assetPlanLimitDetails: z.ZodObject<{
143
143
  limit: z.ZodEnum<{
144
144
  apps: "apps";
145
- robots: "robots";
146
145
  seats: "seats";
146
+ robots: "robots";
147
147
  app_users: "app_users";
148
148
  live_video_ms_per_month: "live_video_ms_per_month";
149
149
  asset_bytes_per_robot: "asset_bytes_per_robot";
@@ -165,8 +165,8 @@ export declare const assetPlanLimitDetails: z.ZodObject<{
165
165
  }>>;
166
166
  addon: z.ZodNullable<z.ZodEnum<{
167
167
  apps: "apps";
168
- robots: "robots";
169
168
  seats: "seats";
169
+ robots: "robots";
170
170
  app_user_packs: "app_user_packs";
171
171
  live_video_packs: "live_video_packs";
172
172
  }>>;
@@ -225,10 +225,22 @@ export declare const fileTooLargeDetails: z.ZodObject<{
225
225
  size_bytes: z.ZodNullable<z.ZodNumber>;
226
226
  }, z.core.$strip>;
227
227
  export type FileTooLargeDetails = z.infer<typeof fileTooLargeDetails>;
228
+ /**
229
+ * The `details` of a `502 payment_provider_unavailable` refusal (2026-10-04,
230
+ * fleetless/fleetless#104): Mollie did not answer, or answered with an
231
+ * error, so nothing was charged and nothing changed. `status` is Mollie's
232
+ * own HTTP status, `null` for a timeout — the two ways "did not answer"
233
+ * happens, told apart for whoever reads a support ticket.
234
+ */
235
+ export declare const paymentProviderUnavailableDetails: z.ZodObject<{
236
+ provider: z.ZodLiteral<"mollie">;
237
+ status: z.ZodNullable<z.ZodNumber>;
238
+ }, z.core.$strip>;
239
+ export type PaymentProviderUnavailableDetails = z.infer<typeof paymentProviderUnavailableDetails>;
228
240
  /**
229
241
  * The codes in use today. The wire deliberately allows any string — this
230
242
  * list is the shared vocabulary, not a closed set, so a new refusal never
231
243
  * needs a contracts release before it can be reported honestly.
232
244
  */
233
- export declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "bridge_too_old", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_slug", "unknown_field_path", "unknown_type", "unknown_topic", "invalid_rate", "invalid_range", "config_conflict", "no_data", "robot_offline", "bridge_timeout", "unauthorized", "forbidden", "invalid_credentials", "token_expired", "token_revoked", "email_taken", "identifier_taken", "weak_password", "account_blocked", "busy", "parameter_invalid", "cancel_rejected", "job_lost", "job_unknown_to_bridge", "action_server_lost", "action_failed", "goal_rejected", "goal_send_failed", "result_failed", "goal_uncontrollable", "bridge_disconnected", "config_changed", "publisher_busy", "unknown_command", "not_subscribable", "camera_offline", "no_snapshot_yet", "live_unavailable", "wrong_kind", "not_recorded", "not_aggregatable", "quota_exceeded", "credential_in_use", "goal_timeout", "robot_in_use", "robot_deletion_partial", "job_queue_full", "invalid_uuid", "rate_limited", "tier_required", "token_spent", "service_timeout", "asset_missing", "dynamic_registration_disabled", "client_limit_reached", "idp_unavailable", "mcp_disabled", "tool_not_available", "capability_required", "last_owner", "role_name_taken", "role_in_use", "last_role", "target_state_conflict", "signup_closed", "draft_not_a_document", "internal_error", "not_cancellable", "unsupported_media_type", "wrong_browser", "invalid_yaml", "unstorable_yaml", "registration_closed", "domain_not_allowed", "email_unverified", "origin_not_allowed", "template_invalid", "provider_disabled", "provider_misconfigured", "invalid_redirect_uri", "interaction_expired", "invalid_code", "method_not_allowed", "plan_limit", "plan_required", "org_locked", "file_too_large"];
245
+ export declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "bridge_too_old", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_slug", "unknown_field_path", "unknown_type", "unknown_topic", "invalid_rate", "invalid_range", "config_conflict", "no_data", "robot_offline", "bridge_timeout", "unauthorized", "forbidden", "invalid_credentials", "token_expired", "token_revoked", "email_taken", "identifier_taken", "weak_password", "account_blocked", "busy", "parameter_invalid", "cancel_rejected", "job_lost", "job_unknown_to_bridge", "action_server_lost", "action_failed", "goal_rejected", "goal_send_failed", "result_failed", "goal_uncontrollable", "bridge_disconnected", "config_changed", "publisher_busy", "unknown_command", "not_subscribable", "camera_offline", "no_snapshot_yet", "live_unavailable", "wrong_kind", "not_recorded", "not_aggregatable", "quota_exceeded", "credential_in_use", "goal_timeout", "robot_in_use", "robot_deletion_partial", "job_queue_full", "invalid_uuid", "rate_limited", "tier_required", "token_spent", "service_timeout", "asset_missing", "dynamic_registration_disabled", "client_limit_reached", "idp_unavailable", "mcp_disabled", "tool_not_available", "capability_required", "last_owner", "role_name_taken", "role_in_use", "last_role", "target_state_conflict", "signup_closed", "draft_not_a_document", "internal_error", "not_cancellable", "unsupported_media_type", "wrong_browser", "invalid_yaml", "unstorable_yaml", "registration_closed", "domain_not_allowed", "email_unverified", "origin_not_allowed", "template_invalid", "provider_disabled", "provider_misconfigured", "invalid_redirect_uri", "interaction_expired", "invalid_code", "method_not_allowed", "plan_limit", "plan_required", "org_locked", "file_too_large", "billing_unavailable", "payment_provider_unavailable"];
234
246
  export type ErrorCode = (typeof ERROR_CODES)[number];
package/dist/errors.js CHANGED
@@ -154,6 +154,17 @@ export const fileTooLargeDetails = z.object({
154
154
  description: 'The refused file\'s size, in bytes: the announced size, or the bytes that arrived when none (or a false one) was announced; `null` only when the body limit stopped the upload and nobody counted the bytes.',
155
155
  }),
156
156
  });
157
+ /**
158
+ * The `details` of a `502 payment_provider_unavailable` refusal (2026-10-04,
159
+ * fleetless/fleetless#104): Mollie did not answer, or answered with an
160
+ * error, so nothing was charged and nothing changed. `status` is Mollie's
161
+ * own HTTP status, `null` for a timeout — the two ways "did not answer"
162
+ * happens, told apart for whoever reads a support ticket.
163
+ */
164
+ export const paymentProviderUnavailableDetails = z.object({
165
+ provider: z.literal('mollie').meta({ description: 'The payment provider. Always `mollie` today.' }),
166
+ status: z.number().int().nullable().meta({ description: "Mollie's own HTTP status, or `null` when the request timed out instead of answering." }),
167
+ });
157
168
  /**
158
169
  * The codes in use today. The wire deliberately allows any string — this
159
170
  * list is the shared vocabulary, not a closed set, so a new refusal never
@@ -1028,4 +1039,24 @@ export const ERROR_CODES = [
1028
1039
  * does not help, only a smaller file does.
1029
1040
  */
1030
1041
  'file_too_large',
1042
+ // 2026-10-04 — billing through Mollie (#104).
1043
+ /**
1044
+ * `503`: this cloud takes no payments, because no payment provider is
1045
+ * configured — `MOLLIE_API_KEY` is unset. Every mutating
1046
+ * billing route answers it, and `GET /api/billing` answers `200` with
1047
+ * `available: false` instead, so a caller can render "billing is off"
1048
+ * without parsing an error. An upgrade or an add-on is a request to
1049
+ * Fleetless instead, exactly as it is today (#103's Feedback request).
1050
+ * Retrying does not help: nothing here changes until a key is
1051
+ * configured.
1052
+ */
1053
+ 'billing_unavailable',
1054
+ /**
1055
+ * `502`: Mollie did not answer, or answered with an error, so nothing was
1056
+ * charged and nothing changed. `details` is `paymentProviderUnavailableDetails`: Mollie's own
1057
+ * HTTP status, or `null` for a timeout. Distinct from `billing_unavailable`,
1058
+ * which is this cloud's own configuration and never Mollie's fault.
1059
+ * Retrying may work.
1060
+ */
1061
+ 'payment_provider_unavailable',
1031
1062
  ];
package/dist/index.d.ts CHANGED
@@ -47,8 +47,8 @@ export { auditActor, auditEvent, auditQuery, auditListResponse, AUDIT_CSV_COLUMN
47
47
  export type { AuditActor, AuditEvent, AuditQuery, AuditListResponse } from './audit.js';
48
48
  export { alertRowCondition, alertSeverity, alertState, datapointAlertRow, alertListResponse, orgFiringAlertsResponse, orgAlertsQuery, datapointDisplay, putDatapointDisplayRequest, } from './alerts.js';
49
49
  export type { AlertRowCondition, AlertSeverity, AlertState, DatapointAlertRow, AlertListResponse, OrgFiringAlertsResponse, OrgAlertsQuery, DatapointDisplay, PutDatapointDisplayRequest, } from './alerts.js';
50
- export { apiError, parameterViolation, parameterInvalidDetails, cancelRejectedDetails, invalidCodeDetails, ERROR_CODES, planLimitDetails, assetPlanLimitDetails, planRequiredDetails, orgLockedDetails, fileTooLargeDetails, } from './errors.js';
51
- export type { ApiError, ParameterViolation, ParameterInvalidDetails, CancelRejectedDetails, InvalidCodeDetails, ErrorCode, PlanLimitDetails, AssetPlanLimitDetails, PlanRequiredDetails, OrgLockedDetails, FileTooLargeDetails, } from './errors.js';
50
+ export { apiError, parameterViolation, parameterInvalidDetails, cancelRejectedDetails, invalidCodeDetails, ERROR_CODES, planLimitDetails, assetPlanLimitDetails, planRequiredDetails, orgLockedDetails, fileTooLargeDetails, paymentProviderUnavailableDetails, } from './errors.js';
51
+ export type { ApiError, ParameterViolation, ParameterInvalidDetails, CancelRejectedDetails, InvalidCodeDetails, ErrorCode, PlanLimitDetails, AssetPlanLimitDetails, PlanRequiredDetails, OrgLockedDetails, FileTooLargeDetails, PaymentProviderUnavailableDetails, } from './errors.js';
52
52
  export { oauthErrorCode, oauthError, oauthRedirectResponse, oauthCodeTokenRequest, oauthRefreshTokenRequest, oauthTokenRequest, oauthTokenResponse, redirectUri, codeChallengeMethod, oauthAuthorizeQuery, dynamicClientRegistrationRequest, MCP_DCR_MAX_REDIRECT_URIS, dynamicClientRegistrationResponse, authorizationServerMetadata, protectedResourceMetadata, } from './oauth.js';
53
53
  export type { OauthErrorCode, OauthError, OauthRedirectResponse, OauthCodeTokenRequest, OauthRefreshTokenRequest, OauthTokenRequest, OauthTokenResponse, RedirectUri, OauthAuthorizeQuery, DynamicClientRegistrationRequest, DynamicClientRegistrationResponse, AuthorizationServerMetadata, ProtectedResourceMetadata, } from './oauth.js';
54
54
  export { ROUTES, ROUTE_SECTIONS, IN_HANDLER_ROUTES, developerSignInRoutes } from './routes.js';
@@ -57,3 +57,5 @@ export { planId, PLAN_ORDER, planLimitKey, planLimits, planFeature, planFeatures
57
57
  export type { PlanId, PlanLimitKey, PlanLimits, PlanFeature, PlanFeatures, PlanPrices, PlanSupport, PlanCatalogueEntry, AddonKey, AddonCatalogueEntry, } from './plans.js';
58
58
  export { planCurrency, orgAddons, orgPlanUsage, planChangeKeep, planChangeReason, pendingPlanChange, orgLock, orgPlan, planChangeRequest, planOverrides, adminPlanChangeRequest, } from './plans.js';
59
59
  export type { PlanCurrency, OrgAddons, OrgPlanUsage, PlanChangeKeep, PlanChangeReason, PendingPlanChange, OrgLock, OrgPlan, PlanChangeRequest, PlanOverrides, AdminPlanChangeRequest, } from './plans.js';
60
+ export { countryCode, EU_COUNTRIES, SELLER_COUNTRY, DE_VAT_RATE_PERCENT, isEuCountry, currencyForCountry, payerKind, billingCycle, vatTreatment, payerRefusalRule, vatFor, viesCountry, normalizeVatId, periodNetCents, prorateCents, vatCents, chargeAmounts, DAY_MS, periodDays, remainingDays, changeNetCents, checkoutQuote, BILLING_RETRY_DAYS, BILLING_LOCK_DAY, billingAddress, billingDetails, checkoutRequest, checkoutResponse, checkoutStatus, vatIdStatus, vatIdCheckRequest, vatIdCheckResponse, billingChangeRequest, billingCancelRequest, billingDetailsUpdate, paymentMethodChangeRequest, billingAccountStatus, paymentMethod, invoiceStatus, billingInvoice, billingAccount, billingView, billingChangeResponse, } from './billing.js';
61
+ export type { PayerKind, BillingCycle, VatTreatment, PayerRefusalRule, VatDecision, ChargeAmounts, BillingState, QuoteLine, CheckoutQuote, BillingAddress, BillingDetails, CheckoutRequest, CheckoutResponse, CheckoutStatus, VatIdStatus, VatIdCheckRequest, VatIdCheckResponse, BillingChangeRequest, BillingCancelRequest, BillingDetailsUpdate, PaymentMethodChangeRequest, BillingAccountStatus, PaymentMethod, InvoiceStatus, BillingInvoice, BillingAccount, BillingView, BillingChangeResponse, } from './billing.js';
package/dist/index.js CHANGED
@@ -56,7 +56,9 @@ export { apiError, parameterViolation, parameterInvalidDetails, cancelRejectedDe
56
56
  // 2026-10-02 — plans (#103). The plan errors (I-3).
57
57
  planLimitDetails, assetPlanLimitDetails, planRequiredDetails, orgLockedDetails,
58
58
  // 2026-10-03 — the per-file asset limit (#136).
59
- fileTooLargeDetails, } from './errors.js';
59
+ fileTooLargeDetails,
60
+ // 2026-10-04 — billing through Mollie (#104).
61
+ paymentProviderUnavailableDetails, } from './errors.js';
60
62
  export { oauthErrorCode, oauthError, oauthRedirectResponse, oauthCodeTokenRequest, oauthRefreshTokenRequest, oauthTokenRequest, oauthTokenResponse, redirectUri, codeChallengeMethod, oauthAuthorizeQuery, dynamicClientRegistrationRequest, MCP_DCR_MAX_REDIRECT_URIS, dynamicClientRegistrationResponse, authorizationServerMetadata, protectedResourceMetadata, } from './oauth.js';
61
63
  export { ROUTES, ROUTE_SECTIONS, IN_HANDLER_ROUTES, developerSignInRoutes } from './routes.js';
62
64
  // 2026-10-02 — plans (#103). The catalogue (I-1) only: `PLANS` and `ADDONS`
@@ -67,3 +69,5 @@ export { planId, PLAN_ORDER, planLimitKey, planLimits, planFeature, planFeatures
67
69
  // 2026-10-02 — plans (#103). The organization's plan, plan changes and the
68
70
  // admin request (I-2).
69
71
  export { planCurrency, orgAddons, orgPlanUsage, planChangeKeep, planChangeReason, pendingPlanChange, orgLock, orgPlan, planChangeRequest, planOverrides, adminPlanChangeRequest, } from './plans.js';
72
+ // 2026-10-04 — billing through Mollie (#104).
73
+ export { countryCode, EU_COUNTRIES, SELLER_COUNTRY, DE_VAT_RATE_PERCENT, isEuCountry, currencyForCountry, payerKind, billingCycle, vatTreatment, payerRefusalRule, vatFor, viesCountry, normalizeVatId, periodNetCents, prorateCents, vatCents, chargeAmounts, DAY_MS, periodDays, remainingDays, changeNetCents, checkoutQuote, BILLING_RETRY_DAYS, BILLING_LOCK_DAY, billingAddress, billingDetails, checkoutRequest, checkoutResponse, checkoutStatus, vatIdStatus, vatIdCheckRequest, vatIdCheckResponse, billingChangeRequest, billingCancelRequest, billingDetailsUpdate, paymentMethodChangeRequest, billingAccountStatus, paymentMethod, invoiceStatus, billingInvoice, billingAccount, billingView, billingChangeResponse, } from './billing.js';