@billkit-eu/sdk 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billkit-eu/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Official Node.js SDK for BillKit: a Stripe-Billing-shape multi-tenant SaaS API on Mollie.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
package/src/client.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  BillingPortalSessions,
13
13
  CheckoutSessions,
14
14
  Coupons,
15
+ CreditNotes,
15
16
  Customers,
16
17
  Disputes,
17
18
  Events,
@@ -58,6 +59,7 @@ export class BillKit {
58
59
  readonly coupons: Coupons;
59
60
  readonly taxRates: TaxRates;
60
61
  readonly invoices: Invoices;
62
+ readonly creditNotes: CreditNotes;
61
63
  readonly auditLogs: AuditLogs;
62
64
  readonly payments: Payments;
63
65
  readonly billingPortalSessions: BillingPortalSessions;
@@ -81,6 +83,7 @@ export class BillKit {
81
83
  this.coupons = new Coupons(transport);
82
84
  this.taxRates = new TaxRates(transport);
83
85
  this.invoices = new Invoices(transport);
86
+ this.creditNotes = new CreditNotes(transport);
84
87
  this.auditLogs = new AuditLogs(transport);
85
88
  this.payments = new Payments(transport);
86
89
  this.billingPortalSessions = new BillingPortalSessions(transport);
package/src/index.ts CHANGED
@@ -81,9 +81,11 @@ export type {
81
81
  CreateTaxRateParams,
82
82
  CreateUsageRecordParams,
83
83
  CreateWebhookEndpointParams,
84
+ CreditNotesListParams,
84
85
  EventsListParams,
85
86
  IdempotencyOptions,
86
87
  ListParams,
88
+ PriceTier,
87
89
  PricesListParams,
88
90
  RotateProviderCredentialParams,
89
91
  SetPortalBrandingParams,
@@ -95,6 +97,7 @@ export type {
95
97
  UpdateWebhookEndpointParams,
96
98
  UsageRecordsListParams,
97
99
  ValidateCouponParams,
100
+ VoidInvoiceParams,
98
101
  } from "./resources.js";
99
102
  export { DEFAULT_RETRY_POLICY, type RetryPolicy } from "./retry.js";
100
103
  export { VERSION } from "./version.js";
package/src/resources.ts CHANGED
@@ -109,6 +109,15 @@ export interface UpdateCustomerParams extends IdempotencyOptions {
109
109
  metadata?: Record<string, string>;
110
110
  }
111
111
 
112
+ /** Query parameters accepted by `GET /v1/customers`. */
113
+ export interface CustomerListParams extends BaseListParams {
114
+ /**
115
+ * `false` for customers who have paid, `true` for abandoned checkouts,
116
+ * omitted for both.
117
+ */
118
+ provisional?: boolean;
119
+ }
120
+
112
121
  /**
113
122
  * Body for `POST /v1/customers/{id}/vat_number`. The VAT number is
114
123
  * sent through VIES server-side; the response carries
@@ -138,6 +147,16 @@ export interface CreateProductParams extends IdempotencyOptions {
138
147
  marketing_features?: string[];
139
148
  /** Small string metadata map echoed back on the Product object. */
140
149
  metadata?: Record<string, string>;
150
+ /**
151
+ * Let a *buyer* type a coupon code at the embedded checkout for this
152
+ * product. Defaults to `false`. A coupon you apply yourself by passing
153
+ * `coupon_code` when you create a Checkout Session is unaffected — that
154
+ * is you discounting your own sale, and it has never needed this flag.
155
+ *
156
+ * The code is redeemed only once the payment settles, so a shopper who
157
+ * tries a single-use code and abandons the checkout does not use it up.
158
+ */
159
+ allow_promotion_codes?: boolean;
141
160
  }
142
161
 
143
162
  export interface UpdateProductParams extends IdempotencyOptions {
@@ -147,6 +166,8 @@ export interface UpdateProductParams extends IdempotencyOptions {
147
166
  metadata?: Record<string, string>;
148
167
  /** Set false to stop selling a product without deleting history. */
149
168
  active?: boolean;
169
+ /** See {@link CreateProductParams.allow_promotion_codes}. */
170
+ allow_promotion_codes?: boolean;
150
171
  }
151
172
 
152
173
  /**
@@ -159,16 +180,94 @@ export interface UpdatePriceParams extends IdempotencyOptions {
159
180
  active: boolean;
160
181
  }
161
182
 
183
+ /**
184
+ * One band of a tiered price.
185
+ *
186
+ * `up_to` is inclusive, and the **last band must be `"inf"`** because a
187
+ * bounded top band cannot price the usage above it. Bands must strictly
188
+ * increase.
189
+ *
190
+ * A band names a unit rate (`unit_amount` in whole minor units, or
191
+ * `unit_amount_decimal` for a finer one), a `flat_amount` charged once for
192
+ * reaching the band, or both. Write a free band as `unit_amount: 0` rather
193
+ * than by omitting the rate, so "free" is something the price says instead
194
+ * of something it forgot.
195
+ */
196
+ export interface PriceTier {
197
+ up_to: number | "inf";
198
+ /** Whole minor units per unit in this band. */
199
+ unit_amount?: number;
200
+ /**
201
+ * A rate finer than one minor unit, **as a string** — see
202
+ * {@link CreatePriceParams.unit_amount_decimal} for why it is never a
203
+ * `number`.
204
+ */
205
+ unit_amount_decimal?: string;
206
+ /** Charged once when the usage reaches this band. Whole minor units. */
207
+ flat_amount?: number;
208
+ }
209
+
162
210
  export interface CreatePriceParams extends IdempotencyOptions {
163
211
  /** Existing Product id returned from `client.products.create`. */
164
212
  product_id: string;
165
- amount_cents: number;
213
+ /**
214
+ * Whole minor units per period (licensed) or per unit (metered).
215
+ *
216
+ * Optional because a metered price can be priced by
217
+ * {@link CreatePriceParams.unit_amount_decimal} or by
218
+ * {@link CreatePriceParams.tiers} instead. Exactly one of the three; a
219
+ * price with none of them is refused server-side.
220
+ */
221
+ amount_cents?: number;
222
+ /**
223
+ * A per-unit rate smaller than one minor unit, in **minor units**, to 12
224
+ * decimal places. `"0.02"` is 0.02 cents, i.e. EUR 0.0002 per unit, which
225
+ * is the canonical per-API-call price and not expressible as an integer.
226
+ * Metered prices only.
227
+ *
228
+ * **It is a `string`, and that is load-bearing.** A JS `number` is an
229
+ * IEEE-754 double and cannot hold 0.0002 exactly, so the rate would be
230
+ * corrupted before it was ever multiplied by a quantity. The type forbids
231
+ * a number at compile time, and the SDK throws a `TypeError` if an
232
+ * untyped JavaScript caller passes one anyway.
233
+ *
234
+ * The period's whole quantity is multiplied by the rate and rounded
235
+ * **once**, at the invoice.
236
+ */
237
+ unit_amount_decimal?: string;
238
+ /**
239
+ * `"per_unit"` (the default) multiplies one rate by the quantity.
240
+ * `"tiered"` prices by bands and requires
241
+ * {@link CreatePriceParams.tiers} and
242
+ * {@link CreatePriceParams.tiers_mode}. Metered prices only.
243
+ */
244
+ billing_scheme?: "per_unit" | "tiered";
245
+ /**
246
+ * How a tier table is read, and there is **no default** because the same
247
+ * table means two different bills. `"graduated"` prices the units inside
248
+ * each band; `"volume"` lets the period total pick one band which then
249
+ * prices every unit. 1,500 units against "first 1,000 at EUR 0.01, then
250
+ * EUR 0.005" is EUR 12.50 graduated and EUR 7.50 by volume.
251
+ */
252
+ tiers_mode?: "graduated" | "volume";
253
+ /** The band table. Required when `billing_scheme` is `"tiered"`, refused otherwise. */
254
+ tiers?: PriceTier[];
166
255
  currency: string;
167
256
  interval: "month" | "year" | (string & {});
168
257
  metadata?: Record<string, string>;
169
258
  trial_days?: number;
170
259
  trial_verification_cents?: number;
171
- payment_methods?: Array<"creditcard" | "directdebit" | (string & {})>;
260
+ payment_methods?: Array<"creditcard" | "directdebit" | "ideal" | "applepay" | (string & {})>;
261
+ /**
262
+ * What a cancellation refunds without being asked. `"none"` (the default)
263
+ * nothing; `"full"` the whole last charge; `"prorated"` the unused part
264
+ * of the current period. Both non-none modes also end access
265
+ * immediately, and both stay bounded by the refund window below.
266
+ *
267
+ * Metered prices must leave this at `"none"`: ending access mid-period
268
+ * would strand usage that has not been billed yet.
269
+ */
270
+ refund_on_cancel?: "none" | "full" | "prorated";
172
271
  /**
173
272
  * Per-Price refund-window override (`POST /v1/prices`). `undefined`
174
273
  * inherits the default policy table (7d / 30d initial, 3d renewal);
@@ -189,12 +288,15 @@ export interface CreatePriceParams extends IdempotencyOptions {
189
288
  tax_behavior?: "inclusive" | "exclusive" | "unspecified";
190
289
  /**
191
290
  * `"licensed"` (the default when omitted) bills `amount_cents` per
192
- * period regardless of consumption. `"metered"` bills
193
- * `amount_cents` **per reported unit**: post consumption with
194
- * `subscriptions.createUsageRecord` and the renewal invoice charges
195
- * `amount_cents × sum(quantity)` for the period. Metered prices
196
- * must be `interval: "month"`, carry `amount_cents > 0`, and cannot
197
- * have `trial_days`.
291
+ * period regardless of consumption. `"metered"` bills **per reported
292
+ * unit**: post consumption with `subscriptions.createUsageRecord`, and
293
+ * at each period close BillKit invoices the period's total and charges
294
+ * the stored mandate.
295
+ *
296
+ * A metered unit is priced by `amount_cents`, by `unit_amount_decimal`,
297
+ * or by `tiers` — exactly one. Metered prices must be
298
+ * `interval: "month"`, cannot have `trial_days`, and cannot set
299
+ * `refund_on_cancel`.
198
300
  */
199
301
  usage_type?: "licensed" | "metered";
200
302
  }
@@ -213,6 +315,22 @@ export interface CreateUsageRecordParams extends IdempotencyOptions {
213
315
  * the record must land in the period the usage occurred.
214
316
  */
215
317
  occurred_at?: number;
318
+ /**
319
+ * Your own id for the event being metered, unique within this
320
+ * subscription. This is the dedupe an `Idempotency-Key` cannot do.
321
+ *
322
+ * The key covers a retry of *one HTTP request*, including the SDK's own
323
+ * internal retries. `identifier` covers a retry of *your* call — a job
324
+ * runner replaying a task, a queue delivering twice, your code
325
+ * re-invoking after its own timeout — which arrives at the API as a
326
+ * genuinely new request with a new key. A second report of the same
327
+ * identifier returns the first record unchanged instead of billing
328
+ * twice.
329
+ *
330
+ * If your reporting pipeline is at-least-once, this is the one that
331
+ * matters.
332
+ */
333
+ identifier?: string;
216
334
  /** Small string metadata map echoed back on the record. */
217
335
  metadata?: Record<string, string>;
218
336
  }
@@ -254,7 +372,7 @@ export interface CreateCheckoutSessionParams extends IdempotencyOptions {
254
372
  * the customer's available methods; when set, must be in the price's
255
373
  * `payment_methods` allowlist.
256
374
  */
257
- method?: "creditcard" | "directdebit" | (string & {});
375
+ method?: "creditcard" | "directdebit" | "ideal" | "applepay" | (string & {});
258
376
  /** Optional coupon code applied at checkout; atomically claimed. */
259
377
  coupon_code?: string;
260
378
  /**
@@ -321,7 +439,14 @@ export interface CreateOneShotPaymentParams extends IdempotencyOptions {
321
439
  * *request* type the server validates, so an SDK that lags a newly-added
322
440
  * method should not be the thing that blocks the call.
323
441
  */
324
- method: "creditcard" | "directdebit" | "ideal" | "bancontact" | "eps" | (string & {});
442
+ method:
443
+ | "creditcard"
444
+ | "directdebit"
445
+ | "ideal"
446
+ | "bancontact"
447
+ | "eps"
448
+ | "applepay"
449
+ | (string & {});
325
450
  /** Where Mollie returns the payer after the hosted checkout. */
326
451
  success_url: string;
327
452
  /** Optional page for an abandoned/cancelled payment. */
@@ -441,6 +566,23 @@ export interface AuditLogsListParams extends BaseListParams {
441
566
  actor_id?: string;
442
567
  }
443
568
 
569
+ /**
570
+ * `creditNotes.list` params.
571
+ *
572
+ * `invoice_id` answers "was this sale credited, and by how much", which is
573
+ * the question when reconciling one invoice; `customer_id` answers it for
574
+ * everything credited back to one buyer.
575
+ */
576
+ export interface CreditNotesListParams extends BaseListParams {
577
+ invoice_id?: string;
578
+ customer_id?: string;
579
+ }
580
+
581
+ /** `invoices.void` params. `reason` is recorded on the audit row only. */
582
+ export interface VoidInvoiceParams extends IdempotencyOptions {
583
+ reason?: string;
584
+ }
585
+
444
586
  export interface CreateBillingPortalSessionParams extends IdempotencyOptions {
445
587
  subscription_id: string;
446
588
  return_url: string;
@@ -468,6 +610,40 @@ function splitIdempotency<P extends IdempotencyOptions>(
468
610
  return { body: dropUndefined(rest as Record<string, unknown>), idempotencyKey };
469
611
  }
470
612
 
613
+ /**
614
+ * Refuse a sub-minor-unit rate that arrived as a `number`.
615
+ *
616
+ * The type already forbids it, so this exists for the callers the type
617
+ * system cannot reach: plain JavaScript, a value that came through `any`,
618
+ * a body parsed from JSON. A double cannot hold 0.0002 exactly, so
619
+ * accepting one would work for the rates that happen to round-trip and
620
+ * silently mis-price the ones that do not — the worst of the three
621
+ * available behaviours, and the reason the field is a string in the first
622
+ * place.
623
+ */
624
+ function assertDecimalRateIsString(value: unknown, field: string): void {
625
+ if (value === undefined || value === null || typeof value === "string") return;
626
+ throw new TypeError(
627
+ `${field} must be a string, not a ${typeof value}. A JavaScript number cannot ` +
628
+ "hold a rate like 0.0002 exactly, so it would be corrupted before it was ever " +
629
+ `multiplied by a quantity. Pass it as a string: "${String(value)}".`,
630
+ );
631
+ }
632
+
633
+ /** Same check at the price level and inside every band of a tier table. */
634
+ function assertPriceRatesAreStrings(params: CreatePriceParams): CreatePriceParams {
635
+ assertDecimalRateIsString(params.unit_amount_decimal, "unit_amount_decimal");
636
+ // Inside a tier is where a rate is most likely to be typed as a bare
637
+ // literal, so the guard has to reach in there too.
638
+ (params.tiers ?? []).forEach((tier, index) => {
639
+ assertDecimalRateIsString(
640
+ (tier as PriceTier | undefined)?.unit_amount_decimal,
641
+ `tiers[${index}].unit_amount_decimal`,
642
+ );
643
+ });
644
+ return params;
645
+ }
646
+
471
647
  /**
472
648
  * Shared transport wrapper. Resources subclass this so each method
473
649
  * reads as a single line, "verb to path with params", instead of
@@ -550,7 +726,17 @@ export class Customers extends BaseResource {
550
726
  return this.del<T>(`/v1/customers/${id}`, params);
551
727
  }
552
728
 
553
- list<T = unknown>(params: BaseListParams = {}): Promise<ListResponseEnvelope<T>> {
729
+ /**
730
+ * List customers, newest first.
731
+ *
732
+ * `provisional` filters on whether the customer ever completed a
733
+ * payment. A checkout that captures an email commits its Customer
734
+ * before the charge, so a checkout nobody finished leaves a row behind:
735
+ * pass `false` for real customers only, `true` for the abandoned ones
736
+ * (the cart-recovery worklist), or omit for both. Abandoned rows are
737
+ * swept after the tenant's retention window.
738
+ */
739
+ list<T = unknown>(params: CustomerListParams = {}): Promise<ListResponseEnvelope<T>> {
554
740
  return this.get<ListResponseEnvelope<T>>("/v1/customers", params);
555
741
  }
556
742
 
@@ -615,9 +801,23 @@ export class Products extends BaseResource {
615
801
  }
616
802
 
617
803
  export class Prices extends BaseResource {
618
- /** Create immutable billing terms for an existing Product. */
619
- create<T = unknown>(params: CreatePriceParams): Promise<T> {
620
- return this.post<T, CreatePriceParams>("/v1/prices", params);
804
+ /**
805
+ * Create immutable billing terms for an existing Product.
806
+ *
807
+ * A licensed price sends `amount_cents`. A metered price sends one of
808
+ * `amount_cents`, `unit_amount_decimal` (a rate finer than one minor
809
+ * unit, as a string) or `billing_scheme: "tiered"` with `tiers` and
810
+ * `tiers_mode`. Throws `TypeError` before any HTTP call if a decimal
811
+ * rate arrives as a number — see
812
+ * {@link CreatePriceParams.unit_amount_decimal}.
813
+ */
814
+ // `async` on purpose. The rate guard throws, and a synchronous throw out
815
+ // of a method typed `Promise<T>` escapes `.catch()` entirely — the caller
816
+ // would have to wrap the call site in try/catch as well, which nobody
817
+ // does for a promise-returning API. Marking it async turns the throw into
818
+ // a rejection, so one error path handles both.
819
+ async create<T = unknown>(params: CreatePriceParams): Promise<T> {
820
+ return this.post<T, CreatePriceParams>("/v1/prices", assertPriceRatesAreStrings(params));
621
821
  }
622
822
 
623
823
  retrieve<T = unknown>(id: string): Promise<T> {
@@ -783,13 +983,16 @@ export class Subscriptions extends BaseResource {
783
983
  *
784
984
  * Only valid when the subscription's price is `usage_type:
785
985
  * "metered"`; a licensed subscription is rejected with `400
786
- * parameter_invalid`. Records accumulate until the renewal invoice
787
- * rolls them up (`amount_cents × sum(quantity)`); the record's
788
- * `invoice_id` stays `null` until then.
986
+ * parameter_invalid`. Records accumulate until the next period close
987
+ * rolls them into one invoice line; the record's `invoice_id` stays
988
+ * `null` until then. Records are immutable once written — they are the
989
+ * audit trail behind that line — so there is no update or delete.
789
990
  *
790
- * Supports `Idempotency-Key` replay: retrying with the same key
791
- * returns the same record instead of double-counting the usage,
792
- * which is what makes at-least-once reporting pipelines safe.
991
+ * Two dedupe mechanisms, covering different failures. The
992
+ * `Idempotency-Key` the SDK sends covers a retry of this HTTP request,
993
+ * including its own internal retries. `params.identifier` covers a
994
+ * retry of *your* call, which arrives as a new request with a new key.
995
+ * See {@link CreateUsageRecordParams.identifier}.
793
996
  */
794
997
  createUsageRecord<T = unknown>(id: string, params: CreateUsageRecordParams): Promise<T> {
795
998
  return this.post<T, CreateUsageRecordParams>(`/v1/subscriptions/${id}/usage_records`, params);
@@ -819,6 +1022,28 @@ export class Subscriptions extends BaseResource {
819
1022
  filters: { invoice_id: options.invoice_id },
820
1023
  });
821
1024
  }
1025
+
1026
+ /**
1027
+ * Price the pending usage, before the period close bills it.
1028
+ *
1029
+ * `listUsageRecords({ invoice_id: "pending" })` gives the quantity; this
1030
+ * gives the money. `net_cents` / `tax_cents` / `gross_cents` are
1031
+ * computed through the same rate or tier table and the same VAT
1032
+ * resolution the close itself uses, so it is a forecast of the real
1033
+ * invoice rather than an estimate.
1034
+ *
1035
+ * **Read `will_charge` before promising a customer an amount.** A period
1036
+ * whose total is under `minimum_charge_cents` (EUR 1.00) is not charged,
1037
+ * because the payment provider would refuse it. The usage is not lost:
1038
+ * it stays pending and rolls into the next period, which is then billed
1039
+ * for both.
1040
+ *
1041
+ * `open_invoice_id` names an earlier cycle that is invoiced and still
1042
+ * unsettled; while one is open, this period cannot be charged.
1043
+ */
1044
+ retrieveUsageSummary<T = unknown>(id: string): Promise<T> {
1045
+ return this.get<T>(`/v1/subscriptions/${id}/usage_summary`);
1046
+ }
822
1047
  }
823
1048
 
824
1049
  export class Refunds extends BaseResource {
@@ -1146,6 +1371,63 @@ export class Invoices extends BaseResource {
1146
1371
  iter<T = unknown>(options: { pageSize?: number } = {}): AsyncIterableIterator<T> {
1147
1372
  return paginate<T>((p) => this.get("/v1/invoices", p), { pageSize: options.pageSize });
1148
1373
  }
1374
+
1375
+ /**
1376
+ * Void an invoice: state that the sale was never owed.
1377
+ *
1378
+ * The invoice keeps its number and stays readable — a gapless series
1379
+ * cannot lose a row — and stops being a receivable. Use it for an
1380
+ * invoice that should not have been issued.
1381
+ *
1382
+ * A **paid** invoice is refused with a `ConflictError` whose `code` is
1383
+ * `"invoice_not_voidable"`. That is deliberate rather than a
1384
+ * limitation: once the money has moved, "never owed" is false, and the
1385
+ * document that reverses a real sale is a credit note — refund the
1386
+ * payment and one is issued when the refund settles.
1387
+ *
1388
+ * Idempotent: re-voiding an already-void invoice returns it unchanged.
1389
+ */
1390
+ void<T = unknown>(id: string, params: VoidInvoiceParams = {}): Promise<T> {
1391
+ return this.post<T, VoidInvoiceParams>(`/v1/invoices/${id}/void`, params);
1392
+ }
1393
+ }
1394
+
1395
+ /**
1396
+ * Read-only access to credit notes — the documents that reverse an
1397
+ * issued invoice.
1398
+ *
1399
+ * There is no create: a credit note is issued for you when a refund
1400
+ * settles, never on request, so that a numbered legal record is only
1401
+ * minted once the money has actually moved. A refund that is still
1402
+ * pending, one that fails, and a refund of a one-off charge that was
1403
+ * never invoiced all produce none.
1404
+ */
1405
+ export class CreditNotes extends BaseResource {
1406
+ retrieve<T = unknown>(id: string): Promise<T> {
1407
+ return this.get<T>(`/v1/credit_notes/${id}`);
1408
+ }
1409
+
1410
+ /**
1411
+ * Download the rendered credit note PDF as raw bytes. Same storage
1412
+ * split as {@link Invoices.retrievePdf}: bytes inline or a followed
1413
+ * `302`, and `501 rendering_pending` on a deployment with no renderer.
1414
+ */
1415
+ retrievePdf(id: string): Promise<ArrayBuffer> {
1416
+ return this.t.requestBinary({ method: "GET", path: `/v1/credit_notes/${id}/pdf` });
1417
+ }
1418
+
1419
+ list<T = unknown>(params: CreditNotesListParams = {}): Promise<ListResponseEnvelope<T>> {
1420
+ return this.get<ListResponseEnvelope<T>>("/v1/credit_notes", params);
1421
+ }
1422
+
1423
+ iter<T = unknown>(
1424
+ options: { pageSize?: number; invoice_id?: string; customer_id?: string } = {},
1425
+ ): AsyncIterableIterator<T> {
1426
+ return paginate<T>((p) => this.get("/v1/credit_notes", p), {
1427
+ pageSize: options.pageSize,
1428
+ filters: { invoice_id: options.invoice_id, customer_id: options.customer_id },
1429
+ });
1430
+ }
1149
1431
  }
1150
1432
 
1151
1433
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.2.1";
1
+ export const VERSION = "0.4.0";