@basaltkit/subscriptions 1.0.0 → 1.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/LICENSE +1 -1
- package/dist/index.d.ts +298 -1
- package/dist/index.js +232 -0
- package/package.json +1 -1
package/LICENSE
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -261,6 +261,303 @@ declare class FakeBillingGateway implements BillingGateway {
|
|
|
261
261
|
verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* One-off / reference / mobile-money payments — the model used by Angolan
|
|
266
|
+
* providers (ProxyPay, EMIS/Multicaixa, AppyPay, UNITEL Money), where there's no
|
|
267
|
+
* card-on-file recurring charge or self-service portal. This complements
|
|
268
|
+
* `BillingGateway` (card subscriptions). Recurring billing is modelled by
|
|
269
|
+
* creating one payment per period (invoice → reference → webhook confirms → the
|
|
270
|
+
* period is activated).
|
|
271
|
+
*/
|
|
272
|
+
/** A one-off payment request handed to a `PaymentGateway`. */
|
|
273
|
+
interface PaymentRequest {
|
|
274
|
+
/** Who is paying — a tenant/user/customer id you reconcile against. */
|
|
275
|
+
billableId: string;
|
|
276
|
+
/** Amount in the currency's major unit (e.g. 5000 = 5000,00 Kz). */
|
|
277
|
+
amount: number;
|
|
278
|
+
/** ISO 4217. Defaults to the gateway's own (AOA for Angolan gateways). */
|
|
279
|
+
currency?: string;
|
|
280
|
+
/** Your order/invoice id — for idempotency and reconciliation. */
|
|
281
|
+
reference?: string;
|
|
282
|
+
description?: string;
|
|
283
|
+
customer?: {
|
|
284
|
+
name?: string;
|
|
285
|
+
email?: string;
|
|
286
|
+
phone?: string;
|
|
287
|
+
};
|
|
288
|
+
/** When the reference / request stops being payable (epoch ms). */
|
|
289
|
+
expiresAt?: number;
|
|
290
|
+
/** Passed to the gateway and echoed back on the webhook. */
|
|
291
|
+
metadata?: Record<string, string>;
|
|
292
|
+
}
|
|
293
|
+
/** How the customer is told to pay — a reference, a redirect, or a push. */
|
|
294
|
+
interface PaymentInstruction {
|
|
295
|
+
/** The gateway's id for this payment (status checks + reconciliation). */
|
|
296
|
+
id: string;
|
|
297
|
+
status: 'pending' | 'paid' | 'failed';
|
|
298
|
+
/** Reference-based (Multicaixa/EMIS): the entity + reference to pay. */
|
|
299
|
+
reference?: {
|
|
300
|
+
entity: string;
|
|
301
|
+
reference: string;
|
|
302
|
+
amount: number;
|
|
303
|
+
};
|
|
304
|
+
/** Redirect-based (hosted page): send the customer here. */
|
|
305
|
+
url?: string;
|
|
306
|
+
/** Push-based (mobile money): a prompt was sent to this phone. */
|
|
307
|
+
push?: {
|
|
308
|
+
phone: string;
|
|
309
|
+
};
|
|
310
|
+
/** The raw gateway payload, for logging/debugging. */
|
|
311
|
+
raw?: unknown;
|
|
312
|
+
}
|
|
313
|
+
/** A payment webhook, translated to domain terms — gateway payloads never leak. */
|
|
314
|
+
interface PaymentEvent {
|
|
315
|
+
/** Unique id at the gateway — for idempotent processing. */
|
|
316
|
+
id: string;
|
|
317
|
+
type: 'payment.succeeded' | 'payment.failed';
|
|
318
|
+
/** The gateway payment/reference id from `createPayment`. */
|
|
319
|
+
paymentId: string;
|
|
320
|
+
amount: number;
|
|
321
|
+
/** From the request metadata, when the gateway echoes it back. */
|
|
322
|
+
billableId?: string;
|
|
323
|
+
reference?: string;
|
|
324
|
+
raw?: unknown;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Payment gateway driver contract for one-off / reference / mobile-money
|
|
328
|
+
* charges. The app talks to Basalt; only drivers talk to ProxyPay / EMIS / etc.
|
|
329
|
+
* A driver translates raw webhook payloads into `PaymentEvent`.
|
|
330
|
+
*/
|
|
331
|
+
interface PaymentGateway {
|
|
332
|
+
readonly name: string;
|
|
333
|
+
/** Create a payment — returns a reference, a redirect URL, or a push. */
|
|
334
|
+
createPayment(request: PaymentRequest): Promise<PaymentInstruction>;
|
|
335
|
+
/**
|
|
336
|
+
* Verifies the signature and translates the payload. Throws
|
|
337
|
+
* `WebhookInvalidError` on a bad signature; returns null for a verified event
|
|
338
|
+
* that isn't a payment (gateways emit event types we don't act on).
|
|
339
|
+
*/
|
|
340
|
+
verifyWebhook(rawBody: string, signature: string | undefined): PaymentEvent | null;
|
|
341
|
+
/** Poll a payment's status — a fallback when you can't receive webhooks. */
|
|
342
|
+
getPayment?(id: string): Promise<PaymentInstruction>;
|
|
343
|
+
}
|
|
344
|
+
/** Controllable in-process payment gateway — the test/dev driver. */
|
|
345
|
+
declare class FakePaymentGateway implements PaymentGateway {
|
|
346
|
+
readonly name = "fake";
|
|
347
|
+
readonly payments: PaymentRequest[];
|
|
348
|
+
private counter;
|
|
349
|
+
createPayment(request: PaymentRequest): Promise<PaymentInstruction>;
|
|
350
|
+
verifyWebhook(rawBody: string, signature: string | undefined): PaymentEvent;
|
|
351
|
+
}
|
|
352
|
+
type PaymentRecordStatus = 'pending' | 'paid' | 'failed';
|
|
353
|
+
/** A payment as tracked in your app's ledger, keyed by the gateway payment id. */
|
|
354
|
+
interface PaymentRecord {
|
|
355
|
+
/** Gateway payment id — `PaymentInstruction.id` / `PaymentEvent.paymentId`. */
|
|
356
|
+
id: string;
|
|
357
|
+
status: PaymentRecordStatus;
|
|
358
|
+
amount: number;
|
|
359
|
+
billableId?: string;
|
|
360
|
+
reference?: string;
|
|
361
|
+
/** Epoch ms. */
|
|
362
|
+
createdAt: number;
|
|
363
|
+
updatedAt: number;
|
|
364
|
+
raw?: unknown;
|
|
365
|
+
}
|
|
366
|
+
/** Input to record a freshly-created (pending) payment. */
|
|
367
|
+
interface NewPayment {
|
|
368
|
+
id: string;
|
|
369
|
+
amount: number;
|
|
370
|
+
billableId?: string;
|
|
371
|
+
reference?: string;
|
|
372
|
+
raw?: unknown;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A ledger of payments keyed by the gateway payment id. Apps record a payment
|
|
376
|
+
* as `pending` on `createPayment` and let the webhook flip it to `paid`/`failed`
|
|
377
|
+
* — no more hand-rolling this per app. A memory implementation ships here; back
|
|
378
|
+
* it with your database in production (a `Payment` table keyed by `id`).
|
|
379
|
+
*/
|
|
380
|
+
interface PaymentStore {
|
|
381
|
+
/** Insert a pending payment. Idempotent: a no-op if the id already exists. */
|
|
382
|
+
create(payment: NewPayment): Promise<void>;
|
|
383
|
+
/** Apply a terminal status; upserts if the payment wasn't recorded first. */
|
|
384
|
+
setStatus(id: string, status: PaymentRecordStatus, patch?: {
|
|
385
|
+
amount?: number;
|
|
386
|
+
raw?: unknown;
|
|
387
|
+
}): Promise<void>;
|
|
388
|
+
get(id: string): Promise<PaymentRecord | undefined>;
|
|
389
|
+
}
|
|
390
|
+
/** In-memory `PaymentStore` — per-process; swap for a durable one in production. */
|
|
391
|
+
declare class MemoryPaymentStore implements PaymentStore {
|
|
392
|
+
private readonly records;
|
|
393
|
+
create(payment: NewPayment): Promise<void>;
|
|
394
|
+
setStatus(id: string, status: PaymentRecordStatus, patch?: {
|
|
395
|
+
amount?: number;
|
|
396
|
+
raw?: unknown;
|
|
397
|
+
}): Promise<void>;
|
|
398
|
+
get(id: string): Promise<PaymentRecord | undefined>;
|
|
399
|
+
}
|
|
400
|
+
/** Result of applying a `PaymentEvent` to the ledger. */
|
|
401
|
+
interface PaymentApplyResult {
|
|
402
|
+
/** false = this event id was already processed (deduped) — you did nothing. */
|
|
403
|
+
fresh: boolean;
|
|
404
|
+
record?: PaymentRecord;
|
|
405
|
+
}
|
|
406
|
+
interface PaymentLedgerOptions {
|
|
407
|
+
/** Where payments are stored. Default: in-memory. */
|
|
408
|
+
store?: PaymentStore;
|
|
409
|
+
/**
|
|
410
|
+
* Webhook-id dedupe store. Default: in-memory. Share one durable store (e.g.
|
|
411
|
+
* Redis) across the app so a retried callback is applied exactly once.
|
|
412
|
+
*/
|
|
413
|
+
webhooks?: WebhookStore;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Ties a `PaymentStore` to webhook idempotency so a retried callback is applied
|
|
417
|
+
* once. Record a payment on create, then feed every verified `PaymentEvent`
|
|
418
|
+
* through `apply` — it dedupes by `event.id` and updates the ledger.
|
|
419
|
+
*
|
|
420
|
+
* ```ts
|
|
421
|
+
* const ledger = new PaymentLedger()
|
|
422
|
+
* const inst = await gateway.createPayment(req)
|
|
423
|
+
* await ledger.created(inst, req) // pending
|
|
424
|
+
* // in the webhook route:
|
|
425
|
+
* const event = gateway.verifyWebhook(raw, sig)
|
|
426
|
+
* if (event) {
|
|
427
|
+
* const { fresh, record } = await ledger.apply(event)
|
|
428
|
+
* if (fresh && record?.status === 'paid') activate(record.billableId!)
|
|
429
|
+
* }
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
declare class PaymentLedger {
|
|
433
|
+
private readonly store;
|
|
434
|
+
private readonly webhooks;
|
|
435
|
+
constructor(options?: PaymentLedgerOptions);
|
|
436
|
+
/** Record a just-created payment as pending. Call after `createPayment`. */
|
|
437
|
+
created(instruction: PaymentInstruction, request: PaymentRequest): Promise<void>;
|
|
438
|
+
/**
|
|
439
|
+
* Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
|
|
440
|
+
* fresh event, flips the ledger record to paid/failed. If persisting fails the
|
|
441
|
+
* dedupe claim is released so the gateway's retry can reprocess.
|
|
442
|
+
*
|
|
443
|
+
* `onFresh` runs **inside the idempotency claim**, after the ledger is
|
|
444
|
+
* updated — use it for domain side effects (activate a subscription, mark a
|
|
445
|
+
* booking paid) that must apply exactly once with the payment. If it throws,
|
|
446
|
+
* the claim is released so the whole thing reprocesses on the gateway's retry.
|
|
447
|
+
*/
|
|
448
|
+
apply(event: PaymentEvent, onFresh?: (record: PaymentRecord | undefined, event: PaymentEvent) => Promise<void> | void): Promise<PaymentApplyResult>;
|
|
449
|
+
get(id: string): Promise<PaymentRecord | undefined>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Recurring billing for gateways with **no card-on-file** (ProxyPay, AppyPay,
|
|
454
|
+
* Multicaixa/EMIS): model a subscription as **one payment reference per period**.
|
|
455
|
+
* Each period you issue a fresh reference; when the webhook confirms it, the
|
|
456
|
+
* subscription's paid-through date is extended by one interval.
|
|
457
|
+
*
|
|
458
|
+
* Drive it with a scheduler: periodically call `due()` and `issueNext()` for the
|
|
459
|
+
* ones returned, and feed every verified `PaymentEvent` through `handleEvent()`.
|
|
460
|
+
*/
|
|
461
|
+
type RecurringInterval = 'monthly' | 'yearly';
|
|
462
|
+
type RecurringStatus = 'pending' | 'active' | 'past_due' | 'canceled';
|
|
463
|
+
interface RecurringSubscription {
|
|
464
|
+
/** The customer/tenant this subscription bills. One per billableId. */
|
|
465
|
+
billableId: string;
|
|
466
|
+
plan: string;
|
|
467
|
+
/** Price per period, in the currency's major unit. */
|
|
468
|
+
amount: number;
|
|
469
|
+
interval: RecurringInterval;
|
|
470
|
+
status: RecurringStatus;
|
|
471
|
+
/** Active until this instant (epoch ms). Undefined before the first payment. */
|
|
472
|
+
paidThrough?: number;
|
|
473
|
+
/** The outstanding (unpaid) payment id awaiting confirmation, if any. */
|
|
474
|
+
pendingPaymentId?: string;
|
|
475
|
+
/** Kept for gateways that need it each period (e.g. phone for Express push). */
|
|
476
|
+
customer?: PaymentRequest['customer'];
|
|
477
|
+
createdAt: number;
|
|
478
|
+
updatedAt: number;
|
|
479
|
+
}
|
|
480
|
+
interface RecurringStore {
|
|
481
|
+
save(sub: RecurringSubscription): Promise<void>;
|
|
482
|
+
get(billableId: string): Promise<RecurringSubscription | undefined>;
|
|
483
|
+
list(): Promise<RecurringSubscription[]>;
|
|
484
|
+
}
|
|
485
|
+
/** In-memory `RecurringStore` — swap for a durable one in production. */
|
|
486
|
+
declare class MemoryRecurringStore implements RecurringStore {
|
|
487
|
+
private readonly subs;
|
|
488
|
+
save(sub: RecurringSubscription): Promise<void>;
|
|
489
|
+
get(billableId: string): Promise<RecurringSubscription | undefined>;
|
|
490
|
+
list(): Promise<RecurringSubscription[]>;
|
|
491
|
+
}
|
|
492
|
+
/** Add one billing interval to an epoch-ms instant (calendar-aware). */
|
|
493
|
+
declare function addInterval(ms: number, interval: RecurringInterval): number;
|
|
494
|
+
interface RecurringBillingOptions {
|
|
495
|
+
/** Payment driver (ProxyPay / AppyPay / …). */
|
|
496
|
+
gateway: PaymentGateway;
|
|
497
|
+
/** Ledger for payment records + webhook idempotency. Default: in-memory. */
|
|
498
|
+
ledger?: PaymentLedger;
|
|
499
|
+
/** Where subscriptions live. Default: in-memory. */
|
|
500
|
+
store?: RecurringStore;
|
|
501
|
+
/** Days before period end that a subscription becomes `due()`. Default 5. */
|
|
502
|
+
leadDays?: number;
|
|
503
|
+
/** ISO 4217 currency passed to the gateway (defaults to the gateway's own). */
|
|
504
|
+
currency?: string;
|
|
505
|
+
}
|
|
506
|
+
interface SubscribeInput {
|
|
507
|
+
billableId: string;
|
|
508
|
+
plan: string;
|
|
509
|
+
amount: number;
|
|
510
|
+
interval: RecurringInterval;
|
|
511
|
+
customer?: PaymentRequest['customer'];
|
|
512
|
+
/** Extra fields passed to the gateway and echoed on the webhook. */
|
|
513
|
+
metadata?: Record<string, string>;
|
|
514
|
+
}
|
|
515
|
+
interface HandleEventResult {
|
|
516
|
+
/** false = the event was a duplicate (already applied) — nothing changed. */
|
|
517
|
+
applied: boolean;
|
|
518
|
+
subscription?: RecurringSubscription;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Coordinates reference-per-period recurring billing over a `PaymentGateway`.
|
|
522
|
+
* Stateful pieces are pluggable (`store`, `ledger`) so you can back them with a
|
|
523
|
+
* database; the defaults are in-memory.
|
|
524
|
+
*/
|
|
525
|
+
declare class RecurringReferenceBilling {
|
|
526
|
+
private readonly gateway;
|
|
527
|
+
private readonly ledger;
|
|
528
|
+
private readonly store;
|
|
529
|
+
private readonly leadMs;
|
|
530
|
+
private readonly currency;
|
|
531
|
+
constructor(options: RecurringBillingOptions);
|
|
532
|
+
get(billableId: string): Promise<RecurringSubscription | undefined>;
|
|
533
|
+
/** Start a subscription and issue the first period's reference. */
|
|
534
|
+
subscribe(input: SubscribeInput): Promise<{
|
|
535
|
+
subscription: RecurringSubscription;
|
|
536
|
+
instruction: PaymentInstruction;
|
|
537
|
+
}>;
|
|
538
|
+
/**
|
|
539
|
+
* Issue the next period's reference for a subscription (call for the ones
|
|
540
|
+
* `due()` returns, or to re-collect after a lapse). Replaces any outstanding
|
|
541
|
+
* reference as the one now awaited.
|
|
542
|
+
*/
|
|
543
|
+
issueNext(billableId: string, metadata?: Record<string, string>): Promise<PaymentInstruction>;
|
|
544
|
+
private issueFor;
|
|
545
|
+
/**
|
|
546
|
+
* Feed a verified `PaymentEvent`. Applies it once (via the ledger's
|
|
547
|
+
* idempotency): on success for the outstanding reference, extends
|
|
548
|
+
* `paidThrough` by one interval and marks the subscription active; on failure,
|
|
549
|
+
* marks it `past_due`.
|
|
550
|
+
*/
|
|
551
|
+
handleEvent(event: PaymentEvent): Promise<HandleEventResult>;
|
|
552
|
+
/**
|
|
553
|
+
* Subscriptions that need their next reference issued now: not canceled,
|
|
554
|
+
* nothing outstanding, and within `leadDays` of the paid-through date (or
|
|
555
|
+
* never paid). Run this on a schedule and call `issueNext()` for each.
|
|
556
|
+
*/
|
|
557
|
+
due(now?: number): Promise<RecurringSubscription[]>;
|
|
558
|
+
cancel(billableId: string): Promise<void>;
|
|
559
|
+
}
|
|
560
|
+
|
|
264
561
|
declare class StripeRequestError extends BasaltError {
|
|
265
562
|
readonly httpStatus: number;
|
|
266
563
|
constructor(httpStatus: number, message: string);
|
|
@@ -473,4 +770,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
|
|
|
473
770
|
*/
|
|
474
771
|
declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
|
|
475
772
|
|
|
476
|
-
export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, NotSubscribedError, type PlanDefinition, type Plans, type PortalInput, QuotaExceededError, type RedisLike, RedisUsageStore, type RedisUsageStoreOptions, type RedisWebhookClient, RedisWebhookStore, type RedisWebhookStoreOptions, SUBSCRIPTIONS, StripeBillingGateway, type StripeGatewayOptions, StripeRequestError, type SubscriptionRecord, type SubscriptionStatus, type SubscriptionStore, Subscriptions, type SubscriptionsOptions, type SubscriptionsPluginOptions, type SwapInput, UnknownPlanError, type UsageConsumeResult, type UsageStore, type WebhookEvent, WebhookInvalidError, type WebhookStore, billingRoutes, billingWebhookRoute, definePlans, featureLimit, isMeter, meter, planPrice, subscriptionsPlugin };
|
|
773
|
+
export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FakePaymentGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, type HandleEventResult, MemoryPaymentStore, MemoryRecurringStore, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, type NewPayment, NotSubscribedError, type PaymentApplyResult, type PaymentEvent, type PaymentGateway, type PaymentInstruction, PaymentLedger, type PaymentLedgerOptions, type PaymentRecord, type PaymentRecordStatus, type PaymentRequest, type PaymentStore, type PlanDefinition, type Plans, type PortalInput, QuotaExceededError, type RecurringBillingOptions, type RecurringInterval, RecurringReferenceBilling, type RecurringStatus, type RecurringStore, type RecurringSubscription, type RedisLike, RedisUsageStore, type RedisUsageStoreOptions, type RedisWebhookClient, RedisWebhookStore, type RedisWebhookStoreOptions, SUBSCRIPTIONS, StripeBillingGateway, type StripeGatewayOptions, StripeRequestError, type SubscribeInput, type SubscriptionRecord, type SubscriptionStatus, type SubscriptionStore, Subscriptions, type SubscriptionsOptions, type SubscriptionsPluginOptions, type SwapInput, UnknownPlanError, type UsageConsumeResult, type UsageStore, type WebhookEvent, WebhookInvalidError, type WebhookStore, addInterval, billingRoutes, billingWebhookRoute, definePlans, featureLimit, isMeter, meter, planPrice, subscriptionsPlugin };
|
package/dist/index.js
CHANGED
|
@@ -193,6 +193,232 @@ var FakeBillingGateway = class {
|
|
|
193
193
|
}
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
+
// src/payment.ts
|
|
197
|
+
var FakePaymentGateway = class {
|
|
198
|
+
name = "fake";
|
|
199
|
+
payments = [];
|
|
200
|
+
counter = 0;
|
|
201
|
+
async createPayment(request) {
|
|
202
|
+
this.payments.push(request);
|
|
203
|
+
const id = `fake_pay_${++this.counter}`;
|
|
204
|
+
return { id, status: "pending", reference: { entity: "00000", reference: id, amount: request.amount } };
|
|
205
|
+
}
|
|
206
|
+
verifyWebhook(rawBody, signature) {
|
|
207
|
+
if (signature !== "valid") throw new WebhookInvalidError();
|
|
208
|
+
return JSON.parse(rawBody);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
var MemoryPaymentStore = class {
|
|
212
|
+
records = /* @__PURE__ */ new Map();
|
|
213
|
+
async create(payment) {
|
|
214
|
+
if (this.records.has(payment.id)) return;
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
this.records.set(payment.id, {
|
|
217
|
+
id: payment.id,
|
|
218
|
+
status: "pending",
|
|
219
|
+
amount: payment.amount,
|
|
220
|
+
...payment.billableId ? { billableId: payment.billableId } : {},
|
|
221
|
+
...payment.reference ? { reference: payment.reference } : {},
|
|
222
|
+
createdAt: now,
|
|
223
|
+
updatedAt: now,
|
|
224
|
+
...payment.raw !== void 0 ? { raw: payment.raw } : {}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async setStatus(id, status, patch = {}) {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const rec = this.records.get(id) ?? {
|
|
230
|
+
id,
|
|
231
|
+
status,
|
|
232
|
+
amount: patch.amount ?? 0,
|
|
233
|
+
createdAt: now,
|
|
234
|
+
updatedAt: now
|
|
235
|
+
};
|
|
236
|
+
rec.status = status;
|
|
237
|
+
rec.updatedAt = now;
|
|
238
|
+
if (patch.amount != null) rec.amount = patch.amount;
|
|
239
|
+
if (patch.raw !== void 0) rec.raw = patch.raw;
|
|
240
|
+
this.records.set(id, rec);
|
|
241
|
+
}
|
|
242
|
+
async get(id) {
|
|
243
|
+
return this.records.get(id);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
var PaymentLedger = class {
|
|
247
|
+
store;
|
|
248
|
+
webhooks;
|
|
249
|
+
constructor(options = {}) {
|
|
250
|
+
this.store = options.store ?? new MemoryPaymentStore();
|
|
251
|
+
this.webhooks = options.webhooks ?? new MemoryWebhookStore();
|
|
252
|
+
}
|
|
253
|
+
/** Record a just-created payment as pending. Call after `createPayment`. */
|
|
254
|
+
async created(instruction, request) {
|
|
255
|
+
await this.store.create({
|
|
256
|
+
id: instruction.id,
|
|
257
|
+
amount: request.amount,
|
|
258
|
+
...request.billableId ? { billableId: request.billableId } : {},
|
|
259
|
+
...request.reference ? { reference: request.reference } : {},
|
|
260
|
+
...instruction.raw !== void 0 ? { raw: instruction.raw } : {}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
|
|
265
|
+
* fresh event, flips the ledger record to paid/failed. If persisting fails the
|
|
266
|
+
* dedupe claim is released so the gateway's retry can reprocess.
|
|
267
|
+
*
|
|
268
|
+
* `onFresh` runs **inside the idempotency claim**, after the ledger is
|
|
269
|
+
* updated — use it for domain side effects (activate a subscription, mark a
|
|
270
|
+
* booking paid) that must apply exactly once with the payment. If it throws,
|
|
271
|
+
* the claim is released so the whole thing reprocesses on the gateway's retry.
|
|
272
|
+
*/
|
|
273
|
+
async apply(event, onFresh) {
|
|
274
|
+
const fresh = await this.webhooks.markProcessed(event.id);
|
|
275
|
+
if (!fresh) return { fresh: false };
|
|
276
|
+
try {
|
|
277
|
+
const status = event.type === "payment.succeeded" ? "paid" : "failed";
|
|
278
|
+
await this.store.setStatus(event.paymentId, status, {
|
|
279
|
+
amount: event.amount,
|
|
280
|
+
...event.raw !== void 0 ? { raw: event.raw } : {}
|
|
281
|
+
});
|
|
282
|
+
const record = await this.store.get(event.paymentId);
|
|
283
|
+
if (onFresh) await onFresh(record, event);
|
|
284
|
+
return { fresh: true, ...record ? { record } : {} };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
await this.webhooks.release(event.id);
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
get(id) {
|
|
291
|
+
return this.store.get(id);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/recurring.ts
|
|
296
|
+
var MemoryRecurringStore = class {
|
|
297
|
+
subs = /* @__PURE__ */ new Map();
|
|
298
|
+
async save(sub) {
|
|
299
|
+
this.subs.set(sub.billableId, sub);
|
|
300
|
+
}
|
|
301
|
+
async get(billableId) {
|
|
302
|
+
return this.subs.get(billableId);
|
|
303
|
+
}
|
|
304
|
+
async list() {
|
|
305
|
+
return [...this.subs.values()];
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
function addInterval(ms, interval) {
|
|
309
|
+
const d = new Date(ms);
|
|
310
|
+
if (interval === "yearly") d.setFullYear(d.getFullYear() + 1);
|
|
311
|
+
else d.setMonth(d.getMonth() + 1);
|
|
312
|
+
return d.getTime();
|
|
313
|
+
}
|
|
314
|
+
var RecurringReferenceBilling = class {
|
|
315
|
+
gateway;
|
|
316
|
+
ledger;
|
|
317
|
+
store;
|
|
318
|
+
leadMs;
|
|
319
|
+
currency;
|
|
320
|
+
constructor(options) {
|
|
321
|
+
this.gateway = options.gateway;
|
|
322
|
+
this.ledger = options.ledger ?? new PaymentLedger();
|
|
323
|
+
this.store = options.store ?? new MemoryRecurringStore();
|
|
324
|
+
this.leadMs = (options.leadDays ?? 5) * 864e5;
|
|
325
|
+
this.currency = options.currency;
|
|
326
|
+
}
|
|
327
|
+
get(billableId) {
|
|
328
|
+
return this.store.get(billableId);
|
|
329
|
+
}
|
|
330
|
+
/** Start a subscription and issue the first period's reference. */
|
|
331
|
+
async subscribe(input) {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
const subscription = {
|
|
334
|
+
billableId: input.billableId,
|
|
335
|
+
plan: input.plan,
|
|
336
|
+
amount: input.amount,
|
|
337
|
+
interval: input.interval,
|
|
338
|
+
status: "pending",
|
|
339
|
+
...input.customer ? { customer: input.customer } : {},
|
|
340
|
+
createdAt: now,
|
|
341
|
+
updatedAt: now
|
|
342
|
+
};
|
|
343
|
+
const instruction = await this.issueFor(subscription, input.metadata);
|
|
344
|
+
return { subscription, instruction };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Issue the next period's reference for a subscription (call for the ones
|
|
348
|
+
* `due()` returns, or to re-collect after a lapse). Replaces any outstanding
|
|
349
|
+
* reference as the one now awaited.
|
|
350
|
+
*/
|
|
351
|
+
async issueNext(billableId, metadata) {
|
|
352
|
+
const sub = await this.store.get(billableId);
|
|
353
|
+
if (!sub) throw new Error(`no recurring subscription for ${billableId}`);
|
|
354
|
+
if (sub.status === "canceled") throw new Error(`subscription ${billableId} is canceled`);
|
|
355
|
+
return this.issueFor(sub, metadata);
|
|
356
|
+
}
|
|
357
|
+
async issueFor(sub, metadata) {
|
|
358
|
+
const reference = `${sub.billableId}:${sub.plan}:${Date.now()}`;
|
|
359
|
+
const request = {
|
|
360
|
+
billableId: sub.billableId,
|
|
361
|
+
amount: sub.amount,
|
|
362
|
+
reference,
|
|
363
|
+
...this.currency ? { currency: this.currency } : {},
|
|
364
|
+
...sub.customer ? { customer: sub.customer } : {},
|
|
365
|
+
metadata: { recurring: "1", plan: sub.plan, interval: sub.interval, ...metadata }
|
|
366
|
+
};
|
|
367
|
+
const instruction = await this.gateway.createPayment(request);
|
|
368
|
+
await this.ledger.created(instruction, request);
|
|
369
|
+
sub.pendingPaymentId = instruction.id;
|
|
370
|
+
sub.updatedAt = Date.now();
|
|
371
|
+
await this.store.save(sub);
|
|
372
|
+
return instruction;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Feed a verified `PaymentEvent`. Applies it once (via the ledger's
|
|
376
|
+
* idempotency): on success for the outstanding reference, extends
|
|
377
|
+
* `paidThrough` by one interval and marks the subscription active; on failure,
|
|
378
|
+
* marks it `past_due`.
|
|
379
|
+
*/
|
|
380
|
+
async handleEvent(event) {
|
|
381
|
+
let subscription;
|
|
382
|
+
const { fresh } = await this.ledger.apply(event, async (record) => {
|
|
383
|
+
const billableId = event.billableId ?? record?.billableId;
|
|
384
|
+
if (!billableId) return;
|
|
385
|
+
const sub = await this.store.get(billableId);
|
|
386
|
+
if (!sub || event.paymentId !== sub.pendingPaymentId) return;
|
|
387
|
+
if (event.type === "payment.succeeded") {
|
|
388
|
+
const base = sub.paidThrough && sub.paidThrough > Date.now() ? sub.paidThrough : Date.now();
|
|
389
|
+
sub.paidThrough = addInterval(base, sub.interval);
|
|
390
|
+
sub.status = "active";
|
|
391
|
+
delete sub.pendingPaymentId;
|
|
392
|
+
} else {
|
|
393
|
+
sub.status = "past_due";
|
|
394
|
+
}
|
|
395
|
+
sub.updatedAt = Date.now();
|
|
396
|
+
await this.store.save(sub);
|
|
397
|
+
subscription = sub;
|
|
398
|
+
});
|
|
399
|
+
return { applied: fresh, ...subscription ? { subscription } : {} };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Subscriptions that need their next reference issued now: not canceled,
|
|
403
|
+
* nothing outstanding, and within `leadDays` of the paid-through date (or
|
|
404
|
+
* never paid). Run this on a schedule and call `issueNext()` for each.
|
|
405
|
+
*/
|
|
406
|
+
async due(now = Date.now()) {
|
|
407
|
+
const subs = await this.store.list();
|
|
408
|
+
return subs.filter(
|
|
409
|
+
(s) => s.status !== "canceled" && !s.pendingPaymentId && (s.paidThrough == null || s.paidThrough - now <= this.leadMs)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
async cancel(billableId) {
|
|
413
|
+
const sub = await this.store.get(billableId);
|
|
414
|
+
if (!sub) return;
|
|
415
|
+
sub.status = "canceled";
|
|
416
|
+
delete sub.pendingPaymentId;
|
|
417
|
+
sub.updatedAt = Date.now();
|
|
418
|
+
await this.store.save(sub);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
196
422
|
// src/drivers/stripe.ts
|
|
197
423
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
198
424
|
import { BasaltError as BasaltError3 } from "@basaltkit/core";
|
|
@@ -692,13 +918,18 @@ function billingWebhookRoute(gateway) {
|
|
|
692
918
|
}
|
|
693
919
|
export {
|
|
694
920
|
FakeBillingGateway,
|
|
921
|
+
FakePaymentGateway,
|
|
695
922
|
FeatureUnavailableError,
|
|
696
923
|
GatewayUnsupportedError,
|
|
924
|
+
MemoryPaymentStore,
|
|
925
|
+
MemoryRecurringStore,
|
|
697
926
|
MemorySubscriptionStore,
|
|
698
927
|
MemoryUsageStore,
|
|
699
928
|
MemoryWebhookStore,
|
|
700
929
|
NotSubscribedError,
|
|
930
|
+
PaymentLedger,
|
|
701
931
|
QuotaExceededError,
|
|
932
|
+
RecurringReferenceBilling,
|
|
702
933
|
RedisUsageStore,
|
|
703
934
|
RedisWebhookStore,
|
|
704
935
|
SUBSCRIPTIONS,
|
|
@@ -707,6 +938,7 @@ export {
|
|
|
707
938
|
Subscriptions,
|
|
708
939
|
UnknownPlanError,
|
|
709
940
|
WebhookInvalidError,
|
|
941
|
+
addInterval,
|
|
710
942
|
billingRoutes,
|
|
711
943
|
billingWebhookRoute,
|
|
712
944
|
definePlans,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/subscriptions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Billing for Basalt, Cashier/Soulbscription-style: declarative plans, subscriptions with trials, feature flags, usage limits, gateway drivers and idempotent webhooks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|