@billkit-eu/sdk 0.1.0 → 0.2.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/CHANGELOG.md +31 -0
- package/README.md +82 -8
- package/dist/index.cjs +181 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +243 -15
- package/dist/index.d.ts +243 -15
- package/dist/index.js +181 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +28 -22
- package/src/index.ts +3 -0
- package/src/resources.ts +254 -21
- package/src/retry.ts +31 -3
- package/src/transport.ts +58 -4
- package/src/version.ts +1 -1
package/src/errors.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* { "error": { "type": "...", "code": "...", "message": "...", "param": "..." } }
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* subclass they care about rather than branching on
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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,10 +871,29 @@ 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
|
}
|
|
@@ -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.
|
|
901
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
package/src/transport.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface RequestOptions {
|
|
|
33
33
|
body?: Record<string, unknown> | undefined;
|
|
34
34
|
idempotencyKey?: string | undefined;
|
|
35
35
|
extraHeaders?: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* How to read a **successful** response body. `"json"` (the default)
|
|
38
|
+
* parses it; `"binary"` hands back the raw `ArrayBuffer`, for
|
|
39
|
+
* endpoints that serve a document rather than a resource (the invoice
|
|
40
|
+
* PDF). Error responses are always read as JSON either way, so the
|
|
41
|
+
* typed error hierarchy behaves identically on both paths.
|
|
42
|
+
*/
|
|
43
|
+
responseType?: "json" | "binary";
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
export interface TransportConfig {
|
|
@@ -126,8 +134,7 @@ function buildHeaders(
|
|
|
126
134
|
return headers;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
const text = await response.text();
|
|
137
|
+
function parseJsonText(text: string): unknown {
|
|
131
138
|
if (!text) return null;
|
|
132
139
|
try {
|
|
133
140
|
return JSON.parse(text);
|
|
@@ -136,6 +143,32 @@ async function parseJson(response: Response): Promise<unknown> {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
async function parseJson(response: Response): Promise<unknown> {
|
|
147
|
+
return parseJsonText(await response.text());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read the body once, as the caller asked for it.
|
|
152
|
+
*
|
|
153
|
+
* A `Response` body can only be consumed once, so the choice has to be
|
|
154
|
+
* made here rather than after the status check. On the binary path a
|
|
155
|
+
* *failed* response is still decoded as UTF-8 JSON: an error is an error
|
|
156
|
+
* envelope no matter which endpoint produced it, and losing that would
|
|
157
|
+
* mean the PDF call throwing a shapeless error where every other call
|
|
158
|
+
* throws a typed one.
|
|
159
|
+
*/
|
|
160
|
+
async function readBody(
|
|
161
|
+
response: Response,
|
|
162
|
+
responseType: "json" | "binary",
|
|
163
|
+
): Promise<{ parsed: unknown; binary: ArrayBuffer | undefined }> {
|
|
164
|
+
if (responseType !== "binary") {
|
|
165
|
+
return { parsed: await parseJson(response), binary: undefined };
|
|
166
|
+
}
|
|
167
|
+
const buffer = await response.arrayBuffer();
|
|
168
|
+
if (response.ok) return { parsed: null, binary: buffer };
|
|
169
|
+
return { parsed: parseJsonText(new TextDecoder().decode(buffer)), binary: undefined };
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
function parseRetryAfterMs(header: string | null): number | undefined {
|
|
140
173
|
if (!header) return undefined;
|
|
141
174
|
const n = Number.parseFloat(header);
|
|
@@ -198,7 +231,22 @@ export class Transport {
|
|
|
198
231
|
this.fetchFn = fetchFn.bind(globalThis);
|
|
199
232
|
}
|
|
200
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Fetch a binary document (currently only the invoice PDF).
|
|
236
|
+
*
|
|
237
|
+
* Same retry policy, same timeout, same typed errors as
|
|
238
|
+
* {@link Transport.request}; only the success-path decoding differs.
|
|
239
|
+
* `fetch` follows the storage adapter's `302` to the signed URL by
|
|
240
|
+
* itself, and the WHATWG spec drops the `Authorization` header on that
|
|
241
|
+
* cross-origin hop — which is correct, since a presigned URL carries
|
|
242
|
+
* its own credential and must not be handed BillKit's API key.
|
|
243
|
+
*/
|
|
244
|
+
requestBinary(options: Omit<RequestOptions, "responseType">): Promise<ArrayBuffer> {
|
|
245
|
+
return this.request<ArrayBuffer>({ ...options, responseType: "binary" });
|
|
246
|
+
}
|
|
247
|
+
|
|
201
248
|
async request<T = unknown>(options: RequestOptions): Promise<T> {
|
|
249
|
+
const responseType = options.responseType ?? "json";
|
|
202
250
|
const idempotencyKey = autoIdempotencyKey(options.method, options.idempotencyKey);
|
|
203
251
|
const url = buildUrl(this.baseUrl, options.path, options.query);
|
|
204
252
|
const headers = buildHeaders(
|
|
@@ -222,6 +270,7 @@ export class Transport {
|
|
|
222
270
|
const startedAt = Date.now();
|
|
223
271
|
let response: Response;
|
|
224
272
|
let parsedBody: unknown;
|
|
273
|
+
let binaryBody: ArrayBuffer | undefined;
|
|
225
274
|
try {
|
|
226
275
|
// ``body`` is only spread when present so a GET request goes
|
|
227
276
|
// out without a body field. Some hosts (Cloudflare Workers'
|
|
@@ -240,7 +289,7 @@ export class Transport {
|
|
|
240
289
|
};
|
|
241
290
|
if (body !== undefined) init.body = body;
|
|
242
291
|
response = await this.fetchFn(url, init);
|
|
243
|
-
parsedBody = await
|
|
292
|
+
({ parsed: parsedBody, binary: binaryBody } = await readBody(response, responseType));
|
|
244
293
|
} catch (err) {
|
|
245
294
|
lastError = connectionError(err, this.timeoutMs);
|
|
246
295
|
if (!shouldRetry(null, attempt, this.retryPolicy)) throw lastError;
|
|
@@ -267,6 +316,7 @@ export class Transport {
|
|
|
267
316
|
});
|
|
268
317
|
|
|
269
318
|
if (response.ok) {
|
|
319
|
+
if (responseType === "binary") return binaryBody as T;
|
|
270
320
|
return (parsedBody ?? undefined) as T;
|
|
271
321
|
}
|
|
272
322
|
|
|
@@ -278,7 +328,11 @@ export class Transport {
|
|
|
278
328
|
retryAfter: retryAfterMs === undefined ? undefined : retryAfterMs / 1000,
|
|
279
329
|
});
|
|
280
330
|
|
|
281
|
-
|
|
331
|
+
// `error.code` is what separates a transient
|
|
332
|
+
// `409 idempotency_in_progress` from every other (permanent) 409;
|
|
333
|
+
// see `IN_PROGRESS_CODE`. The key on the wire is unchanged across
|
|
334
|
+
// attempts, so the retry replays rather than re-charges.
|
|
335
|
+
if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs, error.code)) {
|
|
282
336
|
throw error;
|
|
283
337
|
}
|
|
284
338
|
lastError = error;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.2.0";
|