Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Testing provider

Try revenue-sdk with no account, no API keys, no fetch stubs and no test cards — a seedable in-memory provider from revenue-sdk/testing.

revenue-sdk/testing ships createInMemoryProvider, a seedable in-memory RevenueProvider you hand to createClient like any other: no account, no API keys, no fetch stubs, no sandbox, no test cards.

Every client.* example in these docs runs unchanged against it — it implements the same RevenueProvider interface, and it reports every capability enabled by default.

Basic usage

import { createClient } from 'revenue-sdk';
import { createInMemoryProvider } from 'revenue-sdk/testing';

const provider = createInMemoryProvider({
  products: [{ id: 'pro', name: 'Pro', prices: [{ amount: 2900, interval: 'month' }] }],
  customers: [{ id: 'cus-1', email: '[email protected]' }],
  subscriptions: [{ id: 'sub-1', customerId: 'cus-1', productId: 'pro', status: 'active' }],
});

const client = createClient({ provider });

const subscription = await client.subscriptions.get({ id: 'sub-1' });
subscription.status; // 'active'

The seed is optional — createInMemoryProvider() starts empty, and anything looked up by an ID that isn’t seeded throws RevenueError with code not_found.

Simulating a provider and its capabilities

A second options argument simulates a specific provider’s identity and its capability gaps, so code that branches on client.capabilities is testable:

const provider = createInMemoryProvider(
  {},
  {
    name: 'paddle',
    capabilities: {
      hostedCheckout: false,
      checkoutSuccessUrl: false,
      checkoutExpiresAt: false,
      revoke: false,
    },
  },
);
const client = createClient({ provider });

client.providerName; // 'paddle'
await client.subscriptions.revoke({ id: 'sub-1' }); // throws RevenueError { code: 'unsupported' }
Option Type Default Description
name ProviderName 'testing' Simulate a specific provider’s identity. Also tags the pagination cursors.
capabilities Partial<RevenueCapabilities> Merged over the full-featured defaults — override only what your test needs.

Check the capability matrix for the values each real provider reports.

Asserting against state

The provider exposes its data on provider.state, so you can assert on the effects of your code:

import { expect, test } from 'vitest';
import { createClient } from 'revenue-sdk';
import { createInMemoryProvider } from 'revenue-sdk/testing';

test('cancelling schedules the end of the period', async () => {
  const provider = createInMemoryProvider({
    customers: [{ id: 'cus-1', email: '[email protected]' }],
    subscriptions: [
      { id: 'sub-1', customerId: 'cus-1', currentPeriodEnd: new Date('2026-09-01') },
    ],
  });
  const client = createClient({ provider });

  const subscription = await client.subscriptions.cancel({ id: 'sub-1' });

  expect(subscription.cancelAtPeriodEnd).toBe(true);
  expect(subscription.endsAt).toEqual(new Date('2026-09-01'));
  expect(provider.state.subscriptions[0]!.cancelAtPeriodEnd).toBe(true);
});

test('creating a checkout records it', async () => {
  const provider = createInMemoryProvider({ products: [{ id: 'pro' }] });
  const client = createClient({ provider });

  const checkout = await client.checkouts.create({
    items: [{ product: 'pro' }],
    customerEmail: '[email protected]',
    metadata: { userId: 'user_123' },
  });

  expect(checkout.status).toBe('open');
  expect(provider.state.checkouts[0]!.metadata).toEqual({ userId: 'user_123' });
});

provider.state is { products: Product[], customers: Customer[], subscriptions: Subscription[], orders: Order[], licenseKeys: LicenseKey[], checkouts: Checkout[], usageEvents: InMemoryUsageEvent[] }. Mutating operations write straight into it, so the seeded objects and the returned ones are the same instances.

Reported usage events

usage.report appends to state.usageEvents, so you can assert on what your code would have billed:

const provider = createInMemoryProvider();

await createClient({ provider }).usage.report({
  customerId: 'cus-1',
  eventName: 'api_request',
  value: 25,
  metadata: { region: 'eu' },
});

expect(provider.state.usageEvents[0]).toEqual({
  customerId: 'cus-1',
  eventName: 'api_request',
  payload: { value: 25, region: 'eu' },
  idempotencyKey: undefined,
  timestamp: undefined,
});
PropType
customerId?string

The reported customer.

Typestring
eventName?string

The reported meter event name.

Typestring
payload?Record<string, string | number | boolean>

metadata with value already merged in — exactly what a real provider receives. {} when neither was given.

TypeRecord<string, string | number | boolean>
idempotencyKey?string

The key as passed, undefined when omitted. The in-memory provider does not deduplicate.

Typestring
timestamp?Date

The timestamp as passed, undefined when omitted.

TypeDate

The type is exported as InMemoryUsageEvent. Override capabilities: { usageReporting: false } to exercise the Lemon Squeezy and Paddle fallback path — see Usage-based billing.

Seeded orders

orders seeds surface on state.orders, filtered by customerId when you pass one, and paginated like everything else. orders.getInvoiceUrl returns a deterministic fake — https://invoices.example.com/<order id> — for any seeded order, and throws not_found for an unknown one. Override capabilities: { listOrdersByCustomer: false } to exercise the Lemon Squeezy path, where orders.list({ customerId }) throws unsupported.

const provider = createInMemoryProvider({
  orders: [
    { id: 'ord-1', customerId: 'cus-1', amount: 2900, currency: 'usd', status: 'paid' },
    { id: 'ord-2', customerId: 'cus-1', amount: 2900, status: 'partially_refunded', refundStatus: 'partial' },
  ],
});
const { items } = await createClient({ provider }).orders.list({ customerId: 'cus-1' });

expect(items.map((order) => order.status)).toEqual(['paid', 'partially_refunded']);

Seeded license keys

licenseKeys seeds surface on state.licenseKeys, and licenseKeys.update writes into them. Override capabilities: { licenseKeys: false } to exercise the Stripe and Paddle fallback path.

const provider = createInMemoryProvider({
  licenseKeys: [{ id: 'lk-1', key: 'AAAA-BBBB', activationLimit: 3 }],
});

await createClient({ provider }).licenseKeys.update({ id: 'lk-1', disabled: true });

expect(provider.state.licenseKeys[0]!.status).toBe('disabled');

Testing webhook handlers

signWebhook signs a raw body the way a real provider does and returns the exact headers that provider’s verifyWebhook accepts, so your handler test runs the real verification path instead of skipping it:

import { expect, test } from 'vitest';
import { signWebhook } from 'revenue-sdk/testing';
import { handleWebhook } from './webhook-handler.ts';

const SECRET = 'whsec_ovyN6cPrTv56AApvzCaJno08SSmGJmgb';

test('a paid order is recorded', async () => {
  const body = JSON.stringify({
    type: 'order.paid',
    data: { id: 'order-1', status: 'paid', total_amount: 2900, currency: 'usd' },
  });
  const headers = await signWebhook({ provider: 'polar', secret: SECRET, body });

  const response = await handleWebhook(
    new Request('https://example.com/webhooks/polar', { method: 'POST', headers, body }),
  );

  expect(response.status).toBe(200);
});

Sign the exact string your handler will read — every provider signs the raw bytes, so re-serializing the JSON in between invalidates the signature.

PropType
provider?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments'

Whose signature scheme to use. The in-memory provider sends no webhooks.

Type'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments'
secret?string

The signing secret, exactly as the provider's dashboard shows it — the same value your handler verifies with.

Typestring
body?string

The raw request body.

Typestring
timestamp?Date

Defaults to now. Back-date it by more than 300 seconds to exercise your replay rejection.

TypeDate
id?string

Message ID for the Standard Webhooks providers (Polar, Dodo Payments). Defaults to a random msg_ id.

Typestring

The returned record uses lowercase header names: webhook-id / webhook-timestamp / webhook-signature for Polar and Dodo Payments, stripe-signature for Stripe, paddle-signature for Paddle, and x-signature for Lemon Squeezy.

Pagination is exercised

The in-memory provider uses a deliberately small page size of 2 and ignores limit, so a seed with three or more items returns a cursor and your cursor-following code runs in tests rather than always fitting on one page:

const provider = createInMemoryProvider({
  products: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
});
const client = createClient({ provider });

const first = await client.products.list();
first.items.length; // 2
first.cursor; // defined — there's a third product

let count = 0;
for await (const _ of client.products.listAll()) count++;
count; // 3

Cursors are tagged with the simulated provider name, so they behave exactly like real ones — including rejecting a cursor from a different provider.

Seed reference

products, customers, subscriptions, orders, and licenseKeys are arrays; every field is optional and gets a sensible default. Missing IDs are generated (product-1, price-2, customer-3, …).

PropType
products?InMemoryProductSeed[]

{ id?, name?, description?, prices? }. name defaults to the id.

TypeInMemoryProductSeed[]
customers?InMemoryCustomerSeed[]

{ id?, email?, name?, metadata? }. email defaults to <id>@example.com.

TypeInMemoryCustomerSeed[]
subscriptions?InMemorySubscriptionSeed[]

{ id?, status?, cancelAtPeriodEnd?, pauseAtPeriodEnd?, customerId?, productId?, quantity?, currency?, amount?, interval?, currentPeriodEnd?, trialEndsAt?, resumesAt?, endsAt?, metadata? }.

TypeInMemorySubscriptionSeed[]
orders?InMemoryOrderSeed[]

{ id?, status?, amount?, currency?, customerId?, customerEmail?, subscriptionId?, createdAt?, refundStatus?, metadata? }. status defaults to paid; nothing else is defaulted, so an unseeded field stays undefined.

TypeInMemoryOrderSeed[]
licenseKeys?InMemoryLicenseKeySeed[]

{ id?, key?, status?, activationLimit?, activationCount?, expiresAt?, customerId?, productId? }. key defaults to the id, status to active.

TypeInMemoryLicenseKeySeed[]

Price seeds default to a fixed, recurring, monthly price of 1000 in usd, and checkoutRef defaults to the parent product’s ID:

PropType
id?string

Generated when omitted.

Typestring
checkoutRef?string

Defaults to the parent product's id.

Typestring
type?'one_time' | 'recurring'

Price type.

Type'one_time' | 'recurring'
Defaultrecurring
model?PriceModel

Pricing model.

TypePriceModel
Defaultfixed
amount?number | null

Minor units. Pass null explicitly for non-fixed models.

Typenumber | null
Default1000
currency?string

Lowercase ISO 4217 code.

Typestring
Defaultusd
interval?BillingInterval

Omitted when type is 'one_time'.

TypeBillingInterval
Defaultmonth
intervalCount?number

Intervals per billing cycle.

Typenumber
trialDays?number

Trial length in days.

Typenumber

Behavior of the mutating operations

Call Effect on state
checkouts.create Appends a Checkout with a generated ID, status: 'open', a fake URL, and expiresAt carried through from the params.
subscriptions.cancel Sets cancelAtPeriodEnd: true and endsAt = currentPeriodEnd. Status unchanged.
subscriptions.uncancel Clears cancelAtPeriodEnd and endsAt.
subscriptions.changePlan Sets productId to params.product, and quantity when given.
subscriptions.endTrial Sets status: 'active' and clears trialEndsAt.
subscriptions.pause With behavior: 'period_end' sets pauseAtPeriodEnd: true and leaves the status alone; otherwise sets status: 'paused'. Stores resumesAt when given.
subscriptions.resume Sets status: 'active', clears pauseAtPeriodEnd and resumesAt.
subscriptions.revoke Sets status: 'canceled', clears cancelAtPeriodEnd, and sets endedAt.
customerPortal.createSession Returns a fake portal URL, or throws not_found for an unknown customer.
usage.report Appends an InMemoryUsageEvent; value is merged into payload.
licenseKeys.update Sets status to disabled/active from disabled, and writes activationLimit and expiresAt when given (null clears them).

Last updated on August 8, 2026

Was this page helpful?