Skip to content
Open
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
45 changes: 45 additions & 0 deletions packages/@emulators/polar/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
97 changes: 97 additions & 0 deletions packages/@emulators/polar/src/__tests__/polar.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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);
});
});
32 changes: 32 additions & 0 deletions packages/@emulators/polar/src/entities.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions packages/@emulators/polar/src/formatters.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
5 changes: 5 additions & 0 deletions packages/@emulators/polar/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { randomUUID } from "crypto";

export function generateUuid(): string {
return randomUUID();
}
74 changes: 74 additions & 0 deletions packages/@emulators/polar/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<AppEnv>, 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;
56 changes: 56 additions & 0 deletions packages/@emulators/polar/src/routes/checkouts.ts
Original file line number Diff line number Diff line change
@@ -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 = {};
Comment thread
vercel[bot] marked this conversation as resolved.
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);
});
}
18 changes: 18 additions & 0 deletions packages/@emulators/polar/src/routes/organizations.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
18 changes: 18 additions & 0 deletions packages/@emulators/polar/src/routes/products.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading