diff --git a/e2e/cli/push.test.ts b/e2e/cli/push.test.ts index 0b9dc9b..b2f13db 100644 --- a/e2e/cli/push.test.ts +++ b/e2e/cli/push.test.ts @@ -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. @@ -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(); + } + }); }); diff --git a/packages/paykit/src/cli/__tests__/format.test.ts b/packages/paykit/src/cli/__tests__/format.test.ts new file mode 100644 index 0000000..2d343a3 --- /dev/null +++ b/packages/paykit/src/cli/__tests__/format.test.ts @@ -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"); + }); +}); diff --git a/packages/paykit/src/cli/utils/format.ts b/packages/paykit/src/cli/utils/format.ts index baa3933..5611bbf 100644 --- a/packages/paykit/src/cli/utils/format.ts +++ b/packages/paykit/src/cli/utils/format.ts @@ -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; } diff --git a/packages/paykit/src/cli/utils/shared.ts b/packages/paykit/src/cli/utils/shared.ts index f45d1bd..de4892e 100644 --- a/packages/paykit/src/cli/utils/shared.ts +++ b/packages/paykit/src/cli/utils/shared.ts @@ -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); }); } diff --git a/packages/paykit/src/core/__tests__/validate-options.test.ts b/packages/paykit/src/core/__tests__/validate-options.test.ts new file mode 100644 index 0000000..8861ee9 --- /dev/null +++ b/packages/paykit/src/core/__tests__/validate-options.test.ts @@ -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", + ); + }); +}); diff --git a/packages/paykit/src/core/context.ts b/packages/paykit/src/core/context.ts index f31b511..7ee5c47 100644 --- a/packages/paykit/src/core/context.ts +++ b/packages/paykit/src/core/context.ts @@ -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"; @@ -43,7 +44,9 @@ export async function createContext(options: PayKitOptions): Promise statement-breakpoint +UPDATE "paykit_product" +SET "price_currency" = 'usd' +WHERE "price_amount" IS NOT NULL; diff --git a/packages/paykit/src/database/migrations/meta/0002_snapshot.json b/packages/paykit/src/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..904f625 --- /dev/null +++ b/packages/paykit/src/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,1352 @@ +{ + "id": "e6a25108-0c13-4942-a2a0-f77eb0692c00", + "prevId": "f20b685c-77de-49ad-903d-6dc82acf06be", + "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_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_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 2b1ff3a..d2408b3 100644 --- a/packages/paykit/src/database/migrations/meta/_journal.json +++ b/packages/paykit/src/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1780368513363, "tag": "0001_stripe_only_schema", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1782216653893, + "tag": "0002_add-product-price-currency", + "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 029fd43..b66acfc 100644 --- a/packages/paykit/src/database/schema.ts +++ b/packages/paykit/src/database/schema.ts @@ -85,6 +85,7 @@ export const product = pgTable( group: text("group").notNull().default(""), isDefault: boolean("is_default").notNull().default(false), priceAmount: integer("price_amount"), + priceCurrency: text("price_currency"), priceInterval: text("price_interval"), hash: text("hash"), stripeProductId: text("stripe_product_id"), diff --git a/packages/paykit/src/index.ts b/packages/paykit/src/index.ts index 3219dc0..bce8e5c 100644 --- a/packages/paykit/src/index.ts +++ b/packages/paykit/src/index.ts @@ -26,6 +26,7 @@ export type { ReportResult, } from "./entitlement/entitlement.service"; export { PAYKIT_STRIPE_API_VERSION } from "./stripe/stripe-provider"; +export type { StripeCurrency } from "./stripe/currency"; export type { StripeOptions } from "./stripe/stripe-provider"; export type { Customer, diff --git a/packages/paykit/src/product/__tests__/product-sync.service.test.ts b/packages/paykit/src/product/__tests__/product-sync.service.test.ts new file mode 100644 index 0000000..3219ca0 --- /dev/null +++ b/packages/paykit/src/product/__tests__/product-sync.service.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getLatestProductSnapshot: vi.fn(), + getProviderProduct: vi.fn(), + insertProductVersion: vi.fn(), + replaceProductFeatures: vi.fn(), + updateProductName: vi.fn(), + upsertFeature: vi.fn(), + upsertProviderProduct: vi.fn(), +})); + +vi.mock("../product.service", () => mocks); + +import { dryRunSyncProducts, syncProducts } from "../product-sync.service"; + +function createContext() { + const provider = { + id: "stripe", + syncProducts: vi.fn().mockResolvedValue({ + results: [{ id: "pro", providerProduct: { priceId: "price_eur", productId: "prod_123" } }], + }), + }; + + return { + database: {}, + products: { + features: [], + plans: [ + { + group: "base", + hash: "hash_eur", + id: "pro", + includes: [], + isDefault: false, + name: "Pro", + priceAmount: 2900, + priceCurrency: "eur", + priceInterval: "month", + trialDays: null, + }, + ], + }, + provider, + }; +} + +const existingProduct = { + createdAt: new Date("2026-01-01T00:00:00Z"), + group: "base", + hash: "hash_usd", + id: "pro", + internalId: "prod_old", + isDefault: false, + name: "Pro", + priceAmount: 2900, + priceCurrency: "usd", + priceInterval: "month", + stripePriceId: "price_usd", + stripeProductId: "prod_123", + updatedAt: new Date("2026-01-01T00:00:00Z"), + version: 1, +}; + +describe("product-sync.service", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getLatestProductSnapshot.mockResolvedValue({ + features: [], + product: existingProduct, + }); + mocks.getProviderProduct.mockResolvedValue({ + priceId: "price_usd", + productId: "prod_123", + }); + mocks.insertProductVersion.mockImplementation(async (_database, input) => ({ + ...existingProduct, + ...input, + internalId: "prod_new", + stripePriceId: null, + stripeProductId: null, + })); + }); + + it("marks products as changed when currency changes", async () => { + const ctx = createContext(); + + await expect(dryRunSyncProducts(ctx as never)).resolves.toEqual([ + { action: "created", id: "pro", version: 1 }, + ]); + }); + + it("creates a new product version and syncs provider products with the new currency", async () => { + const ctx = createContext(); + + await expect(syncProducts(ctx as never)).resolves.toEqual([ + { action: "created", id: "pro", version: 2 }, + ]); + + expect(mocks.insertProductVersion).toHaveBeenCalledWith( + ctx.database, + expect.objectContaining({ + priceCurrency: "eur", + version: 2, + }), + ); + expect(ctx.provider.syncProducts).toHaveBeenCalledWith({ + products: [ + { + existingProviderProduct: { productId: "prod_123" }, + id: "pro", + name: "Pro", + priceAmount: 2900, + priceCurrency: "eur", + priceInterval: "month", + }, + ], + }); + expect(mocks.upsertProviderProduct).toHaveBeenCalledWith(ctx.database, { + productInternalId: "prod_new", + providerId: "stripe", + providerProduct: { priceId: "price_eur", productId: "prod_123" }, + }); + }); +}); diff --git a/packages/paykit/src/product/__tests__/product.service.test.ts b/packages/paykit/src/product/__tests__/product.service.test.ts new file mode 100644 index 0000000..45843d6 --- /dev/null +++ b/packages/paykit/src/product/__tests__/product.service.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PayKitDatabase } from "../../database"; +import type { StoredProduct } from "../../types/models"; +import type { NormalizedPlan } from "../../types/schema"; +import { getProductByPlan } from "../product.service"; + +const now = new Date("2026-01-01T00:00:00Z"); + +function createStoredProduct(overrides: Partial = {}): StoredProduct { + return { + createdAt: now, + group: "base", + hash: "old_hash", + id: "pro", + internalId: "prod_123", + isDefault: false, + name: "Pro", + priceAmount: 2900, + priceCurrency: "usd", + priceInterval: "month", + stripePriceId: "price_123", + stripeProductId: "prod_stripe_123", + updatedAt: now, + version: 1, + ...overrides, + }; +} + +function createPlan(overrides: Partial = {}): NormalizedPlan { + return { + group: "base", + hash: "new_hash", + id: "pro", + includes: [], + isDefault: false, + name: "Pro", + priceAmount: 2900, + priceCurrency: "usd", + priceInterval: "month", + trialDays: null, + ...overrides, + }; +} + +function createDatabase(storedProduct: StoredProduct) { + const productFindFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(storedProduct); + const productFeatureFindMany = vi.fn().mockResolvedValue([]); + + return { + database: { + query: { + product: { + findFirst: productFindFirst, + }, + productFeature: { + findMany: productFeatureFindMany, + }, + }, + } as unknown as PayKitDatabase, + productFeatureFindMany, + productFindFirst, + }; +} + +describe("product.service", () => { + it("falls back to a semantically matching product when only the hash changed", async () => { + const storedProduct = createStoredProduct(); + const { database } = createDatabase(storedProduct); + + await expect(getProductByPlan(database, createPlan())).resolves.toEqual(storedProduct); + }); + + it("does not fall back when the stored product differs from the normalized plan", async () => { + const { database } = createDatabase(createStoredProduct({ priceAmount: 3900 })); + + await expect(getProductByPlan(database, createPlan())).resolves.toBeNull(); + }); +}); diff --git a/packages/paykit/src/product/product-sync.service.ts b/packages/paykit/src/product/product-sync.service.ts index 7e65bfa..e488870 100644 --- a/packages/paykit/src/product/product-sync.service.ts +++ b/packages/paykit/src/product/product-sync.service.ts @@ -57,11 +57,37 @@ function planChanged( existing.product.group !== next.group || existing.product.isDefault !== next.isDefault || (existing.product.priceAmount ?? null) !== next.priceAmount || + (existing.product.priceCurrency ?? null) !== next.priceCurrency || (existing.product.priceInterval ?? null) !== next.priceInterval || featuresChanged(existing.features, next.includes) ); } +function priceChanged( + existing: Awaited>, + next: NormalizedPlan, +): boolean { + if (!existing) { + return true; + } + + return ( + (existing.product.priceAmount ?? null) !== next.priceAmount || + (existing.product.priceCurrency ?? null) !== next.priceCurrency || + (existing.product.priceInterval ?? null) !== next.priceInterval + ); +} + +function withoutExistingPrice( + providerProduct: Record | null, +): Record | null { + if (!providerProduct?.productId) { + return null; + } + + return { productId: providerProduct.productId }; +} + export async function dryRunSyncProducts(ctx: PayKitContext): Promise { const results: SyncProductResult[] = []; @@ -99,6 +125,7 @@ export async function syncProducts(ctx: PayKitContext): Promise | null; storedProductInternalId: string; @@ -112,6 +139,7 @@ export async function syncProducts(ctx: PayKitContext): Promise | null): string { + return JSON.stringify(config ?? null); +} + +function productFeaturesMatch( + existing: readonly StoredProductFeature[], + next: readonly NormalizedPlanFeature[], +): boolean { + if (existing.length !== next.length) { + return false; + } + + return existing.every((storedFeature, index) => { + const nextFeature = next[index]; + return ( + nextFeature != null && + storedFeature.featureId === nextFeature.id && + storedFeature.limit === nextFeature.limit && + storedFeature.resetInterval === nextFeature.resetInterval && + serializeFeatureConfig(storedFeature.config) === serializeFeatureConfig(nextFeature.config) + ); + }); +} + +function productSnapshotMatchesPlan( + snapshot: StoredProductSnapshot, + plan: NormalizedPlan, +): boolean { + return ( + snapshot.product.group === plan.group && + snapshot.product.isDefault === plan.isDefault && + (snapshot.product.priceAmount ?? null) === plan.priceAmount && + (snapshot.product.priceCurrency ?? null) === plan.priceCurrency && + (snapshot.product.priceInterval ?? null) === plan.priceInterval && + productFeaturesMatch(snapshot.features, plan.includes) + ); +} + +/** Finds the stored product for a normalized plan, including old hash-compatible rows. */ +export async function getProductByPlan( + database: PayKitDatabase, + plan: NormalizedPlan, +): Promise { + const exact = await getProductByHash(database, plan.id, plan.hash); + if (exact) { + return exact; + } + + const latest = await getLatestProductSnapshot(database, plan.id); + if (!latest || !productSnapshotMatchesPlan(latest, plan)) { + return null; + } + + return latest.product; +} + export async function getProductByInternalId( database: PayKitDatabase, internalId: string, @@ -146,6 +202,7 @@ export async function insertProductVersion( isDefault: boolean; name: string; priceAmount: number | null; + priceCurrency: string | null; priceInterval: string | null; version: number; }, @@ -161,6 +218,7 @@ export async function insertProductVersion( isDefault: input.isDefault, name: input.name, priceAmount: input.priceAmount, + priceCurrency: input.priceCurrency, priceInterval: input.priceInterval, updatedAt: now, version: input.version, @@ -175,6 +233,7 @@ export async function insertProductVersion( isDefault: input.isDefault, name: input.name, priceAmount: input.priceAmount, + priceCurrency: input.priceCurrency, priceInterval: input.priceInterval, stripePriceId: null, stripeProductId: null, diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index 5a10295..a2a1d69 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -157,6 +157,7 @@ export interface PaymentProvider { id: string; name: string; priceAmount: number; + priceCurrency: string; priceInterval?: string | null; existingProviderProduct?: Record | null; }>; diff --git a/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts new file mode 100644 index 0000000..f3d0d5e --- /dev/null +++ b/packages/paykit/src/stripe/__tests__/stripe-provider.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createStripeProvider } from "../stripe-provider"; + +function createStripeClientMock() { + return { + invoices: { + addLines: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({ id: "in_123" }), + finalizeInvoice: vi.fn().mockResolvedValue({ + currency: "eur", + id: "in_123", + status: "open", + total: 2900, + }), + }, + prices: { + create: vi.fn().mockResolvedValue({ id: "price_123" }), + }, + products: { + create: vi.fn().mockResolvedValue({ id: "prod_123" }), + }, + }; +} + +describe("stripe-provider", () => { + it("creates Stripe prices with the product currency", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "usd", + secretKey: "sk_test_123", + }); + + await provider.syncProducts({ + products: [ + { + existingProviderProduct: null, + id: "pro", + name: "Pro", + priceAmount: 2900, + priceCurrency: "eur", + priceInterval: "month", + }, + ], + }); + + expect(client.prices.create).toHaveBeenCalledWith({ + currency: "eur", + product: "prod_123", + recurring: { interval: "month" }, + unit_amount: 2900, + }); + }); + + it("creates invoices with the configured currency", async () => { + const client = createStripeClientMock(); + const provider = createStripeProvider(client as never, { + currency: "eur", + secretKey: "sk_test_123", + }); + + await provider.createInvoice({ + lines: [], + providerCustomerId: "cus_123", + }); + + expect(client.invoices.create).toHaveBeenCalledWith({ + auto_advance: true, + collection_method: "charge_automatically", + currency: "eur", + customer: "cus_123", + }); + }); +}); diff --git a/packages/paykit/src/stripe/currency.ts b/packages/paykit/src/stripe/currency.ts new file mode 100644 index 0000000..6f7ce02 --- /dev/null +++ b/packages/paykit/src/stripe/currency.ts @@ -0,0 +1,13 @@ +export const DEFAULT_STRIPE_CURRENCY = "usd"; + +export const SUPPORTED_STRIPE_CURRENCIES = ["usd", "eur"] as const; + +export type StripeCurrency = (typeof SUPPORTED_STRIPE_CURRENCIES)[number]; + +export function getStripeCurrency(options: { currency?: StripeCurrency }): StripeCurrency { + return options.currency ?? DEFAULT_STRIPE_CURRENCY; +} + +export function isSupportedStripeCurrency(value: string): value is StripeCurrency { + return SUPPORTED_STRIPE_CURRENCIES.includes(value as StripeCurrency); +} diff --git a/packages/paykit/src/stripe/stripe-provider.ts b/packages/paykit/src/stripe/stripe-provider.ts index 1e2b54e..ae57e00 100644 --- a/packages/paykit/src/stripe/stripe-provider.ts +++ b/packages/paykit/src/stripe/stripe-provider.ts @@ -3,6 +3,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 { DEFAULT_STRIPE_CURRENCY, getStripeCurrency, type StripeCurrency } from "./currency"; /** * Stripe API version PayKit is tested against. Users can override via @@ -27,6 +28,11 @@ const STRIPE_WEBHOOK_EVENTS: StripeSdk.WebhookEndpointCreateParams.EnabledEvent[ export interface StripeOptions { secretKey: string; webhookSecret: string; + /** + * Currency used for new Stripe prices and PayKit-created invoices. + * @default "usd" + */ + currency?: StripeCurrency; /** Override the Stripe API version (e.g. for preview features). */ apiVersion?: string; /** Enable Stripe Managed Payments (requires a preview API version). */ @@ -586,7 +592,7 @@ export function createStripeProvider( client: StripeSdk, options: StripeAdapterOptions, ): PaymentProvider { - const currency = "usd"; + const currency = getStripeCurrency(options); return { id: "stripe", @@ -927,7 +933,7 @@ export function createStripeProvider( } const priceParams: StripeSdk.PriceCreateParams = { - currency, + currency: product.priceCurrency, product: productId, unit_amount: product.priceAmount, }; @@ -1078,6 +1084,7 @@ export function createStripeProvider( } export function createStripeAdapter(options: StripeAdapterOptions): PaymentProvider { + const optionsWithDefaults = { ...options, currency: options.currency ?? DEFAULT_STRIPE_CURRENCY }; const apiVersion = options.apiVersion ?? PAYKIT_STRIPE_API_VERSION; if (options.managedPayments) { if (!apiVersion.endsWith(".preview") || apiVersion < STRIPE_MANAGED_PAYMENTS_MIN_VERSION) { @@ -1093,5 +1100,5 @@ export function createStripeAdapter(options: StripeAdapterOptions): PaymentProvi maxNetworkRetries: 3, }); - return createStripeProvider(client, options); + return createStripeProvider(client, optionsWithDefaults); } diff --git a/packages/paykit/src/subscription/subscription.service.ts b/packages/paykit/src/subscription/subscription.service.ts index 5f55629..ccbda6d 100644 --- a/packages/paykit/src/subscription/subscription.service.ts +++ b/packages/paykit/src/subscription/subscription.service.ts @@ -13,7 +13,7 @@ import { upsertInvoiceRecord } from "../invoice/invoice.service"; import { getDefaultPaymentMethod } from "../payment-method/payment-method.service"; import { getDefaultProductInGroup, - getProductByHash, + getProductByPlan, getProductByInternalId, getProductByProviderData, getProductFeatures, @@ -58,6 +58,9 @@ export async function subscribeToPlan( } else if (subCtx.isFreeTarget) { // Switching from paid to free always happens at period end. result = await handleCancelToFree(ctx, subCtx); + } else if (subCtx.isPaidCurrencyChange) { + // Cross-currency paid changes need a new checkout-backed subscription. + result = await createCheckoutSubscribe(ctx, subCtx); } else if (!subCtx.isUpgrade) { // Paid downgrades stay active now and schedule the cheaper plan for later. result = await handleScheduledDowngrade(ctx, subCtx); @@ -100,7 +103,7 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI const matchingProduct = input.productInternalId ? await getProductByInternalId(ctx.database, input.productInternalId) : normalizedPlan - ? await getProductByHash(ctx.database, input.planId, normalizedPlan.hash) + ? await getProductByPlan(ctx.database, normalizedPlan) : null; const storedPlan = matchingProduct ? withProviderInfo(matchingProduct, providerId) : null; @@ -148,9 +151,15 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI const activeAmount = activeSubscription?.priceAmount ?? 0; const targetAmount = storedPlan.priceAmount ?? 0; + const isPaidCurrencyChange = + activeSubscription != null && + activeSubscription.priceCurrency !== null && + storedPlan.priceCurrency !== null && + activeSubscription.priceCurrency !== storedPlan.priceCurrency; const isUpgrade = activeSubscription != null && hasProviderSubscription(activeSubscription) && + !isPaidCurrencyChange && targetAmount > activeAmount; const planFeatures = normalizedPlan @@ -163,6 +172,7 @@ export async function loadSubscribeContext(ctx: PayKitContext, input: SubscribeI customerId: input.customerId, isFreeTarget, isPaidTarget, + isPaidCurrencyChange, isUpgrade, normalizedPlan, planFeatures, @@ -235,11 +245,11 @@ async function cancelExistingProviderSubscriptionForCheckout( return; } - if (!completion.subCtx.isUpgrade) { + if (!completion.subCtx.isUpgrade && !completion.subCtx.isPaidCurrencyChange) { throw PayKitError.from( "BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, - `Checkout completion is only valid for new paid subscriptions or upgrades to "${completion.subCtx.storedPlan.id}"`, + `Checkout completion is only valid for new paid subscriptions, upgrades, or cross-currency changes to "${completion.subCtx.storedPlan.id}"`, ); } @@ -1236,6 +1246,7 @@ function mapJoinRowToSubscriptionWithCatalog(row: { planIsDefault: row.product.isDefault, planName: row.product.name, priceAmount: row.product.priceAmount, + priceCurrency: row.product.priceCurrency, priceInterval: row.product.priceInterval, providerProduct: stripeProduct, }; diff --git a/packages/paykit/src/subscription/subscription.types.ts b/packages/paykit/src/subscription/subscription.types.ts index d4fd111..a069976 100644 --- a/packages/paykit/src/subscription/subscription.types.ts +++ b/packages/paykit/src/subscription/subscription.types.ts @@ -39,6 +39,7 @@ export interface SubscriptionWithCatalog extends StoredSubscription { planIsDefault: boolean; planName: string; priceAmount: number | null; + priceCurrency: string | null; priceInterval: string | null; providerProduct: Record | null; } diff --git a/packages/paykit/src/types/__tests__/schema.test.ts b/packages/paykit/src/types/__tests__/schema.test.ts new file mode 100644 index 0000000..c4e5946 --- /dev/null +++ b/packages/paykit/src/types/__tests__/schema.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeSchema, plan } from "../schema"; + +describe("types/schema", () => { + it("stores configured currency on paid normalized plans", () => { + const products = [ + plan({ + group: "base", + id: "pro", + price: { amount: 29, interval: "month" }, + }), + ]; + + const schema = normalizeSchema(products, { priceCurrency: "eur" }); + + expect(schema.planMap.get("pro")?.priceCurrency).toBe("eur"); + }); + + it("changes plan hash when configured currency changes", () => { + const products = [ + plan({ + group: "base", + id: "pro", + price: { amount: 29, interval: "month" }, + }), + ]; + + const usd = normalizeSchema(products, { priceCurrency: "usd" }); + const eur = normalizeSchema(products, { priceCurrency: "eur" }); + + expect(usd.planMap.get("pro")?.hash).not.toBe(eur.planMap.get("pro")?.hash); + }); + + it("keeps free normalized plans currencyless", () => { + const products = [ + plan({ + default: true, + group: "base", + id: "free", + }), + ]; + + const schema = normalizeSchema(products, { priceCurrency: "eur" }); + + expect(schema.planMap.get("free")?.priceCurrency).toBeNull(); + }); +}); diff --git a/packages/paykit/src/types/schema.ts b/packages/paykit/src/types/schema.ts index cc44e50..72ab104 100644 --- a/packages/paykit/src/types/schema.ts +++ b/packages/paykit/src/types/schema.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; import * as z from "zod"; +import { DEFAULT_STRIPE_CURRENCY } from "../stripe/currency"; + const payKitFeatureSymbol = Symbol.for("paykit.feature"); const payKitFeatureIncludeSymbol = Symbol.for("paykit.feature_include"); const payKitPlanSymbol = Symbol.for("paykit.plan"); @@ -132,6 +134,7 @@ export interface NormalizedPlan { isDefault: boolean; name: string; priceAmount: number | null; + priceCurrency: string | null; priceInterval: PriceInterval | null; trialDays: number | null; } @@ -325,6 +328,7 @@ export function computePlanHash(plan: Omit): string { group: plan.group, isDefault: plan.isDefault, priceAmount: plan.priceAmount, + priceCurrency: plan.priceCurrency, priceInterval: plan.priceInterval, features: plan.includes.map((f) => ({ id: f.id, @@ -336,7 +340,10 @@ export function computePlanHash(plan: Omit): string { return createHash("sha256").update(payload).digest("hex").slice(0, 16); } -export function normalizeSchema(products: PayKitProductsModule | undefined): NormalizedSchema { +export function normalizeSchema( + products: PayKitProductsModule | undefined, + input?: { priceCurrency?: string }, +): NormalizedSchema { if (!products) { return { features: [], @@ -424,6 +431,7 @@ export function normalizeSchema(products: PayKitProductsModule | undefined): Nor isDefault, 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, trialDays: null, };