Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/paykit/src/cli/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand Down
21 changes: 2 additions & 19 deletions packages/paykit/src/entitlement/entitlement.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "../types/interval";

export interface EntitlementBalance {
limit: number;
Expand All @@ -28,29 +29,11 @@ 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);
}
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);
Comment thread
t3duk marked this conversation as resolved.
}

return nextResetAt;
Expand Down
6 changes: 5 additions & 1 deletion packages/paykit/src/product/product-sync.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)
);
});
Expand Down
5 changes: 4 additions & 1 deletion packages/paykit/src/product/product.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
});
}
Expand Down
5 changes: 4 additions & 1 deletion packages/paykit/src/providers/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 2 additions & 20 deletions packages/paykit/src/subscription/subscription.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, { productId: string; priceId: string | null }>;

export async function warnOnDuplicateActiveSubscriptionGroups(
Expand Down Expand Up @@ -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,
});
}
Expand Down
69 changes: 69 additions & 0 deletions packages/paykit/src/types/__tests__/interval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";

import { addInterval, getStripeRecurringInterval } 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" });
});
});
116 changes: 116 additions & 0 deletions packages/paykit/src/types/interval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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.number().int().positive("Reset interval seconds must be a positive integer"),
]);
Comment thread
t3duk marked this conversation as resolved.

export type PlanInterval = z.infer<typeof planIntervalSchema>;
export type MeteredResetInterval = z.infer<typeof meteredResetIntervalSchema>;

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

function parseSecondInterval(interval: string): number | null {
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 {
if (typeof interval === "number") {
return new Date(date.getTime() + interval * 1000);
}

const secondInterval = parseSecondInterval(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 };
}
4 changes: 3 additions & 1 deletion packages/paykit/src/types/product.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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({
Expand Down
Loading