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; });