Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 45 additions & 2 deletions e2e/cli/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ describe("paykitjs push", () => {
name: product.name,
group: product.group,
is_default: product.isDefault,
priceCurrency: product.priceCurrency,
})
.from(product)
.orderBy(asc(product.id));
expect(dbRows).toEqual([
{ id: "free", name: "Free", group: "base", is_default: true },
{ id: "pro", name: "Pro", group: "base", is_default: false },
{ id: "free", name: "Free", group: "base", is_default: true, priceCurrency: null },
{ id: "pro", name: "Pro", group: "base", is_default: false, priceCurrency: "usd" },
]);

// Verify paid plan (pro) was synced to Stripe.
Expand Down Expand Up @@ -116,4 +117,46 @@ describe("paykitjs push", () => {
await database.end();
}
});

it("should sync eur prices to the database and Stripe", async () => {
const config = await getPayKitConfig({ cwd: fixture.cwd });
const database = resolveDatabase(config.options.database);
try {
const ctx = await createContext({
...config.options,
database,
stripe: {
...config.options.stripe,
currency: "eur",
},
});
const results = await syncProducts(ctx);

const proResult = results.find((r) => r.id === "pro");
expect(proResult).toMatchObject({ action: "created", version: 2 });

const proRows = await ctx.database
.select({
priceCurrency: product.priceCurrency,
stripePriceId: product.stripePriceId,
})
.from(product)
.where(eq(product.id, "pro"))
.orderBy(desc(product.version))
.limit(1);
const proProduct = proRows[0];
expect(proProduct?.priceCurrency).toBe("eur");
if (!proProduct?.stripePriceId) {
throw new Error("Missing Stripe price metadata for synced EUR plan");
}

const stripePrice = await fixture.stripeClient.prices.retrieve(proProduct.stripePriceId);
expect(stripePrice.currency).toBe("eur");

const diffs = await dryRunSyncProducts(ctx);
expect(diffs.find((d) => d.id === "pro")?.action).toBe("unchanged");
} finally {
await database.end();
}
});
});
13 changes: 13 additions & 0 deletions packages/paykit/src/cli/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";

import { formatPrice } from "../utils/format";

describe("cli/format", () => {
it("formats monthly prices in eur", () => {
expect(formatPrice(2900, "month", "eur")).toBe("€29/mo");
});

it("formats decimal prices in eur", () => {
expect(formatPrice(2999, "month", "eur")).toBe("€29.99/mo");
});
});
13 changes: 11 additions & 2 deletions packages/paykit/src/cli/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,18 @@ export function maskConnectionString(url: string): string {
}
}

export function formatPrice(amountCents: number, interval: string | null): string {
export function formatPrice(
amountCents: number,
interval: string | null,
currency = "usd",
): string {
const dollars = amountCents / 100;
const formatted = dollars % 1 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`;
const formatted = new Intl.NumberFormat("en-US", {
currency: currency.toUpperCase(),
maximumFractionDigits: dollars % 1 === 0 ? 0 : 2,
minimumFractionDigits: dollars % 1 === 0 ? 0 : 2,
style: "currency",
}).format(dollars);
if (!interval) {
return formatted;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/paykit/src/cli/utils/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ export function formatProductDiffs(
const productsById = new Map(products.map((pl) => [pl.id, pl]));
return diffs.map((diff) => {
const plan = productsById.get(diff.id);
const price = plan ? deps.formatPrice(plan.priceAmount ?? 0, plan.priceInterval) : "$0";
const price = plan
? deps.formatPrice(plan.priceAmount ?? 0, plan.priceInterval, plan.priceCurrency ?? "usd")
: "$0";
return deps.formatPlanLine(diff.action, diff.id, price);
});
}
Expand Down
40 changes: 40 additions & 0 deletions packages/paykit/src/core/__tests__/validate-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";

import type { PayKitOptions } from "../../types/options";
import { assertValidPayKitOptions } from "../validate-options";

function createOptions(currency?: string): PayKitOptions {
return {
database: "postgresql://localhost:5432/paykit",
stripe: {
...(currency !== undefined ? { currency: currency as never } : {}),
secretKey: "sk_test_123",
webhookSecret: "whsec_123",
},
};
}

describe("core/validate-options", () => {
it("accepts usd and eur Stripe currencies", () => {
expect(() => assertValidPayKitOptions(createOptions("usd"))).not.toThrow();
expect(() => assertValidPayKitOptions(createOptions("eur"))).not.toThrow();
});

it("rejects unsupported Stripe currencies", () => {
expect(() => assertValidPayKitOptions(createOptions("gbp"))).toThrow(
"currently supports Stripe currencies: usd, eur",
);
});

it("rejects non-lowercase Stripe currencies", () => {
expect(() => assertValidPayKitOptions(createOptions("EUR"))).toThrow(
"must be a lowercase three-letter currency code",
);
});

it("rejects empty Stripe currency", () => {
expect(() => assertValidPayKitOptions(createOptions(""))).toThrow(
"must be a lowercase three-letter currency code",
);
});
});
5 changes: 4 additions & 1 deletion packages/paykit/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Pool } from "pg";

import { createDatabase, type PayKitDatabase } from "../database/index";
import type { PaymentProvider } from "../providers/provider";
import { getStripeCurrency } from "../stripe/currency";
import { createStripeAdapter } from "../stripe/stripe-provider";
import type { PayKitOptions } from "../types/options";
import { normalizeSchema, type NormalizedSchema } from "../types/schema";
Expand Down Expand Up @@ -43,7 +44,9 @@ export async function createContext(options: PayKitOptions): Promise<PayKitConte
basePath,
database,
provider,
products: normalizeSchema(options.products),
products: normalizeSchema(options.products, {
priceCurrency: getStripeCurrency(options.stripe),
}),
logger: createPayKitLogger(options.logging),
};
}
25 changes: 25 additions & 0 deletions packages/paykit/src/core/validate-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isSupportedStripeCurrency, SUPPORTED_STRIPE_CURRENCIES } from "../stripe/currency";
import type { PayKitOptions } from "../types/options";

function hasLegacyPlansOption(options: object): options is { plans: unknown } {
Expand Down Expand Up @@ -28,6 +29,30 @@ export function assertValidPayKitOptions(
for (const origin of options.trustedOrigins ?? []) {
assertValidTrustedOrigin(origin);
}

const currency = options.stripe?.currency;
if (currency !== undefined) {
assertValidStripeCurrency(currency);
}
}

function assertValidStripeCurrency(currency: unknown): void {
if (
typeof currency !== "string" ||
currency !== currency.toLowerCase() ||
!/^[a-z]{3}$/.test(currency)
) {
const received = typeof currency === "string" ? currency : String(currency);
throw new Error(
`PayKit option \`stripe.currency\` must be a lowercase three-letter currency code. Received "${received}".`,
);
}

if (!isSupportedStripeCurrency(currency)) {
throw new Error(
`PayKit currently supports Stripe currencies: ${SUPPORTED_STRIPE_CURRENCIES.join(", ")}. Received "${currency}".`,
);
}
}

function assertValidTrustedOrigin(origin: string): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/paykit/src/customer/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
product,
subscription,
} from "../database/schema";
import { getProductByHash } from "../product/product.service";
import { getProductByPlan } from "../product/product.service";
import type { ProviderCustomer } from "../providers/provider";
import {
getActiveSubscriptionInGroup,
Expand Down Expand Up @@ -160,7 +160,7 @@ export async function ensureDefaultPlansForCustomer(
continue;
}

const storedPlan = await getProductByHash(ctx.database, defaultPlan.id, defaultPlan.hash);
const storedPlan = await getProductByPlan(ctx.database, defaultPlan);
if (!storedPlan) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE "paykit_product" ADD COLUMN "price_currency" text;--> statement-breakpoint
UPDATE "paykit_product"
SET "price_currency" = 'usd'
WHERE "price_amount" IS NOT NULL;
Loading
Loading