diff --git a/landing/content/docs/concepts/entitlements.mdx b/landing/content/docs/concepts/entitlements.mdx index 6b088455..83397d62 100644 --- a/landing/content/docs/concepts/entitlements.mdx +++ b/landing/content/docs/concepts/entitlements.mdx @@ -65,7 +65,12 @@ If the customer doesn't have sufficient balance, `success` is `false` and the ba Metered balances reset lazily. PayKit doesn't run a scheduled job to reset balances at midnight. Instead, when a `check()` or `report()` happens after the reset time, PayKit detects the period has passed and resets the balance automatically. -Reset intervals are set per feature grant in your plan definition: `day`, `week`, `month`, or `year`. +Reset intervals are set per feature grant in your plan definition. You can use named cadences like `day`, `week`, `biweek`, `month`, `quarterly`, `biyear`, and `year`, or pass a positive integer number of seconds for custom windows. + +```ts +messages({ limit: 100, reset: "quarterly" }) +messages({ limit: 100, reset: 3_600 }) +``` Lazy resets mean a customer who doesn't use the app won't have their balance reset until they do. The reset time is still calculated from the period boundary, not from when the reset was detected. diff --git a/landing/content/docs/concepts/plans-and-features.mdx b/landing/content/docs/concepts/plans-and-features.mdx index 4ad53a33..c01f0fb6 100644 --- a/landing/content/docs/concepts/plans-and-features.mdx +++ b/landing/content/docs/concepts/plans-and-features.mdx @@ -42,7 +42,15 @@ For **metered features**, pass a `limit` and a `reset` interval: messages({ limit: 100, reset: "month" }) ``` -The `reset` interval can be `day`, `week`, `month`, or `year`. +The `reset` interval can be: + +- a named cadence: `day`, `week`, `biweek`, `month`, `quarterly`, `biyear`, or `year` +- a positive integer number of seconds for custom periods such as `60`, `3_600`, or `86_400` + +```ts +messages({ limit: 100, reset: "biweek" }) +messages({ limit: 100, reset: 3_600 }) +``` ## Defining plans @@ -122,9 +130,19 @@ Plans without a `price` are free. Paid plans take an `amount` in dollars and an price: { amount: 19, interval: "month" } ``` -- `interval` can be `month` or `year` +- `interval` can be `day`, `week`, `month`, `quarterly`, `biyear`, or `year` - `amount` is in dollars, max $999,999.99 +```ts +price: { amount: 19, interval: "week" } +price: { amount: 49, interval: "quarterly" } +price: { amount: 99, interval: "biyear" } +``` + + + Billing intervals are provider-safe presets. Meter resets can also use arbitrary second values, but plan pricing cannot. + + ## Passing plans to PayKit Pass your plans array to `createPayKit`. You can import them directly or re-export them from a module object. diff --git a/landing/content/docs/flows/metered-usage.mdx b/landing/content/docs/flows/metered-usage.mdx index 0290973b..b6c05632 100644 --- a/landing/content/docs/flows/metered-usage.mdx +++ b/landing/content/docs/flows/metered-usage.mdx @@ -47,9 +47,17 @@ export const pro = plan({ messages({ limit: 2_000, reset: "month" }), ], }); + +export const burst = plan({ + id: "burst", + name: "Burst", + group: "addons", + price: { amount: 5, interval: "week" }, + includes: [messages({ limit: 500, reset: 3_600 })], // reset every hour (3600 seconds) +}); ``` -Free customers get 100 messages per month; Pro customers get 2,000. +Free customers get 100 messages per month; Pro customers get 2,000. Custom second-based resets are useful for short-lived buckets like hourly quotas, while named resets cover common billing cadences such as `biweek` or `quarterly`. @@ -112,6 +120,8 @@ PayKit uses lazy resets. When the reset period passes, it doesn't reset balances This means you don't need any cron jobs or scheduled tasks. Resets happen on-demand, exactly when needed. +Named reset values are `day`, `week`, `biweek`, `month`, `quarterly`, `biyear`, and `year`. For anything custom, pass a positive integer number of seconds. + diff --git a/landing/content/docs/flows/subscription-billing.mdx b/landing/content/docs/flows/subscription-billing.mdx index 4fd5fb11..40254d6f 100644 --- a/landing/content/docs/flows/subscription-billing.mdx +++ b/landing/content/docs/flows/subscription-billing.mdx @@ -35,20 +35,28 @@ This guide walks through a complete subscription billing flow: defining plans, s proModels(), ], }); + + export const team = plan({ + id: "team", + name: "Team", + group: "base", + price: { amount: 49, interval: "quarterly" }, + includes: [messages({ limit: 10_000, reset: "quarterly" }), proModels()], + }); ``` Pass your plans to `createPayKit`: ```ts title="paykit.ts" - import { free, pro } from "./plans"; + import { free, pro, team } from "./plans"; export const paykit = createPayKit({ // ... - plans: [free, pro], + plans: [free, pro, team], }); ``` - For the full reference on plan groups, feature types, and pricing options, see [Plans & Features](/docs/concepts/plans-and-features). + Plan billing intervals support `day`, `week`, `month`, `quarterly`, `biyear`, and `year`. For the full reference on plan groups, feature types, and pricing options, see [Plans & Features](/docs/concepts/plans-and-features). diff --git a/landing/content/docs/get-started/installation.mdx b/landing/content/docs/get-started/installation.mdx index e5c0a7c6..a3308cab 100644 --- a/landing/content/docs/get-started/installation.mdx +++ b/landing/content/docs/get-started/installation.mdx @@ -172,9 +172,7 @@ export const free = plan({ name: "Free", group: "base", default: true, - includes: [ - messages({ limit: 100, reset: "month" }) - ], + includes: [messages({ limit: 100, reset: "month" })], }); export const pro = plan({ @@ -182,13 +180,12 @@ export const pro = plan({ name: "Pro", group: "base", price: { amount: 19, interval: "month" }, - includes: [ - messages({ limit: 2000, reset: "month" }), - proModels() - ], + includes: [messages({ limit: 2_000, reset: "month" }), proModels()], }); ``` +Plan pricing supports `day`, `week`, `month`, `quarterly`, `biyear`, and `year`. Metered feature resets support those common named cadences plus `biweek`, and can also use any positive integer number of seconds for custom reset windows. + Then pass your plans to the main options: ```ts title="paykit.ts" diff --git a/packages/paykit/src/cli/utils/format.ts b/packages/paykit/src/cli/utils/format.ts index f7f5c4cf..9b933e75 100644 --- a/packages/paykit/src/cli/utils/format.ts +++ b/packages/paykit/src/cli/utils/format.ts @@ -37,9 +37,21 @@ export function formatPrice(amountCents: number, interval: string | null): strin if (!interval) { return formatted; } + if (interval === "day") { + return `${formatted}/day`; + } + if (interval === "week") { + return `${formatted}/wk`; + } if (interval === "month") { return `${formatted}/mo`; } + if (interval === "quarterly") { + return `${formatted}/3 mo`; + } + if (interval === "biyear") { + return `${formatted}/6 mo`; + } if (interval === "year") { return `${formatted}/yr`; } diff --git a/packages/paykit/src/entitlement/entitlement.service.ts b/packages/paykit/src/entitlement/entitlement.service.ts index 48feec30..ddb129fd 100644 --- a/packages/paykit/src/entitlement/entitlement.service.ts +++ b/packages/paykit/src/entitlement/entitlement.service.ts @@ -2,6 +2,7 @@ import { type SQL, and, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"; import type { PayKitDatabase } from "../database"; import { entitlement, productFeature, subscription } from "../database/schema"; +import { addInterval, getSecondInterval } from "../types/interval"; export interface EntitlementBalance { limit: number; @@ -28,29 +29,18 @@ interface ActiveEntitlementRow { resetInterval: string | null; } -function addResetInterval(date: Date, resetInterval: string): Date { - const next = new Date(date); - if (resetInterval === "day") next.setUTCDate(next.getUTCDate() + 1); - if (resetInterval === "week") next.setUTCDate(next.getUTCDate() + 7); - if (resetInterval === "month") { - const day = next.getUTCDate(); - next.setUTCMonth(next.getUTCMonth() + 1); - // Clamp: if day overflowed (e.g. Jan 31 → Mar 3), go to last day of target month - if (next.getUTCDate() !== day) next.setUTCDate(0); - } - if (resetInterval === "year") { - const day = next.getUTCDate(); - next.setUTCFullYear(next.getUTCFullYear() + 1); - if (next.getUTCDate() !== day) next.setUTCDate(0); +function getNextResetAt(currentResetAt: Date, now: Date, resetInterval: string): Date { + const secondInterval = getSecondInterval(resetInterval); + if (secondInterval !== null) { + const elapsedMs = now.getTime() - currentResetAt.getTime(); + const missedIntervals = Math.max(0, Math.ceil(elapsedMs / (secondInterval * 1000))); + return addInterval(currentResetAt, missedIntervals * secondInterval); } - return next; -} -function getNextResetAt(currentResetAt: Date, now: Date, resetInterval: string): Date { let nextResetAt = new Date(currentResetAt); while (nextResetAt <= now) { - nextResetAt = addResetInterval(nextResetAt, resetInterval); + nextResetAt = addInterval(nextResetAt, resetInterval); } return nextResetAt; diff --git a/packages/paykit/src/product/product-sync.service.ts b/packages/paykit/src/product/product-sync.service.ts index b109da19..d94f241d 100644 --- a/packages/paykit/src/product/product-sync.service.ts +++ b/packages/paykit/src/product/product-sync.service.ts @@ -1,5 +1,6 @@ import type { PayKitContext } from "../core/context"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; +import { serializeMeteredResetInterval } from "../types/interval"; import type { StoredProductFeature } from "../types/models"; import type { NormalizedPlan, NormalizedPlanFeature } from "../types/schema"; import { @@ -39,7 +40,10 @@ function featuresChanged( return ( storedFeature.featureId !== nextFeature.id || storedFeature.limit !== nextFeature.limit || - storedFeature.resetInterval !== nextFeature.resetInterval || + storedFeature.resetInterval !== + (nextFeature.resetInterval + ? serializeMeteredResetInterval(nextFeature.resetInterval) + : null) || serializeFeatureConfig(storedFeature.config) !== serializeFeatureConfig(nextFeature.config) ); }); diff --git a/packages/paykit/src/product/product.service.ts b/packages/paykit/src/product/product.service.ts index 2b198c61..6b73c4de 100644 --- a/packages/paykit/src/product/product.service.ts +++ b/packages/paykit/src/product/product.service.ts @@ -4,6 +4,7 @@ import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import { generateId } from "../core/utils"; import type { PayKitDatabase } from "../database"; import { feature, product, productFeature } from "../database/schema"; +import { serializeMeteredResetInterval } from "../types/interval"; import type { StoredFeature, StoredProduct, StoredProductFeature } from "../types/models"; import type { NormalizedFeature, NormalizedPlanFeature } from "../types/schema"; @@ -198,7 +199,9 @@ export async function replaceProductFeatures( featureId: planFeature.id, limit: planFeature.limit, productInternalId: input.productInternalId, - resetInterval: planFeature.resetInterval, + resetInterval: planFeature.resetInterval + ? serializeMeteredResetInterval(planFeature.resetInterval) + : null, updatedAt: now, }); } diff --git a/packages/paykit/src/providers/stripe.ts b/packages/paykit/src/providers/stripe.ts index 308187c9..ae6d9788 100644 --- a/packages/paykit/src/providers/stripe.ts +++ b/packages/paykit/src/providers/stripe.ts @@ -2,6 +2,7 @@ import StripeSdk from "stripe"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import type { NormalizedWebhookEvent } from "../types/events"; +import { getStripeRecurringInterval, type PlanInterval } from "../types/interval"; import type { ProviderTestClock, StripeProviderConfig, StripeRuntime } from "./provider"; type StripeInvoiceWithExtras = StripeSdk.Invoice & { @@ -835,8 +836,10 @@ export function createStripeProvider( unit_amount: data.priceAmount, }; if (data.priceInterval) { + const recurring = getStripeRecurringInterval(data.priceInterval as PlanInterval); priceParams.recurring = { - interval: data.priceInterval as "month" | "year", + interval: recurring.interval, + interval_count: recurring.count, }; } const stripePrice = await client.prices.create(priceParams); diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index a69d7781..1872a583 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -24,6 +24,7 @@ import type { NormalizedWebhookEvent, UpsertSubscriptionAction, } from "../types/events"; +import { addInterval } from "../types/interval"; import type { StoredSubscription } from "../types/models"; import type { NormalizedPlanFeature } from "../types/schema"; import type { @@ -1143,23 +1144,6 @@ async function deleteScheduledSubscriptionsInGroupIfNeeded( }); } -function addResetInterval(date: Date, resetInterval: string): Date { - const next = new Date(date); - if (resetInterval === "day") next.setUTCDate(next.getUTCDate() + 1); - if (resetInterval === "week") next.setUTCDate(next.getUTCDate() + 7); - if (resetInterval === "month") { - const day = next.getUTCDate(); - next.setUTCMonth(next.getUTCMonth() + 1); - if (next.getUTCDate() !== day) next.setUTCDate(0); - } - if (resetInterval === "year") { - const day = next.getUTCDate(); - next.setUTCFullYear(next.getUTCFullYear() + 1); - if (next.getUTCDate() !== day) next.setUTCDate(0); - } - return next; -} - type ProviderProductMap = Record; export async function warnOnDuplicateActiveSubscriptionGroups( @@ -1371,9 +1355,7 @@ export async function insertSubscriptionRecord( featureId: planFeature.id, id: generateId("ent"), limit: isBoolean ? null : (planFeature.limit ?? null), - nextResetAt: planFeature.resetInterval - ? addResetInterval(now, planFeature.resetInterval) - : null, + nextResetAt: planFeature.resetInterval ? addInterval(now, planFeature.resetInterval) : null, subscriptionId: row.id, }); } diff --git a/packages/paykit/src/types/__tests__/interval.test.ts b/packages/paykit/src/types/__tests__/interval.test.ts new file mode 100644 index 00000000..31e03d55 --- /dev/null +++ b/packages/paykit/src/types/__tests__/interval.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + addInterval, + getSecondInterval, + getStripeRecurringInterval, + meteredResetIntervalSchema, +} from "../interval"; +import { feature, normalizeSchema, plan } from "../schema"; + +describe("types/interval", () => { + it("accepts the new provider-safe plan intervals", () => { + expect(() => + plan({ + id: "daily", + price: { amount: 10, interval: "day" }, + }), + ).not.toThrow(); + + expect(() => + plan({ + id: "quarterly", + price: { amount: 10, interval: "quarterly" }, + }), + ).not.toThrow(); + + expect(() => + plan({ + id: "biyear", + price: { amount: 10, interval: "biyear" }, + }), + ).not.toThrow(); + }); + + it("accepts named and numeric meter reset intervals", () => { + const messages = feature({ id: "messages", type: "metered" }); + const jobs = feature({ id: "jobs", type: "metered" }); + const normalized = normalizeSchema([ + plan({ + id: "pro", + includes: [messages({ limit: 100, reset: "biweek" }), jobs({ limit: 200, reset: 90 })], + }), + ]); + + expect(normalized.plans[0]?.includes.map((include) => include.resetInterval)).toEqual([ + 90, + "biweek", + ]); + }); + + it("adds the new reset intervals correctly", () => { + expect(addInterval(new Date("2024-01-01T00:00:00.000Z"), "biweek").toISOString()).toBe( + "2024-01-15T00:00:00.000Z", + ); + expect(addInterval(new Date("2024-01-31T00:00:00.000Z"), "quarterly").toISOString()).toBe( + "2024-04-30T00:00:00.000Z", + ); + expect(addInterval(new Date("2024-01-31T00:00:00.000Z"), "biyear").toISOString()).toBe( + "2024-07-31T00:00:00.000Z", + ); + expect(addInterval(new Date("2024-01-01T00:00:00.000Z"), 90).toISOString()).toBe( + "2024-01-01T00:01:30.000Z", + ); + expect(addInterval(new Date("2024-01-01T00:00:00.000Z"), "90").toISOString()).toBe( + "2024-01-01T00:01:30.000Z", + ); + }); + + it("maps quarterly and biyear Stripe intervals with counts", () => { + expect(getStripeRecurringInterval("quarterly")).toEqual({ count: 3, interval: "month" }); + expect(getStripeRecurringInterval("biyear")).toEqual({ count: 6, interval: "month" }); + expect(getStripeRecurringInterval("week")).toEqual({ interval: "week" }); + }); + + it("round-trips numeric second resets through the shared schema", () => { + expect(meteredResetIntervalSchema.parse("90")).toBe(90); + expect(meteredResetIntervalSchema.parse(90)).toBe(90); + expect(getSecondInterval("90")).toBe(90); + }); +}); diff --git a/packages/paykit/src/types/interval.ts b/packages/paykit/src/types/interval.ts new file mode 100644 index 00000000..3d07c440 --- /dev/null +++ b/packages/paykit/src/types/interval.ts @@ -0,0 +1,120 @@ +import * as z from "zod"; + +export const planIntervalValues = ["day", "week", "month", "quarterly", "biyear", "year"] as const; +export const meteredResetIntervalValues = [ + "day", + "week", + "biweek", + "month", + "quarterly", + "biyear", + "year", +] as const; + +export const planIntervalSchema = z.enum(planIntervalValues); +export const meteredResetIntervalSchema = z.union([ + z.enum(meteredResetIntervalValues), + z.coerce.number().int().positive("Reset interval seconds must be a positive integer"), +]); + +export type PlanInterval = z.infer; +export type MeteredResetInterval = z.infer; + +function addMonths(date: Date, months: number): Date { + const next = new Date(date); + const day = next.getUTCDate(); + next.setUTCMonth(next.getUTCMonth() + months); + if (next.getUTCDate() !== day) next.setUTCDate(0); + return next; +} + +function addYears(date: Date, years: number): Date { + const next = new Date(date); + const day = next.getUTCDate(); + next.setUTCFullYear(next.getUTCFullYear() + years); + if (next.getUTCDate() !== day) next.setUTCDate(0); + return next; +} + +export function getSecondInterval(interval: string | number): number | null { + if (typeof interval === "number") { + if (!Number.isSafeInteger(interval) || interval <= 0) { + throw new Error(`Invalid interval seconds: "${String(interval)}"`); + } + + return interval; + } + + if (!/^\d+$/u.test(interval)) { + return null; + } + + const seconds = Number(interval); + if (!Number.isSafeInteger(seconds) || seconds <= 0) { + throw new Error(`Invalid interval seconds: "${interval}"`); + } + + return seconds; +} + +export function addInterval(date: Date, interval: string | number): Date { + const secondInterval = getSecondInterval(interval); + if (secondInterval !== null) { + return new Date(date.getTime() + secondInterval * 1000); + } + + if (interval === "day") { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + 1); + return next; + } + + if (interval === "week") { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + 7); + return next; + } + + if (interval === "biweek") { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + 14); + return next; + } + + if (interval === "month") { + return addMonths(date, 1); + } + + if (interval === "quarterly") { + return addMonths(date, 3); + } + + if (interval === "biyear") { + return addMonths(date, 6); + } + + if (interval === "year") { + return addYears(date, 1); + } + + throw new Error(`Unsupported interval: "${interval}"`); +} + +export function serializeMeteredResetInterval(interval: MeteredResetInterval): string { + return typeof interval === "number" ? String(interval) : interval; +} + +export function getStripeRecurringInterval(interval: PlanInterval): { + count?: number; + interval: "day" | "week" | "month" | "year"; +} { + if (interval === "quarterly") { + return { count: 3, interval: "month" }; + } + + if (interval === "biyear") { + return { count: 6, interval: "month" }; + } + + return { interval }; +} diff --git a/packages/paykit/src/types/product.ts b/packages/paykit/src/types/product.ts index 29af5e84..8190cd03 100644 --- a/packages/paykit/src/types/product.ts +++ b/packages/paykit/src/types/product.ts @@ -1,5 +1,7 @@ import * as z from "zod"; +import { planIntervalSchema } from "./interval"; + const productIdSchema = z .string() .min(1, "Product id must not be empty") @@ -11,7 +13,7 @@ const priceSchema = z.object({ .number() .positive("Price amount must be positive") .max(999_999.99, "Price amount must not exceed $999,999.99"), - interval: z.enum(["month", "year"]).optional(), + interval: planIntervalSchema.optional(), }); const productConfigSchema = z.object({ diff --git a/packages/paykit/src/types/schema.ts b/packages/paykit/src/types/schema.ts index ff18cd05..7fea2e3b 100644 --- a/packages/paykit/src/types/schema.ts +++ b/packages/paykit/src/types/schema.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import * as z from "zod"; +import { meteredResetIntervalSchema, planIntervalSchema } from "./interval"; +import type { MeteredResetInterval, PlanInterval } from "./interval"; + const payKitFeatureSymbol = Symbol.for("paykit.feature"); const payKitFeatureIncludeSymbol = Symbol.for("paykit.feature_include"); const payKitPlanSymbol = Symbol.for("paykit.plan"); @@ -19,12 +22,12 @@ const priceSchema = z.object({ .number() .positive("Price amount must be positive") .max(999_999.99, "Price amount must not exceed $999,999.99"), - interval: z.enum(["month", "year"]), + interval: planIntervalSchema, }); const meteredFeatureConfigSchema = z.object({ limit: z.number().int().positive("Feature limit must be a positive integer"), - reset: z.enum(["day", "week", "month", "year"]), + reset: meteredResetIntervalSchema, }); function formatValidationError( @@ -46,10 +49,10 @@ function deriveNameFromId(id: string): string { } export type FeatureType = "boolean" | "metered"; -export type PriceInterval = z.infer["interval"]; -export type MeteredResetInterval = z.infer["reset"]; +export type PriceInterval = PlanInterval; export type PlanPrice = z.infer; export type MeteredFeatureConfig = z.infer; +export type { MeteredResetInterval }; export interface PayKitFeatureDefinition< TId extends string = string,