diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..6313b56c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..3de7e7b2
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,26 @@
+name: Publish
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: https://registry.npmjs.org
+
+ - run: npm ci
+ - run: npm run build --if-present
+ - run: npm publish
diff --git a/apps/web/content/docs/database.mdx b/apps/web/content/docs/database.mdx
index 639512bb..7013b64d 100644
--- a/apps/web/content/docs/database.mdx
+++ b/apps/web/content/docs/database.mdx
@@ -47,6 +47,8 @@ PayKit creates tables prefixed with `paykit_`. The key ones are:
PayKit owns these tables. Don't write to them directly. Use the PayKit API.
+`paykit_subscription` rows can share one provider subscription: [combined checkout and add-ons](/docs/subscriptions#combined-checkout-add-ons) put a base plan and its add-ons on a single provider subscription, tracked as one row per plan. `paykit_product` records whether a plan is billed as a flat recurring price or [metered via Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing).
+
## Migrations
`paykitjs push` applies any pending migrations. Run it on initial setup and whenever you update your plan configuration.
diff --git a/apps/web/content/docs/entitlements.mdx b/apps/web/content/docs/entitlements.mdx
index 6b088455..fb3ad799 100644
--- a/apps/web/content/docs/entitlements.mdx
+++ b/apps/web/content/docs/entitlements.mdx
@@ -99,3 +99,7 @@ export async function POST(request: Request) {
```
This pattern ensures you don't charge usage for failed requests, and you don't serve responses to customers who've hit their limit.
+
+
+ `report()` decrements a local quota — it never involves your payment provider. If you need Stripe to actually calculate a bill from usage, see [Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing).
+
diff --git a/apps/web/content/docs/metered-usage.mdx b/apps/web/content/docs/metered-usage.mdx
index e4750405..2c4a8f90 100644
--- a/apps/web/content/docs/metered-usage.mdx
+++ b/apps/web/content/docs/metered-usage.mdx
@@ -146,3 +146,68 @@ export async function POST(request: Request) {
If you just need on/off access without tracking usage, use `boolean` type instead. `check` still returns `allowed`, but there's no balance to track or reset.
+
+## Stripe usage-based billing
+
+Everything above is a **local quota**: PayKit tracks a balance in your database and gates access with `check()`/`report()`. Stripe is never involved, and there's nothing to bill — the plan's price, if any, is a flat recurring fee.
+
+Stripe usage-based billing is a different thing: Stripe itself calculates a charge from usage you report, with no cap. Define it with a `meteredBy` price instead of a flat `amount`:
+
+```ts title="products.ts"
+const apiCalls = feature({ id: "api_calls", type: "metered" });
+
+export const usagePlan = plan({
+ id: "usage",
+ name: "API Usage",
+ group: "usage",
+ price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" },
+});
+```
+
+When you sync this plan, PayKit creates a Stripe [Billing Meter](https://docs.stripe.com/billing/subscriptions/usage-based) and a metered Price for it automatically. Report usage with `reportUsage()` instead of `report()`:
+
+```ts
+await paykit.reportUsage({
+ customerId: userId,
+ featureId: "api_calls",
+ quantity: 1,
+});
+```
+
+Stripe aggregates reported usage and bills it on the customer's next invoice at `unitAmount` per unit. There's no local balance to check beforehand — `reportUsage()` fails only if the customer has no active subscription for a plan metered by that feature.
+
+
+ Unlike `report()`, `reportUsage()` doesn't swallow failures — its entire purpose is the billing side effect, so a failed call throws. If you need at-least-once delivery, pass a stable `eventId`; PayKit forwards it as Stripe's `identifier`, which Stripe deduplicates on.
+
+
+### Combining a quota with billed overage
+
+A single price can't be both a flat fee and metered overage in Stripe. To offer "some usage included, then billed per unit beyond that," compose two plans and combine them into one checkout with `addOnPlanIds`:
+
+```ts title="products.ts"
+export const pro = plan({
+ id: "pro",
+ name: "Pro",
+ group: "base",
+ price: { amount: 19, interval: "month" },
+ includes: [messages({ limit: 2_000, reset: "month" })], // included quota
+});
+
+export const overage = plan({
+ id: "overage",
+ name: "Extra Usage",
+ group: "usage",
+ price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" }, // billed beyond it
+});
+```
+
+```ts
+await paykit.subscribe({
+ customerId: "user_123",
+ planId: "pro",
+ addOnPlanIds: ["overage"],
+ successUrl: "https://myapp.com/billing/success",
+});
+```
+
+Gate the included quota with `check()`/`report()` as usual. Once a customer exceeds it, call `reportUsage()` for the extra units instead — Stripe bills those separately at `unitAmount`.
diff --git a/apps/web/content/docs/plans-and-features.mdx b/apps/web/content/docs/plans-and-features.mdx
index 94f30b53..0e5b200f 100644
--- a/apps/web/content/docs/plans-and-features.mdx
+++ b/apps/web/content/docs/plans-and-features.mdx
@@ -95,6 +95,38 @@ Groups let you model common billing patterns:
Every plan that has `default: true` must belong to a group. Without a group, PayKit doesn't know which set of plans a customer is being placed into.
+### Combining groups in one checkout
+
+Groups are mutually exclusive within themselves, but a customer can hold one active plan per group at the same time — that's how a `base` plan and an `addons` plan coexist. Pass `addOnPlanIds` to `subscribe()` to combine a plan from each group into a single checkout, instead of running a separate checkout per group:
+
+```ts
+export const pro = plan({
+ id: "pro",
+ name: "Pro",
+ group: "base",
+ price: { amount: 19, interval: "month" },
+ includes: [messages({ limit: 2_000, reset: "month" })],
+});
+
+export const extraMessages = plan({
+ id: "extra_messages",
+ name: "Extra Messages",
+ group: "addons",
+ price: { amount: 5, interval: "month" },
+ includes: [messages({ limit: 500, reset: "month" })],
+});
+
+// One checkout, one payment, both plans active afterward.
+await paykit.subscribe({
+ customerId: "user_123",
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ successUrl: "https://myapp.com/billing/success",
+});
+```
+
+The customer's `messages` balance pools across both plans — 2,000 from `pro` plus 500 from `extra_messages`, 2,500 total — since [entitlements](/docs/entitlements) aggregate every active subscription for a feature, not just one. See [Combined checkout](/docs/subscriptions#combined-checkout-add-ons) for the full behavior, constraints, and how to add or remove a single add-on later.
+
## Default plans
The default plan is the fallback for a group. It's typically your free tier.
@@ -125,6 +157,25 @@ price: { amount: 19, interval: "month" }
- `interval` can be `month` or `year`
- `amount` is in dollars, max $999,999.99
+### Metered Stripe pricing
+
+For pay-as-you-go pricing where Stripe calculates the bill from reported usage, use `meteredBy` and `unitAmount` instead of `amount`:
+
+```ts
+const apiCalls = feature({ id: "api_calls", type: "metered" });
+
+export const usagePlan = plan({
+ id: "usage",
+ name: "API Usage",
+ group: "usage",
+ price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" },
+});
+```
+
+`unitAmount` is the price per unit, charged for whatever quantity you report via `paykit.reportUsage()`. See [Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing) for how reporting works and how this differs from the local metered quotas above.
+
+This is a separate concept from a `metered`-type feature's `limit`/`reset` — that's a local quota PayKit tracks and gates on for you, with no Stripe involvement. `meteredBy` prices are for genuine usage-based billing, where Stripe itself computes the charge. You can combine both: a licensed base plan with a local quota, plus a `meteredBy` add-on for usage beyond it, joined into one checkout via `addOnPlanIds`.
+
## Passing products to PayKit
Pass your products array to `createPayKit`. You can import them directly or re-export them from a module object.
diff --git a/apps/web/content/docs/subscriptions.mdx b/apps/web/content/docs/subscriptions.mdx
index 11ca6168..23f7fd89 100644
--- a/apps/web/content/docs/subscriptions.mdx
+++ b/apps/web/content/docs/subscriptions.mdx
@@ -95,6 +95,71 @@ await paykit.subscribe({ customerId: "user_123", planId: "pro" });
// Scheduled target changes from "free" to "pro".
```
+## Combined checkout (add-ons)
+
+Pass `addOnPlanIds` alongside `planId` to combine a base plan with one or more add-ons into a single checkout. The customer sees every line item on the same provider-hosted page and confirms one payment for all of them.
+
+
+
+ ```ts
+ const result = await paykit.subscribe({
+ customerId: "user_123",
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ successUrl: "https://myapp.com/billing/success",
+ cancelUrl: "https://myapp.com/billing",
+ });
+
+ if (result.paymentUrl) {
+ // Redirect user to provider checkout — one page, both line items
+ }
+ ```
+
+
+ ```tsx
+ const { paymentUrl } = await paykitClient.subscribe({
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ successUrl: "/billing/success",
+ cancelUrl: "/billing",
+ });
+
+ if (paymentUrl) {
+ window.location.href = paymentUrl;
+ }
+ ```
+
+
+
+Behind the scenes, this creates one provider subscription with one line item per plan. Every plan in the list — the base plan and every add-on — is tracked as its own subscription record, so [entitlements](/docs/entitlements) and renewals stay correct for each of them independently.
+
+A few constraints apply to combined checkout:
+
+- Every plan must be paid. Free plans can't be combined this way.
+- Each plan must belong to a different [group](/docs/plans-and-features#plan-groups). You can't combine two plans from the same group in one call.
+- None of the plans can already have an active subscription for that customer. To add or remove a single plan on an already-active subscription, use `addAddOn`/`removeAddOn` below instead.
+
+
+ Combined checkout is for new subscriptions. Upgrading or downgrading the base plan afterward works exactly like a normal `subscribe()` call — see [Upgrades](#upgrades) and [Downgrades](#downgrades) above — and leaves any add-ons on the subscription untouched.
+
+
+## Managing add-ons
+
+Once a customer has an active subscription, `addAddOn` and `removeAddOn` attach or detach a single add-on plan without touching the rest of the subscription.
+
+```ts
+// Attach extra_messages to the customer's existing subscription.
+// Charges their saved card immediately, the same way an upgrade does.
+await paykit.addAddOn({ customerId: "user_123", planId: "extra_messages" });
+
+// Detach it again. Ends immediately — no need to wait for a webhook.
+await paykit.removeAddOn({ customerId: "user_123", planId: "extra_messages" });
+```
+
+`addAddOn` requires the customer to already have an active provider-backed subscription to attach to. If they have more than one — for example, they subscribed to two groups separately instead of combining them — pass `targetSubscriptionId` to say which one to attach to.
+
+`removeAddOn` fails with a clear error if the plan is the subscription's only remaining item — Stripe doesn't allow removing the last item from a subscription. Cancel the whole subscription with `subscribe()` to the group's default plan instead.
+
## Behavior summary
| Scenario | Behavior |
@@ -114,6 +179,7 @@ await paykit.subscribe({ customerId: "user_123", planId: "pro" });
|---|---|---|
| `planId` | Yes | The target plan ID. Must be a valid plan from your config. |
| `customerId` | Server only | The customer to subscribe. Not needed on the client (resolved from `identify`). |
+| `addOnPlanIds` | No | Additional plan IDs to combine with `planId` into one checkout. See [Combined checkout](#combined-checkout-add-ons). |
| `successUrl` | Recommended | Where to redirect after successful checkout. |
| `cancelUrl` | Recommended | Where to redirect if the customer cancels checkout. |
| `forceCheckout` | No | Forces a checkout flow even if the customer already has a payment method. |
diff --git a/apps/web/content/docs/webhook-events.mdx b/apps/web/content/docs/webhook-events.mdx
index f6ece8ae..e937438e 100644
--- a/apps/web/content/docs/webhook-events.mdx
+++ b/apps/web/content/docs/webhook-events.mdx
@@ -43,6 +43,10 @@ Payload shape:
| `customerId` | `string` | Your internal customer identifier |
| `subscriptions` | `Subscription[]` | The customer's updated subscriptions |
+
+ If the customer used [combined checkout or add-ons](/docs/subscriptions#combined-checkout-add-ons), `subscriptions` contains one entry per plan — the base plan and every add-on — even though they're billed together on a single provider subscription.
+
+
## Wildcard handler
`"*"` catches all PayKit events. Useful for logging or debugging.
diff --git a/e2e/core/addon/add-addon.test.ts b/e2e/core/addon/add-addon.test.ts
new file mode 100644
index 00000000..c95703b0
--- /dev/null
+++ b/e2e/core/addon/add-addon.test.ts
@@ -0,0 +1,86 @@
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("add-addon: attaching an add-on to an already-active subscription", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_add_addon",
+ email: "add-addon@test.com",
+ name: "Add Addon Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("adding extra_messages attaches a second item to the same Stripe subscription", async () => {
+ try {
+ const result = await t.paykit.addAddOn({ customerId, planId: "extra_messages" });
+ expect(result.paymentUrl).toBeNull();
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "active" },
+ });
+
+ const rows = await t.database
+ .select({
+ stripeSubscriptionId: subscription.stripeSubscriptionId,
+ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId,
+ })
+ .from(subscription)
+ .where(eq(subscription.customerId, customerId));
+ const activeRows = rows.filter((row) => row.stripeSubscriptionId != null);
+
+ const distinctSubscriptionIds = new Set(activeRows.map((row) => row.stripeSubscriptionId));
+ expect(distinctSubscriptionIds.size).toBe(1);
+
+ const itemIds = activeRows
+ .map((row) => row.stripeSubscriptionItemId)
+ .filter((id): id is string => id != null);
+ expect(new Set(itemIds).size).toBe(itemIds.length);
+ expect(itemIds.length).toBeGreaterThanOrEqual(2);
+
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 700,
+ remaining: 700,
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/addon/remove-addon-out-of-band.test.ts b/e2e/core/addon/remove-addon-out-of-band.test.ts
new file mode 100644
index 00000000..ee482cc8
--- /dev/null
+++ b/e2e/core/addon/remove-addon-out-of-band.test.ts
@@ -0,0 +1,120 @@
+import { and, eq } from "drizzle-orm";
+import { default as Stripe } from "stripe";
+import { afterAll, beforeAll, describe, it } from "vitest";
+
+import { product, subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ harness,
+ subscribeCustomer,
+ type TestPayKit,
+ waitForWebhook,
+} from "../../test-utils";
+import { env } from "../../test-utils/env";
+
+/**
+ * Removing a subscription item outside of `removeAddOn` (e.g. directly in the
+ * Stripe Dashboard) must still be caught: reconcileRemovedSubscriptionItems is
+ * the eventual-consistency backstop that ends the now-stale local row once the
+ * resulting webhook arrives.
+ */
+describe.skipIf(harness.id !== "stripe")(
+ "remove-addon-out-of-band: an item deleted directly in Stripe still gets reconciled",
+ () => {
+ let t: TestPayKit;
+ let customerId: string;
+ let stripeClient: Stripe;
+
+ beforeAll(async () => {
+ stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 });
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_remove_addon_oob",
+ email: "remove-addon-oob@test.com",
+ name: "Remove Addon Out Of Band Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+ await t.paykit.addAddOn({ customerId, planId: "extra_messages" });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("deleting the add-on's Stripe item directly still ends the local row", async () => {
+ try {
+ const addonRow = await t.database
+ .select({ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId })
+ .from(subscription)
+ .innerJoin(product, eq(product.internalId, subscription.productInternalId))
+ .where(
+ and(
+ eq(subscription.customerId, customerId),
+ eq(product.id, "extra_messages"),
+ eq(subscription.status, "active"),
+ ),
+ )
+ .limit(1);
+ const itemId = addonRow[0]?.stripeSubscriptionItemId;
+ if (!itemId) {
+ throw new Error("Expected extra_messages to have an active stripeSubscriptionItemId");
+ }
+
+ const beforeDelete = new Date();
+ await stripeClient.subscriptionItems.del(itemId, { proration_behavior: "none" });
+
+ await waitForWebhook({
+ after: beforeDelete,
+ database: t.database,
+ eventType: "subscription.updated",
+ timeout: 30_000,
+ });
+
+ // Poll until reconciliation ends the stale row.
+ let ended = false;
+ for (let i = 0; i < 30; i++) {
+ const row = await t.database.query.subscription.findFirst({
+ where: eq(subscription.stripeSubscriptionItemId, itemId),
+ });
+ if (row?.status === "ended") {
+ ended = true;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+ if (!ended) {
+ throw new Error(
+ "extra_messages row was never reconciled to ended after out-of-band removal",
+ );
+ }
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 500,
+ remaining: 500,
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+ },
+);
diff --git a/e2e/core/addon/remove-addon.test.ts b/e2e/core/addon/remove-addon.test.ts
new file mode 100644
index 00000000..1151780c
--- /dev/null
+++ b/e2e/core/addon/remove-addon.test.ts
@@ -0,0 +1,75 @@
+import { and, eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("remove-addon: detaching an active add-on", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_remove_addon",
+ email: "remove-addon@test.com",
+ name: "Remove Addon Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+ await t.paykit.addAddOn({ customerId, planId: "extra_messages" });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("removing extra_messages ends it immediately and leaves pro untouched", async () => {
+ try {
+ await t.paykit.removeAddOn({ customerId, planId: "extra_messages" });
+
+ // Ends synchronously, without waiting on a webhook.
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "ended" },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+
+ const activeRows = await t.database
+ .select({ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId })
+ .from(subscription)
+ .where(and(eq(subscription.customerId, customerId), eq(subscription.status, "active")));
+ expect(activeRows.length).toBe(1);
+
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 500,
+ remaining: 500,
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/addon/remove-last-item-rejected.test.ts b/e2e/core/addon/remove-last-item-rejected.test.ts
new file mode 100644
index 00000000..7cd79b89
--- /dev/null
+++ b/e2e/core/addon/remove-last-item-rejected.test.ts
@@ -0,0 +1,53 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectProduct,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("remove-last-item-rejected: can't remove a subscription's only item", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_remove_last_item",
+ email: "remove-last-item@test.com",
+ name: "Remove Last Item Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("removing the only item on the subscription throws a clear error", async () => {
+ try {
+ await expect(t.paykit.removeAddOn({ customerId, planId: "pro" })).rejects.toMatchObject({
+ code: "PROVIDER_SUBSCRIPTION_ITEM_REMOVAL_REJECTED",
+ });
+
+ // The plan is untouched by the rejected attempt.
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/checkout/combined-checkout.test.ts b/e2e/core/checkout/combined-checkout.test.ts
new file mode 100644
index 00000000..9e4364f7
--- /dev/null
+++ b/e2e/core/checkout/combined-checkout.test.ts
@@ -0,0 +1,104 @@
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomer,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ type TestPayKit,
+ waitForWebhook,
+} from "../../test-utils";
+
+describe("combined-checkout: base plan + add-on in one checkout", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomer({
+ t,
+ customer: {
+ id: "test_combined_checkout",
+ email: "combined-checkout@test.com",
+ name: "Combined Checkout Test",
+ },
+ });
+ customerId = customer.customerId;
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("subscribing to a plan with an add-on returns one checkout URL for both", async () => {
+ try {
+ const beforeCheckout = new Date();
+
+ const result = await t.paykit.subscribe({
+ addOnPlanIds: ["extra_messages"],
+ customerId,
+ planId: "pro",
+ successUrl: "https://example.com/success",
+ });
+
+ if (!result.paymentUrl) {
+ throw new Error("Expected a combined checkout URL");
+ }
+
+ await t.harness.completeCheckout(result.paymentUrl);
+
+ await waitForWebhook({
+ database: t.database,
+ eventType: "checkout.completed",
+ after: beforeCheckout,
+ timeout: 120_000,
+ });
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active", hasPeriodEnd: true },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "active", hasPeriodEnd: true },
+ });
+
+ const rows = await t.database
+ .select({
+ stripeSubscriptionId: subscription.stripeSubscriptionId,
+ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId,
+ })
+ .from(subscription)
+ .where(eq(subscription.customerId, customerId));
+ const activeRows = rows.filter((row) => row.stripeSubscriptionId != null);
+
+ const distinctSubscriptionIds = new Set(activeRows.map((row) => row.stripeSubscriptionId));
+ expect(distinctSubscriptionIds.size).toBe(1);
+
+ const itemIds = activeRows
+ .map((row) => row.stripeSubscriptionItemId)
+ .filter((id): id is string => id != null);
+ expect(new Set(itemIds).size).toBe(itemIds.length);
+ expect(itemIds.length).toBeGreaterThanOrEqual(2);
+
+ // Entitlements pool across both the base plan and the add-on.
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 700,
+ remaining: 700,
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/checkout/renewal-after-combined-checkout.test.ts b/e2e/core/checkout/renewal-after-combined-checkout.test.ts
new file mode 100644
index 00000000..668269da
--- /dev/null
+++ b/e2e/core/checkout/renewal-after-combined-checkout.test.ts
@@ -0,0 +1,142 @@
+import { and, eq, isNull, ne } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ advanceTestClock,
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ subscribeCustomer,
+ type TestPayKit,
+ waitForWebhook,
+} from "../../test-utils";
+
+/**
+ * This is the exact corruption scenario a combined multi-item Stripe subscription
+ * risks if the webhook path only ever resolves one PayKit row per Stripe
+ * subscription: the second (and any further) renewal-driven webhook would only
+ * update one of the two local rows, leaving the other frozen forever.
+ */
+describe("renewal-after-combined-checkout: both items roll forward together", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_renewal_combined",
+ email: "renewal-combined@test.com",
+ name: "Renewal Combined Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({
+ t,
+ customerId,
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("advancing the clock past renewal keeps both plan and add-on active with fresh periods", async () => {
+ try {
+ const rowsBefore = await t.database
+ .select({
+ planInternalId: subscription.productInternalId,
+ currentPeriodEndAt: subscription.currentPeriodEndAt,
+ })
+ .from(subscription)
+ .where(and(eq(subscription.customerId, customerId), ne(subscription.status, "ended")));
+ expect(rowsBefore.length).toBeGreaterThanOrEqual(2);
+
+ const firstRow = rowsBefore[0];
+ if (!firstRow?.currentPeriodEndAt) {
+ throw new Error("Expected active subscription row to have a currentPeriodEndAt");
+ }
+ const periodEnd = new Date(firstRow.currentPeriodEndAt);
+ const advanceTo = new Date(periodEnd.getTime() + 86_400_000);
+ const beforeAdvance = new Date();
+
+ await advanceTestClock({ t, customerId, frozenTime: advanceTo });
+ await waitForWebhook({
+ after: beforeAdvance,
+ database: t.database,
+ eventType: "subscription.updated",
+ timeout: 30_000,
+ });
+
+ // Poll until BOTH rows have rolled their period forward — not just one.
+ let rowsAfter: Array<{ currentPeriodEndAt: Date | null; status: string }> = [];
+ for (let i = 0; i < 60; i++) {
+ rowsAfter = await t.database
+ .select({
+ currentPeriodEndAt: subscription.currentPeriodEndAt,
+ status: subscription.status,
+ })
+ .from(subscription)
+ .where(and(eq(subscription.customerId, customerId), eq(subscription.status, "active")));
+
+ const bothRolledForward =
+ rowsAfter.length >= 2 &&
+ rowsAfter.every((row) => {
+ if (!row.currentPeriodEndAt) return false;
+ return new Date(row.currentPeriodEndAt).getTime() > periodEnd.getTime();
+ });
+ if (bothRolledForward) break;
+
+ if (i === 59) {
+ throw new Error(
+ `Not every subscription row rolled forward after renewal: ${JSON.stringify(rowsAfter)}`,
+ );
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+
+ expect(rowsAfter.length).toBeGreaterThanOrEqual(2);
+ for (const row of rowsAfter) {
+ expect(row.status).toBe("active");
+ }
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "active" },
+ });
+
+ // Entitlements still pool correctly after renewal — neither row went stale.
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 700,
+ remaining: 700,
+ });
+
+ const noEndedRows = await t.database
+ .select({ id: subscription.id })
+ .from(subscription)
+ .where(and(eq(subscription.customerId, customerId), isNull(subscription.endedAt)));
+ expect(noEndedRows.length).toBeGreaterThanOrEqual(2);
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/subscribe/downgrade-with-addon.test.ts b/e2e/core/subscribe/downgrade-with-addon.test.ts
new file mode 100644
index 00000000..25426fd4
--- /dev/null
+++ b/e2e/core/subscribe/downgrade-with-addon.test.ts
@@ -0,0 +1,140 @@
+import { and, eq, ne } from "drizzle-orm";
+import { default as Stripe } from "stripe";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { product, subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectProduct,
+ expectSingleActivePlanInGroup,
+ expectSingleScheduledPlanInGroup,
+ harness,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+import { env } from "../../test-utils/env";
+
+/**
+ * Regression target: scheduleSubscriptionChange used to replace the schedule's
+ * next phase with a single new item, silently dropping any other items (like
+ * an add-on) that were on the subscription. This asserts the add-on survives
+ * into the scheduled phase alongside the downgraded base plan.
+ */
+describe.skipIf(harness.id !== "stripe")(
+ "downgrade-with-addon: scheduled downgrade keeps the add-on on both phases",
+ () => {
+ let t: TestPayKit;
+ let customerId: string;
+ let stripeClient: Stripe;
+
+ beforeAll(async () => {
+ stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 });
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_downgrade_addon",
+ email: "downgrade-addon@test.com",
+ name: "Downgrade With Addon Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({
+ t,
+ customerId,
+ planId: "ultra",
+ addOnPlanIds: ["extra_messages"],
+ });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("downgrading ultra to pro schedules the change and keeps extra_messages on the subscription", async () => {
+ try {
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "ultra",
+ expected: { status: "active", canceled: true },
+ });
+ await expectSingleActivePlanInGroup({
+ database: t.database,
+ customerId,
+ group: "base",
+ planId: "ultra",
+ });
+ await expectSingleScheduledPlanInGroup({
+ database: t.database,
+ customerId,
+ group: "base",
+ planId: "pro",
+ });
+
+ // The add-on is untouched by the downgrade.
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "active" },
+ });
+
+ const subRows = await t.database
+ .select({ stripeSubscriptionId: subscription.stripeSubscriptionId })
+ .from(subscription)
+ .innerJoin(product, eq(product.internalId, subscription.productInternalId))
+ .where(
+ and(
+ eq(subscription.customerId, customerId),
+ eq(product.id, "extra_messages"),
+ ne(subscription.status, "ended"),
+ ),
+ )
+ .limit(1);
+ const stripeSubscriptionId = subRows[0]?.stripeSubscriptionId;
+ if (!stripeSubscriptionId) {
+ throw new Error("Expected extra_messages row to carry a stripeSubscriptionId");
+ }
+
+ const proProduct = await t.database.query.product.findFirst({
+ where: eq(product.id, "pro"),
+ });
+ const addonProduct = await t.database.query.product.findFirst({
+ where: eq(product.id, "extra_messages"),
+ });
+ if (!proProduct?.stripePriceId || !addonProduct?.stripePriceId) {
+ throw new Error("Expected pro and extra_messages to be synced with Stripe prices");
+ }
+
+ const stripeSub = await stripeClient.subscriptions.retrieve(stripeSubscriptionId, {
+ expand: ["schedule"],
+ });
+ const scheduleId =
+ typeof stripeSub.schedule === "string" ? stripeSub.schedule : stripeSub.schedule?.id;
+ if (!scheduleId) {
+ throw new Error("Expected a subscription schedule to exist after a scheduled downgrade");
+ }
+ const schedule = await stripeClient.subscriptionSchedules.retrieve(scheduleId);
+ const nextPhase = schedule.phases[1];
+ if (!nextPhase) {
+ throw new Error("Expected a second schedule phase for the downgrade");
+ }
+ const nextPhasePriceIds = nextPhase.items.map((item) =>
+ typeof item.price === "string" ? item.price : item.price.id,
+ );
+
+ expect(nextPhasePriceIds).toContain(proProduct.stripePriceId);
+ expect(nextPhasePriceIds).toContain(addonProduct.stripePriceId);
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+ },
+);
diff --git a/e2e/core/subscribe/upgrade-with-addon.test.ts b/e2e/core/subscribe/upgrade-with-addon.test.ts
new file mode 100644
index 00000000..b89b5214
--- /dev/null
+++ b/e2e/core/subscribe/upgrade-with-addon.test.ts
@@ -0,0 +1,117 @@
+import { and, eq, ne } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { product, subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectExactMeteredBalance,
+ expectProduct,
+ expectSingleActivePlanInGroup,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("upgrade-with-addon: upgrading the base plan leaves the add-on's item untouched", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_upgrade_addon",
+ email: "upgrade-addon@test.com",
+ name: "Upgrade With Addon Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({
+ t,
+ customerId,
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("upgrading pro to ultra activates ultra and leaves extra_messages on its own item", async () => {
+ try {
+ const addonRowBefore = await t.database
+ .select({ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId })
+ .from(subscription)
+ .innerJoin(product, eq(product.internalId, subscription.productInternalId))
+ .where(and(eq(subscription.customerId, customerId), eq(product.id, "extra_messages")))
+ .limit(1);
+ const addonItemIdBefore = addonRowBefore[0]?.stripeSubscriptionItemId;
+ if (!addonItemIdBefore) {
+ throw new Error(
+ "Expected extra_messages to have a stripeSubscriptionItemId before upgrade",
+ );
+ }
+
+ await subscribeCustomer({ t, customerId, planId: "ultra" });
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "ultra",
+ expected: { status: "active", hasPeriodEnd: true },
+ });
+ await expectSingleActivePlanInGroup({
+ database: t.database,
+ customerId,
+ group: "base",
+ planId: "ultra",
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "ended" },
+ });
+
+ // The add-on is still active on the same Stripe subscription item.
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { status: "active" },
+ });
+ const addonRowAfter = await t.database
+ .select({
+ stripeSubscriptionId: subscription.stripeSubscriptionId,
+ stripeSubscriptionItemId: subscription.stripeSubscriptionItemId,
+ })
+ .from(subscription)
+ .innerJoin(product, eq(product.internalId, subscription.productInternalId))
+ .where(
+ and(
+ eq(subscription.customerId, customerId),
+ eq(product.id, "extra_messages"),
+ ne(subscription.status, "ended"),
+ ),
+ )
+ .limit(1);
+ expect(addonRowAfter[0]?.stripeSubscriptionItemId).toBe(addonItemIdBefore);
+
+ // Ultra (10,000) + extra_messages (200) pool together.
+ await expectExactMeteredBalance({
+ paykit: t.paykit,
+ customerId,
+ featureId: "messages",
+ limit: 10_200,
+ remaining: 10_200,
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/usage/metered-plan-sync.test.ts b/e2e/core/usage/metered-plan-sync.test.ts
new file mode 100644
index 00000000..5e8ca09a
--- /dev/null
+++ b/e2e/core/usage/metered-plan-sync.test.ts
@@ -0,0 +1,59 @@
+import { default as Stripe } from "stripe";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { syncProducts } from "../../../packages/paykit/src/product/product-sync.service";
+import { createTestPayKit, dumpStateOnFailure, harness, type TestPayKit } from "../../test-utils";
+import { env } from "../../test-utils/env";
+
+describe.skipIf(harness.id !== "stripe")(
+ "metered-plan-sync: a metered plan syncs to a Stripe Meter + metered Price",
+ () => {
+ let t: TestPayKit;
+ let stripeClient: Stripe;
+
+ beforeAll(async () => {
+ stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 });
+ t = await createTestPayKit();
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("creates a Stripe Billing Meter + metered Price, and re-syncing is idempotent", async () => {
+ try {
+ const productRow = await t.database.query.product.findFirst({
+ where: (p, { eq }) => eq(p.id, "metered_usage"),
+ });
+ if (!productRow?.stripePriceId) {
+ throw new Error("Expected metered_usage to be synced with a Stripe price");
+ }
+ expect(productRow.priceUsageType).toBe("metered");
+ expect(productRow.meteredFeatureId).toBe("api_calls");
+
+ const price = await stripeClient.prices.retrieve(productRow.stripePriceId);
+ expect(price.recurring?.usage_type).toBe("metered");
+ expect(price.recurring?.meter).toBeTruthy();
+
+ const meterId = price.recurring!.meter!;
+ const meter = await stripeClient.billing.meters.retrieve(meterId);
+ expect(meter.event_name).toBe("api_calls");
+
+ // Re-syncing must not create a duplicate meter or price.
+ await syncProducts(t.ctx);
+
+ const productRowAfter = await t.database.query.product.findFirst({
+ where: (p, { eq }) => eq(p.id, "metered_usage"),
+ });
+ expect(productRowAfter?.stripePriceId).toBe(productRow.stripePriceId);
+
+ const meters = await stripeClient.billing.meters.list({ status: "active" });
+ const matchingMeters = meters.data.filter((m) => m.event_name === "api_calls");
+ expect(matchingMeters.length).toBe(1);
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+ },
+);
diff --git a/e2e/core/usage/report-usage-not-metered.test.ts b/e2e/core/usage/report-usage-not-metered.test.ts
new file mode 100644
index 00000000..583b500c
--- /dev/null
+++ b/e2e/core/usage/report-usage-not-metered.test.ts
@@ -0,0 +1,46 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("report-usage-not-metered: reporting usage without an active metered subscription", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_report_usage_not_metered",
+ email: "report-usage-not-metered@test.com",
+ name: "Report Usage Not Metered Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({ t, customerId, planId: "pro" });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("throws USAGE_NOT_METERED_FOR_CUSTOMER when there is no active metered plan", async () => {
+ try {
+ await expect(
+ t.paykit.reportUsage({ customerId, featureId: "api_calls", quantity: 1 }),
+ ).rejects.toMatchObject({
+ code: "USAGE_NOT_METERED_FOR_CUSTOMER",
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/usage/report-usage.test.ts b/e2e/core/usage/report-usage.test.ts
new file mode 100644
index 00000000..b3d194b9
--- /dev/null
+++ b/e2e/core/usage/report-usage.test.ts
@@ -0,0 +1,78 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectProduct,
+ subscribeCustomer,
+ type TestPayKit,
+} from "../../test-utils";
+
+describe("report-usage: reporting usage against a combined licensed + metered subscription", () => {
+ let t: TestPayKit;
+ let customerId: string;
+
+ beforeAll(async () => {
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_report_usage",
+ email: "report-usage@test.com",
+ name: "Report Usage Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ // A licensed base plan combined with a metered-usage plan in one checkout.
+ await subscribeCustomer({
+ t,
+ customerId,
+ planId: "pro",
+ addOnPlanIds: ["metered_usage"],
+ });
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("reports usage events against the metered plan", async () => {
+ try {
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { status: "active" },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "metered_usage",
+ expected: { status: "active" },
+ });
+
+ const first = await t.paykit.reportUsage({
+ customerId,
+ eventId: `test_report_usage_1_${String(Date.now())}`,
+ featureId: "api_calls",
+ quantity: 100,
+ });
+ expect(first.success).toBe(true);
+ expect(first.providerEventId).toBeTruthy();
+
+ const second = await t.paykit.reportUsage({
+ customerId,
+ eventId: `test_report_usage_2_${String(Date.now())}`,
+ featureId: "api_calls",
+ quantity: 50,
+ });
+ expect(second.success).toBe(true);
+ expect(second.providerEventId).not.toBe(first.providerEventId);
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+});
diff --git a/e2e/core/webhook/subscription-deleted-multi-item.test.ts b/e2e/core/webhook/subscription-deleted-multi-item.test.ts
new file mode 100644
index 00000000..609f9938
--- /dev/null
+++ b/e2e/core/webhook/subscription-deleted-multi-item.test.ts
@@ -0,0 +1,113 @@
+import { and, desc, eq, isNotNull } from "drizzle-orm";
+import { default as Stripe } from "stripe";
+import { afterAll, beforeAll, describe, it } from "vitest";
+
+import { subscription } from "../../../packages/paykit/src/database/schema";
+import {
+ createTestCustomerWithPM,
+ createTestPayKit,
+ dumpStateOnFailure,
+ expectProduct,
+ expectSingleActivePlanInGroup,
+ harness,
+ subscribeCustomer,
+ type TestPayKit,
+ waitForWebhook,
+} from "../../test-utils";
+import { env } from "../../test-utils/env";
+
+describe.skipIf(harness.id !== "stripe")(
+ "subscription-deleted-multi-item: deleting a combined subscription ends every item",
+ () => {
+ let t: TestPayKit;
+ let customerId: string;
+ let providerSubscriptionId: string;
+ let stripeClient: Stripe;
+
+ beforeAll(async () => {
+ stripeClient = new Stripe(env.E2E_STRIPE_SK!, { maxNetworkRetries: 3 });
+ t = await createTestPayKit();
+ const customer = await createTestCustomerWithPM({
+ t,
+ customer: {
+ id: "test_sub_deleted_multi",
+ email: "sub-deleted-multi@test.com",
+ name: "Subscription Deleted Multi Test",
+ },
+ });
+ customerId = customer.customerId;
+
+ await subscribeCustomer({
+ t,
+ customerId,
+ planId: "pro",
+ addOnPlanIds: ["extra_messages"],
+ });
+
+ const subRows = await t.database
+ .select({ stripeSubscriptionId: subscription.stripeSubscriptionId })
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.customerId, customerId),
+ isNotNull(subscription.stripeSubscriptionId),
+ ),
+ )
+ .orderBy(desc(subscription.updatedAt))
+ .limit(1);
+ const stripeSubscriptionId = subRows[0]?.stripeSubscriptionId;
+ if (!stripeSubscriptionId) {
+ throw new Error("Expected stripeSubscriptionId on subscription row");
+ }
+ providerSubscriptionId = stripeSubscriptionId;
+ });
+
+ afterAll(async () => {
+ await t?.cleanup();
+ });
+
+ it("canceling the whole Stripe subscription ends both the plan and the add-on locally", async () => {
+ try {
+ const beforeCancel = new Date();
+
+ await stripeClient.subscriptions.cancel(providerSubscriptionId);
+ await waitForWebhook({
+ after: beforeCancel,
+ database: t.database,
+ eventType: "subscription.deleted",
+ timeout: 30_000,
+ });
+
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "pro",
+ expected: { canceled: true, status: "canceled" },
+ });
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "extra_messages",
+ expected: { canceled: true, status: "canceled" },
+ });
+
+ // The base group falls back to its default free plan.
+ await expectProduct({
+ database: t.database,
+ customerId,
+ planId: "free",
+ expected: { status: "active", hasPeriodEnd: false },
+ });
+ await expectSingleActivePlanInGroup({
+ database: t.database,
+ customerId,
+ group: "base",
+ planId: "free",
+ });
+ } catch (error) {
+ await dumpStateOnFailure(t.database, t.dbPath);
+ throw error;
+ }
+ });
+ },
+);
diff --git a/e2e/test-utils/products.ts b/e2e/test-utils/products.ts
index 9cee2246..a2330834 100644
--- a/e2e/test-utils/products.ts
+++ b/e2e/test-utils/products.ts
@@ -3,6 +3,7 @@ import { feature, plan } from "paykitjs";
const messagesFeature = feature({ id: "messages", type: "metered" });
const dashboardFeature = feature({ id: "dashboard", type: "boolean" });
const adminFeature = feature({ id: "admin", type: "boolean" });
+const apiCallsFeature = feature({ id: "api_calls", type: "metered" });
export const freePlan = plan({
default: true,
@@ -48,4 +49,18 @@ export const extraMessagesPlan = plan({
price: { amount: 5, interval: "month" },
});
-export const allProducts = [freePlan, proPlan, premiumPlan, ultraPlan, extraMessagesPlan] as const;
+export const meteredUsagePlan = plan({
+ group: "usage",
+ id: "metered_usage",
+ name: "Metered Usage",
+ price: { interval: "month", meteredBy: apiCallsFeature, unitAmount: 0.01 },
+});
+
+export const allProducts = [
+ freePlan,
+ proPlan,
+ premiumPlan,
+ ultraPlan,
+ extraMessagesPlan,
+ meteredUsagePlan,
+] as const;
diff --git a/e2e/test-utils/setup.ts b/e2e/test-utils/setup.ts
index 809b9132..91e8494a 100644
--- a/e2e/test-utils/setup.ts
+++ b/e2e/test-utils/setup.ts
@@ -232,10 +232,12 @@ export async function subscribeCustomer(input: {
t: TestPayKit;
customerId: string;
planId: Parameters[0]["planId"];
+ addOnPlanIds?: Parameters[0]["addOnPlanIds"];
}): Promise {
const beforeSubscribe = new Date();
const result = await input.t.paykit.subscribe({
+ addOnPlanIds: input.addOnPlanIds,
customerId: input.customerId,
planId: input.planId,
successUrl: "https://example.com/success",
diff --git a/package.json b/package.json
index 92361a3c..1481a384 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,8 @@
"dev:web": "pnpm --filter web dev",
"test:unit": "node ./node_modules/vitest/vitest.mjs run --config vitest.unit.config.ts",
"typecheck": "turbo run typecheck",
- "format": "oxfmt --write '**/*.{ts,tsx,js,jsx}'",
- "format:check": "oxfmt --check '**/*.{ts,tsx,js,jsx}'",
+ "format": "oxfmt --write \"**/*.{ts,tsx,js,jsx}\"",
+ "format:check": "oxfmt --check \"**/*.{ts,tsx,js,jsx}\"",
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"changeset": "changeset",
diff --git a/packages/paykit/src/api/define-route.ts b/packages/paykit/src/api/define-route.ts
index 84d50160..10bf11a7 100644
--- a/packages/paykit/src/api/define-route.ts
+++ b/packages/paykit/src/api/define-route.ts
@@ -326,7 +326,7 @@ function createRouteInputSchema(schema: PayKitMethodConfig["input"]) {
overrides[key] = createRoutedReturnUrlSchema(key, fieldSchema);
}
- return Object.keys(overrides).length > 0 ? schema.extend(overrides) : schema;
+ return Object.keys(overrides).length > 0 ? schema.safeExtend(overrides) : schema;
}
function createRoutedReturnUrlSchema(field: string, schema: unknown): z.ZodTypeAny {
diff --git a/packages/paykit/src/api/methods.ts b/packages/paykit/src/api/methods.ts
index 5e1370fc..07023a29 100644
--- a/packages/paykit/src/api/methods.ts
+++ b/packages/paykit/src/api/methods.ts
@@ -9,14 +9,18 @@ import {
upsertCustomer,
} from "../customer/customer.api";
import { check, report } from "../entitlement/entitlement.api";
+import { addAddOn, removeAddOn } from "../subscription/addon.api";
import { subscribe } from "../subscription/subscription.api";
import { advanceTestClock, getTestClock } from "../testing/testing.api";
import type { PayKitOptions } from "../types/options";
+import { reportUsage } from "../usage/usage.api";
import { receiveWebhook } from "../webhook/webhook.api";
import type { PayKitMethod } from "./define-route";
export const baseMethods = {
subscribe,
+ addAddOn,
+ removeAddOn,
customerPortal,
upsertCustomer,
getCustomer,
@@ -24,6 +28,7 @@ export const baseMethods = {
listCustomers: listCustomersMethod,
check,
report,
+ reportUsage,
handleWebhook: receiveWebhook,
} as const;
diff --git a/packages/paykit/src/core/errors.ts b/packages/paykit/src/core/errors.ts
index 9637afd2..1160c9c1 100644
--- a/packages/paykit/src/core/errors.ts
+++ b/packages/paykit/src/core/errors.ts
@@ -25,9 +25,28 @@ export const PAYKIT_ERROR_CODES = defineErrorCodes({
PROVIDER_SIGNATURE_MISSING: "Missing provider webhook signature",
PROVIDER_SUBSCRIPTION_MISSING_ITEMS: "Provider subscription did not include any items",
PROVIDER_SUBSCRIPTION_MISSING_PERIOD: "Provider subscription did not include period end",
+ PROVIDER_SUBSCRIPTION_ITEM_AMBIGUOUS: "Could not determine which subscription item to update",
+ PROVIDER_SUBSCRIPTION_ITEM_REMOVAL_REJECTED: "Provider rejected removing this subscription item",
PROVIDER_PRICE_REQUIRED: "A provider price ID is required",
PROVIDER_TEST_KEY_REQUIRED: "Testing mode requires provider test credentials",
PROVIDER_WEBHOOK_INVALID: "Provider webhook payload is invalid",
+ PROVIDER_OPERATION_UNSUPPORTED: "The configured provider does not support this operation",
+
+ COMBINED_SUBSCRIBE_DUPLICATE_PLAN: "The same plan was listed more than once",
+ COMBINED_SUBSCRIBE_DUPLICATE_GROUP: "Two or more plans belong to the same group",
+ COMBINED_SUBSCRIBE_REQUIRES_PAID_PLANS: "Combined checkout requires all plans to be paid plans",
+ COMBINED_SUBSCRIBE_EXISTING_SUBSCRIPTION:
+ "Customer already has an active subscription for one of these plans",
+
+ ADDON_ALREADY_ACTIVE: "Customer already has an active subscription for this plan",
+ ADDON_NOT_ACTIVE: "Customer does not have an active subscription for this plan",
+ ADDON_ANCHOR_NOT_FOUND:
+ "Customer has no active provider-backed subscription to attach an add-on to",
+ ADDON_ANCHOR_AMBIGUOUS:
+ "Customer has more than one active provider subscription; specify targetSubscriptionId",
+
+ USAGE_NOT_METERED_FOR_CUSTOMER:
+ "Customer has no active subscription for a Stripe-metered plan on this feature",
IDENTIFY_REQUIRED: "identify must be configured to use HTTP API routes",
CUSTOMER_ID_MISMATCH: "customerId does not match authenticated user",
diff --git a/packages/paykit/src/database/migrations/0003_add_subscription_item_id.sql b/packages/paykit/src/database/migrations/0003_add_subscription_item_id.sql
new file mode 100644
index 00000000..8e868abc
--- /dev/null
+++ b/packages/paykit/src/database/migrations/0003_add_subscription_item_id.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "paykit_subscription" ADD COLUMN "stripe_subscription_item_id" text;--> statement-breakpoint
+CREATE INDEX "paykit_subscription_stripe_item_idx" ON "paykit_subscription" USING btree ("stripe_subscription_item_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "paykit_subscription_stripe_sub_item_unique" ON "paykit_subscription" USING btree ("stripe_subscription_id","stripe_subscription_item_id");
\ No newline at end of file
diff --git a/packages/paykit/src/database/migrations/0004_add_metered_pricing.sql b/packages/paykit/src/database/migrations/0004_add_metered_pricing.sql
new file mode 100644
index 00000000..93bc7197
--- /dev/null
+++ b/packages/paykit/src/database/migrations/0004_add_metered_pricing.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "paykit_product" ADD COLUMN "price_usage_type" text DEFAULT 'licensed' NOT NULL;--> statement-breakpoint
+ALTER TABLE "paykit_product" ADD COLUMN "metered_feature_id" text;--> statement-breakpoint
+ALTER TABLE "paykit_product" ADD CONSTRAINT "paykit_product_metered_feature_id_paykit_feature_id_fk" FOREIGN KEY ("metered_feature_id") REFERENCES "public"."paykit_feature"("id") ON DELETE no action ON UPDATE no action;
\ No newline at end of file
diff --git a/packages/paykit/src/database/migrations/meta/0003_snapshot.json b/packages/paykit/src/database/migrations/meta/0003_snapshot.json
new file mode 100644
index 00000000..d7464329
--- /dev/null
+++ b/packages/paykit/src/database/migrations/meta/0003_snapshot.json
@@ -0,0 +1,1394 @@
+{
+ "id": "66a8ec21-b529-4f5b-869f-1affc0c71e81",
+ "prevId": "e6a25108-0c13-4942-a2a0-f77eb0692c00",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.paykit_customer": {
+ "name": "paykit_customer",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_test_clock_id": {
+ "name": "stripe_test_clock_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_frozen_time": {
+ "name": "stripe_frozen_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_email": {
+ "name": "stripe_synced_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_name": {
+ "name": "stripe_synced_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_metadata": {
+ "name": "stripe_synced_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_customer_deleted_at_idx": {
+ "name": "paykit_customer_deleted_at_idx",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_customer_stripe_customer_idx": {
+ "name": "paykit_customer_stripe_customer_idx",
+ "columns": [
+ {
+ "expression": "stripe_customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_customer_stripe_test_clock_idx": {
+ "name": "paykit_customer_stripe_test_clock_idx",
+ "columns": [
+ {
+ "expression": "stripe_test_clock_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_entitlement": {
+ "name": "paykit_entitlement",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feature_id": {
+ "name": "feature_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "balance": {
+ "name": "balance",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_reset_at": {
+ "name": "next_reset_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_entitlement_subscription_idx": {
+ "name": "paykit_entitlement_subscription_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_entitlement_customer_feature_idx": {
+ "name": "paykit_entitlement_customer_feature_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "feature_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_entitlement_next_reset_idx": {
+ "name": "paykit_entitlement_next_reset_idx",
+ "columns": [
+ {
+ "expression": "next_reset_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_entitlement_subscription_id_paykit_subscription_id_fk": {
+ "name": "paykit_entitlement_subscription_id_paykit_subscription_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_subscription",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_entitlement_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_entitlement_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_entitlement_feature_id_paykit_feature_id_fk": {
+ "name": "paykit_entitlement_feature_id_paykit_feature_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_feature",
+ "columnsFrom": [
+ "feature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_feature": {
+ "name": "paykit_feature",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_invoice": {
+ "name": "paykit_invoice",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hosted_url": {
+ "name": "hosted_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_invoice_id": {
+ "name": "stripe_invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_payment_id": {
+ "name": "stripe_payment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_payment_method_id": {
+ "name": "stripe_payment_method_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_start_at": {
+ "name": "period_start_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end_at": {
+ "name": "period_end_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_invoice_customer_idx": {
+ "name": "paykit_invoice_customer_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_subscription_idx": {
+ "name": "paykit_invoice_subscription_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_stripe_invoice_idx": {
+ "name": "paykit_invoice_stripe_invoice_idx",
+ "columns": [
+ {
+ "expression": "stripe_invoice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_stripe_payment_idx": {
+ "name": "paykit_invoice_stripe_payment_idx",
+ "columns": [
+ {
+ "expression": "stripe_payment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_invoice_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_invoice_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_invoice",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_invoice_subscription_id_paykit_subscription_id_fk": {
+ "name": "paykit_invoice_subscription_id_paykit_subscription_id_fk",
+ "tableFrom": "paykit_invoice",
+ "tableTo": "paykit_subscription",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_metadata": {
+ "name": "paykit_metadata",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_checkout_session_id": {
+ "name": "stripe_checkout_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_metadata_stripe_checkout_session_unique": {
+ "name": "paykit_metadata_stripe_checkout_session_unique",
+ "columns": [
+ {
+ "expression": "stripe_checkout_session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_payment_method": {
+ "name": "paykit_payment_method",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_payment_method_id": {
+ "name": "stripe_payment_method_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "brand": {
+ "name": "brand",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last4": {
+ "name": "last4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_month": {
+ "name": "expiry_month",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_year": {
+ "name": "expiry_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_payment_method_customer_idx": {
+ "name": "paykit_payment_method_customer_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_payment_method_stripe_payment_method_idx": {
+ "name": "paykit_payment_method_stripe_payment_method_idx",
+ "columns": [
+ {
+ "expression": "stripe_payment_method_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_payment_method_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_payment_method_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_payment_method",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_product": {
+ "name": "paykit_product",
+ "schema": "",
+ "columns": {
+ "internal_id": {
+ "name": "internal_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group": {
+ "name": "group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "price_amount": {
+ "name": "price_amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_currency": {
+ "name": "price_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_interval": {
+ "name": "price_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_product_id": {
+ "name": "stripe_product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_price_id": {
+ "name": "stripe_price_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_product_id_version_unique": {
+ "name": "paykit_product_id_version_unique",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_default_idx": {
+ "name": "paykit_product_default_idx",
+ "columns": [
+ {
+ "expression": "is_default",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_stripe_product_idx": {
+ "name": "paykit_product_stripe_product_idx",
+ "columns": [
+ {
+ "expression": "stripe_product_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_stripe_price_idx": {
+ "name": "paykit_product_stripe_price_idx",
+ "columns": [
+ {
+ "expression": "stripe_price_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_product_feature": {
+ "name": "paykit_product_feature",
+ "schema": "",
+ "columns": {
+ "product_internal_id": {
+ "name": "product_internal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feature_id": {
+ "name": "feature_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reset_interval": {
+ "name": "reset_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_product_feature_feature_idx": {
+ "name": "paykit_product_feature_feature_idx",
+ "columns": [
+ {
+ "expression": "feature_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk": {
+ "name": "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk",
+ "tableFrom": "paykit_product_feature",
+ "tableTo": "paykit_product",
+ "columnsFrom": [
+ "product_internal_id"
+ ],
+ "columnsTo": [
+ "internal_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_product_feature_feature_id_paykit_feature_id_fk": {
+ "name": "paykit_product_feature_feature_id_paykit_feature_id_fk",
+ "tableFrom": "paykit_product_feature",
+ "tableTo": "paykit_feature",
+ "columnsFrom": [
+ "feature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paykit_product_feature_product_internal_id_feature_id_pk": {
+ "name": "paykit_product_feature_product_internal_id_feature_id_pk",
+ "columns": [
+ "product_internal_id",
+ "feature_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_subscription": {
+ "name": "paykit_subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "product_internal_id": {
+ "name": "product_internal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_item_id": {
+ "name": "stripe_subscription_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_schedule_id": {
+ "name": "stripe_subscription_schedule_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canceled": {
+ "name": "canceled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cancel_at_period_end": {
+ "name": "cancel_at_period_end",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trial_ends_at": {
+ "name": "trial_ends_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_period_start_at": {
+ "name": "current_period_start_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_period_end_at": {
+ "name": "current_period_end_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canceled_at": {
+ "name": "canceled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scheduled_product_id": {
+ "name": "scheduled_product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_subscription_customer_status_idx": {
+ "name": "paykit_subscription_customer_status_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ended_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_product_idx": {
+ "name": "paykit_subscription_product_idx",
+ "columns": [
+ {
+ "expression": "product_internal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_subscription_idx": {
+ "name": "paykit_subscription_stripe_subscription_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_item_idx": {
+ "name": "paykit_subscription_stripe_item_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_sub_item_unique": {
+ "name": "paykit_subscription_stripe_sub_item_unique",
+ "columns": [
+ {
+ "expression": "stripe_subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stripe_subscription_item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_schedule_idx": {
+ "name": "paykit_subscription_stripe_schedule_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_schedule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_subscription_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_subscription_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_subscription",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_subscription_product_internal_id_paykit_product_internal_id_fk": {
+ "name": "paykit_subscription_product_internal_id_paykit_product_internal_id_fk",
+ "tableFrom": "paykit_subscription",
+ "tableTo": "paykit_product",
+ "columnsFrom": [
+ "product_internal_id"
+ ],
+ "columnsTo": [
+ "internal_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_webhook_event": {
+ "name": "paykit_webhook_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "stripe_event_id": {
+ "name": "stripe_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trace_id": {
+ "name": "trace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "paykit_webhook_event_stripe_event_id_unique": {
+ "name": "paykit_webhook_event_stripe_event_id_unique",
+ "columns": [
+ {
+ "expression": "stripe_event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_webhook_event_stripe_status_idx": {
+ "name": "paykit_webhook_event_stripe_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/paykit/src/database/migrations/meta/0004_snapshot.json b/packages/paykit/src/database/migrations/meta/0004_snapshot.json
new file mode 100644
index 00000000..b9e72ddc
--- /dev/null
+++ b/packages/paykit/src/database/migrations/meta/0004_snapshot.json
@@ -0,0 +1,1421 @@
+{
+ "id": "b95969b0-910d-4c80-97f2-b32083124eac",
+ "prevId": "66a8ec21-b529-4f5b-869f-1affc0c71e81",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.paykit_customer": {
+ "name": "paykit_customer",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_test_clock_id": {
+ "name": "stripe_test_clock_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_frozen_time": {
+ "name": "stripe_frozen_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_email": {
+ "name": "stripe_synced_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_name": {
+ "name": "stripe_synced_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_synced_metadata": {
+ "name": "stripe_synced_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_customer_deleted_at_idx": {
+ "name": "paykit_customer_deleted_at_idx",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_customer_stripe_customer_idx": {
+ "name": "paykit_customer_stripe_customer_idx",
+ "columns": [
+ {
+ "expression": "stripe_customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_customer_stripe_test_clock_idx": {
+ "name": "paykit_customer_stripe_test_clock_idx",
+ "columns": [
+ {
+ "expression": "stripe_test_clock_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_entitlement": {
+ "name": "paykit_entitlement",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feature_id": {
+ "name": "feature_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "balance": {
+ "name": "balance",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_reset_at": {
+ "name": "next_reset_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_entitlement_subscription_idx": {
+ "name": "paykit_entitlement_subscription_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_entitlement_customer_feature_idx": {
+ "name": "paykit_entitlement_customer_feature_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "feature_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_entitlement_next_reset_idx": {
+ "name": "paykit_entitlement_next_reset_idx",
+ "columns": [
+ {
+ "expression": "next_reset_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_entitlement_subscription_id_paykit_subscription_id_fk": {
+ "name": "paykit_entitlement_subscription_id_paykit_subscription_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_subscription",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_entitlement_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_entitlement_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_entitlement_feature_id_paykit_feature_id_fk": {
+ "name": "paykit_entitlement_feature_id_paykit_feature_id_fk",
+ "tableFrom": "paykit_entitlement",
+ "tableTo": "paykit_feature",
+ "columnsFrom": [
+ "feature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_feature": {
+ "name": "paykit_feature",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_invoice": {
+ "name": "paykit_invoice",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subscription_id": {
+ "name": "subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hosted_url": {
+ "name": "hosted_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_invoice_id": {
+ "name": "stripe_invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_payment_id": {
+ "name": "stripe_payment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_payment_method_id": {
+ "name": "stripe_payment_method_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_start_at": {
+ "name": "period_start_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end_at": {
+ "name": "period_end_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_invoice_customer_idx": {
+ "name": "paykit_invoice_customer_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_subscription_idx": {
+ "name": "paykit_invoice_subscription_idx",
+ "columns": [
+ {
+ "expression": "subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_stripe_invoice_idx": {
+ "name": "paykit_invoice_stripe_invoice_idx",
+ "columns": [
+ {
+ "expression": "stripe_invoice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_invoice_stripe_payment_idx": {
+ "name": "paykit_invoice_stripe_payment_idx",
+ "columns": [
+ {
+ "expression": "stripe_payment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_invoice_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_invoice_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_invoice",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_invoice_subscription_id_paykit_subscription_id_fk": {
+ "name": "paykit_invoice_subscription_id_paykit_subscription_id_fk",
+ "tableFrom": "paykit_invoice",
+ "tableTo": "paykit_subscription",
+ "columnsFrom": [
+ "subscription_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_metadata": {
+ "name": "paykit_metadata",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_checkout_session_id": {
+ "name": "stripe_checkout_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_metadata_stripe_checkout_session_unique": {
+ "name": "paykit_metadata_stripe_checkout_session_unique",
+ "columns": [
+ {
+ "expression": "stripe_checkout_session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_payment_method": {
+ "name": "paykit_payment_method",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_payment_method_id": {
+ "name": "stripe_payment_method_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "brand": {
+ "name": "brand",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last4": {
+ "name": "last4",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_month": {
+ "name": "expiry_month",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_year": {
+ "name": "expiry_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_payment_method_customer_idx": {
+ "name": "paykit_payment_method_customer_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_payment_method_stripe_payment_method_idx": {
+ "name": "paykit_payment_method_stripe_payment_method_idx",
+ "columns": [
+ {
+ "expression": "stripe_payment_method_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_payment_method_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_payment_method_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_payment_method",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_product": {
+ "name": "paykit_product",
+ "schema": "",
+ "columns": {
+ "internal_id": {
+ "name": "internal_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group": {
+ "name": "group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "price_amount": {
+ "name": "price_amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_currency": {
+ "name": "price_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_interval": {
+ "name": "price_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_usage_type": {
+ "name": "price_usage_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'licensed'"
+ },
+ "metered_feature_id": {
+ "name": "metered_feature_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_product_id": {
+ "name": "stripe_product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_price_id": {
+ "name": "stripe_price_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_product_id_version_unique": {
+ "name": "paykit_product_id_version_unique",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_default_idx": {
+ "name": "paykit_product_default_idx",
+ "columns": [
+ {
+ "expression": "is_default",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_stripe_product_idx": {
+ "name": "paykit_product_stripe_product_idx",
+ "columns": [
+ {
+ "expression": "stripe_product_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_product_stripe_price_idx": {
+ "name": "paykit_product_stripe_price_idx",
+ "columns": [
+ {
+ "expression": "stripe_price_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_product_metered_feature_id_paykit_feature_id_fk": {
+ "name": "paykit_product_metered_feature_id_paykit_feature_id_fk",
+ "tableFrom": "paykit_product",
+ "tableTo": "paykit_feature",
+ "columnsFrom": [
+ "metered_feature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_product_feature": {
+ "name": "paykit_product_feature",
+ "schema": "",
+ "columns": {
+ "product_internal_id": {
+ "name": "product_internal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feature_id": {
+ "name": "feature_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reset_interval": {
+ "name": "reset_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_product_feature_feature_idx": {
+ "name": "paykit_product_feature_feature_idx",
+ "columns": [
+ {
+ "expression": "feature_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk": {
+ "name": "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk",
+ "tableFrom": "paykit_product_feature",
+ "tableTo": "paykit_product",
+ "columnsFrom": [
+ "product_internal_id"
+ ],
+ "columnsTo": [
+ "internal_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_product_feature_feature_id_paykit_feature_id_fk": {
+ "name": "paykit_product_feature_feature_id_paykit_feature_id_fk",
+ "tableFrom": "paykit_product_feature",
+ "tableTo": "paykit_feature",
+ "columnsFrom": [
+ "feature_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paykit_product_feature_product_internal_id_feature_id_pk": {
+ "name": "paykit_product_feature_product_internal_id_feature_id_pk",
+ "columns": [
+ "product_internal_id",
+ "feature_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_subscription": {
+ "name": "paykit_subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "customer_id": {
+ "name": "customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "product_internal_id": {
+ "name": "product_internal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_item_id": {
+ "name": "stripe_subscription_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_schedule_id": {
+ "name": "stripe_subscription_schedule_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canceled": {
+ "name": "canceled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cancel_at_period_end": {
+ "name": "cancel_at_period_end",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trial_ends_at": {
+ "name": "trial_ends_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_period_start_at": {
+ "name": "current_period_start_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_period_end_at": {
+ "name": "current_period_end_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canceled_at": {
+ "name": "canceled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scheduled_product_id": {
+ "name": "scheduled_product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "paykit_subscription_customer_status_idx": {
+ "name": "paykit_subscription_customer_status_idx",
+ "columns": [
+ {
+ "expression": "customer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ended_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_product_idx": {
+ "name": "paykit_subscription_product_idx",
+ "columns": [
+ {
+ "expression": "product_internal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_subscription_idx": {
+ "name": "paykit_subscription_stripe_subscription_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_item_idx": {
+ "name": "paykit_subscription_stripe_item_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_sub_item_unique": {
+ "name": "paykit_subscription_stripe_sub_item_unique",
+ "columns": [
+ {
+ "expression": "stripe_subscription_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stripe_subscription_item_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_subscription_stripe_schedule_idx": {
+ "name": "paykit_subscription_stripe_schedule_idx",
+ "columns": [
+ {
+ "expression": "stripe_subscription_schedule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "paykit_subscription_customer_id_paykit_customer_id_fk": {
+ "name": "paykit_subscription_customer_id_paykit_customer_id_fk",
+ "tableFrom": "paykit_subscription",
+ "tableTo": "paykit_customer",
+ "columnsFrom": [
+ "customer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "paykit_subscription_product_internal_id_paykit_product_internal_id_fk": {
+ "name": "paykit_subscription_product_internal_id_paykit_product_internal_id_fk",
+ "tableFrom": "paykit_subscription",
+ "tableTo": "paykit_product",
+ "columnsFrom": [
+ "product_internal_id"
+ ],
+ "columnsTo": [
+ "internal_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paykit_webhook_event": {
+ "name": "paykit_webhook_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "stripe_event_id": {
+ "name": "stripe_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trace_id": {
+ "name": "trace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "paykit_webhook_event_stripe_event_id_unique": {
+ "name": "paykit_webhook_event_stripe_event_id_unique",
+ "columns": [
+ {
+ "expression": "stripe_event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "paykit_webhook_event_stripe_status_idx": {
+ "name": "paykit_webhook_event_stripe_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/paykit/src/database/migrations/meta/_journal.json b/packages/paykit/src/database/migrations/meta/_journal.json
index d2408b32..6c459836 100644
--- a/packages/paykit/src/database/migrations/meta/_journal.json
+++ b/packages/paykit/src/database/migrations/meta/_journal.json
@@ -22,6 +22,20 @@
"when": 1782216653893,
"tag": "0002_add-product-price-currency",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1785504155294,
+ "tag": "0003_add_subscription_item_id",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1785506840350,
+ "tag": "0004_add_metered_pricing",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/paykit/src/database/schema.ts b/packages/paykit/src/database/schema.ts
index b66acfcb..9470fa8a 100644
--- a/packages/paykit/src/database/schema.ts
+++ b/packages/paykit/src/database/schema.ts
@@ -87,6 +87,8 @@ export const product = pgTable(
priceAmount: integer("price_amount"),
priceCurrency: text("price_currency"),
priceInterval: text("price_interval"),
+ priceUsageType: text("price_usage_type").notNull().default("licensed"),
+ meteredFeatureId: text("metered_feature_id").references(() => feature.id),
hash: text("hash"),
stripeProductId: text("stripe_product_id"),
stripePriceId: text("stripe_price_id"),
@@ -133,6 +135,7 @@ export const subscription = pgTable(
.notNull()
.references(() => product.internalId),
stripeSubscriptionId: text("stripe_subscription_id"),
+ stripeSubscriptionItemId: text("stripe_subscription_item_id"),
stripeSubscriptionScheduleId: text("stripe_subscription_schedule_id"),
status: text("status").notNull(),
canceled: boolean("canceled").notNull().default(false),
@@ -156,6 +159,11 @@ export const subscription = pgTable(
),
index("paykit_subscription_product_idx").on(table.productInternalId),
index("paykit_subscription_stripe_subscription_idx").on(table.stripeSubscriptionId),
+ index("paykit_subscription_stripe_item_idx").on(table.stripeSubscriptionItemId),
+ uniqueIndex("paykit_subscription_stripe_sub_item_unique").on(
+ table.stripeSubscriptionId,
+ table.stripeSubscriptionItemId,
+ ),
index("paykit_subscription_stripe_schedule_idx").on(table.stripeSubscriptionScheduleId),
],
);
diff --git a/packages/paykit/src/product/__tests__/product.service.test.ts b/packages/paykit/src/product/__tests__/product.service.test.ts
index 45843d67..5a915a4c 100644
--- a/packages/paykit/src/product/__tests__/product.service.test.ts
+++ b/packages/paykit/src/product/__tests__/product.service.test.ts
@@ -15,10 +15,12 @@ function createStoredProduct(overrides: Partial = {}): StoredProd
id: "pro",
internalId: "prod_123",
isDefault: false,
+ meteredFeatureId: null,
name: "Pro",
priceAmount: 2900,
priceCurrency: "usd",
priceInterval: "month",
+ priceUsageType: "licensed",
stripePriceId: "price_123",
stripeProductId: "prod_stripe_123",
updatedAt: now,
@@ -34,10 +36,12 @@ function createPlan(overrides: Partial = {}): NormalizedPlan {
id: "pro",
includes: [],
isDefault: false,
+ meteredFeatureId: null,
name: "Pro",
priceAmount: 2900,
priceCurrency: "usd",
priceInterval: "month",
+ priceUsageType: "licensed",
trialDays: null,
...overrides,
};
@@ -76,4 +80,16 @@ describe("product.service", () => {
await expect(getProductByPlan(database, createPlan())).resolves.toBeNull();
});
+
+ it("does not fall back when the stored product is licensed but the plan is metered", async () => {
+ const storedProduct = createStoredProduct();
+ const { database } = createDatabase(storedProduct);
+
+ await expect(
+ getProductByPlan(
+ database,
+ createPlan({ meteredFeatureId: "api_calls", priceUsageType: "metered" }),
+ ),
+ ).resolves.toBeNull();
+ });
});
diff --git a/packages/paykit/src/product/product-sync.service.ts b/packages/paykit/src/product/product-sync.service.ts
index e4888700..aa0d8891 100644
--- a/packages/paykit/src/product/product-sync.service.ts
+++ b/packages/paykit/src/product/product-sync.service.ts
@@ -1,7 +1,7 @@
import type { PayKitContext } from "../core/context";
import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors";
import type { StoredProductFeature } from "../types/models";
-import type { NormalizedPlan, NormalizedPlanFeature } from "../types/schema";
+import type { NormalizedPlan, NormalizedPlanFeature, PriceUsageType } from "../types/schema";
import {
getLatestProductSnapshot,
getProviderProduct,
@@ -59,6 +59,8 @@ function planChanged(
(existing.product.priceAmount ?? null) !== next.priceAmount ||
(existing.product.priceCurrency ?? null) !== next.priceCurrency ||
(existing.product.priceInterval ?? null) !== next.priceInterval ||
+ existing.product.priceUsageType !== next.priceUsageType ||
+ (existing.product.meteredFeatureId ?? null) !== next.meteredFeatureId ||
featuresChanged(existing.features, next.includes)
);
}
@@ -74,7 +76,9 @@ function priceChanged(
return (
(existing.product.priceAmount ?? null) !== next.priceAmount ||
(existing.product.priceCurrency ?? null) !== next.priceCurrency ||
- (existing.product.priceInterval ?? null) !== next.priceInterval
+ (existing.product.priceInterval ?? null) !== next.priceInterval ||
+ existing.product.priceUsageType !== next.priceUsageType ||
+ (existing.product.meteredFeatureId ?? null) !== next.meteredFeatureId
);
}
@@ -127,6 +131,8 @@ export async function syncProducts(ctx: PayKitContext): Promise | null;
storedProductInternalId: string;
}> = [];
@@ -147,10 +153,12 @@ export async function syncProducts(ctx: PayKitContext): Promise ({
existingProviderProduct: p.existingProviderProduct,
id: p.id,
+ meterEventName: p.meterEventName,
name: p.name,
priceAmount: p.priceAmount,
priceCurrency: p.priceCurrency,
priceInterval: p.priceInterval,
+ usageType: p.usageType,
})),
});
diff --git a/packages/paykit/src/product/product.service.ts b/packages/paykit/src/product/product.service.ts
index a1a9b1d9..05b7e2ff 100644
--- a/packages/paykit/src/product/product.service.ts
+++ b/packages/paykit/src/product/product.service.ts
@@ -5,7 +5,12 @@ import { generateId } from "../core/utils";
import type { PayKitDatabase } from "../database";
import { feature, product, productFeature } from "../database/schema";
import type { StoredFeature, StoredProduct, StoredProductFeature } from "../types/models";
-import type { NormalizedFeature, NormalizedPlan, NormalizedPlanFeature } from "../types/schema";
+import type {
+ NormalizedFeature,
+ NormalizedPlan,
+ NormalizedPlanFeature,
+ PriceUsageType,
+} from "../types/schema";
export interface StoredProductSnapshot {
features: readonly StoredProductFeature[];
@@ -143,6 +148,8 @@ function productSnapshotMatchesPlan(
(snapshot.product.priceAmount ?? null) === plan.priceAmount &&
(snapshot.product.priceCurrency ?? null) === plan.priceCurrency &&
(snapshot.product.priceInterval ?? null) === plan.priceInterval &&
+ snapshot.product.priceUsageType === plan.priceUsageType &&
+ (snapshot.product.meteredFeatureId ?? null) === plan.meteredFeatureId &&
productFeaturesMatch(snapshot.features, plan.includes)
);
}
@@ -200,10 +207,12 @@ export async function insertProductVersion(
hash: string;
id: string;
isDefault: boolean;
+ meteredFeatureId: string | null;
name: string;
priceAmount: number | null;
priceCurrency: string | null;
priceInterval: string | null;
+ priceUsageType: PriceUsageType;
version: number;
},
): Promise {
@@ -216,10 +225,12 @@ export async function insertProductVersion(
id: input.id,
internalId,
isDefault: input.isDefault,
+ meteredFeatureId: input.meteredFeatureId,
name: input.name,
priceAmount: input.priceAmount,
priceCurrency: input.priceCurrency,
priceInterval: input.priceInterval,
+ priceUsageType: input.priceUsageType,
updatedAt: now,
version: input.version,
});
@@ -231,10 +242,12 @@ export async function insertProductVersion(
id: input.id,
internalId,
isDefault: input.isDefault,
+ meteredFeatureId: input.meteredFeatureId,
name: input.name,
priceAmount: input.priceAmount,
priceCurrency: input.priceCurrency,
priceInterval: input.priceInterval,
+ priceUsageType: input.priceUsageType,
stripePriceId: null,
stripeProductId: null,
updatedAt: now,
diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts
index a2a1d69d..f184a07a 100644
--- a/packages/paykit/src/providers/provider.ts
+++ b/packages/paykit/src/providers/provider.ts
@@ -63,6 +63,7 @@ export interface ProviderSubscription {
currentPeriodStartAt?: Date | null;
endedAt?: Date | null;
providerSubscriptionId: string;
+ providerSubscriptionItemId?: string | null;
providerSubscriptionScheduleId?: string | null;
status: string;
}
@@ -107,7 +108,7 @@ export interface PaymentProvider {
createSubscriptionCheckout(data: {
providerCustomerId: string;
- providerProduct: Record;
+ providerProducts: Array>;
successUrl: string;
cancelUrl?: string;
metadata?: Record;
@@ -121,6 +122,21 @@ export interface PaymentProvider {
updateSubscription(data: {
providerProduct: Record;
providerSubscriptionId: string;
+ providerSubscriptionItemId?: string | null;
+ }): Promise;
+
+ /** Adds a new line item (e.g. an add-on) to an existing subscription, charging immediately. */
+ addSubscriptionItem?(data: {
+ providerProduct: Record;
+ providerSubscriptionId: string;
+ /** Stable per-attempt key forwarded to the provider so retries cannot create duplicate items. */
+ idempotencyKey?: string;
+ }): Promise;
+
+ /** Removes one line item from an existing subscription, leaving the rest intact. */
+ removeSubscriptionItem?(data: {
+ providerSubscriptionId: string;
+ providerSubscriptionItemId: string;
}): Promise;
createInvoice(data: {
@@ -131,6 +147,7 @@ export interface PaymentProvider {
scheduleSubscriptionChange(data: {
providerProduct?: Record | null;
+ providerSubscriptionItemId?: string | null;
providerSubscriptionScheduleId?: string | null;
providerSubscriptionId: string;
}): Promise;
@@ -159,6 +176,10 @@ export interface PaymentProvider {
priceAmount: number;
priceCurrency: string;
priceInterval?: string | null;
+ /** @default "licensed" */
+ usageType?: "licensed" | "metered";
+ /** Required when `usageType` is `"metered"` — the Stripe Billing Meter's event name. */
+ meterEventName?: string;
existingProviderProduct?: Record | null;
}>;
}): Promise<{
@@ -168,6 +189,15 @@ export interface PaymentProvider {
}>;
}>;
+ /** Reports a Stripe usage-based billing event for a metered price. */
+ reportUsageEvent?(data: {
+ providerCustomerId: string;
+ meterEventName: string;
+ value: number;
+ identifier?: string;
+ timestamp?: Date;
+ }): Promise<{ providerEventId: string }>;
+
handleWebhook(data: {
allowUnsignedPayload?: boolean;
body: string;
diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts
index ae57e000..180c865e 100644
--- a/packages/paykit/src/stripe/stripe-provider.ts
+++ b/packages/paykit/src/stripe/stripe-provider.ts
@@ -2,7 +2,7 @@ import StripeSdk from "stripe";
import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors";
import type { PaymentProvider, ProviderTestClock } from "../providers/provider";
-import type { NormalizedWebhookEvent } from "../types/events";
+import type { NormalizedSubscription, NormalizedWebhookEvent } from "../types/events";
import { DEFAULT_STRIPE_CURRENCY, getStripeCurrency, type StripeCurrency } from "./currency";
/**
@@ -56,26 +56,34 @@ function toDate(value?: number | null): Date | null {
return typeof value === "number" ? new Date(value * 1000) : null;
}
-function getLatestPeriodEnd(subscription: StripeSubscriptionWithExtras): number | null {
- const firstItem = subscription.items.data[0];
+function getLatestPeriodEnd(
+ subscription: StripeSubscriptionWithExtras,
+ items?: readonly StripeSdk.SubscriptionItem[],
+): number | null {
+ const resolvedItems = items ?? subscription.items.data;
+ const firstItem = resolvedItems[0];
if (!firstItem) {
const subscriptionWithPeriod = subscription as { current_period_end?: number | null };
return subscriptionWithPeriod.current_period_end ?? null;
}
- return subscription.items.data.reduce((latest, item) => {
+ return resolvedItems.reduce((latest, item) => {
return Math.max(latest, item.current_period_end);
}, firstItem.current_period_end);
}
-function getEarliestPeriodStart(subscription: StripeSubscriptionWithExtras): number | null {
- const firstItem = subscription.items.data[0];
+function getEarliestPeriodStart(
+ subscription: StripeSubscriptionWithExtras,
+ items?: readonly StripeSdk.SubscriptionItem[],
+): number | null {
+ const resolvedItems = items ?? subscription.items.data;
+ const firstItem = resolvedItems[0];
if (!firstItem) {
const subscriptionWithPeriod = subscription as { current_period_start?: number | null };
return subscriptionWithPeriod.current_period_start ?? null;
}
- return subscription.items.data.reduce((earliest, item) => {
+ return resolvedItems.reduce((earliest, item) => {
return Math.min(earliest, item.current_period_start);
}, firstItem.current_period_start);
}
@@ -156,34 +164,22 @@ function normalizeStripeInvoice(invoice: StripeInvoiceWithExtras) {
};
}
-function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras) {
- const firstItem = subscription.items.data[0];
- const price = firstItem?.price;
- const providerPriceId = typeof price === "string" ? price : price?.id;
- const providerProductId =
- price && typeof price !== "string"
- ? typeof price.product === "string"
- ? price.product
- : (price.product?.id ?? null)
- : null;
- const periodStart = getEarliestPeriodStart(subscription);
- const periodEnd = getLatestPeriodEnd(subscription);
-
- let providerProduct: Record | null = null;
- if (providerPriceId && providerProductId) {
- providerProduct = { priceId: providerPriceId, productId: providerProductId };
- } else if (providerPriceId) {
- providerProduct = { priceId: providerPriceId };
- }
-
+/** Normalizes every line item on a Stripe subscription, one entry per subscription item. */
+function normalizeStripeSubscriptionItems(
+ subscription: StripeSubscriptionWithExtras,
+ items?: readonly StripeSdk.SubscriptionItem[],
+): NormalizedSubscription[] {
+ const resolvedItems = items ?? subscription.items.data;
+ const periodStart = getEarliestPeriodStart(subscription, resolvedItems);
+ const periodEnd = getLatestPeriodEnd(subscription, resolvedItems);
const cancelAt = (subscription as { cancel_at?: number | null }).cancel_at;
- return {
+
+ const shared = {
cancelAtPeriodEnd: subscription.cancel_at_period_end || (cancelAt != null && cancelAt > 0),
canceledAt: toDate(subscription.canceled_at),
currentPeriodEndAt: toDate(periodEnd),
currentPeriodStartAt: toDate(periodStart),
endedAt: toDate(subscription.ended_at),
- providerProduct,
providerSubscriptionId: subscription.id,
providerSubscriptionScheduleId:
(typeof subscription.schedule === "string"
@@ -191,6 +187,60 @@ function normalizeStripeSubscription(subscription: StripeSubscriptionWithExtras)
: subscription.schedule?.id) ?? null,
status: subscription.status,
};
+
+ if (resolvedItems.length === 0) {
+ return [{ ...shared, providerProduct: null, providerSubscriptionItemId: null }];
+ }
+
+ return resolvedItems.map((item) => {
+ const price = item.price;
+ const providerPriceId = typeof price === "string" ? price : price?.id;
+ const providerProductId =
+ price && typeof price !== "string"
+ ? typeof price.product === "string"
+ ? price.product
+ : (price.product?.id ?? null)
+ : null;
+
+ let providerProduct: Record | null = null;
+ if (providerPriceId && providerProductId) {
+ providerProduct = { priceId: providerPriceId, productId: providerProductId };
+ } else if (providerPriceId) {
+ providerProduct = { priceId: providerPriceId };
+ }
+
+ return { ...shared, providerProduct, providerSubscriptionItemId: item.id };
+ });
+}
+
+/** Normalizes a Stripe subscription to its first item. Use `normalizeStripeSubscriptionItems` for multi-item subscriptions. */
+function normalizeStripeSubscription(
+ subscription: StripeSubscriptionWithExtras,
+): NormalizedSubscription {
+ return normalizeStripeSubscriptionItems(subscription)[0]!;
+}
+
+/**
+ * Normalizes a Stripe subscription to the item matching `itemId`. When `itemId`
+ * is omitted, falls back to the first item.
+ */
+function normalizeStripeSubscriptionItem(
+ subscription: StripeSubscriptionWithExtras,
+ itemId?: string | null,
+ items?: readonly StripeSdk.SubscriptionItem[],
+): NormalizedSubscription {
+ if (!itemId) {
+ return normalizeStripeSubscriptionItems(subscription, items)[0]!;
+ }
+
+ const normalizedItems = normalizeStripeSubscriptionItems(subscription, items);
+ const item = normalizedItems.find(
+ (normalized) => normalized.providerSubscriptionItemId === itemId,
+ );
+ if (!item) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_ITEM_AMBIGUOUS);
+ }
+ return item;
}
function normalizeStripeTestClock(clock: StripeSdk.TestHelpers.TestClock): ProviderTestClock {
@@ -228,6 +278,41 @@ function isStripeResourceMissingError(error: unknown): boolean {
);
}
+/** Metered Stripe prices bill via reported usage and must never be given a `quantity`. */
+function isMeteredSubscriptionItemPrice(price: StripeSdk.Price | string): boolean {
+ return typeof price !== "string" && price.recurring?.usage_type === "metered";
+}
+
+/** Finds or creates the Stripe Billing Meter whose `event_name` matches the PayKit feature id. */
+async function ensureStripeMeter(client: StripeSdk, eventName: string): Promise {
+ const existing = await client.billing.meters
+ .list({ limit: 100, status: "active" })
+ .autoPagingToArray({ limit: 10_000 });
+ const found = existing.find((meter) => meter.event_name === eventName);
+ if (found) {
+ return found.id;
+ }
+
+ const created = await client.billing.meters.create({
+ customer_mapping: { event_payload_key: "stripe_customer_id", type: "by_id" },
+ default_aggregation: { formula: "sum" },
+ display_name: eventName,
+ event_name: eventName,
+ value_settings: { event_payload_key: "value" },
+ });
+ return created.id;
+}
+
+/** All subscription items across every page, since expanded `items.data` caps at one page. */
+async function listAllSubscriptionItems(
+ client: StripeSdk,
+ providerSubscriptionId: string,
+): Promise {
+ return client.subscriptionItems
+ .list({ limit: 100, subscription: providerSubscriptionId })
+ .autoPagingToArray({ limit: 10_000 });
+}
+
async function retrieveExpandedSubscription(
client: StripeSdk,
providerSubscriptionId: string,
@@ -431,10 +516,17 @@ async function createCheckoutCompletedEvents(
}
const sessionMetadata = session.metadata ?? {};
+ const allExpandedItems = expandedSubscription
+ ? await listAllSubscriptionItems(client, expandedSubscription.id)
+ : null;
+ const expandedSubscriptionItems = expandedSubscription
+ ? normalizeStripeSubscriptionItems(expandedSubscription, allExpandedItems ?? [])
+ : null;
events.push({
name: "checkout.completed",
payload: {
+ activeProviderSubscriptionItemIds: allExpandedItems?.map((item) => item.id),
checkoutSessionId: session.id,
invoice: expandedInvoice ? normalizeStripeInvoice(expandedInvoice) : undefined,
metadata: Object.keys(sessionMetadata).length > 0 ? sessionMetadata : undefined,
@@ -445,9 +537,8 @@ async function createCheckoutCompletedEvents(
providerInvoiceId: providerInvoiceId ?? undefined,
providerSubscriptionId: providerSubscriptionId ?? undefined,
status: session.status,
- subscription: expandedSubscription
- ? normalizeStripeSubscription(expandedSubscription)
- : undefined,
+ subscription: expandedSubscriptionItems ? expandedSubscriptionItems[0] : undefined,
+ subscriptions: expandedSubscriptionItems ?? undefined,
},
});
@@ -496,22 +587,22 @@ async function createSubscriptionEvents(event: StripeSdk.Event): Promise = {
- actions: [
- {
- data: {
- providerCustomerId,
- subscription: normalizedSubscription,
- },
- type: "subscription.upsert",
+ actions: normalizedItems.map((item) => ({
+ data: {
+ providerCustomerId,
+ subscription: item,
},
- ],
+ type: "subscription.upsert",
+ })),
name: "subscription.updated",
payload: {
+ activeProviderSubscriptionItemIds: subscription.items.data.map((item) => item.id),
providerCustomerId,
providerEventId: event.id,
- subscription: normalizedSubscription,
+ subscription: normalizedItems[0]!,
+ subscriptions: normalizedItems,
},
};
return [normalizedEvent];
@@ -594,6 +685,20 @@ export function createStripeProvider(
): PaymentProvider {
const currency = getStripeCurrency(options);
+ // Metered usage metadata is immutable, so cache price lookups per provider.
+ const meteredPriceCache = new Map();
+ async function isMeteredPriceId(priceId: string): Promise {
+ const cached = meteredPriceCache.get(priceId);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const price = await client.prices.retrieve(priceId);
+ const isMetered = price.recurring?.usage_type === "metered";
+ meteredPriceCache.set(priceId, isMetered);
+ return isMetered;
+ }
+
return {
id: "stripe",
name: "Stripe",
@@ -680,13 +785,28 @@ export function createStripeProvider(
},
async createSubscriptionCheckout(data) {
+ if (data.providerProducts.length === 0) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
+ }
+
+ const lineItems = await Promise.all(
+ data.providerProducts.map(async (providerProduct) => {
+ const priceId = providerProduct.priceId;
+ if (!priceId) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
+ }
+ const isMetered = await isMeteredPriceId(priceId);
+ return isMetered ? { price: priceId } : { price: priceId, quantity: 1 };
+ }),
+ );
+
const sessionParams: StripeSdk.Checkout.SessionCreateParams & {
managed_payments?: { enabled: boolean };
} = {
cancel_url: data.cancelUrl ?? data.successUrl,
client_reference_id: data.providerCustomerId,
customer: data.providerCustomerId,
- line_items: [{ price: data.providerProduct.priceId, quantity: 1 }],
+ line_items: lineItems,
metadata: data.metadata,
mode: "subscription",
success_url: data.successUrl,
@@ -707,9 +827,14 @@ export function createStripeProvider(
},
async createSubscription(data) {
+ const priceId = data.providerProduct.priceId;
+ if (!priceId) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
+ }
+ const isMetered = await isMeteredPriceId(priceId);
const createParams: StripeSdk.SubscriptionCreateParams = {
customer: data.providerCustomerId,
- items: [{ price: data.providerProduct.priceId }],
+ items: [isMetered ? { price: priceId } : { price: priceId, quantity: 1 }],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
};
@@ -736,23 +861,32 @@ export function createStripeProvider(
},
async updateSubscription(data) {
- const currentSubscription = await retrieveExpandedSubscription(
- client,
- data.providerSubscriptionId,
- );
- const currentItem = currentSubscription.items.data[0];
+ const subscriptionItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
+ const currentItem =
+ (data.providerSubscriptionItemId
+ ? subscriptionItems.find((item) => item.id === data.providerSubscriptionItemId)
+ : undefined) ?? (subscriptionItems.length === 1 ? subscriptionItems[0] : undefined);
if (!currentItem) {
throw PayKitError.from(
"BAD_REQUEST",
- PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_ITEMS,
+ subscriptionItems.length === 0
+ ? PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_MISSING_ITEMS
+ : PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_ITEM_AMBIGUOUS,
);
}
+ const priceId = data.providerProduct.priceId;
+ if (!priceId) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
+ }
+ const isMetered = await isMeteredPriceId(priceId);
+
const updatedSubscription = (await client.subscriptions.update(data.providerSubscriptionId, {
items: [
{
id: currentItem.id,
- price: data.providerProduct.priceId,
+ price: priceId,
+ ...(isMetered ? {} : { quantity: 1 }),
},
],
payment_behavior: "pending_if_incomplete",
@@ -770,23 +904,123 @@ export function createStripeProvider(
? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined)
: null;
+ const updatedItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
+
return {
invoice,
paymentUrl: null,
requiredAction: normalizeRequiredAction(paymentIntent ?? null),
- subscription: normalizeStripeSubscription(updatedSubscription),
+ subscription: normalizeStripeSubscriptionItem(
+ updatedSubscription,
+ currentItem.id,
+ updatedItems,
+ ),
+ };
+ },
+
+ async addSubscriptionItem(data) {
+ const priceId = data.providerProduct.priceId;
+ if (!priceId) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
+ }
+ const isMetered = await isMeteredPriceId(priceId);
+
+ const createdItem = await client.subscriptionItems.create(
+ {
+ payment_behavior: "pending_if_incomplete",
+ price: priceId,
+ proration_behavior: "always_invoice",
+ subscription: data.providerSubscriptionId,
+ ...(isMetered ? {} : { quantity: 1 }),
+ },
+ data.idempotencyKey ? { idempotencyKey: data.idempotencyKey } : undefined,
+ );
+
+ const updatedSubscription = await retrieveExpandedSubscription(
+ client,
+ data.providerSubscriptionId,
+ );
+ const updatedItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
+ const latestInvoice = updatedSubscription.latest_invoice;
+ const invoice =
+ latestInvoice && typeof latestInvoice !== "string"
+ ? normalizeStripeInvoice(latestInvoice)
+ : null;
+ const paymentIntent =
+ latestInvoice && typeof latestInvoice !== "string"
+ ? (latestInvoice.payment_intent as StripeSdk.PaymentIntent | null | undefined)
+ : null;
+
+ return {
+ invoice,
+ paymentUrl: null,
+ providerSubscriptionItemId: createdItem.id,
+ requiredAction: normalizeRequiredAction(paymentIntent ?? null),
+ subscription: normalizeStripeSubscriptionItem(
+ updatedSubscription,
+ createdItem.id,
+ updatedItems,
+ ),
+ };
+ },
+
+ async removeSubscriptionItem(data) {
+ try {
+ await client.subscriptionItems.del(data.providerSubscriptionItemId, {
+ proration_behavior: "create_prorations",
+ });
+ } catch (error) {
+ if (error instanceof StripeSdk.errors.StripeInvalidRequestError) {
+ // e.g. Stripe rejects removing a subscription's last remaining item.
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_ITEM_REMOVAL_REJECTED,
+ error.message,
+ );
+ }
+ throw error;
+ }
+
+ const updatedSubscription = await retrieveExpandedSubscription(
+ client,
+ data.providerSubscriptionId,
+ );
+ const updatedItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
+ const periodStart = getEarliestPeriodStart(updatedSubscription, updatedItems);
+ const periodEnd = getLatestPeriodEnd(updatedSubscription, updatedItems);
+
+ return {
+ paymentUrl: null,
+ requiredAction: null,
+ // Subscription-level state only: the removed item is gone, and reporting
+ // an arbitrary remaining item's id as "the" item would be misleading.
+ subscription: {
+ cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
+ canceledAt: toDate(updatedSubscription.canceled_at),
+ currentPeriodEndAt: toDate(periodEnd),
+ currentPeriodStartAt: toDate(periodStart),
+ endedAt: toDate(updatedSubscription.ended_at),
+ providerSubscriptionId: updatedSubscription.id,
+ providerSubscriptionScheduleId:
+ (typeof updatedSubscription.schedule === "string"
+ ? updatedSubscription.schedule
+ : updatedSubscription.schedule?.id) ?? null,
+ status: updatedSubscription.status,
+ },
};
},
async scheduleSubscriptionChange(data) {
- if (!data.providerProduct?.priceId) {
+ const targetPriceId = data.providerProduct?.priceId;
+ if (!targetPriceId) {
throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_PRICE_REQUIRED);
}
const currentSub = (await client.subscriptions.retrieve(data.providerSubscriptionId, {
expand: ["items"],
})) as StripeSubscriptionWithExtras;
- const periodEndSeconds = getLatestPeriodEnd(currentSub);
+ const subscriptionItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
+ const periodEndSeconds = getLatestPeriodEnd(currentSub, subscriptionItems);
if (typeof periodEndSeconds !== "number") {
throw PayKitError.from(
"BAD_REQUEST",
@@ -794,10 +1028,34 @@ export function createStripeProvider(
);
}
- const currentItems = currentSub.items.data.map((item: { price: { id: string } }) => ({
- price: item.price.id,
- quantity: 1,
- }));
+ const targetItem =
+ (data.providerSubscriptionItemId
+ ? subscriptionItems.find((item) => item.id === data.providerSubscriptionItemId)
+ : undefined) ?? (subscriptionItems.length === 1 ? subscriptionItems[0] : undefined);
+ if (!targetItem) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.PROVIDER_SUBSCRIPTION_ITEM_AMBIGUOUS,
+ );
+ }
+
+ const buildPhaseItem = (
+ priceId: string,
+ isMetered: boolean,
+ ): { price: string; quantity?: number } =>
+ isMetered ? { price: priceId } : { price: priceId, quantity: 1 };
+
+ const currentPhaseItems = subscriptionItems.map((item) =>
+ buildPhaseItem(item.price.id, isMeteredSubscriptionItemPrice(item.price)),
+ );
+ // Only the target item's price changes for the next phase; every other item
+ // (e.g. add-ons on the same subscription) must carry over unchanged.
+ const targetPriceIsMetered = await isMeteredPriceId(targetPriceId);
+ const nextPhaseItems = subscriptionItems.map((item) =>
+ item.id === targetItem.id
+ ? buildPhaseItem(targetPriceId, targetPriceIsMetered)
+ : buildPhaseItem(item.price.id, isMeteredSubscriptionItemPrice(item.price)),
+ );
let schedule: StripeSdk.SubscriptionSchedule;
if (data.providerSubscriptionScheduleId) {
@@ -822,12 +1080,12 @@ export function createStripeProvider(
end_behavior: "release",
phases: [
{
- items: currentItems,
+ items: currentPhaseItems,
start_date: currentPhaseStart,
end_date: periodEndSeconds,
},
{
- items: [{ price: data.providerProduct.priceId, quantity: 1 }],
+ items: nextPhaseItems,
start_date: periodEndSeconds,
},
],
@@ -837,11 +1095,16 @@ export function createStripeProvider(
client,
data.providerSubscriptionId,
);
+ const updatedItems = await listAllSubscriptionItems(client, data.providerSubscriptionId);
return {
paymentUrl: null,
requiredAction: null,
- subscription: normalizeStripeSubscription(updatedSubscription),
+ subscription: normalizeStripeSubscriptionItem(
+ updatedSubscription,
+ targetItem.id,
+ updatedItems,
+ ),
};
},
@@ -937,7 +1200,21 @@ export function createStripeProvider(
product: productId,
unit_amount: product.priceAmount,
};
- if (product.priceInterval) {
+ if (product.usageType === "metered") {
+ if (!product.meterEventName) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.PROVIDER_INVALID_CONFIG,
+ `Metered product "${product.id}" requires a meterEventName`,
+ );
+ }
+ const meterId = await ensureStripeMeter(client, product.meterEventName);
+ priceParams.recurring = {
+ interval: (product.priceInterval as "month" | "year") ?? "month",
+ meter: meterId,
+ usage_type: "metered",
+ };
+ } else if (product.priceInterval) {
priceParams.recurring = {
interval: product.priceInterval as "month" | "year",
};
@@ -951,6 +1228,20 @@ export function createStripeProvider(
return { results };
},
+ async reportUsageEvent(data) {
+ const event = await client.billing.meterEvents.create({
+ event_name: data.meterEventName,
+ identifier: data.identifier,
+ payload: {
+ stripe_customer_id: data.providerCustomerId,
+ value: String(data.value),
+ },
+ timestamp: data.timestamp ? Math.floor(data.timestamp.getTime() / 1000) : undefined,
+ });
+
+ return { providerEventId: event.identifier };
+ },
+
async createInvoice(data) {
const stripeInvoice = await client.invoices.create({
auto_advance: data.autoAdvance ?? true,
diff --git a/packages/paykit/src/subscription/addon.api.ts b/packages/paykit/src/subscription/addon.api.ts
new file mode 100644
index 00000000..60f7d379
--- /dev/null
+++ b/packages/paykit/src/subscription/addon.api.ts
@@ -0,0 +1,52 @@
+import * as z from "zod";
+
+import { definePayKitMethod } from "../api/define-route";
+import { addSubscriptionAddOn, removeSubscriptionAddOn } from "./subscription.service";
+
+const addAddOnBodySchema = z.object({
+ planId: z.string(),
+ targetSubscriptionId: z.string().optional(),
+});
+
+const removeAddOnBodySchema = z.object({
+ planId: z.string(),
+});
+
+/** Attaches an add-on plan to the customer's existing subscription, charging their saved card. */
+export const addAddOn = definePayKitMethod(
+ {
+ input: addAddOnBodySchema,
+ requireCustomer: true,
+ route: {
+ client: true,
+ method: "POST",
+ path: "/add-add-on",
+ },
+ },
+ async (ctx) =>
+ addSubscriptionAddOn(ctx.paykit, {
+ customerId: ctx.customer.id,
+ planId: ctx.input.planId,
+ targetSubscriptionId: ctx.input.targetSubscriptionId,
+ }),
+);
+
+/** Removes an active add-on plan from the customer's subscription. */
+export const removeAddOn = definePayKitMethod(
+ {
+ input: removeAddOnBodySchema,
+ requireCustomer: true,
+ route: {
+ client: true,
+ method: "POST",
+ path: "/remove-add-on",
+ },
+ },
+ async (ctx) => {
+ await removeSubscriptionAddOn(ctx.paykit, {
+ customerId: ctx.customer.id,
+ planId: ctx.input.planId,
+ });
+ return { success: true as const };
+ },
+);
diff --git a/packages/paykit/src/subscription/subscription.api.ts b/packages/paykit/src/subscription/subscription.api.ts
index 8eeb8514..c532957b 100644
--- a/packages/paykit/src/subscription/subscription.api.ts
+++ b/packages/paykit/src/subscription/subscription.api.ts
@@ -15,6 +15,7 @@ export const subscribe = definePayKitMethod(
},
async (ctx) => {
return subscribeToPlan(ctx.paykit, {
+ addOnPlanIds: ctx.input.addOnPlanIds,
customerId: ctx.customer.id,
forceCheckout: ctx.input.forceCheckout,
planId: ctx.input.planId,
diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts
index ccbda6de..940df23f 100644
--- a/packages/paykit/src/subscription/subscription.service.ts
+++ b/packages/paykit/src/subscription/subscription.service.ts
@@ -43,6 +43,13 @@ export async function subscribeToPlan(
const startTime = Date.now();
ctx.logger.info({ planId: input.planId, customerId: input.customerId }, "subscribe started");
+ if (input.addOnPlanIds && input.addOnPlanIds.length > 0) {
+ const result = await handleCombinedSubscribe(ctx, input);
+ const duration = Date.now() - startTime;
+ ctx.logger.info({ duration }, "subscribe completed");
+ return result;
+ }
+
const subCtx = await loadSubscribeContext(ctx, input);
let result: SubscribeResult;
@@ -216,6 +223,64 @@ function buildSubscribeResult(input: {
};
}
+/** Starts one combined checkout for a base plan plus one or more add-on plans. */
+async function handleCombinedSubscribe(
+ ctx: PayKitContext,
+ input: SubscribeInput,
+): Promise {
+ const allPlanIds = [input.planId, ...(input.addOnPlanIds ?? [])];
+ if (new Set(allPlanIds).size !== allPlanIds.length) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.COMBINED_SUBSCRIBE_DUPLICATE_PLAN);
+ }
+
+ const subCtxs = await Promise.all(
+ allPlanIds.map((planId) =>
+ loadSubscribeContext(ctx, {
+ customerId: input.customerId,
+ cancelUrl: input.cancelUrl,
+ forceCheckout: input.forceCheckout,
+ planId,
+ successUrl: input.successUrl,
+ }),
+ ),
+ );
+
+ const groups = subCtxs
+ .map((subCtx) => subCtx.storedPlan.group)
+ .filter((group) => group.length > 0);
+ if (new Set(groups).size !== groups.length) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.COMBINED_SUBSCRIBE_DUPLICATE_GROUP);
+ }
+ if (subCtxs.some((subCtx) => subCtx.isFreeTarget)) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.COMBINED_SUBSCRIBE_REQUIRES_PAID_PLANS,
+ );
+ }
+ if (subCtxs.some((subCtx) => subCtx.activeSubscription)) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.COMBINED_SUBSCRIBE_EXISTING_SUBSCRIPTION,
+ );
+ }
+
+ const primary = subCtxs[0]!;
+ const checkoutResult = await ctx.provider.createSubscriptionCheckout({
+ cancelUrl: primary.cancelUrl,
+ metadata: {
+ paykit_customer_id: primary.customerId,
+ paykit_intent: "subscribe",
+ paykit_plan_ids: allPlanIds.join(","),
+ paykit_product_internal_ids: subCtxs.map((subCtx) => subCtx.storedPlan.internalId).join(","),
+ },
+ providerCustomerId: primary.providerCustomerId,
+ providerProducts: subCtxs.map((subCtx) => subCtx.storedPlan.providerProduct!),
+ successUrl: primary.successUrl,
+ });
+
+ return buildSubscribeResult({ paymentUrl: checkoutResult.paymentUrl });
+}
+
interface SubscribeCheckoutCompletion {
customerId: string;
invoice?: Parameters[0]["invoice"];
@@ -304,10 +369,26 @@ export async function applyCheckoutSubscription(
});
}
+/** Resolves the Stripe subscription item matching a plan's price, for combined checkouts with multiple items. */
+function matchCheckoutSubscriptionToPlan(
+ checkoutSubscriptions: readonly ProviderSubscription[],
+ storedProviderProduct: Record | null,
+): ProviderSubscription | undefined {
+ if (checkoutSubscriptions.length === 1) {
+ return checkoutSubscriptions[0];
+ }
+
+ return checkoutSubscriptions.find(
+ (sub) =>
+ storedProviderProduct != null &&
+ (sub as NormalizedSubscription).providerProduct?.priceId === storedProviderProduct.priceId,
+ );
+}
+
export async function prepareSubscribeCheckoutCompleted(
ctx: PayKitContext,
event: NormalizedWebhookEvent<"checkout.completed">,
-): Promise {
+): Promise {
if (event.payload.mode !== "subscription") {
return null;
}
@@ -318,9 +399,10 @@ export async function prepareSubscribeCheckoutCompleted(
}
const customerId = event.payload.metadata?.paykit_customer_id;
- const planId = event.payload.metadata?.paykit_plan_id;
- const productInternalId = event.payload.metadata?.paykit_product_internal_id;
- if (!customerId || !planId) {
+ const planIdsMeta = event.payload.metadata?.paykit_plan_ids;
+ const singlePlanId = event.payload.metadata?.paykit_plan_id;
+ const planIds = planIdsMeta ? planIdsMeta.split(",") : singlePlanId ? [singlePlanId] : null;
+ if (!customerId || !planIds || planIds.length === 0) {
throw PayKitError.from(
"BAD_REQUEST",
PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
@@ -328,8 +410,17 @@ export async function prepareSubscribeCheckoutCompleted(
);
}
- const checkoutSubscription = event.payload.subscription ?? null;
- if (!checkoutSubscription) {
+ const productInternalIdsMeta = event.payload.metadata?.paykit_product_internal_ids;
+ const singleProductInternalId = event.payload.metadata?.paykit_product_internal_id;
+ const productInternalIds = productInternalIdsMeta
+ ? productInternalIdsMeta.split(",")
+ : singleProductInternalId
+ ? [singleProductInternalId]
+ : [];
+
+ const checkoutSubscriptions: ProviderSubscription[] =
+ event.payload.subscriptions ?? (event.payload.subscription ? [event.payload.subscription] : []);
+ if (checkoutSubscriptions.length === 0) {
throw PayKitError.from(
"BAD_REQUEST",
PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
@@ -337,39 +428,59 @@ export async function prepareSubscribeCheckoutCompleted(
);
}
- const subCtx = await loadSubscribeContext(ctx, {
- customerId,
- planId,
- productInternalId: productInternalId ?? undefined,
- successUrl: "https://paykit.invalid/checkout",
- });
- const checkoutProviderProduct = checkoutSubscription.providerProduct;
- const storedProviderProduct = subCtx.storedPlan.providerProduct;
- if (checkoutProviderProduct && storedProviderProduct) {
- const checkoutKeys = Object.keys(checkoutProviderProduct);
- const storedKeys = Object.keys(storedProviderProduct);
- const mismatch =
- checkoutKeys.length !== storedKeys.length ||
- checkoutKeys.some((key) => checkoutProviderProduct[key] !== storedProviderProduct[key]);
- if (mismatch) {
+ const completions: SubscribeCheckoutCompletion[] = [];
+ for (let i = 0; i < planIds.length; i++) {
+ const planId = planIds[i]!;
+ const productInternalId = productInternalIds[i];
+
+ const subCtx = await loadSubscribeContext(ctx, {
+ customerId,
+ planId,
+ productInternalId: productInternalId || undefined,
+ successUrl: "https://paykit.invalid/checkout",
+ });
+ const storedProviderProduct = subCtx.storedPlan.providerProduct;
+ const checkoutSubscription = matchCheckoutSubscriptionToPlan(
+ checkoutSubscriptions,
+ storedProviderProduct,
+ );
+ if (!checkoutSubscription) {
throw PayKitError.from(
"BAD_REQUEST",
PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
- `Checkout product mismatch for plan "${planId}"`,
+ `Checkout completion did not include a matching subscription item for plan "${planId}"`,
);
}
- }
- const completion = {
- customerId,
- invoice: event.payload.invoice ?? null,
- subCtx,
- subscription: checkoutSubscription,
- };
+ const checkoutProviderProduct = (checkoutSubscription as NormalizedSubscription)
+ .providerProduct;
+ if (checkoutProviderProduct && storedProviderProduct) {
+ const checkoutKeys = Object.keys(checkoutProviderProduct);
+ const storedKeys = Object.keys(storedProviderProduct);
+ const mismatch =
+ checkoutKeys.length !== storedKeys.length ||
+ checkoutKeys.some((key) => checkoutProviderProduct[key] !== storedProviderProduct[key]);
+ if (mismatch) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
+ `Checkout product mismatch for plan "${planId}"`,
+ );
+ }
+ }
- await cancelExistingProviderSubscriptionForCheckout(ctx, completion);
+ const completion: SubscribeCheckoutCompletion = {
+ customerId,
+ invoice: event.payload.invoice ?? null,
+ subCtx,
+ subscription: checkoutSubscription,
+ };
- return completion;
+ await cancelExistingProviderSubscriptionForCheckout(ctx, completion);
+ completions.push(completion);
+ }
+
+ return completions;
}
export async function handleSubscribeCheckoutCompleted(
@@ -437,6 +548,7 @@ async function activateScheduledSubscriptionForGroup(
subscriptionCurrentPeriodEndAt?: Date | null;
subscriptionCurrentPeriodStartAt?: Date | null;
stripeSubscriptionId?: string | null;
+ stripeSubscriptionItemId?: string | null;
stripeSubscriptionScheduleId?: string | null;
},
): Promise {
@@ -485,6 +597,7 @@ async function activateScheduledSubscriptionForGroup(
startedAt: targetSub.startedAt ?? activationDate,
status: input.subscriptionStatus,
stripeSubscriptionId: input.stripeSubscriptionId,
+ stripeSubscriptionItemId: input.stripeSubscriptionItemId,
stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId,
});
@@ -504,51 +617,53 @@ export async function applySubscriptionWebhookAction(
}
if (action.type === "subscription.delete") {
- const existingSub = await getSubscriptionByProviderSubscriptionId(ctx.database, {
- providerId: ctx.provider.id,
+ const existingSubs = await getSubscriptionsByProviderSubscriptionId(ctx.database, {
providerSubscriptionId: action.data.providerSubscriptionId,
});
- if (!existingSub) {
+ if (existingSubs.length === 0) {
return customerRow.id;
}
- const effectiveEndDate = existingSub.currentPeriodEndAt ?? new Date();
+ for (const existingSub of existingSubs) {
+ const effectiveEndDate = existingSub.currentPeriodEndAt ?? new Date();
- await endSubscriptions(ctx.database, [existingSub.id], {
- canceled: true,
- endedAt: effectiveEndDate,
- status: "canceled",
- });
+ await endSubscriptions(ctx.database, [existingSub.id], {
+ canceled: true,
+ endedAt: effectiveEndDate,
+ status: "canceled",
+ });
- const existingStoredProduct = await ctx.database.query.product.findFirst({
- where: eq(product.internalId, existingSub.productInternalId),
- });
- const productGroup = existingStoredProduct?.group ?? "";
+ const existingStoredProduct = await ctx.database.query.product.findFirst({
+ where: eq(product.internalId, existingSub.productInternalId),
+ });
+ const productGroup = existingStoredProduct?.group ?? "";
- if (productGroup) {
- const effectiveDate = existingSub.currentPeriodEndAt ?? new Date();
+ if (productGroup) {
+ const effectiveDate = existingSub.currentPeriodEndAt ?? new Date();
- await ensureScheduledDefaultPlan(ctx, {
- customerId: customerRow.id,
- group: productGroup,
- startsAt: effectiveDate,
- });
+ await ensureScheduledDefaultPlan(ctx, {
+ customerId: customerRow.id,
+ group: productGroup,
+ startsAt: effectiveDate,
+ });
- await activateScheduledSubscriptionForGroup(ctx, {
- customerId: customerRow.id,
- productGroup,
- subscriptionCurrentPeriodEndAt: null,
- subscriptionCurrentPeriodStartAt: effectiveDate,
- subscriptionStatus: "active",
- });
+ await activateScheduledSubscriptionForGroup(ctx, {
+ customerId: customerRow.id,
+ productGroup,
+ subscriptionCurrentPeriodEndAt: null,
+ subscriptionCurrentPeriodStartAt: effectiveDate,
+ subscriptionStatus: "active",
+ });
+ }
}
return customerRow.id;
}
- const existingSub = await getSubscriptionByProviderSubscriptionId(ctx.database, {
+ const existingSub = await resolveExistingSubscriptionForItem(ctx.database, {
providerId: ctx.provider.id,
providerSubscriptionId: action.data.subscription.providerSubscriptionId,
+ providerSubscriptionItemId: action.data.subscription.providerSubscriptionItemId,
});
const providerProduct = action.data.subscription.providerProduct;
const storedProduct = providerProduct
@@ -579,6 +694,7 @@ export async function applySubscriptionWebhookAction(
productInternalId: storedProduct.internalId,
startedAt: action.data.subscription.currentPeriodStartAt ?? new Date(),
stripeSubscriptionId: action.data.subscription.providerSubscriptionId,
+ stripeSubscriptionItemId: action.data.subscription.providerSubscriptionItemId ?? null,
stripeSubscriptionScheduleId:
action.data.subscription.providerSubscriptionScheduleId ?? null,
status: action.data.subscription.status,
@@ -596,6 +712,7 @@ export async function applySubscriptionWebhookAction(
await syncSubscriptionBillingState(ctx.database, {
stripeSubscriptionId: action.data.subscription.providerSubscriptionId,
+ stripeSubscriptionItemId: action.data.subscription.providerSubscriptionItemId ?? null,
stripeSubscriptionScheduleId: action.data.subscription.providerSubscriptionScheduleId ?? null,
subscriptionId: targetSub.id,
});
@@ -698,6 +815,7 @@ export async function applySubscriptionWebhookAction(
productGroup: storedProduct.group,
productInternalId: storedProduct.internalId,
stripeSubscriptionId: action.data.subscription.providerSubscriptionId,
+ stripeSubscriptionItemId: action.data.subscription.providerSubscriptionItemId ?? null,
stripeSubscriptionScheduleId:
action.data.subscription.providerSubscriptionScheduleId ?? null,
subscriptionCurrentPeriodEndAt: action.data.subscription.currentPeriodEndAt,
@@ -727,10 +845,12 @@ function hasProviderSubscription(subscription: ActiveSubscription): boolean {
function getProviderSubscriptionRef(subscription: ActiveSubscription): {
subscriptionId: string | null;
+ subscriptionItemId: string | null;
subscriptionScheduleId: string | null;
} {
return {
subscriptionId: subscription?.stripeSubscriptionId ?? null,
+ subscriptionItemId: subscription?.stripeSubscriptionItemId ?? null,
subscriptionScheduleId: subscription?.stripeSubscriptionScheduleId ?? null,
};
}
@@ -965,6 +1085,7 @@ async function handleScheduledDowngrade(
const providerResult = await ctx.provider.scheduleSubscriptionChange({
providerProduct: subCtx.storedPlan.providerProduct!,
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
+ providerSubscriptionItemId: activeSubscriptionRef.subscriptionItemId,
providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId,
});
@@ -1017,6 +1138,7 @@ async function handleUpgrade(
const providerResult = await ctx.provider.updateSubscription({
providerProduct: subCtx.storedPlan.providerProduct!,
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
+ providerSubscriptionItemId: activeSubscriptionRef.subscriptionItemId,
});
await ctx.database.transaction(async (tx) => {
@@ -1060,7 +1182,7 @@ async function createCheckoutSubscribe(
paykit_product_internal_id: subCtx.storedPlan.internalId,
},
providerCustomerId: subCtx.providerCustomerId,
- providerProduct: subCtx.storedPlan.providerProduct!,
+ providerProducts: [subCtx.storedPlan.providerProduct!],
successUrl: subCtx.successUrl,
});
@@ -1095,9 +1217,10 @@ async function upsertProviderBackedTargetSubscription(
): Promise {
let subscriptionId: string | null = null;
if (options?.deferred) {
- const existingSub = await getSubscriptionByProviderSubscriptionId(database, {
+ const existingSub = await resolveExistingSubscriptionForItem(database, {
providerId: subCtx.providerId,
providerSubscriptionId: input.subscription.providerSubscriptionId,
+ providerSubscriptionItemId: input.subscription.providerSubscriptionItemId,
});
if (existingSub) {
subscriptionId = existingSub.id;
@@ -1105,6 +1228,7 @@ async function upsertProviderBackedTargetSubscription(
currentPeriodEndAt: input.subscription.currentPeriodEndAt ?? null,
currentPeriodStartAt: input.subscription.currentPeriodStartAt ?? null,
stripeSubscriptionId: input.subscription.providerSubscriptionId,
+ stripeSubscriptionItemId: input.subscription.providerSubscriptionItemId ?? null,
stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null,
status: input.subscription.status,
subscriptionId: existingSub.id,
@@ -1121,6 +1245,7 @@ async function upsertProviderBackedTargetSubscription(
productInternalId: subCtx.storedPlan.internalId,
startedAt: input.subscription.currentPeriodStartAt ?? new Date(),
stripeSubscriptionId: input.subscription.providerSubscriptionId,
+ stripeSubscriptionItemId: input.subscription.providerSubscriptionItemId ?? null,
stripeSubscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null,
status: input.subscription.status,
});
@@ -1137,6 +1262,167 @@ async function upsertProviderBackedTargetSubscription(
}
}
+/**
+ * Resolves the Stripe subscription an add-on should attach to: the customer's
+ * one active provider-backed subscription, or `targetSubscriptionId` when given
+ * to disambiguate a customer with more than one. A supplied target id is
+ * validated against the customer's active local rows before being returned.
+ */
+async function resolveAnchorProviderSubscription(
+ database: PayKitDatabase,
+ input: { customerId: string; targetSubscriptionId?: string },
+): Promise {
+ const rows = await database
+ .select({ stripeSubscriptionId: subscription.stripeSubscriptionId })
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.customerId, input.customerId),
+ inArray(subscription.status, ["active", "trialing", "past_due"]),
+ or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`),
+ input.targetSubscriptionId ? eq(subscription.id, input.targetSubscriptionId) : undefined,
+ ),
+ );
+
+ const distinctIds = new Set(
+ rows.map((row) => row.stripeSubscriptionId).filter((id): id is string => id != null),
+ );
+
+ if (distinctIds.size === 0) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.ADDON_ANCHOR_NOT_FOUND);
+ }
+ if (distinctIds.size > 1) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.ADDON_ANCHOR_AMBIGUOUS);
+ }
+ return [...distinctIds][0]!;
+}
+
+/** Attaches a new add-on plan to the customer's existing subscription, charging their saved card immediately. */
+export async function addSubscriptionAddOn(
+ ctx: PayKitContext,
+ input: { customerId: string; planId: string; targetSubscriptionId?: string },
+): Promise {
+ const subCtx = await loadSubscribeContext(ctx, {
+ customerId: input.customerId,
+ planId: input.planId,
+ successUrl: "https://paykit.invalid/addon",
+ });
+
+ if (subCtx.isFreeTarget) {
+ throw PayKitError.from(
+ "BAD_REQUEST",
+ PAYKIT_ERROR_CODES.COMBINED_SUBSCRIBE_REQUIRES_PAID_PLANS,
+ );
+ }
+ if (subCtx.activeSubscription) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.ADDON_ALREADY_ACTIVE);
+ }
+
+ const anchorSubscriptionId = await resolveAnchorProviderSubscription(ctx.database, {
+ customerId: input.customerId,
+ targetSubscriptionId: input.targetSubscriptionId,
+ });
+
+ const addSubscriptionItem = ctx.provider.addSubscriptionItem;
+ if (!addSubscriptionItem) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_OPERATION_UNSUPPORTED);
+ }
+
+ // Serialize concurrent requests for the same customer/plan so only one can
+ // attach the add-on, and tie the provider item creation to a pre-generated
+ // local row id so a retried attempt cannot create duplicate provider items.
+ const addOnSubscriptionId = generateId("addon");
+ const providerResult = await ctx.database.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`addon:${input.customerId}:${subCtx.storedPlan.internalId}`}))`,
+ );
+
+ const active = subCtx.storedPlan.group
+ ? await getActiveSubscriptionInGroup(tx, {
+ customerId: input.customerId,
+ group: subCtx.storedPlan.group,
+ })
+ : null;
+ if (active) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.ADDON_ALREADY_ACTIVE);
+ }
+
+ const result = await addSubscriptionItem({
+ idempotencyKey: `paykit_addon:${addOnSubscriptionId}`,
+ providerProduct: subCtx.storedPlan.providerProduct!,
+ providerSubscriptionId: anchorSubscriptionId,
+ });
+
+ const inserted = await insertSubscriptionRecord(tx, {
+ currentPeriodEndAt: result.subscription?.currentPeriodEndAt ?? null,
+ currentPeriodStartAt: result.subscription?.currentPeriodStartAt ?? null,
+ customerId: input.customerId,
+ id: addOnSubscriptionId,
+ planFeatures: subCtx.planFeatures,
+ productInternalId: subCtx.storedPlan.internalId,
+ startedAt: result.subscription?.currentPeriodStartAt ?? new Date(),
+ stripeSubscriptionId: anchorSubscriptionId,
+ stripeSubscriptionItemId: result.providerSubscriptionItemId,
+ status: result.subscription?.status ?? "active",
+ });
+
+ if (result.invoice) {
+ await upsertInvoiceRecord(tx, {
+ customerId: input.customerId,
+ invoice: result.invoice,
+ providerId: subCtx.providerId,
+ subscriptionId: inserted.id,
+ });
+ }
+
+ return result;
+ });
+
+ return buildSubscribeResult({
+ invoice: providerResult.invoice,
+ paymentUrl: null,
+ requiredAction: providerResult.requiredAction,
+ });
+}
+
+/**
+ * Removes an active add-on plan from the customer's subscription. The local row ends
+ * synchronously so the caller's next read is correct without waiting on webhook latency;
+ * `reconcileRemovedSubscriptionItems` is the eventual-consistency backstop for removals
+ * made outside this call (e.g. directly in the Stripe Dashboard).
+ */
+export async function removeSubscriptionAddOn(
+ ctx: PayKitContext,
+ input: { customerId: string; planId: string },
+): Promise {
+ const subCtx = await loadSubscribeContext(ctx, {
+ customerId: input.customerId,
+ planId: input.planId,
+ successUrl: "https://paykit.invalid/addon",
+ });
+
+ const active = subCtx.activeSubscription;
+ if (!active?.stripeSubscriptionId || !active.stripeSubscriptionItemId) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.ADDON_NOT_ACTIVE);
+ }
+
+ const removeSubscriptionItem = ctx.provider.removeSubscriptionItem;
+ if (!removeSubscriptionItem) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_OPERATION_UNSUPPORTED);
+ }
+
+ await removeSubscriptionItem({
+ providerSubscriptionId: active.stripeSubscriptionId,
+ providerSubscriptionItemId: active.stripeSubscriptionItemId,
+ });
+
+ await endSubscriptions(ctx.database, [active.id], {
+ canceled: false,
+ endedAt: new Date(),
+ status: "ended",
+ });
+}
+
async function clearScheduledSubscriptionsInGroupIfNeeded(
database: PayKitContext["database"],
subCtx: SubscribeContext,
@@ -1320,16 +1606,61 @@ export async function getScheduledSubscriptionsInGroup(
export async function getSubscriptionByProviderSubscriptionId(
database: PayKitDatabase,
- input: { providerId: string; providerSubscriptionId: string },
+ input: {
+ providerId: string;
+ providerSubscriptionId: string;
+ providerSubscriptionItemId?: string | null;
+ },
): Promise {
return (
(await database.query.subscription.findFirst({
orderBy: (s, { desc: d }) => [d(s.createdAt)],
- where: eq(subscription.stripeSubscriptionId, input.providerSubscriptionId),
+ where: input.providerSubscriptionItemId
+ ? and(
+ eq(subscription.stripeSubscriptionId, input.providerSubscriptionId),
+ eq(subscription.stripeSubscriptionItemId, input.providerSubscriptionItemId),
+ )
+ : eq(subscription.stripeSubscriptionId, input.providerSubscriptionId),
})) ?? null
);
}
+/** All local rows sharing one Stripe subscription id, across every line item. */
+export async function getSubscriptionsByProviderSubscriptionId(
+ database: PayKitDatabase,
+ input: { providerSubscriptionId: string },
+): Promise {
+ return database.query.subscription.findMany({
+ where: eq(subscription.stripeSubscriptionId, input.providerSubscriptionId),
+ });
+}
+
+/**
+ * Resolves the local row for one Stripe subscription item, self-healing legacy
+ * rows created before item ids existed: if exactly one row for the Stripe
+ * subscription has no item id yet, it's adopted rather than treated as missing
+ * (which would otherwise insert a duplicate row for the same item).
+ */
+async function resolveExistingSubscriptionForItem(
+ database: PayKitDatabase,
+ input: {
+ providerId: string;
+ providerSubscriptionId: string;
+ providerSubscriptionItemId?: string | null;
+ },
+): Promise {
+ const exact = await getSubscriptionByProviderSubscriptionId(database, input);
+ if (exact || !input.providerSubscriptionItemId) {
+ return exact;
+ }
+
+ const allForSubscription = await getSubscriptionsByProviderSubscriptionId(database, {
+ providerSubscriptionId: input.providerSubscriptionId,
+ });
+ const legacyCandidates = allForSubscription.filter((sub) => sub.stripeSubscriptionItemId == null);
+ return legacyCandidates.length === 1 ? legacyCandidates[0]! : null;
+}
+
async function getSubscriptionById(
database: PayKitDatabase,
subscriptionId: string,
@@ -1347,11 +1678,13 @@ export async function insertSubscriptionRecord(
customerId: string;
currentPeriodEndAt?: Date | null;
currentPeriodStartAt?: Date | null;
+ id?: string;
planFeatures: readonly NormalizedPlanFeature[];
productInternalId: string;
scheduledProductId?: string | null;
startedAt?: Date | null;
stripeSubscriptionId?: string | null;
+ stripeSubscriptionItemId?: string | null;
stripeSubscriptionScheduleId?: string | null;
status: string;
trialEndsAt?: Date | null;
@@ -1368,12 +1701,13 @@ export async function insertSubscriptionRecord(
currentPeriodStartAt: input.currentPeriodStartAt ?? null,
customerId: input.customerId,
endedAt: null,
- id: generateId("sub"),
+ id: input.id ?? generateId("sub"),
productInternalId: input.productInternalId,
quantity: 1,
scheduledProductId: input.scheduledProductId ?? null,
startedAt: input.startedAt ?? now,
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
+ stripeSubscriptionItemId: input.stripeSubscriptionItemId ?? null,
stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId ?? null,
status: input.status,
trialEndsAt: input.trialEndsAt ?? null,
@@ -1518,6 +1852,7 @@ export async function activateScheduledSubscription(
startedAt?: Date | null;
status: string;
stripeSubscriptionId?: string | null;
+ stripeSubscriptionItemId?: string | null;
stripeSubscriptionScheduleId?: string | null;
},
): Promise {
@@ -1531,6 +1866,7 @@ export async function activateScheduledSubscription(
endedAt: null,
startedAt: input.startedAt ?? new Date(),
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
+ stripeSubscriptionItemId: input.stripeSubscriptionItemId ?? null,
stripeSubscriptionScheduleId: input.stripeSubscriptionScheduleId ?? null,
status: input.status,
updatedAt: new Date(),
@@ -1603,6 +1939,7 @@ export async function syncSubscriptionBillingState(
currentPeriodStartAt?: Date | null;
startedAt?: Date | null;
stripeSubscriptionId?: string | null;
+ stripeSubscriptionItemId?: string | null;
stripeSubscriptionScheduleId?: string | null;
status?: string;
},
@@ -1630,6 +1967,10 @@ export async function syncSubscriptionBillingState(
input.stripeSubscriptionId !== undefined
? input.stripeSubscriptionId
: existing.stripeSubscriptionId,
+ stripeSubscriptionItemId:
+ input.stripeSubscriptionItemId !== undefined
+ ? input.stripeSubscriptionItemId
+ : existing.stripeSubscriptionItemId,
stripeSubscriptionScheduleId:
input.stripeSubscriptionScheduleId !== undefined
? input.stripeSubscriptionScheduleId
@@ -1640,6 +1981,53 @@ export async function syncSubscriptionBillingState(
.where(eq(subscription.id, input.subscriptionId));
}
+/** Ends any local row for this Stripe subscription whose item no longer exists on it (e.g. a removed add-on). */
+export async function reconcileRemovedSubscriptionItems(
+ ctx: PayKitContext,
+ input: { providerSubscriptionId: string; activeProviderSubscriptionItemIds: readonly string[] },
+): Promise {
+ const rows = await getSubscriptionsByProviderSubscriptionId(ctx.database, {
+ providerSubscriptionId: input.providerSubscriptionId,
+ });
+ const stale = rows.filter(
+ (row) =>
+ row.stripeSubscriptionItemId != null &&
+ row.endedAt == null &&
+ !input.activeProviderSubscriptionItemIds.includes(row.stripeSubscriptionItemId),
+ );
+ if (stale.length === 0) {
+ return;
+ }
+
+ await endSubscriptions(
+ ctx.database,
+ stale.map((row) => row.id),
+ { canceled: false, endedAt: new Date(), status: "ended" },
+ );
+
+ for (const row of stale) {
+ const storedProduct = await ctx.database.query.product.findFirst({
+ where: eq(product.internalId, row.productInternalId),
+ });
+ const productGroup = storedProduct?.group ?? "";
+ if (!productGroup) {
+ continue;
+ }
+
+ await ensureScheduledDefaultPlan(ctx, {
+ customerId: row.customerId,
+ group: productGroup,
+ startsAt: new Date(),
+ });
+ await activateScheduledSubscriptionForGroup(ctx, {
+ customerId: row.customerId,
+ productGroup,
+ subscriptionCurrentPeriodStartAt: new Date(),
+ subscriptionStatus: "active",
+ });
+ }
+}
+
export async function getCurrentSubscriptions(
database: PayKitDatabase,
customerId: string,
diff --git a/packages/paykit/src/subscription/subscription.types.ts b/packages/paykit/src/subscription/subscription.types.ts
index a069976d..e92cf9bf 100644
--- a/packages/paykit/src/subscription/subscription.types.ts
+++ b/packages/paykit/src/subscription/subscription.types.ts
@@ -3,12 +3,32 @@ import * as z from "zod";
import { returnUrl } from "../api/define-route";
import type { StoredSubscription } from "../types/models";
-export const subscribeBodySchema = z.object({
- planId: z.string(),
- forceCheckout: z.boolean().optional(),
- successUrl: returnUrl(),
- cancelUrl: returnUrl().optional(),
-});
+export const subscribeBodySchema = z
+ .object({
+ planId: z.string(),
+ /** Additional plan IDs to combine with `planId` into one checkout (e.g. add-ons). */
+ addOnPlanIds: z
+ .array(z.string())
+ .max(10, "At most 10 add-on plans can be combined into one checkout")
+ .optional(),
+ forceCheckout: z.boolean().optional(),
+ successUrl: returnUrl(),
+ cancelUrl: returnUrl().optional(),
+ })
+ .superRefine((body, ctx) => {
+ if (!body.addOnPlanIds) {
+ return;
+ }
+
+ const allPlanIds = [body.planId, ...body.addOnPlanIds];
+ if (new Set(allPlanIds).size !== allPlanIds.length) {
+ ctx.addIssue({
+ code: "custom",
+ message: "addOnPlanIds must not duplicate planId or list the same plan more than once",
+ path: ["addOnPlanIds"],
+ });
+ }
+ });
export type SubscribeBody = z.infer;
diff --git a/packages/paykit/src/types/events.ts b/packages/paykit/src/types/events.ts
index a9289996..ec5bbd9c 100644
--- a/packages/paykit/src/types/events.ts
+++ b/packages/paykit/src/types/events.ts
@@ -26,6 +26,7 @@ export interface NormalizedSubscription {
endedAt?: Date | null;
providerProduct?: Record | null;
providerSubscriptionId: string;
+ providerSubscriptionItemId?: string | null;
providerSubscriptionScheduleId?: string | null;
status: string;
}
@@ -106,6 +107,7 @@ type EventByName = {
export interface NormalizedWebhookEventMap {
"checkout.completed": {
+ activeProviderSubscriptionItemIds?: string[];
checkoutSessionId: string;
invoice?: CheckoutCompletedInvoice;
metadata?: Record;
@@ -117,6 +119,7 @@ export interface NormalizedWebhookEventMap {
providerEventId?: string;
status: string | null;
subscription?: CheckoutCompletedSubscription;
+ subscriptions?: CheckoutCompletedSubscription[];
};
"payment_method.attached": {
paymentMethod: NormalizedPaymentMethod;
@@ -129,9 +132,11 @@ export interface NormalizedWebhookEventMap {
providerEventId?: string;
};
"subscription.updated": {
+ activeProviderSubscriptionItemIds?: string[];
providerCustomerId: string;
providerEventId?: string;
subscription: NormalizedSubscription;
+ subscriptions?: NormalizedSubscription[];
};
"subscription.deleted": {
providerCustomerId: string;
diff --git a/packages/paykit/src/types/schema.ts b/packages/paykit/src/types/schema.ts
index 72ab1042..c9ea59ad 100644
--- a/packages/paykit/src/types/schema.ts
+++ b/packages/paykit/src/types/schema.ts
@@ -16,13 +16,27 @@ const entityIdSchema = z
const planNameSchema = z.string().min(1, "Plan name must not be empty");
const planGroupSchema = z.string().min(1, "Plan group must not be empty").max(64);
-const priceSchema = z.object({
- amount: z
- .number()
- .positive("Price amount must be positive")
- .max(999_999.99, "Price amount must not exceed $999,999.99"),
+const priceAmountSchema = z
+ .number()
+ .positive("Price amount must be positive")
+ .max(999_999.99, "Price amount must not exceed $999,999.99");
+const meteredUnitAmountSchema = z
+ .number()
+ .min(0.01, "Metered price unit amount must be at least $0.01")
+ .max(999_999.99, "Price amount must not exceed $999,999.99");
+const licensedPriceSchema = z.strictObject({
+ amount: priceAmountSchema,
interval: z.enum(["month", "year"]),
});
+/** A price billed via Stripe usage-based billing: `unitAmount` charges per unit reported through `reportUsage()`. */
+const meteredPriceSchema = z.strictObject({
+ interval: z.enum(["month", "year"]),
+ meteredBy: z.custom>(isPayKitFeature, {
+ message: "meteredBy must be a metered feature returned by feature(...)",
+ }),
+ unitAmount: meteredUnitAmountSchema,
+});
+const priceSchema = z.union([licensedPriceSchema, meteredPriceSchema]);
const meteredFeatureConfigSchema = z.object({
limit: z.number().int().positive("Feature limit must be a positive integer"),
@@ -52,6 +66,12 @@ export type PriceInterval = z.infer["interval"];
export type MeteredResetInterval = z.infer["reset"];
export type PlanPrice = z.infer;
export type MeteredFeatureConfig = z.infer;
+/** "licensed" is a normal recurring price; "metered" is billed via Stripe usage-based billing. */
+export type PriceUsageType = "licensed" | "metered";
+
+function isMeteredPrice(price: PlanPrice): price is z.infer {
+ return "meteredBy" in price;
+}
export interface PayKitFeatureDefinition<
TId extends string = string,
@@ -132,10 +152,12 @@ export interface NormalizedPlan {
id: string;
includes: readonly NormalizedPlanFeature[];
isDefault: boolean;
+ meteredFeatureId: string | null;
name: string;
priceAmount: number | null;
priceCurrency: string | null;
priceInterval: PriceInterval | null;
+ priceUsageType: PriceUsageType;
trialDays: number | null;
}
@@ -327,9 +349,11 @@ export function computePlanHash(plan: Omit): string {
const payload = JSON.stringify({
group: plan.group,
isDefault: plan.isDefault,
+ meteredFeatureId: plan.meteredFeatureId,
priceAmount: plan.priceAmount,
priceCurrency: plan.priceCurrency,
priceInterval: plan.priceInterval,
+ priceUsageType: plan.priceUsageType,
features: plan.includes.map((f) => ({
id: f.id,
limit: f.limit,
@@ -422,6 +446,20 @@ export function normalizeSchema(
} satisfies NormalizedPlanFeature;
});
+ const price = exportedPlan.price;
+ let priceUsageType: PriceUsageType = "licensed";
+ let meteredFeatureId: string | null = null;
+ let priceAmountDollars: number | null = null;
+ if (price) {
+ if (isMeteredPrice(price)) {
+ priceUsageType = "metered";
+ meteredFeatureId = price.meteredBy.id;
+ priceAmountDollars = price.unitAmount;
+ } else {
+ priceAmountDollars = price.amount;
+ }
+ }
+
// oxlint-disable-next-line unicorn/no-array-sort -- spread copy, original not mutated
const sortedIncludes = [...includes].sort((left, right) => left.id.localeCompare(right.id));
const planData = {
@@ -429,10 +467,12 @@ export function normalizeSchema(
id: exportedPlan.id,
includes: sortedIncludes,
isDefault,
+ meteredFeatureId,
name: exportedPlan.name ?? deriveNameFromId(exportedPlan.id),
- priceAmount: exportedPlan.price ? Math.round(exportedPlan.price.amount * 100) : null,
- priceCurrency: exportedPlan.price ? (input?.priceCurrency ?? DEFAULT_STRIPE_CURRENCY) : null,
- priceInterval: exportedPlan.price?.interval ?? null,
+ priceAmount: priceAmountDollars != null ? Math.round(priceAmountDollars * 100) : null,
+ priceCurrency: price ? (input?.priceCurrency ?? DEFAULT_STRIPE_CURRENCY) : null,
+ priceInterval: price?.interval ?? null,
+ priceUsageType,
trialDays: null,
};
plansById.set(exportedPlan.id, { ...planData, hash: computePlanHash(planData) });
diff --git a/packages/paykit/src/usage/usage.api.ts b/packages/paykit/src/usage/usage.api.ts
new file mode 100644
index 00000000..2d252b98
--- /dev/null
+++ b/packages/paykit/src/usage/usage.api.ts
@@ -0,0 +1,25 @@
+import * as z from "zod";
+
+import { definePayKitMethod } from "../api/define-route";
+import { reportUsageToProvider } from "./usage.service";
+
+const reportUsageBodySchema = z.object({
+ featureId: z.string(),
+ quantity: z.number().positive().optional(),
+ eventId: z.string().optional(),
+});
+
+/** Reports a Stripe usage-based billing event for the resolved customer's metered plan. */
+export const reportUsage = definePayKitMethod(
+ {
+ input: reportUsageBodySchema,
+ requireCustomer: true,
+ },
+ async (ctx) =>
+ reportUsageToProvider(ctx.paykit, {
+ customerId: ctx.customer.id,
+ eventId: ctx.input.eventId,
+ featureId: ctx.input.featureId,
+ quantity: ctx.input.quantity,
+ }),
+);
diff --git a/packages/paykit/src/usage/usage.service.ts b/packages/paykit/src/usage/usage.service.ts
new file mode 100644
index 00000000..31beae28
--- /dev/null
+++ b/packages/paykit/src/usage/usage.service.ts
@@ -0,0 +1,74 @@
+import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
+
+import type { PayKitContext } from "../core/context";
+import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors";
+import type { PayKitDatabase } from "../database";
+import { customer, product, subscription } from "../database/schema";
+
+async function getActiveMeteredLinkForFeature(
+ database: PayKitDatabase,
+ input: { customerId: string; featureId: string },
+): Promise<{ providerCustomerId: string } | null> {
+ const rows = await database
+ .select({ stripeCustomerId: customer.stripeCustomerId })
+ .from(subscription)
+ .innerJoin(product, eq(product.internalId, subscription.productInternalId))
+ .innerJoin(customer, eq(customer.id, subscription.customerId))
+ .where(
+ and(
+ eq(subscription.customerId, input.customerId),
+ eq(product.priceUsageType, "metered"),
+ eq(product.meteredFeatureId, input.featureId),
+ inArray(subscription.status, ["active", "trialing", "past_due"]),
+ or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`),
+ ),
+ )
+ .limit(1);
+
+ const row = rows[0];
+ if (!row?.stripeCustomerId) {
+ return null;
+ }
+
+ return { providerCustomerId: row.stripeCustomerId };
+}
+
+/**
+ * Reports a Stripe usage-based billing event for a customer's active metered plan.
+ * Unlike the local entitlement `report()`, this call's entire purpose is the billing
+ * side effect, so failures rethrow rather than being swallowed. Callers wanting
+ * at-least-once safety should pass a stable `eventId`, forwarded as Stripe's `identifier`.
+ */
+export async function reportUsageToProvider(
+ ctx: PayKitContext,
+ input: {
+ customerId: string;
+ featureId: string;
+ quantity?: number;
+ eventId?: string;
+ timestamp?: Date;
+ },
+): Promise<{ success: true; providerEventId: string }> {
+ const link = await getActiveMeteredLinkForFeature(ctx.database, {
+ customerId: input.customerId,
+ featureId: input.featureId,
+ });
+ if (!link) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.USAGE_NOT_METERED_FOR_CUSTOMER);
+ }
+
+ const reportUsageEvent = ctx.provider.reportUsageEvent;
+ if (!reportUsageEvent) {
+ throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_OPERATION_UNSUPPORTED);
+ }
+
+ const { providerEventId } = await reportUsageEvent({
+ identifier: input.eventId,
+ meterEventName: input.featureId,
+ providerCustomerId: link.providerCustomerId,
+ timestamp: input.timestamp,
+ value: input.quantity ?? 1,
+ });
+
+ return { providerEventId, success: true };
+}
diff --git a/packages/paykit/src/webhook/webhook.service.ts b/packages/paykit/src/webhook/webhook.service.ts
index 33151a25..300c961c 100644
--- a/packages/paykit/src/webhook/webhook.service.ts
+++ b/packages/paykit/src/webhook/webhook.service.ts
@@ -12,6 +12,7 @@ import {
handleSubscribeCheckoutCompleted,
applySubscriptionWebhookAction,
prepareSubscribeCheckoutCompleted,
+ reconcileRemovedSubscriptionItems,
} from "../subscription/subscription.service";
import type { AnyNormalizedWebhookEvent, WebhookApplyAction } from "../types/events";
@@ -143,7 +144,7 @@ async function processWebhookEvent(
try {
// Provider calls must happen before the DB transaction opens.
- const checkoutCompletion =
+ const checkoutCompletions =
event.name === "checkout.completed"
? await prepareSubscribeCheckoutCompleted(ctx, event)
: null;
@@ -152,8 +153,8 @@ async function processWebhookEvent(
const txCtx = { ...ctx, database: tx } as PayKitContext;
const ids = new Set();
- if (checkoutCompletion) {
- const customerId = await handleSubscribeCheckoutCompleted(txCtx, checkoutCompletion);
+ for (const completion of checkoutCompletions ?? []) {
+ const customerId = await handleSubscribeCheckoutCompleted(txCtx, completion);
if (customerId) {
ids.add(customerId);
}
@@ -167,6 +168,16 @@ async function processWebhookEvent(
}
}
+ if (
+ event.name === "subscription.updated" &&
+ event.payload.activeProviderSubscriptionItemIds
+ ) {
+ await reconcileRemovedSubscriptionItems(txCtx, {
+ activeProviderSubscriptionItemIds: event.payload.activeProviderSubscriptionItemIds,
+ providerSubscriptionId: event.payload.subscription.providerSubscriptionId,
+ });
+ }
+
return ids;
});