@meterly/sdk 0.1.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/README.md +353 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +439 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +40 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +335 -0
- package/dist/types.js +2 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# Meterly JavaScript SDK
|
|
2
|
+
|
|
3
|
+
Small TypeScript SDK for integrating Meterly prepaid usage control and credit workflows into Node.js and Next.js apps. `guard()` is the preferred path for protected operations that may fail after work starts. Credits are usage entitlement units, not money.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 18 or newer
|
|
8
|
+
- Meterly command API reachable, usually `http://localhost:5000`
|
|
9
|
+
- Meterly query API reachable, usually `http://localhost:5001`
|
|
10
|
+
|
|
11
|
+
The SDK uses native `fetch`. There are no runtime dependencies.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd sdk/js
|
|
17
|
+
npm install
|
|
18
|
+
npm run build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { MeterlyClient } from "@meterly/sdk";
|
|
23
|
+
|
|
24
|
+
const meterly = new MeterlyClient({
|
|
25
|
+
commandApiUrl: "http://localhost:5000",
|
|
26
|
+
queryApiUrl: "http://localhost:5001",
|
|
27
|
+
apiKey: "dev-meterly-key"
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const grant = await meterly.grantCredits(
|
|
31
|
+
{
|
|
32
|
+
externalCustomerId: "customer_123",
|
|
33
|
+
creditAmount: 1000,
|
|
34
|
+
creditUnit: "image_credits",
|
|
35
|
+
reference: "order_123",
|
|
36
|
+
description: "1000 image credits purchase",
|
|
37
|
+
provider: "revenuecat",
|
|
38
|
+
providerEventId: "rc_evt_123"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
idempotencyKey: "grant-order_123"
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const balances = await meterly.getBalances(grant.grant.customerId);
|
|
46
|
+
console.log(balances.balances);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Direct Credit Grants
|
|
50
|
+
|
|
51
|
+
Verify purchase state in your own backend, then grant credits directly when Meterly is the spend and balance source of truth:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
await meterly.grantCredits(
|
|
55
|
+
{
|
|
56
|
+
externalCustomerId: "user_123",
|
|
57
|
+
creditAmount: 1000,
|
|
58
|
+
creditUnit: "image_credits",
|
|
59
|
+
reference: "order_123",
|
|
60
|
+
description: "1000 image credits purchase",
|
|
61
|
+
provider: "revenuecat",
|
|
62
|
+
providerEventId: "rc_evt_123",
|
|
63
|
+
providerEventType: "INITIAL_PURCHASE"
|
|
64
|
+
},
|
|
65
|
+
{ idempotencyKey: "grant-order_123" }
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Meterly creates the customer/account if missing, stores optional provider proof metadata, and applies credits through the ledger-backed AddCredits path. Tenant API keys are server-side only; do not use them in browser or mobile clients.
|
|
70
|
+
|
|
71
|
+
## Guard Helper
|
|
72
|
+
|
|
73
|
+
`guard` wraps reservation, callback execution, commit, and release. The preferred form accepts a single options object:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const result = await meterly.guard(
|
|
77
|
+
{
|
|
78
|
+
customerId,
|
|
79
|
+
amount: 250,
|
|
80
|
+
unit: "image_credits",
|
|
81
|
+
idempotencyKey: "guard-image-generation-001",
|
|
82
|
+
reference: "image-generation",
|
|
83
|
+
description: "Reserve credits for expensive work",
|
|
84
|
+
ttlSeconds: 300
|
|
85
|
+
},
|
|
86
|
+
async ({ reservationId }) => {
|
|
87
|
+
const output = await doExpensiveWork();
|
|
88
|
+
return {
|
|
89
|
+
reservationId,
|
|
90
|
+
output
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The positional form remains supported for compatibility:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const result = await meterly.guard(
|
|
100
|
+
customerId,
|
|
101
|
+
250,
|
|
102
|
+
async ({ reservationId }) => {
|
|
103
|
+
const output = await doExpensiveWork();
|
|
104
|
+
return {
|
|
105
|
+
reservationId,
|
|
106
|
+
output
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
reference: "image-generation",
|
|
111
|
+
description: "Reserve credits for expensive work",
|
|
112
|
+
ttlSeconds: 300
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Behavior:
|
|
118
|
+
|
|
119
|
+
- reserves credits first
|
|
120
|
+
- commits the reservation if the callback succeeds
|
|
121
|
+
- releases the reservation if the callback throws
|
|
122
|
+
- rethrows the original callback error after a successful release
|
|
123
|
+
- throws `MeterlyGuardError` if both the callback and the release fail
|
|
124
|
+
|
|
125
|
+
## Idempotency
|
|
126
|
+
|
|
127
|
+
Every command mutation can send an `idempotencyKey` option. The SDK forwards it as the `Idempotency-Key` header:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
await meterly.grantCredits(
|
|
131
|
+
{
|
|
132
|
+
externalCustomerId: "customer_123",
|
|
133
|
+
creditAmount: 100,
|
|
134
|
+
reference: "support-adjustment-001"
|
|
135
|
+
},
|
|
136
|
+
{ idempotencyKey: "grant-support-adjustment-001" }
|
|
137
|
+
);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Use a stable key for retried operations so network retries and duplicate client submits do not apply credits twice.
|
|
141
|
+
|
|
142
|
+
## Usage Units
|
|
143
|
+
|
|
144
|
+
Usage units let one tenant use separate entitlement units, such as `credits`, `image_credits`, `tokens`, or `gpu_seconds`. The default unit is `credits`.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const units = await meterly.listUsageUnits();
|
|
148
|
+
|
|
149
|
+
await meterly.createUsageUnit({
|
|
150
|
+
code: "gpu_seconds",
|
|
151
|
+
displayName: "GPU seconds",
|
|
152
|
+
description: "Runtime authorization unit for GPU jobs"
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await meterly.guard(
|
|
156
|
+
{
|
|
157
|
+
customerId,
|
|
158
|
+
amount: 30,
|
|
159
|
+
unit: "gpu_seconds",
|
|
160
|
+
reference: "gpu-job-42",
|
|
161
|
+
idempotencyKey: "reserve-gpu-job-42"
|
|
162
|
+
},
|
|
163
|
+
async () => runGpuJob()
|
|
164
|
+
);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Package and provider-mapping flows inherit the package unit. There is no cross-unit conversion; reserve, commit, release, and usage reads operate on the unit you provide.
|
|
168
|
+
|
|
169
|
+
API keys also scope data access to a tenant. In local development, the default tenant comes from:
|
|
170
|
+
|
|
171
|
+
- API key: `dev-meterly-key`
|
|
172
|
+
- tenant: `dev-tenant`
|
|
173
|
+
|
|
174
|
+
Meterly can also issue persistent keys from `POST /api/v1/api-keys` or the admin console. Generated keys start with `mly_test_`, are stored hashed at rest, and the raw key is shown only once when created. Use the generated raw key as `apiKey` in `MeterlyClient`.
|
|
175
|
+
|
|
176
|
+
## HMAC Signing
|
|
177
|
+
|
|
178
|
+
HMAC signing is optional and intended for production backend services that want replay protection beyond bearer-key authentication. Create a key with `signingMode: "hmac"`, store the raw key once on your server, and configure the SDK:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const meterly = new MeterlyClient({
|
|
182
|
+
commandApiUrl: process.env.METERLY_COMMAND_API_URL!,
|
|
183
|
+
queryApiUrl: process.env.METERLY_QUERY_API_URL!,
|
|
184
|
+
apiKey: process.env.METERLY_API_KEY!,
|
|
185
|
+
signingMode: "hmac"
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
For HMAC keys, every request still sends `Authorization: Bearer <api_key>`. The SDK also sends:
|
|
190
|
+
|
|
191
|
+
- `X-Meterly-Timestamp`
|
|
192
|
+
- `X-Meterly-Body-SHA256`
|
|
193
|
+
- `X-Meterly-Signature`
|
|
194
|
+
|
|
195
|
+
Canonical string:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
METHOD
|
|
199
|
+
PATH_WITH_QUERY
|
|
200
|
+
BODY_SHA256
|
|
201
|
+
TIMESTAMP
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The HMAC secret is the raw API key, which Meterly shows only once and never stores in plaintext. Do not use tenant API keys or HMAC signing in browser or mobile clients.
|
|
205
|
+
|
|
206
|
+
## Supported Methods
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
await meterly.createCustomer(input, options);
|
|
210
|
+
await meterly.grantCredits(input, options);
|
|
211
|
+
await meterly.listCreditGrants(options);
|
|
212
|
+
await meterly.addCredits(customerId, amount, options);
|
|
213
|
+
await meterly.useCredits(customerId, amount, options);
|
|
214
|
+
await meterly.reserveCredits(customerId, amount, options);
|
|
215
|
+
await meterly.commitReservation(reservationId);
|
|
216
|
+
await meterly.releaseReservation(reservationId);
|
|
217
|
+
await meterly.getBalance(customerId);
|
|
218
|
+
await meterly.getBalances(customerId);
|
|
219
|
+
await meterly.getUsage(customerId, options);
|
|
220
|
+
await meterly.getLedgerEntries(customerId, options);
|
|
221
|
+
await meterly.getUsageSummary();
|
|
222
|
+
await meterly.listUsageUnits();
|
|
223
|
+
await meterly.createUsageUnit(input);
|
|
224
|
+
await meterly.guard({ customerId, amount, idempotencyKey }, callback);
|
|
225
|
+
await meterly.guard(customerId, amount, callback, options);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`useCredits` is the direct/simple usage debit method. For AI/API work where execution can fail after authorization, prefer object-form `guard()` or the explicit `reserveCredits` -> `commitReservation` / `releaseReservation` flow.
|
|
229
|
+
|
|
230
|
+
Request options:
|
|
231
|
+
|
|
232
|
+
- `reference`
|
|
233
|
+
- `description`
|
|
234
|
+
- `metadata`
|
|
235
|
+
- `ttlSeconds`
|
|
236
|
+
- `idempotencyKey`
|
|
237
|
+
- `unit` or `creditUnit`
|
|
238
|
+
|
|
239
|
+
Current backend compatibility:
|
|
240
|
+
|
|
241
|
+
- `reference` and `description` are sent to Meterly
|
|
242
|
+
- `ttlSeconds` is translated into reservation `expiresAt`
|
|
243
|
+
- `unit` and `creditUnit` are sent as `creditUnit` on credit-affecting writes
|
|
244
|
+
- `idempotencyKey` is sent as the `Idempotency-Key` HTTP header on command mutations
|
|
245
|
+
- `metadata` is typed for future backend support but is not sent today
|
|
246
|
+
|
|
247
|
+
Retry behavior:
|
|
248
|
+
|
|
249
|
+
- the first request for a given `Idempotency-Key` executes normally and stores the response
|
|
250
|
+
- retries with the same key and the same request payload return the stored response
|
|
251
|
+
- retries with the same key and a different request payload return `409 Conflict`
|
|
252
|
+
- concurrent retries with the same key while the first request is still running return `409 Conflict`
|
|
253
|
+
|
|
254
|
+
## Next.js API Route Example
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
258
|
+
import { MeterlyClient, MeterlyGuardError } from "@meterly/sdk";
|
|
259
|
+
|
|
260
|
+
const meterly = new MeterlyClient({
|
|
261
|
+
commandApiUrl: process.env.METERLY_COMMAND_API_URL ?? "http://localhost:5000",
|
|
262
|
+
queryApiUrl: process.env.METERLY_QUERY_API_URL ?? "http://localhost:5001",
|
|
263
|
+
apiKey: process.env.METERLY_API_KEY
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export async function POST(request: NextRequest) {
|
|
267
|
+
const body = await request.json();
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const result = await meterly.guard(
|
|
271
|
+
{
|
|
272
|
+
customerId: body.customerId,
|
|
273
|
+
amount: body.credits,
|
|
274
|
+
idempotencyKey: body.idempotencyKey,
|
|
275
|
+
reference: body.reference,
|
|
276
|
+
description: "Protected Meterly operation",
|
|
277
|
+
ttlSeconds: 300
|
|
278
|
+
},
|
|
279
|
+
async () => {
|
|
280
|
+
return await runProtectedWork(body);
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return NextResponse.json({ ok: true, result });
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof MeterlyGuardError) {
|
|
287
|
+
return NextResponse.json(
|
|
288
|
+
{
|
|
289
|
+
ok: false,
|
|
290
|
+
message: error.message,
|
|
291
|
+
reservationId: error.reservationId
|
|
292
|
+
},
|
|
293
|
+
{ status: 500 }
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return NextResponse.json(
|
|
298
|
+
{
|
|
299
|
+
ok: false,
|
|
300
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
301
|
+
},
|
|
302
|
+
{ status: 500 }
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Local Demo Usage
|
|
309
|
+
|
|
310
|
+
From the repository root:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
docker compose up --build
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
You can exercise the SDK directly against the same local stack:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { MeterlyClient } from "@meterly/sdk";
|
|
320
|
+
|
|
321
|
+
const meterly = new MeterlyClient({
|
|
322
|
+
commandApiUrl: "http://localhost:5000",
|
|
323
|
+
queryApiUrl: "http://localhost:5001",
|
|
324
|
+
apiKey: "dev-meterly-key"
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const granted = await meterly.grantCredits(
|
|
328
|
+
{
|
|
329
|
+
externalCustomerId: `demo_${Date.now()}`,
|
|
330
|
+
creditAmount: 1000,
|
|
331
|
+
reference: "local-demo"
|
|
332
|
+
},
|
|
333
|
+
{ idempotencyKey: "grant-local-demo" }
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await meterly.useCredits(granted.grant.customerId, 120, {
|
|
337
|
+
reference: "demo-usage"
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// For protected work, prefer object-form meterly.guard(...) so failed work releases the hold.
|
|
341
|
+
|
|
342
|
+
const usage = await meterly.getUsage(created.customerId);
|
|
343
|
+
console.log(usage.usage);
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Development
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
npm install
|
|
350
|
+
npm run typecheck
|
|
351
|
+
npm test
|
|
352
|
+
npm run build
|
|
353
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BaseResponse, BalancesResult, CheckoutSessionResult, CreateCheckoutSessionOptions, CreateCustomerInput, CreateCustomerResult, CreateUsageUnitInput, CreditGrantsListResult, CreditPackageGrantsListResult, CreditAccount, GrantCreditsInput, GrantCreditsResult, GrantCreditPackageInput, GrantCreditPackageResult, GuardCallback, GuardOptions, LedgerEntriesQueryOptions, ListCreditGrantsOptions, LedgerEntriesResult, ListCreditPackageGrantsOptions, ListStripePaymentsOptions, MeterlyClientConfig, MeterlyRequestOptions, PaymentProviderStatus, ReserveCreditsOptions, StripePaymentResult, StripePaymentsListResult, StripeReconcileResult, UsageQueryOptions, UsageResult, UsageUnitResult, UsageUnitsListResult, UsageSummary } from "./types";
|
|
2
|
+
interface ApiEnvelope {
|
|
3
|
+
message?: string;
|
|
4
|
+
}
|
|
5
|
+
interface ReservationResponse extends ApiEnvelope {
|
|
6
|
+
reservationId?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class MeterlyClient {
|
|
9
|
+
private readonly commandApiUrl;
|
|
10
|
+
private readonly queryApiUrl;
|
|
11
|
+
private readonly apiKey?;
|
|
12
|
+
private readonly signingMode;
|
|
13
|
+
private readonly fetchImpl;
|
|
14
|
+
private readonly extraHeaders?;
|
|
15
|
+
constructor(config: MeterlyClientConfig);
|
|
16
|
+
createCustomer(input: CreateCustomerInput, options?: MeterlyRequestOptions): Promise<CreateCustomerResult>;
|
|
17
|
+
addCredits(customerId: string, amount: number, options?: MeterlyRequestOptions): Promise<BaseResponse>;
|
|
18
|
+
useCredits(customerId: string, amount: number, options?: MeterlyRequestOptions): Promise<BaseResponse>;
|
|
19
|
+
reserveCredits(customerId: string, amount: number, options?: ReserveCreditsOptions): Promise<Required<Pick<ReservationResponse, "reservationId">> & BaseResponse>;
|
|
20
|
+
commitReservation(reservationId: string, options?: MeterlyRequestOptions): Promise<BaseResponse>;
|
|
21
|
+
releaseReservation(reservationId: string, options?: MeterlyRequestOptions): Promise<BaseResponse>;
|
|
22
|
+
/** @deprecated Legacy checkout compatibility only; not part of the current RevenueCat-first product surface. */
|
|
23
|
+
createCheckoutSession(customerId: string, packageId: string, options?: CreateCheckoutSessionOptions): Promise<CheckoutSessionResult>;
|
|
24
|
+
grantCreditPackage(packageId: string, input: GrantCreditPackageInput, options?: Pick<MeterlyRequestOptions, "idempotencyKey">): Promise<GrantCreditPackageResult>;
|
|
25
|
+
grantCredits(input: GrantCreditsInput, options?: Pick<MeterlyRequestOptions, "idempotencyKey">): Promise<GrantCreditsResult>;
|
|
26
|
+
listCreditGrants(options?: ListCreditGrantsOptions): Promise<CreditGrantsListResult>;
|
|
27
|
+
listCreditPackageGrants(options?: ListCreditPackageGrantsOptions): Promise<CreditPackageGrantsListResult>;
|
|
28
|
+
/** @deprecated Legacy Stripe status compatibility only; current provider setup is RevenueCat-first. */
|
|
29
|
+
getPaymentProviderStatus(): Promise<PaymentProviderStatus>;
|
|
30
|
+
listUsageUnits(status?: string): Promise<UsageUnitsListResult>;
|
|
31
|
+
createUsageUnit(input: CreateUsageUnitInput): Promise<UsageUnitResult>;
|
|
32
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
33
|
+
listStripePayments(options?: ListStripePaymentsOptions): Promise<StripePaymentsListResult>;
|
|
34
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
35
|
+
getStripePayment(id: string): Promise<StripePaymentResult>;
|
|
36
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
37
|
+
reconcileStripePayment(id: string): Promise<StripeReconcileResult>;
|
|
38
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
39
|
+
repairStripePayment(id: string): Promise<StripePaymentResult>;
|
|
40
|
+
getBalance(customerId: string): Promise<CreditAccount | null>;
|
|
41
|
+
getBalances(customerId: string): Promise<BalancesResult>;
|
|
42
|
+
getUsage(customerId: string, options?: UsageQueryOptions): Promise<UsageResult>;
|
|
43
|
+
getLedgerEntries(customerId: string, options?: LedgerEntriesQueryOptions): Promise<LedgerEntriesResult>;
|
|
44
|
+
getUsageSummary(): Promise<UsageSummary | null>;
|
|
45
|
+
guard<TResult>(customerId: string, amount: number, callback: GuardCallback<TResult>, options?: ReserveCreditsOptions): Promise<TResult>;
|
|
46
|
+
guard<TResult>(options: GuardOptions, callback: GuardCallback<TResult>): Promise<TResult>;
|
|
47
|
+
private normalizeGuardArgs;
|
|
48
|
+
private request;
|
|
49
|
+
private requestNullable;
|
|
50
|
+
private buildHeaders;
|
|
51
|
+
private buildMutationHeaders;
|
|
52
|
+
private parseResponse;
|
|
53
|
+
}
|
|
54
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MeterlyClient = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
function normalizeBaseUrl(value, name) {
|
|
7
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
8
|
+
if (!trimmed) {
|
|
9
|
+
throw new Error(`${name} is required`);
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
function assertIntegerAmount(amount) {
|
|
14
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
15
|
+
throw new TypeError("Meterly credit amounts must be positive integers.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function assertTtlSeconds(ttlSeconds) {
|
|
19
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
|
20
|
+
throw new TypeError("ttlSeconds must be a positive integer.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function buildQuery(baseUrl, params) {
|
|
24
|
+
const url = new URL(baseUrl);
|
|
25
|
+
for (const [key, value] of Object.entries(params)) {
|
|
26
|
+
if (value !== undefined && value !== "") {
|
|
27
|
+
url.searchParams.set(key, String(value));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|
|
32
|
+
function toExpiresAt(ttlSeconds) {
|
|
33
|
+
if (ttlSeconds === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
assertTtlSeconds(ttlSeconds);
|
|
37
|
+
return new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
38
|
+
}
|
|
39
|
+
function buildMutationPayload(amount, options) {
|
|
40
|
+
assertIntegerAmount(amount);
|
|
41
|
+
const creditUnit = options?.creditUnit ?? options?.unit;
|
|
42
|
+
return {
|
|
43
|
+
amount,
|
|
44
|
+
...(creditUnit ? { creditUnit } : {}),
|
|
45
|
+
...(options?.reference ? { reference: options.reference } : {}),
|
|
46
|
+
...(options?.description ? { description: options.description } : {})
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function isObject(value) {
|
|
50
|
+
return typeof value === "object" && value !== null;
|
|
51
|
+
}
|
|
52
|
+
function extractMessage(value, fallback) {
|
|
53
|
+
if (isObject(value) && typeof value.message === "string" && value.message.trim()) {
|
|
54
|
+
return value.message;
|
|
55
|
+
}
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
function createApiError(method, url, status, responseBody, statusText) {
|
|
59
|
+
const message = extractMessage(responseBody, statusText || `HTTP ${status}`);
|
|
60
|
+
const details = { status, method, url, responseBody };
|
|
61
|
+
if (status === 409 &&
|
|
62
|
+
typeof message === "string" &&
|
|
63
|
+
message.toLowerCase().includes("insufficient credits")) {
|
|
64
|
+
return new errors_1.MeterlyInsufficientCreditsError(message, details);
|
|
65
|
+
}
|
|
66
|
+
return new errors_1.MeterlyApiError(message, details);
|
|
67
|
+
}
|
|
68
|
+
function normalizeSigningMode(value) {
|
|
69
|
+
if (value === undefined || value === "bearer") {
|
|
70
|
+
return "bearer";
|
|
71
|
+
}
|
|
72
|
+
if (value === "hmac") {
|
|
73
|
+
return "hmac";
|
|
74
|
+
}
|
|
75
|
+
throw new Error("signingMode must be \"bearer\" or \"hmac\".");
|
|
76
|
+
}
|
|
77
|
+
function bodyToString(body) {
|
|
78
|
+
if (body === undefined || body === null) {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
if (typeof body === "string") {
|
|
82
|
+
return body;
|
|
83
|
+
}
|
|
84
|
+
if (body instanceof URLSearchParams) {
|
|
85
|
+
return body.toString();
|
|
86
|
+
}
|
|
87
|
+
throw new Error("Meterly HMAC signing supports string request bodies.");
|
|
88
|
+
}
|
|
89
|
+
function sha256Hex(value) {
|
|
90
|
+
return (0, node_crypto_1.createHash)("sha256").update(value).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
function pathWithQuery(url) {
|
|
93
|
+
const parsed = new URL(url);
|
|
94
|
+
return `${parsed.pathname || "/"}${parsed.search}`;
|
|
95
|
+
}
|
|
96
|
+
class MeterlyClient {
|
|
97
|
+
commandApiUrl;
|
|
98
|
+
queryApiUrl;
|
|
99
|
+
apiKey;
|
|
100
|
+
signingMode;
|
|
101
|
+
fetchImpl;
|
|
102
|
+
extraHeaders;
|
|
103
|
+
constructor(config) {
|
|
104
|
+
this.commandApiUrl = normalizeBaseUrl(config.commandApiUrl, "commandApiUrl");
|
|
105
|
+
this.queryApiUrl = normalizeBaseUrl(config.queryApiUrl, "queryApiUrl");
|
|
106
|
+
this.apiKey = config.apiKey;
|
|
107
|
+
this.signingMode = normalizeSigningMode(config.signingMode);
|
|
108
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
109
|
+
this.extraHeaders = config.headers;
|
|
110
|
+
if (!this.fetchImpl) {
|
|
111
|
+
throw new Error("Fetch is required. Use Node.js 18+ or provide a fetch implementation.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async createCustomer(input, options) {
|
|
115
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/customers`, {
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
customerExternalId: input.customerExternalId,
|
|
118
|
+
...(input.creditUnit ? { creditUnit: input.creditUnit } : {})
|
|
119
|
+
}),
|
|
120
|
+
headers: this.buildMutationHeaders(options)
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async addCredits(customerId, amount, options) {
|
|
124
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/credits/add`, {
|
|
125
|
+
body: JSON.stringify(buildMutationPayload(amount, options)),
|
|
126
|
+
headers: this.buildMutationHeaders(options)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async useCredits(customerId, amount, options) {
|
|
130
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/credits/use`, {
|
|
131
|
+
body: JSON.stringify(buildMutationPayload(amount, options)),
|
|
132
|
+
headers: this.buildMutationHeaders(options)
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async reserveCredits(customerId, amount, options) {
|
|
136
|
+
const response = await this.request("POST", `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/credits/reserve`, {
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
...buildMutationPayload(amount, options),
|
|
139
|
+
...(options?.reservationId ? { reservationId: options.reservationId } : {}),
|
|
140
|
+
...(options?.ttlSeconds !== undefined
|
|
141
|
+
? { expiresAt: toExpiresAt(options.ttlSeconds) }
|
|
142
|
+
: {})
|
|
143
|
+
}),
|
|
144
|
+
headers: this.buildMutationHeaders(options)
|
|
145
|
+
});
|
|
146
|
+
if (!response.reservationId) {
|
|
147
|
+
throw new errors_1.MeterlyApiError("Meterly reserveCredits response did not include reservationId.", {
|
|
148
|
+
status: 502,
|
|
149
|
+
method: "POST",
|
|
150
|
+
url: `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/credits/reserve`,
|
|
151
|
+
responseBody: response
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
message: extractMessage(response, "Credits reserved."),
|
|
156
|
+
reservationId: response.reservationId
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async commitReservation(reservationId, options) {
|
|
160
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/reservations/${encodeURIComponent(reservationId)}/commit`, { headers: this.buildMutationHeaders(options) });
|
|
161
|
+
}
|
|
162
|
+
async releaseReservation(reservationId, options) {
|
|
163
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/reservations/${encodeURIComponent(reservationId)}/release`, { headers: this.buildMutationHeaders(options) });
|
|
164
|
+
}
|
|
165
|
+
/** @deprecated Legacy checkout compatibility only; not part of the current RevenueCat-first product surface. */
|
|
166
|
+
async createCheckoutSession(customerId, packageId, options) {
|
|
167
|
+
const response = await this.request("POST", `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/checkout/sessions`, {
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
packageId,
|
|
170
|
+
...(options?.reference ? { reference: options.reference } : {})
|
|
171
|
+
}),
|
|
172
|
+
headers: this.buildMutationHeaders(options)
|
|
173
|
+
});
|
|
174
|
+
if (!response.checkoutUrl || !response.sessionId) {
|
|
175
|
+
throw new errors_1.MeterlyApiError("Meterly checkout session response missing checkoutUrl/sessionId.", {
|
|
176
|
+
status: 502,
|
|
177
|
+
method: "POST",
|
|
178
|
+
url: `${this.commandApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/checkout/sessions`,
|
|
179
|
+
responseBody: response
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
message: extractMessage(response, "Checkout session created."),
|
|
184
|
+
checkoutUrl: response.checkoutUrl,
|
|
185
|
+
sessionId: response.sessionId
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async grantCreditPackage(packageId, input, options) {
|
|
189
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/credit-packages/${encodeURIComponent(packageId)}/grants`, {
|
|
190
|
+
body: JSON.stringify(input),
|
|
191
|
+
headers: this.buildMutationHeaders(options)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async grantCredits(input, options) {
|
|
195
|
+
assertIntegerAmount(input.creditAmount);
|
|
196
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/credit-grants`, {
|
|
197
|
+
body: JSON.stringify(input),
|
|
198
|
+
headers: this.buildMutationHeaders(options)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async listCreditGrants(options = {}) {
|
|
202
|
+
return this.request("GET", buildQuery(`${this.commandApiUrl}/api/v1/credit-grants`, {
|
|
203
|
+
customerId: options.customerId,
|
|
204
|
+
externalCustomerId: options.externalCustomerId,
|
|
205
|
+
provider: options.provider,
|
|
206
|
+
providerPaymentId: options.providerPaymentId,
|
|
207
|
+
providerEventId: options.providerEventId
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
async listCreditPackageGrants(options = {}) {
|
|
211
|
+
return this.request("GET", buildQuery(`${this.commandApiUrl}/api/v1/credit-package-grants`, {
|
|
212
|
+
customerId: options.customerId,
|
|
213
|
+
externalCustomerId: options.externalCustomerId,
|
|
214
|
+
packageId: options.packageId,
|
|
215
|
+
provider: options.provider,
|
|
216
|
+
providerPaymentId: options.providerPaymentId,
|
|
217
|
+
providerEventId: options.providerEventId
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
/** @deprecated Legacy Stripe status compatibility only; current provider setup is RevenueCat-first. */
|
|
221
|
+
async getPaymentProviderStatus() {
|
|
222
|
+
return this.request("GET", `${this.commandApiUrl}/api/v1/payment-provider/status`);
|
|
223
|
+
}
|
|
224
|
+
async listUsageUnits(status) {
|
|
225
|
+
return this.request("GET", buildQuery(`${this.commandApiUrl}/api/v1/usage-units`, { status }));
|
|
226
|
+
}
|
|
227
|
+
async createUsageUnit(input) {
|
|
228
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/usage-units`, {
|
|
229
|
+
body: JSON.stringify(input),
|
|
230
|
+
headers: this.buildMutationHeaders()
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
234
|
+
async listStripePayments(options = {}) {
|
|
235
|
+
return this.request("GET", buildQuery(`${this.commandApiUrl}/api/v1/payments/stripe`, {
|
|
236
|
+
status: options.status,
|
|
237
|
+
page: options.page,
|
|
238
|
+
pageSize: options.pageSize
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
242
|
+
async getStripePayment(id) {
|
|
243
|
+
return this.request("GET", `${this.commandApiUrl}/api/v1/payments/stripe/${encodeURIComponent(id)}`);
|
|
244
|
+
}
|
|
245
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
246
|
+
async reconcileStripePayment(id) {
|
|
247
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/payments/stripe/${encodeURIComponent(id)}/reconcile`);
|
|
248
|
+
}
|
|
249
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
250
|
+
async repairStripePayment(id) {
|
|
251
|
+
return this.request("POST", `${this.commandApiUrl}/api/v1/payments/stripe/${encodeURIComponent(id)}/repair`);
|
|
252
|
+
}
|
|
253
|
+
async getBalance(customerId) {
|
|
254
|
+
const response = await this.requestNullable("GET", `${this.queryApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/balance`);
|
|
255
|
+
return response?.account ?? null;
|
|
256
|
+
}
|
|
257
|
+
async getBalances(customerId) {
|
|
258
|
+
const response = await this.requestNullable("GET", `${this.queryApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/balances`);
|
|
259
|
+
return {
|
|
260
|
+
message: response?.message ?? "No balances found.",
|
|
261
|
+
customerId,
|
|
262
|
+
balances: response?.balances ?? []
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async getUsage(customerId, options = {}) {
|
|
266
|
+
const response = await this.requestNullable("GET", buildQuery(`${this.queryApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/usage`, {
|
|
267
|
+
page: options.page,
|
|
268
|
+
pageSize: options.pageSize,
|
|
269
|
+
sortOrder: options.sortOrder,
|
|
270
|
+
type: options.type,
|
|
271
|
+
unit: options.unit,
|
|
272
|
+
search: options.search,
|
|
273
|
+
startAt: options.startAt,
|
|
274
|
+
endAt: options.endAt
|
|
275
|
+
}));
|
|
276
|
+
return {
|
|
277
|
+
message: response?.message ?? "No usage events found.",
|
|
278
|
+
usage: response?.usage ?? [],
|
|
279
|
+
pagination: response?.pagination
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async getLedgerEntries(customerId, options = {}) {
|
|
283
|
+
const response = await this.requestNullable("GET", buildQuery(`${this.queryApiUrl}/api/v1/customers/${encodeURIComponent(customerId)}/ledger-entries`, {
|
|
284
|
+
page: options.page,
|
|
285
|
+
pageSize: options.pageSize,
|
|
286
|
+
sortOrder: options.sortOrder
|
|
287
|
+
}));
|
|
288
|
+
return {
|
|
289
|
+
message: response?.message ?? "No ledger entries found.",
|
|
290
|
+
ledgerEntries: response?.ledgerEntries ?? [],
|
|
291
|
+
pagination: response?.pagination
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async getUsageSummary() {
|
|
295
|
+
const response = await this.requestNullable("GET", `${this.queryApiUrl}/api/v1/usage/summary`);
|
|
296
|
+
return response?.summary ?? null;
|
|
297
|
+
}
|
|
298
|
+
async guard(customerIdOrOptions, amountOrCallback, callbackOrOptions, maybeOptions) {
|
|
299
|
+
const { customerId, amount, callback, options } = this.normalizeGuardArgs(customerIdOrOptions, amountOrCallback, callbackOrOptions, maybeOptions);
|
|
300
|
+
const reservation = await this.reserveCredits(customerId, amount, options);
|
|
301
|
+
try {
|
|
302
|
+
const result = await callback({ reservationId: reservation.reservationId });
|
|
303
|
+
await this.commitReservation(reservation.reservationId);
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
catch (callbackError) {
|
|
307
|
+
try {
|
|
308
|
+
await this.releaseReservation(reservation.reservationId);
|
|
309
|
+
}
|
|
310
|
+
catch (releaseError) {
|
|
311
|
+
throw new errors_1.MeterlyGuardError({
|
|
312
|
+
customerId,
|
|
313
|
+
reservationId: reservation.reservationId,
|
|
314
|
+
callbackError,
|
|
315
|
+
releaseError
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
throw callbackError;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
normalizeGuardArgs(customerIdOrOptions, amountOrCallback, callbackOrOptions, maybeOptions) {
|
|
322
|
+
if (typeof customerIdOrOptions === "string") {
|
|
323
|
+
if (typeof amountOrCallback !== "number" || typeof callbackOrOptions !== "function") {
|
|
324
|
+
throw new TypeError("guard(customerId, amount, callback, options) requires a callback.");
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
customerId: customerIdOrOptions,
|
|
328
|
+
amount: amountOrCallback,
|
|
329
|
+
callback: callbackOrOptions,
|
|
330
|
+
options: maybeOptions
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (typeof amountOrCallback !== "function") {
|
|
334
|
+
throw new TypeError("guard(options, callback) requires a callback.");
|
|
335
|
+
}
|
|
336
|
+
const { customerId, amount, ...options } = customerIdOrOptions;
|
|
337
|
+
return {
|
|
338
|
+
customerId,
|
|
339
|
+
amount,
|
|
340
|
+
callback: amountOrCallback,
|
|
341
|
+
options
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async request(method, url, init = {}) {
|
|
345
|
+
const response = await this.fetchImpl(url, {
|
|
346
|
+
...init,
|
|
347
|
+
method,
|
|
348
|
+
headers: this.buildHeaders(method, url, init.headers, init.body),
|
|
349
|
+
cache: "no-store"
|
|
350
|
+
});
|
|
351
|
+
const parsed = await this.parseResponse(method, url, response, false);
|
|
352
|
+
if (parsed === null) {
|
|
353
|
+
throw new errors_1.MeterlyApiError("Meterly API returned an empty response body.", {
|
|
354
|
+
status: response.status,
|
|
355
|
+
method,
|
|
356
|
+
url
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return parsed;
|
|
360
|
+
}
|
|
361
|
+
async requestNullable(method, url, init = {}) {
|
|
362
|
+
const response = await this.fetchImpl(url, {
|
|
363
|
+
...init,
|
|
364
|
+
method,
|
|
365
|
+
headers: this.buildHeaders(method, url, init.headers, init.body),
|
|
366
|
+
cache: "no-store"
|
|
367
|
+
});
|
|
368
|
+
return this.parseResponse(method, url, response, true);
|
|
369
|
+
}
|
|
370
|
+
buildHeaders(method, url, headers, body) {
|
|
371
|
+
const built = new Headers(headers);
|
|
372
|
+
if (!built.has("Content-Type")) {
|
|
373
|
+
built.set("Content-Type", "application/json");
|
|
374
|
+
}
|
|
375
|
+
if (this.apiKey && !built.has("Authorization")) {
|
|
376
|
+
built.set("Authorization", `Bearer ${this.apiKey}`);
|
|
377
|
+
}
|
|
378
|
+
if (this.extraHeaders) {
|
|
379
|
+
for (const [key, value] of Object.entries(this.extraHeaders)) {
|
|
380
|
+
if (!built.has(key)) {
|
|
381
|
+
built.set(key, value);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (this.signingMode === "hmac") {
|
|
386
|
+
if (!this.apiKey) {
|
|
387
|
+
throw new Error("Meterly HMAC signing requires apiKey.");
|
|
388
|
+
}
|
|
389
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
390
|
+
const bodyText = bodyToString(body);
|
|
391
|
+
const bodyHash = sha256Hex(bodyText);
|
|
392
|
+
const canonical = [
|
|
393
|
+
method.toUpperCase(),
|
|
394
|
+
pathWithQuery(url),
|
|
395
|
+
bodyHash,
|
|
396
|
+
timestamp
|
|
397
|
+
].join("\n");
|
|
398
|
+
built.set("X-Meterly-Timestamp", timestamp);
|
|
399
|
+
built.set("X-Meterly-Body-SHA256", bodyHash);
|
|
400
|
+
built.set("X-Meterly-Signature", (0, node_crypto_1.createHmac)("sha256", this.apiKey).update(canonical).digest("hex"));
|
|
401
|
+
}
|
|
402
|
+
return built;
|
|
403
|
+
}
|
|
404
|
+
buildMutationHeaders(options) {
|
|
405
|
+
if (!options?.idempotencyKey) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
"Idempotency-Key": options.idempotencyKey
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async parseResponse(method, url, response, allowNoContent) {
|
|
413
|
+
if (response.status === 204) {
|
|
414
|
+
return allowNoContent ? null : {};
|
|
415
|
+
}
|
|
416
|
+
const text = await response.text();
|
|
417
|
+
let responseBody = undefined;
|
|
418
|
+
if (text) {
|
|
419
|
+
try {
|
|
420
|
+
responseBody = JSON.parse(text);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
if (response.ok) {
|
|
424
|
+
throw new errors_1.MeterlyApiError("Received invalid JSON from Meterly API.", {
|
|
425
|
+
status: response.status,
|
|
426
|
+
method,
|
|
427
|
+
url,
|
|
428
|
+
responseBody: text
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!response.ok) {
|
|
434
|
+
throw createApiError(method, url, response.status, responseBody, response.statusText);
|
|
435
|
+
}
|
|
436
|
+
return (responseBody ?? null);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
exports.MeterlyClient = MeterlyClient;
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface MeterlyApiErrorDetails {
|
|
2
|
+
status: number;
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
responseBody?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export declare class MeterlyApiError extends Error {
|
|
8
|
+
readonly status: number;
|
|
9
|
+
readonly method: string;
|
|
10
|
+
readonly url: string;
|
|
11
|
+
readonly responseBody?: unknown;
|
|
12
|
+
constructor(message: string, details: MeterlyApiErrorDetails);
|
|
13
|
+
}
|
|
14
|
+
export declare class MeterlyInsufficientCreditsError extends MeterlyApiError {
|
|
15
|
+
constructor(message: string, details: MeterlyApiErrorDetails);
|
|
16
|
+
}
|
|
17
|
+
export interface MeterlyGuardErrorOptions {
|
|
18
|
+
customerId: string;
|
|
19
|
+
reservationId: string;
|
|
20
|
+
callbackError: unknown;
|
|
21
|
+
releaseError: unknown;
|
|
22
|
+
}
|
|
23
|
+
export declare class MeterlyGuardError extends Error {
|
|
24
|
+
readonly customerId: string;
|
|
25
|
+
readonly reservationId: string;
|
|
26
|
+
readonly callbackError: unknown;
|
|
27
|
+
readonly releaseError: unknown;
|
|
28
|
+
constructor(options: MeterlyGuardErrorOptions);
|
|
29
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MeterlyGuardError = exports.MeterlyInsufficientCreditsError = exports.MeterlyApiError = void 0;
|
|
4
|
+
class MeterlyApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
method;
|
|
7
|
+
url;
|
|
8
|
+
responseBody;
|
|
9
|
+
constructor(message, details) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "MeterlyApiError";
|
|
12
|
+
this.status = details.status;
|
|
13
|
+
this.method = details.method;
|
|
14
|
+
this.url = details.url;
|
|
15
|
+
this.responseBody = details.responseBody;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.MeterlyApiError = MeterlyApiError;
|
|
19
|
+
class MeterlyInsufficientCreditsError extends MeterlyApiError {
|
|
20
|
+
constructor(message, details) {
|
|
21
|
+
super(message, details);
|
|
22
|
+
this.name = "MeterlyInsufficientCreditsError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.MeterlyInsufficientCreditsError = MeterlyInsufficientCreditsError;
|
|
26
|
+
class MeterlyGuardError extends Error {
|
|
27
|
+
customerId;
|
|
28
|
+
reservationId;
|
|
29
|
+
callbackError;
|
|
30
|
+
releaseError;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
super(`Meterly guard failed for customer ${options.customerId}: callback threw and reservation release also failed.`);
|
|
33
|
+
this.name = "MeterlyGuardError";
|
|
34
|
+
this.customerId = options.customerId;
|
|
35
|
+
this.reservationId = options.reservationId;
|
|
36
|
+
this.callbackError = options.callbackError;
|
|
37
|
+
this.releaseError = options.releaseError;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.MeterlyGuardError = MeterlyGuardError;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { MeterlyClient } from "./client";
|
|
2
|
+
export { MeterlyApiError, MeterlyGuardError, MeterlyInsufficientCreditsError } from "./errors";
|
|
3
|
+
export type { BaseResponse, CheckoutSessionResult, CreateCheckoutSessionOptions, CreateCustomerInput, CreateCustomerResult, CreditAccount, CreditGrant, CreditGrantsListResult, CreditPackageGrant, CreditPackageGrantsListResult, CreditUsageEvent, GrantCreditsInput, GrantCreditsResult, GrantCreditPackageInput, GrantCreditPackageResult, GuardContext, GuardOptions, LedgerEntriesQueryOptions, LedgerEntriesResult, ListCreditGrantsOptions, ListCreditPackageGrantsOptions, MeterlyClientConfig, MeterlyRequestOptions, PaymentProviderStatus, ReserveCreditsOptions, ListStripePaymentsOptions, ReconcileOutcome, StripePayment, StripePaymentResult, StripePaymentStatus, StripePaymentsListResult, StripeReconcileResult, UsageQueryOptions, UsageResult, UsageSummary } from "./types";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MeterlyInsufficientCreditsError = exports.MeterlyGuardError = exports.MeterlyApiError = exports.MeterlyClient = void 0;
|
|
4
|
+
var client_1 = require("./client");
|
|
5
|
+
Object.defineProperty(exports, "MeterlyClient", { enumerable: true, get: function () { return client_1.MeterlyClient; } });
|
|
6
|
+
var errors_1 = require("./errors");
|
|
7
|
+
Object.defineProperty(exports, "MeterlyApiError", { enumerable: true, get: function () { return errors_1.MeterlyApiError; } });
|
|
8
|
+
Object.defineProperty(exports, "MeterlyGuardError", { enumerable: true, get: function () { return errors_1.MeterlyGuardError; } });
|
|
9
|
+
Object.defineProperty(exports, "MeterlyInsufficientCreditsError", { enumerable: true, get: function () { return errors_1.MeterlyInsufficientCreditsError; } });
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
export interface MeterlyClientConfig {
|
|
2
|
+
commandApiUrl: string;
|
|
3
|
+
queryApiUrl: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
signingMode?: "bearer" | "hmac";
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
export interface BaseResponse {
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PaginationMeta {
|
|
13
|
+
page: number;
|
|
14
|
+
pageSize: number;
|
|
15
|
+
returnedItems: number;
|
|
16
|
+
hasMore: boolean;
|
|
17
|
+
sortBy?: string;
|
|
18
|
+
sortOrder?: "asc" | "desc";
|
|
19
|
+
}
|
|
20
|
+
export interface Customer {
|
|
21
|
+
id: string;
|
|
22
|
+
customerExternalId: string;
|
|
23
|
+
creditUnit: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
}
|
|
26
|
+
export interface CreditAccount {
|
|
27
|
+
id: string;
|
|
28
|
+
customerId: string;
|
|
29
|
+
creditUnit: string;
|
|
30
|
+
balance: number;
|
|
31
|
+
reserved: number;
|
|
32
|
+
available: number;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
}
|
|
36
|
+
export interface CustomerRecord {
|
|
37
|
+
customer?: Customer;
|
|
38
|
+
account?: CreditAccount | null;
|
|
39
|
+
}
|
|
40
|
+
export type CreditAction = "ACCOUNT_CREATED" | "CREDITS_ADDED" | "CREDITS_USED" | "CREDITS_USE_REJECTED" | "CREDITS_RESERVED" | "CREDITS_COMMITTED" | "CREDITS_RELEASED" | "RESERVATION_EXPIRED" | string;
|
|
41
|
+
export interface CreditUsageEvent {
|
|
42
|
+
id: string;
|
|
43
|
+
customerId: string;
|
|
44
|
+
reservationId?: string;
|
|
45
|
+
eventId: string;
|
|
46
|
+
type: CreditAction;
|
|
47
|
+
amount: number;
|
|
48
|
+
creditUnit?: string;
|
|
49
|
+
reference?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
occurredAt: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
export interface UsageUnit {
|
|
55
|
+
unitId: string;
|
|
56
|
+
tenantId: string;
|
|
57
|
+
code: string;
|
|
58
|
+
displayName: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
precision: number;
|
|
61
|
+
status: "active" | "archived" | string;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
}
|
|
65
|
+
export interface LedgerEntry {
|
|
66
|
+
id: string;
|
|
67
|
+
customerId: string;
|
|
68
|
+
creditAccountId: string;
|
|
69
|
+
reservationId?: string;
|
|
70
|
+
eventId: string;
|
|
71
|
+
eventType: string;
|
|
72
|
+
eventVersion: number;
|
|
73
|
+
entryType: "DEBIT" | "CREDIT" | "RESERVE" | "RELEASE";
|
|
74
|
+
action: CreditAction;
|
|
75
|
+
amount: number;
|
|
76
|
+
balanceAfter: number;
|
|
77
|
+
availableAfter: number;
|
|
78
|
+
creditUnit: string;
|
|
79
|
+
reference?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
occurredAt: string;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
}
|
|
84
|
+
export interface UsageSummary {
|
|
85
|
+
id: string;
|
|
86
|
+
customerCount: number;
|
|
87
|
+
/** Legacy cross-unit compatibility aggregate; prefer per-unit balance reads. */
|
|
88
|
+
totalBalance: number;
|
|
89
|
+
/** Legacy cross-unit compatibility aggregate; prefer per-unit balance reads. */
|
|
90
|
+
totalReserved: number;
|
|
91
|
+
/** Legacy cross-unit compatibility aggregate; prefer per-unit balance reads. */
|
|
92
|
+
totalAvailable: number;
|
|
93
|
+
/** Legacy cross-unit compatibility aggregate; prefer usageEventsByType or per-unit grant views. */
|
|
94
|
+
totalCreditsAdded: number;
|
|
95
|
+
/** Legacy cross-unit compatibility aggregate; prefer usageEventsByType or per-unit usage views. */
|
|
96
|
+
totalCreditsUsed: number;
|
|
97
|
+
totalReservations: number;
|
|
98
|
+
openReservations: number;
|
|
99
|
+
rejectedUsageEvents: number;
|
|
100
|
+
usageEventsByType?: Record<string, number>;
|
|
101
|
+
}
|
|
102
|
+
export interface CreateCustomerInput {
|
|
103
|
+
customerExternalId: string;
|
|
104
|
+
creditUnit?: string;
|
|
105
|
+
}
|
|
106
|
+
export interface CreateCustomerResult extends BaseResponse {
|
|
107
|
+
customerId: string;
|
|
108
|
+
}
|
|
109
|
+
export interface MeterlyRequestOptions {
|
|
110
|
+
reference?: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
creditUnit?: string;
|
|
113
|
+
unit?: string;
|
|
114
|
+
metadata?: Record<string, unknown>;
|
|
115
|
+
idempotencyKey?: string;
|
|
116
|
+
}
|
|
117
|
+
export interface ReserveCreditsOptions extends MeterlyRequestOptions {
|
|
118
|
+
reservationId?: string;
|
|
119
|
+
ttlSeconds?: number;
|
|
120
|
+
}
|
|
121
|
+
export interface UsageQueryOptions {
|
|
122
|
+
page?: number;
|
|
123
|
+
pageSize?: number;
|
|
124
|
+
sortOrder?: "asc" | "desc";
|
|
125
|
+
type?: string;
|
|
126
|
+
unit?: string;
|
|
127
|
+
search?: string;
|
|
128
|
+
startAt?: string;
|
|
129
|
+
endAt?: string;
|
|
130
|
+
}
|
|
131
|
+
export interface LedgerEntriesQueryOptions {
|
|
132
|
+
page?: number;
|
|
133
|
+
pageSize?: number;
|
|
134
|
+
sortOrder?: "asc" | "desc";
|
|
135
|
+
}
|
|
136
|
+
export interface UsageResult {
|
|
137
|
+
message: string;
|
|
138
|
+
usage: CreditUsageEvent[];
|
|
139
|
+
pagination?: PaginationMeta;
|
|
140
|
+
}
|
|
141
|
+
export interface BalancesResult extends BaseResponse {
|
|
142
|
+
customerId: string;
|
|
143
|
+
balances: CreditAccount[];
|
|
144
|
+
}
|
|
145
|
+
export interface UsageUnitsListResult extends BaseResponse {
|
|
146
|
+
usageUnits: UsageUnit[];
|
|
147
|
+
}
|
|
148
|
+
export interface CreateUsageUnitInput {
|
|
149
|
+
code: string;
|
|
150
|
+
displayName?: string;
|
|
151
|
+
description?: string;
|
|
152
|
+
}
|
|
153
|
+
export interface UsageUnitResult extends BaseResponse {
|
|
154
|
+
usageUnit: UsageUnit;
|
|
155
|
+
}
|
|
156
|
+
export interface LedgerEntriesResult {
|
|
157
|
+
message: string;
|
|
158
|
+
ledgerEntries: LedgerEntry[];
|
|
159
|
+
pagination?: PaginationMeta;
|
|
160
|
+
}
|
|
161
|
+
export interface GuardContext {
|
|
162
|
+
reservationId: string;
|
|
163
|
+
}
|
|
164
|
+
export type GuardCallback<TResult> = (context: GuardContext) => Promise<TResult> | TResult;
|
|
165
|
+
export interface GuardOptions extends ReserveCreditsOptions {
|
|
166
|
+
customerId: string;
|
|
167
|
+
amount: number;
|
|
168
|
+
}
|
|
169
|
+
/** @deprecated Legacy checkout compatibility only; not part of the current RevenueCat-first product surface. */
|
|
170
|
+
export interface CreateCheckoutSessionOptions extends MeterlyRequestOptions {
|
|
171
|
+
}
|
|
172
|
+
/** @deprecated Legacy checkout compatibility only; not part of the current RevenueCat-first product surface. */
|
|
173
|
+
export interface CheckoutSessionResult extends BaseResponse {
|
|
174
|
+
checkoutUrl: string;
|
|
175
|
+
sessionId: string;
|
|
176
|
+
}
|
|
177
|
+
export interface GrantCreditPackageInput {
|
|
178
|
+
externalCustomerId: string;
|
|
179
|
+
reference: string;
|
|
180
|
+
description?: string;
|
|
181
|
+
provider?: string;
|
|
182
|
+
providerAccountId?: string;
|
|
183
|
+
providerPaymentId?: string;
|
|
184
|
+
providerEventId?: string;
|
|
185
|
+
providerEventType?: string;
|
|
186
|
+
verificationSource?: string;
|
|
187
|
+
}
|
|
188
|
+
export interface GrantCreditsInput {
|
|
189
|
+
externalCustomerId: string;
|
|
190
|
+
creditAmount: number;
|
|
191
|
+
creditUnit?: string;
|
|
192
|
+
reference: string;
|
|
193
|
+
description?: string;
|
|
194
|
+
provider?: string;
|
|
195
|
+
providerAccountId?: string;
|
|
196
|
+
providerPaymentId?: string;
|
|
197
|
+
providerEventId?: string;
|
|
198
|
+
providerEventType?: string;
|
|
199
|
+
verificationSource?: string;
|
|
200
|
+
}
|
|
201
|
+
export interface CreditGrant {
|
|
202
|
+
grantId: string;
|
|
203
|
+
tenantId: string;
|
|
204
|
+
customerId: string;
|
|
205
|
+
externalCustomerId: string;
|
|
206
|
+
creditAmount: number;
|
|
207
|
+
creditUnit: string;
|
|
208
|
+
reference: string;
|
|
209
|
+
description?: string;
|
|
210
|
+
provider?: string;
|
|
211
|
+
providerAccountId?: string;
|
|
212
|
+
providerPaymentId?: string;
|
|
213
|
+
providerEventId?: string;
|
|
214
|
+
providerEventType?: string;
|
|
215
|
+
verificationSource: string;
|
|
216
|
+
status: "pending" | "granted" | string;
|
|
217
|
+
createdAt: string;
|
|
218
|
+
}
|
|
219
|
+
export interface GrantCreditsResult extends BaseResponse {
|
|
220
|
+
grant: CreditGrant;
|
|
221
|
+
}
|
|
222
|
+
export interface ListCreditGrantsOptions {
|
|
223
|
+
customerId?: string;
|
|
224
|
+
externalCustomerId?: string;
|
|
225
|
+
provider?: string;
|
|
226
|
+
providerPaymentId?: string;
|
|
227
|
+
providerEventId?: string;
|
|
228
|
+
}
|
|
229
|
+
export interface CreditGrantsListResult extends BaseResponse {
|
|
230
|
+
grants: CreditGrant[];
|
|
231
|
+
}
|
|
232
|
+
export interface CreditPackageGrant {
|
|
233
|
+
grantId: string;
|
|
234
|
+
tenantId: string;
|
|
235
|
+
packageId: string;
|
|
236
|
+
customerId: string;
|
|
237
|
+
externalCustomerId: string;
|
|
238
|
+
creditAmount: number;
|
|
239
|
+
creditUnit: string;
|
|
240
|
+
priceAmount?: number;
|
|
241
|
+
priceCurrency?: string;
|
|
242
|
+
reference: string;
|
|
243
|
+
description?: string;
|
|
244
|
+
provider?: string;
|
|
245
|
+
providerAccountId?: string;
|
|
246
|
+
providerPaymentId?: string;
|
|
247
|
+
providerEventId?: string;
|
|
248
|
+
providerEventType?: string;
|
|
249
|
+
verificationSource: string;
|
|
250
|
+
status: "pending" | "granted" | string;
|
|
251
|
+
createdAt: string;
|
|
252
|
+
}
|
|
253
|
+
export interface GrantCreditPackageResult extends BaseResponse {
|
|
254
|
+
grant: CreditPackageGrant;
|
|
255
|
+
}
|
|
256
|
+
export interface ListCreditPackageGrantsOptions {
|
|
257
|
+
customerId?: string;
|
|
258
|
+
externalCustomerId?: string;
|
|
259
|
+
packageId?: string;
|
|
260
|
+
provider?: string;
|
|
261
|
+
providerPaymentId?: string;
|
|
262
|
+
providerEventId?: string;
|
|
263
|
+
}
|
|
264
|
+
export interface CreditPackageGrantsListResult extends BaseResponse {
|
|
265
|
+
grants: CreditPackageGrant[];
|
|
266
|
+
}
|
|
267
|
+
export interface HostedConnectStatus {
|
|
268
|
+
supported: boolean;
|
|
269
|
+
status: "coming_soon" | string;
|
|
270
|
+
message: string;
|
|
271
|
+
}
|
|
272
|
+
/** @deprecated Legacy Stripe status compatibility only; current provider setup is RevenueCat-first. */
|
|
273
|
+
export interface PaymentProviderStatus {
|
|
274
|
+
tenantId: string;
|
|
275
|
+
provider: "stripe" | string;
|
|
276
|
+
mode: "self_hosted" | string;
|
|
277
|
+
enabled: boolean;
|
|
278
|
+
configured: boolean;
|
|
279
|
+
checkoutAvailable: boolean;
|
|
280
|
+
webhookEndpoint: string;
|
|
281
|
+
successUrlConfigured: boolean;
|
|
282
|
+
cancelUrlConfigured: boolean;
|
|
283
|
+
webhookSecretConfigured: boolean;
|
|
284
|
+
secretKeyConfigured: boolean;
|
|
285
|
+
hostedConnect: HostedConnectStatus;
|
|
286
|
+
}
|
|
287
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
288
|
+
export type StripePaymentStatus = "received" | "processed" | "duplicate" | "failed" | "reconciled" | string;
|
|
289
|
+
export type ReconcileOutcome = "reconciled" | "mismatch" | "pending" | "failed" | string;
|
|
290
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
291
|
+
export interface StripePayment {
|
|
292
|
+
stripeEventId: string;
|
|
293
|
+
stripeSessionId?: string;
|
|
294
|
+
tenantId: string;
|
|
295
|
+
projectId?: string;
|
|
296
|
+
customerId: string;
|
|
297
|
+
packageId: string;
|
|
298
|
+
credits: number;
|
|
299
|
+
amount?: number;
|
|
300
|
+
currency?: string;
|
|
301
|
+
reference?: string;
|
|
302
|
+
status: StripePaymentStatus;
|
|
303
|
+
creditEventId?: string;
|
|
304
|
+
errorMessage?: string;
|
|
305
|
+
createdAt: string;
|
|
306
|
+
processedAt?: string;
|
|
307
|
+
reconciledAt?: string;
|
|
308
|
+
}
|
|
309
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
310
|
+
export interface ListStripePaymentsOptions {
|
|
311
|
+
status?: StripePaymentStatus;
|
|
312
|
+
page?: number;
|
|
313
|
+
pageSize?: number;
|
|
314
|
+
}
|
|
315
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
316
|
+
export interface StripePaymentsListResult extends BaseResponse {
|
|
317
|
+
payments: StripePayment[];
|
|
318
|
+
pagination: {
|
|
319
|
+
page: number;
|
|
320
|
+
pageSize: number;
|
|
321
|
+
hasMore: boolean;
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
325
|
+
export interface StripePaymentResult extends BaseResponse {
|
|
326
|
+
payment: StripePayment;
|
|
327
|
+
}
|
|
328
|
+
/** @deprecated Legacy Stripe compatibility only. */
|
|
329
|
+
export interface StripeReconcileResult extends BaseResponse {
|
|
330
|
+
outcome: ReconcileOutcome;
|
|
331
|
+
status: StripePaymentStatus;
|
|
332
|
+
errorMessage?: string;
|
|
333
|
+
detail?: string;
|
|
334
|
+
payment: StripePayment;
|
|
335
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meterly/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Developer-facing JavaScript SDK for Meterly credit workflows",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
20
|
+
"test": "tsx test/guard.test.ts"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.9.1",
|
|
24
|
+
"tsx": "^4.20.6",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
}
|
|
27
|
+
}
|