@billkit-eu/sdk 0.1.0 → 0.2.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/src/errors.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  *
6
6
  * { "error": { "type": "...", "code": "...", "message": "...", "param": "..." } }
7
7
  *
8
- * Each `type` maps to one error class so callers can `catch` on the
9
- * subclass they care about rather than branching on HTTP status codes.
8
+ * The HTTP **status** picks the class so callers can `catch` on the
9
+ * subclass they care about rather than branching on status codes; the
10
+ * envelope's `type`/`code`/`param` ride along on the thrown object.
11
+ * See {@link classForStatus} for why the status, not `type`, is the
12
+ * authority.
10
13
  */
11
14
 
12
15
  export interface ErrorEnvelope {
@@ -96,21 +99,6 @@ export class RateLimitError extends BillKitError {
96
99
  }
97
100
  }
98
101
 
99
- const TYPE_TO_CLASS: Record<string, new (msg: string, opts: BillKitErrorOptions) => BillKitError> =
100
- {
101
- api_connection_error: APIConnectionError,
102
- // ``api_error`` is the Stripe-convention type for 5xx, so surface it as
103
- // ServerError (a subclass of APIError) so `catch (e instanceof
104
- // ServerError)` works without false negatives.
105
- api_error: ServerError,
106
- authentication_error: AuthenticationError,
107
- permission_error: PermissionError,
108
- invalid_request_error: InvalidRequestError,
109
- idempotency_error: ConflictError,
110
- conflict: ConflictError,
111
- rate_limit_error: RateLimitError,
112
- };
113
-
114
102
  function fallbackType(status: number): string {
115
103
  if (status === 401) return "authentication_error";
116
104
  if (status === 403) return "permission_error";
@@ -121,15 +109,35 @@ function fallbackType(status: number): string {
121
109
  return "invalid_request_error";
122
110
  }
123
111
 
124
- function fallbackClass(
112
+ /**
113
+ * Pick the exception class from the HTTP **status**, not the envelope
114
+ * `type`.
115
+ *
116
+ * The status is the field the API cannot get wrong. The `type` is
117
+ * accurate for errors BillKit raises itself, but a request that never
118
+ * reaches a route handler — an unmatched path, a method the route does
119
+ * not allow — is serialised by the framework-level handler as
120
+ * `{"type": "api_error", "code": "unhandled"}` *with a 4xx status*.
121
+ * Trusting `type` there mapped a plain `404 Not Found` (a typo in a
122
+ * resource id, or an SDK/API version skew) onto `ServerError`, telling
123
+ * the caller BillKit had broken when their own request was at fault —
124
+ * and `ServerError` is the class retry/alerting policies key on.
125
+ *
126
+ * The envelope `type` is still preserved verbatim on
127
+ * {@link BillKitError.type} for callers that want it; only the class is
128
+ * status-driven.
129
+ */
130
+ function classForStatus(
125
131
  status: number,
126
132
  ): new (msg: string, opts: BillKitErrorOptions) => BillKitError {
133
+ if (status >= 500) return ServerError;
127
134
  if (status === 401) return AuthenticationError;
128
135
  if (status === 403) return PermissionError;
129
136
  if (status === 404) return ResourceMissingError;
130
137
  if (status === 409) return ConflictError;
131
138
  if (status === 429) return RateLimitError;
132
- if (status >= 500) return ServerError;
139
+ // Everything else below 500 (400, 405, 422, 451 …) is a request the
140
+ // caller has to change.
133
141
  return InvalidRequestError;
134
142
  }
135
143
 
@@ -149,9 +157,7 @@ export function errorFromResponse(args: {
149
157
  const message =
150
158
  envelope.message ?? `BillKit API returned HTTP ${status} with no error body.`;
151
159
 
152
- let cls = TYPE_TO_CLASS[type] ?? fallbackClass(status);
153
- if (status === 404 && cls === InvalidRequestError) cls = ResourceMissingError;
154
- if (status === 409 && cls === InvalidRequestError) cls = ConflictError;
160
+ const cls = classForStatus(status);
155
161
 
156
162
  const options: BillKitErrorOptions & { retryAfter?: number | undefined } = {
157
163
  type,
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * ```ts
7
7
  * import { BillKit } from "@billkit-eu/sdk";
8
8
  *
9
- * const client = new BillKit({ apiKey: "sk_test_..." });
9
+ * const client = new BillKit({ apiKey: "bk_test_..." });
10
10
  * const customer = await client.customers.create<{ id: string }>({
11
11
  * email: "ada@example.com",
12
12
  * });
@@ -45,7 +45,7 @@
45
45
  * a logger to opt in:
46
46
  *
47
47
  * ```ts
48
- * const client = new BillKit({ apiKey: "sk_test_...", logger: console });
48
+ * const client = new BillKit({ apiKey: "bk_test_...", logger: console });
49
49
  * ```
50
50
  *
51
51
  * See {@link BillKitLogger} for what is logged and what is withheld
@@ -79,6 +79,7 @@ export type {
79
79
  CreateProductParams,
80
80
  CreateRefundParams,
81
81
  CreateTaxRateParams,
82
+ CreateUsageRecordParams,
82
83
  CreateWebhookEndpointParams,
83
84
  EventsListParams,
84
85
  IdempotencyOptions,
@@ -86,11 +87,13 @@ export type {
86
87
  PricesListParams,
87
88
  RotateProviderCredentialParams,
88
89
  SetPortalBrandingParams,
90
+ SubscriptionsListParams,
89
91
  UpdateCouponParams,
90
92
  UpdateCustomerParams,
91
93
  UpdateProductParams,
92
94
  UpdateTaxRateParams,
93
95
  UpdateWebhookEndpointParams,
96
+ UsageRecordsListParams,
94
97
  ValidateCouponParams,
95
98
  } from "./resources.js";
96
99
  export { DEFAULT_RETRY_POLICY, type RetryPolicy } from "./retry.js";
package/src/resources.ts CHANGED
@@ -34,7 +34,10 @@ import type { QueryValue, Transport } from "./transport.js";
34
34
  export interface BaseListParams {
35
35
  limit?: number;
36
36
  starting_after?: string;
37
- ending_before?: string;
37
+ // Deliberately no `ending_before`: BillKit's cursor pagination binds
38
+ // `limit` + `starting_after` only (see `api/billkit/api/pagination.py`).
39
+ // Advertising a backwards cursor the server ignores is worse than not
40
+ // having one — the request succeeds and silently re-serves page 1.
38
41
  readonly [key: string]: QueryValue;
39
42
  }
40
43
 
@@ -48,6 +51,27 @@ export interface PricesListParams extends BaseListParams {
48
51
  product_id?: string;
49
52
  }
50
53
 
54
+ /**
55
+ * `subscriptions.list` params. Both filters take a comma-separated
56
+ * list (`"active,past_due"`); an unrecognised value is rejected with
57
+ * `400 parameter_invalid` rather than silently ignored.
58
+ *
59
+ * The two answer different questions, and mixing them up is the most
60
+ * common mistake against this route. `status` is where the subscription
61
+ * stands with its payments. `renewal_state` is what happens at the end
62
+ * of the current period. A paused subscription keeps `status: "active"`,
63
+ * because the customer has paid for the period they are in, so
64
+ * `renewal_state: "paused"` is the only way to find paused ones —
65
+ * `status: "paused"` is not an accepted value and is rejected.
66
+ */
67
+ export interface SubscriptionsListParams extends BaseListParams {
68
+ customer_id?: string;
69
+ /** `incomplete` | `trialing` | `active` | `past_due` | `canceled`, CSV. */
70
+ status?: string;
71
+ /** `auto_renew` | `paused` | `canceling` | `stopped`, CSV. */
72
+ renewal_state?: string;
73
+ }
74
+
51
75
  /** Optional idempotency knob carried by every mutating call. */
52
76
  export interface IdempotencyOptions {
53
77
  /** Coalesces retries across process restarts. The SDK generates
@@ -125,6 +149,16 @@ export interface UpdateProductParams extends IdempotencyOptions {
125
149
  active?: boolean;
126
150
  }
127
151
 
152
+ /**
153
+ * Body for `POST /v1/prices/{id}`. `active` is the only field a price
154
+ * accepts, and it moves both ways: `false` withdraws the price from sale,
155
+ * `true` puts it back. The amount, currency and interval are fixed at
156
+ * creation, so neither direction changes what anyone was charged.
157
+ */
158
+ export interface UpdatePriceParams extends IdempotencyOptions {
159
+ active: boolean;
160
+ }
161
+
128
162
  export interface CreatePriceParams extends IdempotencyOptions {
129
163
  /** Existing Product id returned from `client.products.create`. */
130
164
  product_id: string;
@@ -153,6 +187,44 @@ export interface CreatePriceParams extends IdempotencyOptions {
153
187
  * regardless of what tax rates exist now or later.
154
188
  */
155
189
  tax_behavior?: "inclusive" | "exclusive" | "unspecified";
190
+ /**
191
+ * `"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`.
198
+ */
199
+ usage_type?: "licensed" | "metered";
200
+ }
201
+
202
+ /**
203
+ * Body for `POST /v1/subscriptions/{id}/usage_records`. Only valid
204
+ * against a subscription whose price is `usage_type: "metered"`; the
205
+ * server rejects a licensed subscription with `400 parameter_invalid`.
206
+ */
207
+ export interface CreateUsageRecordParams extends IdempotencyOptions {
208
+ /** Units consumed, `1..1_000_000`. Post multiple records to accumulate. */
209
+ quantity: number;
210
+ /**
211
+ * Epoch seconds when the consumption happened. Omit to let the
212
+ * server stamp receipt time. Useful when reporting is batched and
213
+ * the record must land in the period the usage occurred.
214
+ */
215
+ occurred_at?: number;
216
+ /** Small string metadata map echoed back on the record. */
217
+ metadata?: Record<string, string>;
218
+ }
219
+
220
+ /**
221
+ * `subscriptions.listUsageRecords` params. `invoice_id` filters by
222
+ * billing state: `"pending"` selects records not yet rolled into an
223
+ * invoice, and a concrete `inv_...` id selects the records that
224
+ * invoice billed. Omit it to list everything.
225
+ */
226
+ export interface UsageRecordsListParams extends BaseListParams {
227
+ invoice_id?: "pending" | (string & {});
156
228
  }
157
229
 
158
230
  export interface CreateCheckoutSessionParams extends IdempotencyOptions {
@@ -191,6 +263,19 @@ export interface CreateCheckoutSessionParams extends IdempotencyOptions {
191
263
  * `0` disables a trial that the price would otherwise grant.
192
264
  */
193
265
  trial_days_override?: number;
266
+ /**
267
+ * `"hosted"` (the default) returns a `url` you redirect the buyer to.
268
+ * `"embedded"` returns a `client_secret` instead, for
269
+ * `mountCheckoutElement()` / `<CheckoutElement/>` from
270
+ * `@billkit-eu/js` — the card fields then render in a cross-origin
271
+ * iframe on your own page.
272
+ */
273
+ ui_mode?: "hosted" | "embedded";
274
+ /**
275
+ * Small string map carried onto the session. Up to 50 keys, key ≤ 40
276
+ * chars, value ≤ 500 chars.
277
+ */
278
+ metadata?: Record<string, string>;
194
279
  }
195
280
 
196
281
  export interface CreateRefundParams extends IdempotencyOptions {
@@ -451,6 +536,16 @@ export class Customers extends BaseResource {
451
536
  return this.post<T, UpdateCustomerParams>(`/v1/customers/${id}`, params);
452
537
  }
453
538
 
539
+ /**
540
+ * Delete a customer. Resolves to `{ id, object: "customer", deleted:
541
+ * true }`, not the customer.
542
+ *
543
+ * The customer leaves the API: `retrieve()` 404s and they drop out of
544
+ * `list()`. Their payments, invoices and refunds are untouched, and so
545
+ * is their personal data — use {@link Customers.purge} for a GDPR
546
+ * erasure. Refused while they hold a subscription that can still
547
+ * charge them.
548
+ */
454
549
  delete<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
455
550
  return this.del<T>(`/v1/customers/${id}`, params);
456
551
  }
@@ -497,16 +592,19 @@ export class Products extends BaseResource {
497
592
  return this.get<T>(`/v1/products/${id}`);
498
593
  }
499
594
 
500
- /** Patch mutable Product fields. */
595
+ /**
596
+ * Patch mutable Product fields, or archive it with `active: false`.
597
+ *
598
+ * Archiving is how you stop offering something. The product keeps its
599
+ * id and still comes back from `retrieve()` and `list()`, because what
600
+ * was sold under it has to stay readable, so there is no `delete()`.
601
+ * A checkout against any of its prices is refused from then on, and
602
+ * `active: true` un-archives.
603
+ */
501
604
  update<T = unknown>(id: string, params: UpdateProductParams): Promise<T> {
502
605
  return this.post<T, UpdateProductParams>(`/v1/products/${id}`, params);
503
606
  }
504
607
 
505
- /** Archive a Product. */
506
- delete<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
507
- return this.del<T>(`/v1/products/${id}`, params);
508
- }
509
-
510
608
  list<T = unknown>(params: BaseListParams = {}): Promise<ListResponseEnvelope<T>> {
511
609
  return this.get<ListResponseEnvelope<T>>("/v1/products", params);
512
610
  }
@@ -526,6 +624,30 @@ export class Prices extends BaseResource {
526
624
  return this.get<T>(`/v1/prices/${id}`);
527
625
  }
528
626
 
627
+ /**
628
+ * Archive a Price so it stops selling, or put it back on sale.
629
+ *
630
+ * `update(id, { active: false })` archives. The price keeps its id and
631
+ * is still returned by `retrieve()` and by `list()`, because
632
+ * subscriptions renew against it by id and what they are charged has to
633
+ * stay readable. Subscriptions already on it keep renewing at it. What
634
+ * stops is new business: a checkout session against the price is
635
+ * refused and it is no longer offered as a plan change.
636
+ *
637
+ * `{ active: true }` undoes that. `active` is the only field because
638
+ * `amount_cents`, `currency` and `interval` are fixed at creation, and
639
+ * since none of them move here neither direction can change what a past
640
+ * charge was made under. To charge something different, create a new
641
+ * price.
642
+ *
643
+ * Sending the value a price already has returns it unchanged and emits
644
+ * no second event, so a retry is safe. Archiving emits
645
+ * `price.archived`; putting one back emits `price.updated`.
646
+ */
647
+ update<T = unknown>(id: string, params: UpdatePriceParams): Promise<T> {
648
+ return this.post<T, UpdatePriceParams>(`/v1/prices/${id}`, params);
649
+ }
650
+
529
651
  list<T = unknown>(params: PricesListParams = {}): Promise<ListResponseEnvelope<T>> {
530
652
  return this.get<ListResponseEnvelope<T>>("/v1/prices", params);
531
653
  }
@@ -575,12 +697,33 @@ export class Subscriptions extends BaseResource {
575
697
  return this.get<T>(`/v1/subscriptions/${id}`);
576
698
  }
577
699
 
578
- list<T = unknown>(params: BaseListParams = {}): Promise<ListResponseEnvelope<T>> {
700
+ /**
701
+ * List subscriptions, newest first, optionally filtered.
702
+ *
703
+ * Reach for `renewal_state: "paused"` rather than `status: "paused"`
704
+ * to find paused subscriptions; see `SubscriptionsListParams`.
705
+ */
706
+ list<T = unknown>(params: SubscriptionsListParams = {}): Promise<ListResponseEnvelope<T>> {
579
707
  return this.get<ListResponseEnvelope<T>>("/v1/subscriptions", params);
580
708
  }
581
709
 
582
- iter<T = unknown>(options: { pageSize?: number } = {}): AsyncIterableIterator<T> {
583
- return paginate<T>((p) => this.get("/v1/subscriptions", p), { pageSize: options.pageSize });
710
+ /**
711
+ * Walk every page of `list()`. Filters are carried onto each page
712
+ * request, so a filtered walk narrows server-side instead of paging
713
+ * the whole history and discarding rows client-side.
714
+ */
715
+ iter<T = unknown>(
716
+ options: {
717
+ pageSize?: number;
718
+ customer_id?: string;
719
+ status?: string;
720
+ renewal_state?: string;
721
+ } = {},
722
+ ): AsyncIterableIterator<T> {
723
+ // `undefined` query values are pruned by the transport, so the
724
+ // filter can be spread as-is without a conditional per key.
725
+ const { pageSize, ...filter } = options;
726
+ return paginate<T>((p) => this.get("/v1/subscriptions", { ...filter, ...p }), { pageSize });
584
727
  }
585
728
 
586
729
  cancel<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
@@ -634,6 +777,48 @@ export class Subscriptions extends BaseResource {
634
777
  { idempotencyKey: params.idempotencyKey },
635
778
  );
636
779
  }
780
+
781
+ /**
782
+ * Report consumption against a metered subscription.
783
+ *
784
+ * Only valid when the subscription's price is `usage_type:
785
+ * "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.
789
+ *
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.
793
+ */
794
+ createUsageRecord<T = unknown>(id: string, params: CreateUsageRecordParams): Promise<T> {
795
+ return this.post<T, CreateUsageRecordParams>(`/v1/subscriptions/${id}/usage_records`, params);
796
+ }
797
+
798
+ /**
799
+ * List usage records for one subscription.
800
+ *
801
+ * Pass `invoice_id: "pending"` to reconcile what has been reported
802
+ * but not yet billed, or a concrete invoice id to see what that
803
+ * invoice charged for.
804
+ */
805
+ listUsageRecords<T = unknown>(
806
+ id: string,
807
+ params: UsageRecordsListParams = {},
808
+ ): Promise<ListResponseEnvelope<T>> {
809
+ return this.get<ListResponseEnvelope<T>>(`/v1/subscriptions/${id}/usage_records`, params);
810
+ }
811
+
812
+ /** Walk every page of `listUsageRecords()` for one subscription. */
813
+ iterUsageRecords<T = unknown>(
814
+ id: string,
815
+ options: { pageSize?: number; invoice_id?: string } = {},
816
+ ): AsyncIterableIterator<T> {
817
+ return paginate<T>((p) => this.get(`/v1/subscriptions/${id}/usage_records`, p), {
818
+ pageSize: options.pageSize,
819
+ filters: { invoice_id: options.invoice_id },
820
+ });
821
+ }
637
822
  }
638
823
 
639
824
  export class Refunds extends BaseResource {
@@ -686,15 +871,34 @@ export class WebhookEndpoints extends BaseResource {
686
871
  return this.get<T>(`/v1/webhook_endpoints/${id}`);
687
872
  }
688
873
 
874
+ /**
875
+ * Update an endpoint, or stop delivery with `status: "disabled"`.
876
+ *
877
+ * Disabling keeps the endpoint, its signing secret and its delivery
878
+ * history, and `status: "enabled"` resumes. Use {@link
879
+ * WebhookEndpoints.delete} when the endpoint should not exist at all:
880
+ * disabling is reversible and deleting is not.
881
+ */
689
882
  update<T = unknown>(id: string, params: UpdateWebhookEndpointParams): Promise<T> {
690
883
  return this.post<T, UpdateWebhookEndpointParams>(`/v1/webhook_endpoints/${id}`, params);
691
884
  }
692
885
 
886
+ /**
887
+ * Delete an endpoint. Resolves to `{ id, object: "webhook_endpoint",
888
+ * deleted: true }`, not the endpoint.
889
+ *
890
+ * A URL registered by mistake should not be a permanent fixture of the
891
+ * account, so this removes it: `retrieve()` 404s afterwards and it is
892
+ * gone from `list()`. Its delivery attempts go with it, because they
893
+ * are readable only through the endpoint that owns them. The events
894
+ * themselves are untouched and still in `client.events`, so what you
895
+ * were sent stays on record.
896
+ */
693
897
  delete<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
694
898
  return this.del<T>(`/v1/webhook_endpoints/${id}`, params);
695
899
  }
696
900
 
697
- /** Rotate the signing secret. The new `whsec_...` is returned once. */
901
+ /** Rotate the signing secret. The new `bkwhsec_...` is returned once. */
698
902
  rotateSecret<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
699
903
  return this.postEmpty<T>(`/v1/webhook_endpoints/${id}/rotate_secret`, params);
700
904
  }
@@ -834,14 +1038,18 @@ export class Coupons extends BaseResource {
834
1038
  return this.get<T>(`/v1/coupons/${id}`);
835
1039
  }
836
1040
 
1041
+ /**
1042
+ * Update a coupon's limits, or withdraw it with `active: false`.
1043
+ *
1044
+ * A withdrawn code is refused at checkout while the coupon stays
1045
+ * readable and discounts already applied keep working out, so there is
1046
+ * no `delete()`: a coupon that has been redeemed is part of what a
1047
+ * customer was charged. `active: true` brings the campaign back.
1048
+ */
837
1049
  update<T = unknown>(id: string, params: UpdateCouponParams): Promise<T> {
838
1050
  return this.post<T, UpdateCouponParams>(`/v1/coupons/${id}`, params);
839
1051
  }
840
1052
 
841
- delete<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
842
- return this.del<T>(`/v1/coupons/${id}`, params);
843
- }
844
-
845
1053
  /**
846
1054
  * Server-side dry-run of a coupon redemption.
847
1055
  *
@@ -876,14 +1084,18 @@ export class TaxRates extends BaseResource {
876
1084
  return this.get<T>(`/v1/tax_rates/${id}`);
877
1085
  }
878
1086
 
1087
+ /**
1088
+ * Correct a rate, retire it with `active: false`, or bring one back.
1089
+ *
1090
+ * Retiring is how you stop charging VAT in a country. The rate stays
1091
+ * readable, because an invoice records the percentage it charged and
1092
+ * you have to be able to point at the rate that produced it, so there
1093
+ * is no `delete()`.
1094
+ */
879
1095
  update<T = unknown>(id: string, params: UpdateTaxRateParams): Promise<T> {
880
1096
  return this.post<T, UpdateTaxRateParams>(`/v1/tax_rates/${id}`, params);
881
1097
  }
882
1098
 
883
- delete<T = unknown>(id: string, params: IdempotencyOptions = {}): Promise<T> {
884
- return this.del<T>(`/v1/tax_rates/${id}`, params);
885
- }
886
-
887
1099
  list<T = unknown>(params: BaseListParams = {}): Promise<ListResponseEnvelope<T>> {
888
1100
  return this.get<ListResponseEnvelope<T>>("/v1/tax_rates", params);
889
1101
  }
@@ -897,15 +1109,36 @@ export class TaxRates extends BaseResource {
897
1109
  * Read-only access to generated invoices.
898
1110
  *
899
1111
  * Invoices are produced by the billing pipeline; tenants don't create
900
- * them directly. PDF retrieval returns a 302 redirect to the storage
901
- * adapter's signed URL. Follow it transparently with the runtime's
902
- * fetch settings.
1112
+ * them directly. Fetch the rendered document with
1113
+ * {@link Invoices.retrievePdf}.
903
1114
  */
904
1115
  export class Invoices extends BaseResource {
905
1116
  retrieve<T = unknown>(id: string): Promise<T> {
906
1117
  return this.get<T>(`/v1/invoices/${id}`);
907
1118
  }
908
1119
 
1120
+ /**
1121
+ * Download the rendered invoice PDF as raw bytes.
1122
+ *
1123
+ * ```ts
1124
+ * const pdf = await client.invoices.retrievePdf("inv_123");
1125
+ * await writeFile("invoice.pdf", Buffer.from(pdf));
1126
+ * ```
1127
+ *
1128
+ * Blob-backed deployments stream the bytes inline; S3-backed ones
1129
+ * answer `302` to a presigned URL, which `fetch` follows for us under
1130
+ * the SDK's own timeout and retry policy — so both storage adapters
1131
+ * look identical from here.
1132
+ *
1133
+ * Deployments with `INVOICE_PDF_ENABLED=false` never render one and
1134
+ * answer `501 rendering_pending`, which surfaces as a `ServerError`
1135
+ * whose `code` is `"rendering_pending"`; `retrieve()` still returns the
1136
+ * structured invoice for tenants who render their own.
1137
+ */
1138
+ retrievePdf(id: string): Promise<ArrayBuffer> {
1139
+ return this.t.requestBinary({ method: "GET", path: `/v1/invoices/${id}/pdf` });
1140
+ }
1141
+
909
1142
  list<T = unknown>(params: BaseListParams = {}): Promise<ListResponseEnvelope<T>> {
910
1143
  return this.get<ListResponseEnvelope<T>>("/v1/invoices", params);
911
1144
  }
package/src/retry.ts CHANGED
@@ -2,9 +2,12 @@
2
2
  * Retry policy for transient failures.
3
3
  *
4
4
  * Retries 5xx + network errors with jittered exponential backoff.
5
- * 4xx (including 409 Idempotency-Key conflicts) are caller-fault and
6
- * never retried. The SDK auto-generates an `Idempotency-Key` for every
7
- * mutating call so retrying a 5xx never double-charges.
5
+ * 4xx are caller-fault and never retried, with one deliberate
6
+ * exception: `409 idempotency_in_progress`. See
7
+ * {@link IN_PROGRESS_CODE}.
8
+ *
9
+ * The SDK auto-generates an `Idempotency-Key` for every mutating call
10
+ * and reuses it across attempts, so retrying never double-charges.
8
11
  */
9
12
 
10
13
  export interface RetryPolicy {
@@ -37,14 +40,39 @@ export function backoffForMs(attempt: number, policy: RetryPolicy): number {
37
40
  return Math.max(0, jittered);
38
41
  }
39
42
 
43
+ /**
44
+ * The one 409 error code that is transient rather than caller-fault.
45
+ *
46
+ * The server returns it when a request carrying the *same*
47
+ * `Idempotency-Key` is still in flight ("Retry after a short delay",
48
+ * `Retry-After: 1`). It is the only 4xx where doing nothing is the
49
+ * dangerous option: the call may well have charged the customer, the
50
+ * caller cannot see the outcome, and the obvious workaround — retry
51
+ * with a *fresh* key — is precisely what turns one charge into two.
52
+ *
53
+ * Retrying is safe because the transport reuses the original
54
+ * `Idempotency-Key` on every attempt, so the retry either loses the
55
+ * race again or replays the first call's recorded response.
56
+ */
57
+ export const IN_PROGRESS_CODE = "idempotency_in_progress";
58
+
40
59
  export function shouldRetry(
41
60
  status: number | null,
42
61
  attempt: number,
43
62
  policy: RetryPolicy,
44
63
  retryAfterMs?: number,
64
+ /**
65
+ * `error.code` from the parsed response envelope, when there was one.
66
+ * Only consulted for 409s; every other decision is status-driven.
67
+ */
68
+ errorCode?: string,
45
69
  ): boolean {
46
70
  if (attempt >= policy.maxAttempts) return false;
47
71
  if (status === null) return true; // network error
72
+ // A 409 from a *different* code (`idempotency_key_in_use`, a
73
+ // conflicting subscription state) is a genuine caller-fault conflict
74
+ // that retrying can only repeat, so it still fails fast.
75
+ if (status === 409) return errorCode === IN_PROGRESS_CODE;
48
76
  if (status === 429) {
49
77
  // 429 is retried only when the server supplies a short, parseable
50
78
  // Retry-After value; otherwise we surface the exception so the