diff --git a/packages/@emulators/polar/package.json b/packages/@emulators/polar/package.json new file mode 100644 index 00000000..99922035 --- /dev/null +++ b/packages/@emulators/polar/package.json @@ -0,0 +1,45 @@ +{ + "name": "@emulators/polar", + "version": "0.6.1", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "homepage": "https://emulate.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/emulate.git", + "directory": "packages/@emulators/polar" + }, + "bugs": { + "url": "https://github.com/vercel-labs/emulate/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "vitest run", + "clean": "rm -rf dist .turbo", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@emulators/core": "workspace:*" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} \ No newline at end of file diff --git a/packages/@emulators/polar/src/__tests__/polar.test.ts b/packages/@emulators/polar/src/__tests__/polar.test.ts new file mode 100644 index 00000000..7256e817 --- /dev/null +++ b/packages/@emulators/polar/src/__tests__/polar.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "@emulators/core"; +import { + Store, + WebhookDispatcher, + authMiddleware, + createApiErrorHandler, + createErrorHandler, + type TokenMap, +} from "@emulators/core"; +import { polarPlugin, seedFromConfig, getPolarStore } from "../index.js"; + +const base = "http://localhost:4000"; + +function createTestApp() { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + tokenMap.set("polar_test_token", { + login: "testuser@example.com", + id: 1, + scopes: [], + }); + + const app = new Hono(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokenMap)); + polarPlugin.register(app as any, store, webhooks, base, tokenMap); + + return { app, store, webhooks, tokenMap }; +} + +function authHeaders(): Record { + return { Authorization: "Bearer polar_test_token", "Content-Type": "application/json" }; +} + +describe("Polar plugin", () => { + let app: Hono; + let store: Store; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + store = testApp.store; + }); + + it("seeds and lists organizations and products", async () => { + seedFromConfig(store, base, { + organizations: [{ name: "My Org", slug: "my-org" }], + products: [{ name: "Pro Plan", price: 2000, organization_slug: "my-org" }], + }); + + const resOrgs = await app.request(`${base}/v1/organizations`, { headers: authHeaders() }); + expect(resOrgs.status).toBe(200); + const orgsBody = (await resOrgs.json()) as any; + expect(orgsBody.items.length).toBe(1); + expect(orgsBody.items[0].slug).toBe("my-org"); + + const resProds = await app.request(`${base}/v1/products`, { headers: authHeaders() }); + expect(resProds.status).toBe(200); + const prodsBody = (await resProds.json()) as any; + expect(prodsBody.items.length).toBe(1); + expect(prodsBody.items[0].name).toBe("Pro Plan"); + expect(prodsBody.items[0].price).toBe(2000); + }); + + it("creates and retrieves custom checkout", async () => { + const ps = getPolarStore(store); + + // Seed a product first + const prod = ps.products.insert({ + polar_id: "prod_123", + name: "Standard Plan", + price: 1000, + organization_id: "org_123", + }); + + const resCreate = await app.request(`${base}/v1/checkouts/custom`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + product_id: "prod_123", + customer_email: "user@example.com", + }), + }); + expect(resCreate.status).toBe(201); + const created = (await resCreate.json()) as any; + expect(created.id).toBeDefined(); + expect(created.product_id).toBe("prod_123"); + + const resGet = await app.request(`${base}/v1/checkouts/custom/${created.id}`, { headers: authHeaders() }); + expect(resGet.status).toBe(200); + const retrieved = (await resGet.json()) as any; + expect(retrieved.id).toBe(created.id); + }); +}); diff --git a/packages/@emulators/polar/src/entities.ts b/packages/@emulators/polar/src/entities.ts new file mode 100644 index 00000000..bdf138d9 --- /dev/null +++ b/packages/@emulators/polar/src/entities.ts @@ -0,0 +1,32 @@ +import type { Entity } from "@emulators/core"; + +export interface PolarOrganization extends Entity { + polar_id: string; + name: string; + slug: string; +} + +export interface PolarProduct extends Entity { + polar_id: string; + name: string; + description?: string; + price: number; + organization_id: string; +} + +export interface PolarCheckout extends Entity { + polar_id: string; + url: string; + status: "open" | "confirmed" | "failed"; + product_id: string; + organization_id: string; + customer_email?: string; +} + +export interface PolarSubscription extends Entity { + polar_id: string; + status: "active" | "canceled"; + user_id: string; + product_id: string; + organization_id: string; +} diff --git a/packages/@emulators/polar/src/formatters.ts b/packages/@emulators/polar/src/formatters.ts new file mode 100644 index 00000000..1ba408a1 --- /dev/null +++ b/packages/@emulators/polar/src/formatters.ts @@ -0,0 +1,41 @@ +import type { PolarOrganization, PolarProduct, PolarCheckout, PolarSubscription } from "../entities.js"; + +export function formatOrganization(o: PolarOrganization) { + return { + id: o.polar_id, + name: o.name, + slug: o.slug, + }; +} + +export function formatProduct(p: PolarProduct) { + return { + id: p.polar_id, + name: p.name, + description: p.description, + price: p.price, + organization_id: p.organization_id, + }; +} + +export function formatCheckout(c: PolarCheckout) { + return { + id: c.polar_id, + client_secret: "mock_client_secret_" + c.polar_id, + url: c.url, + status: c.status, + product_id: c.product_id, + organization_id: c.organization_id, + customer_email: c.customer_email, + }; +} + +export function formatSubscription(s: PolarSubscription) { + return { + id: s.polar_id, + status: s.status, + user_id: s.user_id, + product_id: s.product_id, + organization_id: s.organization_id, + }; +} diff --git a/packages/@emulators/polar/src/helpers.ts b/packages/@emulators/polar/src/helpers.ts new file mode 100644 index 00000000..cec0516d --- /dev/null +++ b/packages/@emulators/polar/src/helpers.ts @@ -0,0 +1,5 @@ +import { randomUUID } from "crypto"; + +export function generateUuid(): string { + return randomUUID(); +} diff --git a/packages/@emulators/polar/src/index.ts b/packages/@emulators/polar/src/index.ts new file mode 100644 index 00000000..71d0eadb --- /dev/null +++ b/packages/@emulators/polar/src/index.ts @@ -0,0 +1,74 @@ +import type { Hono } from "@emulators/core"; +import type { ServicePlugin, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext } from "@emulators/core"; +import { getPolarStore } from "./store.js"; +import { generateUuid } from "./helpers.js"; +import { organizationRoutes } from "./routes/organizations.js"; +import { productRoutes } from "./routes/products.js"; +import { checkoutRoutes } from "./routes/checkouts.js"; +import { subscriptionRoutes } from "./routes/subscriptions.js"; + +export { getPolarStore, type PolarStore } from "./store.js"; +export * from "./entities.js"; + +export interface PolarSeedConfig { + organizations?: Array<{ + name: string; + slug: string; + }>; + products?: Array<{ + name: string; + description?: string; + price: number; + organization_slug?: string; + }>; +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: PolarSeedConfig): void { + const ps = getPolarStore(store); + + if (config.organizations) { + for (const o of config.organizations) { + if (ps.organizations.findOneBy("slug", o.slug)) continue; + ps.organizations.insert({ + polar_id: generateUuid(), + name: o.name, + slug: o.slug, + }); + } + } + + if (config.products) { + for (const p of config.products) { + let orgId = "default_org"; + if (p.organization_slug) { + const org = ps.organizations.findOneBy("slug", p.organization_slug); + if (org) { + orgId = org.polar_id; + } + } + ps.products.insert({ + polar_id: generateUuid(), + name: p.name, + description: p.description, + price: p.price, + organization_id: orgId, + }); + } + } +} + +export const polarPlugin: ServicePlugin = { + name: "polar", + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + organizationRoutes(ctx); + productRoutes(ctx); + checkoutRoutes(ctx); + subscriptionRoutes(ctx); + }, + seed(_store: Store, _baseUrl: string): void { + // Empty default seed + }, +}; + +export default polarPlugin; diff --git a/packages/@emulators/polar/src/routes/checkouts.ts b/packages/@emulators/polar/src/routes/checkouts.ts new file mode 100644 index 00000000..f91231b6 --- /dev/null +++ b/packages/@emulators/polar/src/routes/checkouts.ts @@ -0,0 +1,56 @@ +import type { RouteContext } from "@emulators/core"; +import { getPolarStore } from "../store.js"; +import { generateUuid } from "../helpers.js"; +import { formatCheckout } from "../formatters.js"; + +export function checkoutRoutes({ app, store, webhooks }: RouteContext): void { + const ps = getPolarStore(store); + + app.post("/v1/checkouts/custom", async (c) => { + let body: any = {}; + try { + body = await c.req.json(); + } catch { + // Empty + } + + const productId = body.product_id; + if (!productId) { + return c.json({ error: "Missing product_id" }, 400); + } + + const prod = ps.products.findOneBy("polar_id", productId); + const orgId = prod ? prod.organization_id : "default_org"; + + const checkoutId = generateUuid(); + const checkout = ps.checkouts.insert({ + polar_id: checkoutId, + url: `https://polar.sh/checkout/${checkoutId}`, + status: "open", + product_id: productId, + organization_id: orgId, + customer_email: body.customer_email || undefined, + }); + + await webhooks.dispatch( + "checkout.created", + undefined, + { + type: "checkout.created", + data: formatCheckout(checkout), + }, + "polar" + ); + + return c.json(formatCheckout(checkout), 201); + }); + + app.get("/v1/checkouts/custom/:id", (c) => { + const id = c.req.param("id"); + const checkout = ps.checkouts.findOneBy("polar_id", id); + if (!checkout) { + return c.json({ error: "Checkout not found" }, 404); + } + return c.json(formatCheckout(checkout), 200); + }); +} diff --git a/packages/@emulators/polar/src/routes/organizations.ts b/packages/@emulators/polar/src/routes/organizations.ts new file mode 100644 index 00000000..3ca20899 --- /dev/null +++ b/packages/@emulators/polar/src/routes/organizations.ts @@ -0,0 +1,18 @@ +import type { RouteContext } from "@emulators/core"; +import { getPolarStore } from "../store.js"; +import { formatOrganization } from "../formatters.js"; + +export function organizationRoutes({ app, store }: RouteContext): void { + const ps = getPolarStore(store); + + app.get("/v1/organizations", (c) => { + const orgs = ps.organizations.all(); + return c.json({ + items: orgs.map(formatOrganization), + pagination: { + total_count: orgs.length, + max_page: 1, + } + }, 200); + }); +} diff --git a/packages/@emulators/polar/src/routes/products.ts b/packages/@emulators/polar/src/routes/products.ts new file mode 100644 index 00000000..db6bc305 --- /dev/null +++ b/packages/@emulators/polar/src/routes/products.ts @@ -0,0 +1,18 @@ +import type { RouteContext } from "@emulators/core"; +import { getPolarStore } from "../store.js"; +import { formatProduct } from "../formatters.js"; + +export function productRoutes({ app, store }: RouteContext): void { + const ps = getPolarStore(store); + + app.get("/v1/products", (c) => { + const prods = ps.products.all(); + return c.json({ + items: prods.map(formatProduct), + pagination: { + total_count: prods.length, + max_page: 1, + } + }, 200); + }); +} diff --git a/packages/@emulators/polar/src/routes/subscriptions.ts b/packages/@emulators/polar/src/routes/subscriptions.ts new file mode 100644 index 00000000..8141ae43 --- /dev/null +++ b/packages/@emulators/polar/src/routes/subscriptions.ts @@ -0,0 +1,18 @@ +import type { RouteContext } from "@emulators/core"; +import { getPolarStore } from "../store.js"; +import { formatSubscription } from "../formatters.js"; + +export function subscriptionRoutes({ app, store }: RouteContext): void { + const ps = getPolarStore(store); + + app.get("/v1/subscriptions", (c) => { + const subs = ps.subscriptions.all(); + return c.json({ + items: subs.map(formatSubscription), + pagination: { + total_count: subs.length, + max_page: 1, + } + }, 200); + }); +} diff --git a/packages/@emulators/polar/src/store.ts b/packages/@emulators/polar/src/store.ts new file mode 100644 index 00000000..6e36be9e --- /dev/null +++ b/packages/@emulators/polar/src/store.ts @@ -0,0 +1,18 @@ +import { Store, type Collection } from "@emulators/core"; +import type { PolarOrganization, PolarProduct, PolarCheckout, PolarSubscription } from "./entities.js"; + +export interface PolarStore { + organizations: Collection; + products: Collection; + checkouts: Collection; + subscriptions: Collection; +} + +export function getPolarStore(store: Store): PolarStore { + return { + organizations: store.collection("polar.organizations", ["polar_id", "slug"]), + products: store.collection("polar.products", ["polar_id", "organization_id"]), + checkouts: store.collection("polar.checkouts", ["polar_id", "product_id"]), + subscriptions: store.collection("polar.subscriptions", ["polar_id", "user_id"]), + }; +} diff --git a/packages/@emulators/polar/tsconfig.json b/packages/@emulators/polar/tsconfig.json new file mode 100644 index 00000000..1b71a2df --- /dev/null +++ b/packages/@emulators/polar/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/packages/@emulators/polar/tsup.config.ts b/packages/@emulators/polar/tsup.config.ts new file mode 100644 index 00000000..7a3d66a9 --- /dev/null +++ b/packages/@emulators/polar/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, +}); diff --git a/packages/@emulators/polar/vitest.config.ts b/packages/@emulators/polar/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/@emulators/polar/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index b48ac8ed..5ae4ca0c 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -69,6 +69,7 @@ "@emulators/vercel": "workspace:*", "@emulators/resend": "workspace:*", "@emulators/stripe": "workspace:*", + "@emulators/polar": "workspace:*", "@emulators/clerk": "workspace:*", "tsup": "^8", "typescript": "^5.7" diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 41015811..04529ef4 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -25,6 +25,7 @@ const SERVICE_NAME_LIST = [ "aws", "resend", "stripe", + "polar", "mongoatlas", "clerk", ] as const; @@ -440,6 +441,23 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + polar: { + label: "Polar.sh Merchant of Record emulator", + endpoints: "organizations, products, checkouts, subscriptions", + async load() { + const mod = await import("@emulators/polar"); + return { plugin: mod.polarPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback() { + return { login: "admin", id: 1, scopes: [] }; + }, + initConfig: { + polar: { + organizations: [{ name: "My Org", slug: "my-org" }], + products: [{ name: "Pro Plan", price: 2000, organization_slug: "my-org" }], + }, + }, + }, mongoatlas: { label: "MongoDB Atlas service emulator", endpoints: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4e036d..406f1514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -613,6 +613,22 @@ importers: specifier: ^4.1.0 version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + packages/@emulators/polar: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + packages/@emulators/resend: dependencies: '@emulators/core': @@ -722,6 +738,9 @@ importers: '@emulators/okta': specifier: workspace:* version: link:../@emulators/okta + '@emulators/polar': + specifier: workspace:* + version: link:../@emulators/polar '@emulators/resend': specifier: workspace:* version: link:../@emulators/resend