diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1e9d01..8d91dc9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,23 @@ # Changelog -## 0.14.1 +## 0.15.0 ### New Features -- **Autumn `balances.update`** โ€” the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s. +- **Polar billing emulator** (`@emulators/polar`, `npx emulate --service polar`) โ€” a stateful emulator for the subscription and usage-based billing surface applications use through `@polar-sh/sdk`: customers with external ids (unique, deliverable-looking emails, Polar's exact 422 messages), meters with filters and aggregations, event ingestion (unknown external customers accepted and attributed once the customer exists), meter credit and custom benefits, recurring products with fixed or metered prices and trials, subscriptions (server-side creation for free products only, product changes with `next_period` pending updates, cancel at period end, revoke), customer state with active subscriptions, granted benefits, and meter balances, customer meters, hosted checkout (a free subscription upgraded in place via `subscription_id`, confirmation deferred until settled by `POST /checkout/:secret/settle` or an auto-settle delay), and customer portal sessions. Faults and the request ledger key on Polar's real operation ids (for example `customers:get_state_external`, `events:ingest`). +## 0.14.1 + + +### New Features + +- **Autumn `balances.update`** โ€” the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s. + + ## 0.14.0 ### New Features diff --git a/README.md b/README.md index f496d6bf..c46ba8eb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ All services start with sensible defaults. No config file needed: - **PostHog** on `http://localhost:4016` - **MCP** on `http://localhost:4017` - **GitLab** on `http://localhost:4018` (full real GraphQL schema) +- **Polar** on `http://localhost:4019` (subscriptions, usage metering, checkout, and customer portal) Every running service also exposes a public control plane under `/_emulate`: @@ -174,7 +175,7 @@ github: ## Deployed Instances -All services are available on host-based routing when deployed: `github`, `gitlab`, `mcp`, `vercel`, `google`, `okta`, `microsoft`, `spotify`, `slack`, `apple`, `aws`, `resend`, `stripe`, `mongoatlas`, `clerk`, `x`, `workos`, `autumn`, and `posthog`. Each one supports three addressing forms: +All services are available on host-based routing when deployed: `github`, `gitlab`, `mcp`, `vercel`, `google`, `okta`, `microsoft`, `spotify`, `slack`, `apple`, `aws`, `resend`, `stripe`, `mongoatlas`, `clerk`, `x`, `workos`, `autumn`, `posthog`, and `polar`. Each one supports three addressing forms: ```text https://github.emulators.dev # service host (control plane only) @@ -264,7 +265,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()])); | Option | Default | Description | | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `service` | _(required)_ | Service name: `'vercel'`, `'github'`, `'gitlab'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'spotify'`, `'x'`, `'workos'`, `'autumn'`, or `'posthog'` | +| `service` | _(required)_ | Service name: `'vercel'`, `'github'`, `'gitlab'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, `'spotify'`, `'x'`, `'workos'`, `'autumn'`, `'posthog'`, `'mcp'`, or `'polar'` | | `port` | `4000` | Port for the HTTP server | | `seed` | none | Inline seed data (same shape as YAML config) | | `baseUrl` | none | Override advertised base URL. Per-service `baseUrl` in seed config takes highest priority, then this option, then `EMULATE_BASE_URL` env var (supports `{service}`), then `PORTLESS_URL` (supports `{service}`, automatically set by the `portless` CLI wrapper), then `http://localhost:`. | @@ -817,6 +818,31 @@ curl -s -X POST http://localhost:4018/api/graphql \ Because the full schema is real, this surface is well suited to testing GraphQL clients and generators against a large, production-shaped type system without calling gitlab.com. Use `/_emulate/manifest` for the declared coverage and `/_emulate/ledger` to inspect calls. +## Polar Billing API + +Polar emulates the subscription and usage-based billing paths used by applications built with `@polar-sh/sdk`. It includes customers, meters, events, benefits, products, subscriptions, hosted checkout, and customer portal sessions. + +```bash +npx emulate --service polar +``` + +When all services run together, Polar uses `http://localhost:4019`. Any non-empty bearer token is accepted. + +```ts +import { Polar } from "@polar-sh/sdk"; + +const polar = new Polar({ + accessToken: "polar_oat_test", + serverURL: "http://localhost:4019", +}); + +const state = await polar.customers.getStateExternal({ externalId: "customer_123" }); +``` + +Checkout confirmation deliberately leaves the new or upgraded subscription pending for a short interval. Set `checkout.settle_delay_ms` in seed data to control that interval, use `null` to disable automatic settlement, or call `POST /checkout/:clientSecret/settle` to make the subscription visible immediately. + +The package serves its hand-authored API description at `GET /openapi.json`. Use `GET /_emulate/manifest` for declared coverage, `GET /_emulate/ledger` to inspect calls, and `POST /_emulate/faults` to inject failures by Polar operation ID. + ## Google OAuth + Gmail, Calendar, and Drive APIs OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local inbox, calendar, and drive flows. diff --git a/apps/web/app/docs/deployment/page.mdx b/apps/web/app/docs/deployment/page.mdx index 717b4609..a27369a7 100644 --- a/apps/web/app/docs/deployment/page.mdx +++ b/apps/web/app/docs/deployment/page.mdx @@ -87,7 +87,7 @@ Instances are created lazily. Creating one returns the base URL, control base UR ## Hosted services -The hosted catalog includes all 13 services: GitHub, Vercel, Google, Okta, Microsoft Entra ID, Spotify, Slack, Apple, AWS, Resend, Stripe, MongoDB Atlas, and Clerk. +The hosted catalog includes Vercel, GitHub, GitLab, Google, Slack, Apple, Microsoft Entra ID, Okta, AWS, Resend, Stripe, MongoDB Atlas, Clerk, Spotify, X, WorkOS, Autumn, PostHog, MCP, and Polar. ## Docs subdomain diff --git a/apps/web/app/docs/page.mdx b/apps/web/app/docs/page.mdx index f2ce2aaa..02e28edd 100644 --- a/apps/web/app/docs/page.mdx +++ b/apps/web/app/docs/page.mdx @@ -1,6 +1,6 @@ # Getting Started -Local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Okta, MongoDB Atlas, Resend, and Stripe APIs. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. +Local drop-in replacement for developer APIs including Vercel, GitHub, Google, Slack, Stripe, and Polar. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. ## Quick Start @@ -23,8 +23,13 @@ All services start with sensible defaults. No config file needed: - **MongoDB Atlas** on `http://localhost:4010` - **Clerk** on `http://localhost:4011` - **Spotify** on `http://localhost:4012` -- **PostHog** on `http://localhost:4016` +- **X** on `http://localhost:4013` - **WorkOS** on `http://localhost:4014` +- **Autumn** on `http://localhost:4015` +- **PostHog** on `http://localhost:4016` +- **MCP** on `http://localhost:4017` +- **GitLab** on `http://localhost:4018` +- **Polar** on `http://localhost:4019` ## Control Plane diff --git a/apps/web/app/docs/polar/layout.tsx b/apps/web/app/docs/polar/layout.tsx new file mode 100644 index 00000000..5e72f0e6 --- /dev/null +++ b/apps/web/app/docs/polar/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("polar"); + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/docs/polar/page.mdx b/apps/web/app/docs/polar/page.mdx new file mode 100644 index 00000000..0642542f --- /dev/null +++ b/apps/web/app/docs/polar/page.mdx @@ -0,0 +1,78 @@ +# Polar + +Polar merchant-of-record billing emulation for subscription and usage-based billing integrations. The emulator supports the real request paths and response shapes used by `@polar-sh/sdk` 0.49.0. + +## Start + +```bash +npx emulate --service polar +``` + +When all services run together, Polar uses `http://localhost:4019`. Pass any non-empty bearer token. + +```ts +import { Polar } from "@polar-sh/sdk"; + +const polar = new Polar({ + accessToken: "polar_oat_test", + serverURL: "http://localhost:4019", +}); +``` + +## Supported billing flow + +The API includes customers, meters, usage events, meter-credit and custom benefits, recurring products, subscriptions, checkouts, customer sessions, and an organization stub. Customer state calculates active subscriptions, granted benefits, and meter balances from the same stored entities. + +Programmatic subscription creation accepts free products. Use `POST /v1/checkouts/` and the hosted `/checkout/:clientSecret` page for paid products and trials. + +## Delayed checkout settlement + +Confirming a hosted checkout redirects to the configured success URL but leaves the new subscription or upgrade pending. By default it settles after 2500 milliseconds when state is next read. Seed `checkout.settle_delay_ms` to change the delay, set it to `null` to disable automatic settlement, or call `POST /checkout/:clientSecret/settle` explicitly. + +```yaml +polar: + checkout: + settle_delay_ms: null +``` + +## Seed data + +Meters, benefits, and products are upserted by name or description. Customers are upserted by external ID. Product and benefit references may use an ID or their seed name. + +```yaml +polar: + meters: + - name: API calls + filter: + conjunction: and + clauses: + - property: name + operator: eq + value: api.call + aggregation: + func: sum + property: count + benefits: + - type: meter_credit + description: 100 API calls + meter: API calls + units: 100 + products: + - name: Free + recurring_interval: month + prices: + - amount_type: fixed + price_amount: 0 + price_currency: usd + benefits: + - 100 API calls + customers: + - external_id: customer_123 + email: customer@example.com + subscriptions: + - product: Free +``` + +## Inspect and fault + +`GET /openapi.json` serves the hand-authored API description. Use `GET /_emulate/ledger` to inspect authenticated requests and side effects. Faults can target Polar operation IDs such as `customers:get_state_external` through `POST /_emulate/faults`. diff --git a/apps/web/app/docs/programmatic-api/page.mdx b/apps/web/app/docs/programmatic-api/page.mdx index 93c1e94b..cf93d347 100644 --- a/apps/web/app/docs/programmatic-api/page.mdx +++ b/apps/web/app/docs/programmatic-api/page.mdx @@ -69,7 +69,9 @@ afterAll(() => Promise.all([github.close(), vercel.close()])); Service name: 'vercel', 'github', 'google', 'slack',{" "} 'apple', 'microsoft', 'aws', 'okta',{" "} - 'mongoatlas', 'resend', or 'stripe' + 'mongoatlas', 'resend', 'stripe', 'clerk',{" "} + 'spotify', 'x', 'workos', 'autumn', 'posthog',{" "} + 'mcp', 'gitlab', or 'polar' @@ -243,6 +245,12 @@ npm install @emulators/github @emulators/google @emulators/stripe PostHog API, OpenAPI OAuth discovery, CIMD OAuth + + + @emulators/polar + + Polar subscriptions, usage metering, hosted checkout, and customer portal + @emulators/core diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 1ef1d420..9a73e7d1 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -11,8 +11,8 @@ export default function LandingPage() { Local API emulation for dev and CI

- Stateful, production-fidelity replacements for Stripe, GitHub, Google, AWS, and 7 more services. No API keys. - No network. Not mocks. + Stateful, production-fidelity replacements for Stripe, Polar, GitHub, Google, AWS, and more services. No API + keys. No network. Not mocks.

@@ -62,7 +62,7 @@ export default function LandingPage() {

Zero config

Run npx emulate{" "} - and all 11 services start with sensible defaults. Seed data via YAML when you need it. + and every service starts with sensible defaults. Seed data via YAML when you need it.

diff --git a/apps/web/components/hero-terminal.tsx b/apps/web/components/hero-terminal.tsx index 907916a4..9d3a6104 100644 --- a/apps/web/components/hero-terminal.tsx +++ b/apps/web/components/hero-terminal.tsx @@ -9,11 +9,20 @@ const services = [ { name: "Slack", port: 4003, slug: "slack" }, { name: "Apple", port: 4004, slug: "apple" }, { name: "Microsoft", port: 4005, slug: "microsoft" }, - { name: "AWS", port: 4006, slug: "aws" }, - { name: "Okta", port: 4007, slug: "okta" }, - { name: "MongoDB Atlas", port: 4008, slug: "mongoatlas" }, - { name: "Resend", port: 4009, slug: "resend" }, - { name: "Stripe", port: 4010, slug: "stripe" }, + { name: "Okta", port: 4006, slug: "okta" }, + { name: "AWS", port: 4007, slug: "aws" }, + { name: "Resend", port: 4008, slug: "resend" }, + { name: "Stripe", port: 4009, slug: "stripe" }, + { name: "MongoDB Atlas", port: 4010, slug: "mongoatlas" }, + { name: "Clerk", port: 4011, slug: "clerk" }, + { name: "Spotify", port: 4012, slug: "spotify" }, + { name: "X", port: 4013, slug: "x" }, + { name: "WorkOS", port: 4014, slug: "workos" }, + { name: "Autumn", port: 4015, slug: "autumn" }, + { name: "PostHog", port: 4016, slug: "posthog" }, + { name: "MCP", port: 4017, slug: "mcp" }, + { name: "GitLab", port: 4018, slug: "gitlab" }, + { name: "Polar", port: 4019, slug: "polar" }, ]; export function HeroTerminal({ pixelFont }: { pixelFont: string }) { diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 9e3e5f1c..de2d60b0 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -24,6 +24,7 @@ export const allDocsPages: NavItem[] = [ { name: "WorkOS", href: "/docs/workos" }, { name: "Spotify", href: "/docs/spotify" }, { name: "PostHog", href: "/docs/posthog" }, + { name: "Polar", href: "/docs/polar" }, { name: "Authentication", href: "/docs/authentication" }, { name: "Service Manifest", href: "/docs/manifest" }, { name: "Request Ledger", href: "/docs/ledger" }, diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index bb6fa136..7d21b9d5 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -19,6 +19,7 @@ export const PAGE_TITLES: Record = { workos: "WorkOS", spotify: "Spotify", posthog: "PostHog", + polar: "Polar", authentication: "Authentication", manifest: "Service Manifest", ledger: "Request Ledger", diff --git a/packages/@emulators/adapter-next/package.json b/packages/@emulators/adapter-next/package.json index b06994b8..463a7b38 100644 --- a/packages/@emulators/adapter-next/package.json +++ b/packages/@emulators/adapter-next/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/adapter-next", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/apple/package.json b/packages/@emulators/apple/package.json index 9ed98331..4c6a397c 100644 --- a/packages/@emulators/apple/package.json +++ b/packages/@emulators/apple/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/apple", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/autumn/package.json b/packages/@emulators/autumn/package.json index 88db2f2a..95d54e69 100644 --- a/packages/@emulators/autumn/package.json +++ b/packages/@emulators/autumn/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/autumn", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/aws/package.json b/packages/@emulators/aws/package.json index 299f9b2d..a5bfac8f 100644 --- a/packages/@emulators/aws/package.json +++ b/packages/@emulators/aws/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/aws", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/clerk/package.json b/packages/@emulators/clerk/package.json index 9fd9fbea..2128614b 100644 --- a/packages/@emulators/clerk/package.json +++ b/packages/@emulators/clerk/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/clerk", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/cloudflare/package.json b/packages/@emulators/cloudflare/package.json index c02ea594..6d9d9f65 100644 --- a/packages/@emulators/cloudflare/package.json +++ b/packages/@emulators/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/cloudflare", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/core/package.json b/packages/@emulators/core/package.json index 17a37e98..569f1ec1 100644 --- a/packages/@emulators/core/package.json +++ b/packages/@emulators/core/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/core", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/github/package.json b/packages/@emulators/github/package.json index 2406c0cc..185c7269 100644 --- a/packages/@emulators/github/package.json +++ b/packages/@emulators/github/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/github", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/gitlab/package.json b/packages/@emulators/gitlab/package.json index 699965db..cd08052c 100644 --- a/packages/@emulators/gitlab/package.json +++ b/packages/@emulators/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/gitlab", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/google/package.json b/packages/@emulators/google/package.json index f5b80e60..a52ef238 100644 --- a/packages/@emulators/google/package.json +++ b/packages/@emulators/google/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/google", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/mcp/package.json b/packages/@emulators/mcp/package.json index 92d8b07b..740ecc8c 100644 --- a/packages/@emulators/mcp/package.json +++ b/packages/@emulators/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mcp", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/microsoft/package.json b/packages/@emulators/microsoft/package.json index 6b8e1bf3..fb24c74c 100644 --- a/packages/@emulators/microsoft/package.json +++ b/packages/@emulators/microsoft/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/microsoft", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/mongoatlas/package.json b/packages/@emulators/mongoatlas/package.json index ae2639f0..6f2b71c6 100644 --- a/packages/@emulators/mongoatlas/package.json +++ b/packages/@emulators/mongoatlas/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mongoatlas", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/okta/package.json b/packages/@emulators/okta/package.json index 76e63a6c..49b543fe 100644 --- a/packages/@emulators/okta/package.json +++ b/packages/@emulators/okta/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/okta", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/polar/package.json b/packages/@emulators/polar/package.json new file mode 100644 index 00000000..b7207029 --- /dev/null +++ b/packages/@emulators/polar/package.json @@ -0,0 +1,44 @@ +{ + "name": "@emulators/polar", + "version": "0.15.0", + "private": true, + "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" + }, + "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": { + "@polar-sh/sdk": "0.49.0", + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} 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..002c2e0f --- /dev/null +++ b/packages/@emulators/polar/src/__tests__/polar.test.ts @@ -0,0 +1,364 @@ +import { createServer } from "@emulators/core"; +import { HTTPClient, Polar } from "@polar-sh/sdk"; +import { HTTPValidationError } from "@polar-sh/sdk/models/errors/httpvalidationerror.js"; +import { ResourceNotFound } from "@polar-sh/sdk/models/errors/resourcenotfound.js"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { polarPlugin, seedFromConfig } from "../index.js"; +import { manifest } from "../manifest.js"; + +const PORT = 41881; +const BASE = `http://localhost:${PORT}`; + +let polar: Polar; +let localFetch: typeof fetch; +let sumMeterId: string; +let maxMeterId: string; +let benefitId: string; +let freeProductId: string; +let paidProductId: string; +let secondPaidProductId: string; +let customerId: string; +let subscriptionId: string; + +async function validationError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(HTTPValidationError); + return error as HTTPValidationError; + } + throw new Error("Expected an HTTPValidationError"); +} + +beforeAll(() => { + const { app, store } = createServer(polarPlugin, { + port: PORT, + baseUrl: BASE, + manifest, + fallbackUser: { login: "polar_oat_emulate", id: 1, scopes: [] }, + }); + seedFromConfig(store, BASE, { checkout: { settle_delay_ms: null } }); + localFetch = (input, init) => app.fetch(new Request(input, init)); + polar = new Polar({ + accessToken: "polar_oat_test", + serverURL: BASE, + httpClient: new HTTPClient({ fetcher: localFetch }), + }); +}); + +describe.sequential("polar emulator with the real @polar-sh/sdk", () => { + it("requires a bearer token and publishes the operation IDs", async () => { + const unauthorized = await localFetch(`${BASE}/v1/customers/`); + expect(unauthorized.status).toBe(401); + expect(await unauthorized.json()).toMatchObject({ error: "Unauthorized" }); + + const openapi = (await (await localFetch(`${BASE}/openapi.json`)).json()) as { + paths: Record>; + }; + expect(openapi.paths["/v1/customers/external/{external_id}/state"]?.get.operationId).toBe( + "customers:get_state_external", + ); + + await polar.customers.list({}); + const ledger = (await (await localFetch(`${BASE}/_emulate/ledger`)).json()) as { + entries: Array<{ operationId?: string; identity: { user?: { login: string } } }>; + }; + expect(ledger.entries.find((entry) => entry.operationId === "customers:list")?.identity.user?.login).toBe( + "polar_oat_test", + ); + }); + + it("creates and lists meters, benefits, and products with metadata", async () => { + const sumMeter = await polar.meters.create({ + name: "API calls", + filter: { conjunction: "and", clauses: [{ property: "name", operator: "eq", value: "api.call" }] }, + aggregation: { func: "sum", property: "count" }, + metadata: { source: "test" }, + }); + sumMeterId = sumMeter.id; + + const maxMeter = await polar.meters.create({ + name: "Peak payload", + filter: { conjunction: "and", clauses: [{ property: "name", operator: "eq", value: "api.call" }] }, + aggregation: { func: "max", property: "count" }, + metadata: { source: "test" }, + }); + maxMeterId = maxMeter.id; + + const benefit = await polar.benefits.create({ + type: "meter_credit", + description: "One hundred API calls", + properties: { meterId: sumMeter.id, units: 100, rollover: false }, + metadata: { source: "test" }, + }); + benefitId = benefit.id; + const customBenefit = await polar.benefits.create({ + type: "custom", + description: "Private support channel", + properties: { note: "Invite after subscription" }, + metadata: { source: "test" }, + }); + expect(customBenefit).toMatchObject({ + type: "custom", + properties: { note: "Invite after subscription" }, + }); + + const free = await polar.products.create({ + name: "Free", + recurringInterval: "month", + prices: [{ amountType: "fixed", priceAmount: 0, priceCurrency: "usd" }], + metadata: { tier: "free" }, + }); + freeProductId = free.id; + const withBenefits = await polar.products.updateBenefits({ + id: free.id, + productBenefitsUpdate: { benefits: [benefit.id] }, + }); + expect(withBenefits.benefits.map((item) => item.id)).toEqual([benefit.id]); + + const paid = await polar.products.create({ + name: "Pro", + recurringInterval: "month", + trialInterval: "day", + trialIntervalCount: 14, + prices: [{ amountType: "fixed", priceAmount: 2000, priceCurrency: "usd" }], + metadata: { tier: "pro" }, + }); + paidProductId = paid.id; + const secondPaid = await polar.products.create({ + name: "Scale", + recurringInterval: "month", + prices: [{ amountType: "fixed", priceAmount: 4000, priceCurrency: "usd" }], + }); + secondPaidProductId = secondPaid.id; + const meteredProduct = await polar.products.create({ + name: "Usage", + recurringInterval: "month", + prices: [ + { amountType: "fixed", priceAmount: 1000, priceCurrency: "usd" }, + { amountType: "metered_unit", meterId: sumMeter.id, unitAmount: "0.5", priceCurrency: "usd" }, + ], + }); + expect(meteredProduct.prices.find((price) => price.amountType === "metered_unit")).toMatchObject({ + meterId: sumMeter.id, + unitAmount: "0.5", + }); + + const meterPage = await polar.meters.list({}); + expect(meterPage.result.items.find((item) => item.id === sumMeter.id)?.metadata).toEqual({ source: "test" }); + const benefitPage = await polar.benefits.list({}); + expect(benefitPage.result.items.find((item) => item.id === benefit.id)?.metadata).toEqual({ source: "test" }); + const productPage = await polar.products.list({}); + expect(productPage.result.items.find((item) => item.id === paid.id)?.metadata).toEqual({ tier: "pro" }); + const organizationPage = await polar.organizations.listOrganizations({}); + expect(organizationPage.result.items[0]).toMatchObject({ slug: "emulate", name: "Emulate" }); + }); + + it("enforces customer uniqueness and maps unknown state to ResourceNotFound", async () => { + const customer = await polar.customers.create({ + externalId: "acct_main", + email: "main@example.com", + name: "Main Customer", + metadata: { segment: "test" }, + }); + customerId = customer.id; + + const duplicateEmail = await validationError( + polar.customers.create({ externalId: "acct_other", email: "main@example.com" }), + ); + expect(duplicateEmail.detail?.[0]).toMatchObject({ + loc: ["body", "email"], + msg: "A customer with this email address already exists.", + }); + + const duplicateExternalId = await validationError( + polar.customers.create({ externalId: "acct_main", email: "other@example.com" }), + ); + expect(duplicateExternalId.detail?.[0]).toMatchObject({ + loc: ["body", "external_id"], + msg: "A customer with this external ID already exists.", + }); + + await expect(polar.customers.getStateExternal({ externalId: "missing" })).rejects.toBeInstanceOf(ResourceNotFound); + }); + + it("creates free subscriptions and exposes grants and meter credits in customer state", async () => { + const subscription = await polar.subscriptions.create({ + externalCustomerId: "acct_main", + productId: freeProductId, + }); + subscriptionId = subscription.id; + + await expect( + polar.subscriptions.create({ externalCustomerId: "acct_main", productId: paidProductId }), + ).rejects.toBeTruthy(); + + const state = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(state.activeSubscriptions.map((item) => item.id)).toContain(subscription.id); + expect(state.grantedBenefits.map((item) => item.benefitId)).toContain(benefitId); + expect(state.activeMeters.find((item) => item.meterId === sumMeterId)).toMatchObject({ + consumedUnits: 0, + creditedUnits: 100, + balance: 100, + }); + const customerMeters = await polar.customerMeters.list({ externalCustomerId: "acct_main" }); + expect(customerMeters.result.items.find((item) => item.meterId === sumMeterId)).toMatchObject({ + customerId, + creditedUnits: 100, + }); + }); + + it("aggregates events and connects events ingested before customer creation", async () => { + const ingest = await polar.events.ingest({ + events: [ + { name: "api.call", externalCustomerId: "acct_main", externalId: "evt_main_1", metadata: { count: 3 } }, + { name: "api.call", externalCustomerId: "acct_main", externalId: "evt_main_2", metadata: { count: 7 } }, + { name: "ignored", externalCustomerId: "acct_main", metadata: { count: 100 } }, + { name: "api.call", externalCustomerId: "acct_late", externalId: "evt_late", metadata: { count: 9 } }, + ], + }); + expect(ingest).toMatchObject({ inserted: 4, duplicates: 0 }); + + const state = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(state.activeMeters.find((item) => item.meterId === sumMeterId)).toMatchObject({ + consumedUnits: 10, + creditedUnits: 100, + balance: 90, + }); + expect(state.activeMeters.find((item) => item.meterId === maxMeterId)?.consumedUnits).toBe(7); + + await polar.customers.create({ externalId: "acct_late", email: "late@example.com" }); + const late = await polar.customers.getStateExternal({ externalId: "acct_late" }); + expect(late.activeMeters.find((item) => item.meterId === sumMeterId)?.consumedUnits).toBe(9); + + const events = await polar.events.list({ externalCustomerId: "acct_main", name: "api.call" }); + expect(events.items).toHaveLength(2); + expect(events.items.every((event) => event.name === "api.call" && event.externalCustomerId === "acct_main")).toBe( + true, + ); + }); + + it("keeps confirmed checkout subscriptions pending until settlement", async () => { + const checkout = await polar.checkouts.create({ + products: [paidProductId], + subscriptionId, + successUrl: "https://example.test/success?checkout_id={CHECKOUT_ID}", + }); + expect(checkout.url).toBe(`${BASE}/checkout/${checkout.clientSecret}`); + + const page = await localFetch(checkout.url); + expect(page.status).toBe(200); + expect(await page.text()).toContain("Start free trial"); + + const confirmed = await localFetch(`${checkout.url}/confirm`, { method: "POST", redirect: "manual" }); + expect(confirmed.status).toBe(303); + expect(confirmed.headers.get("location")).toBe(`https://example.test/success?checkout_id=${checkout.id}`); + + const pendingState = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(pendingState.activeSubscriptions.find((item) => item.id === subscriptionId)?.productId).toBe(freeProductId); + + const settled = await localFetch(`${checkout.url}/settle`, { method: "POST" }); + expect(settled.status).toBe(200); + const settledState = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(settledState.activeSubscriptions.find((item) => item.id === subscriptionId)).toMatchObject({ + productId: paidProductId, + status: "trialing", + }); + expect(settledState.activeSubscriptions.find((item) => item.id === subscriptionId)?.trialEnd).toBeInstanceOf(Date); + + const secondCheckout = await polar.checkouts.create({ + products: [paidProductId], + externalCustomerId: "acct_main", + successUrl: "https://example.test/success", + }); + await localFetch(`${secondCheckout.url}/confirm`, { method: "POST", redirect: "manual" }); + await localFetch(`${secondCheckout.url}/settle`, { method: "POST" }); + const stateWithSecondSubscription = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(stateWithSecondSubscription.activeSubscriptions).toHaveLength(2); + }); + + it("schedules, clears, cancels, resumes, and revokes subscription updates", async () => { + const scheduled = await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { productId: secondPaidProductId, prorationBehavior: "next_period" }, + }); + expect(scheduled.productId).toBe(paidProductId); + expect(scheduled.pendingUpdate?.productId).toBe(secondPaidProductId); + + const cleared = await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { pendingUpdate: null }, + }); + expect(cleared.pendingUpdate).toBeNull(); + + const canceling = await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { cancelAtPeriodEnd: true }, + }); + expect(canceling.cancelAtPeriodEnd).toBe(true); + const resumed = await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { cancelAtPeriodEnd: false }, + }); + expect(resumed.cancelAtPeriodEnd).toBe(false); + + const revoked = await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { revoke: true }, + }); + expect(revoked.status).toBe("canceled"); + const state = await polar.customers.getStateExternal({ externalId: "acct_main" }); + expect(state.activeSubscriptions.some((item) => item.id === subscriptionId)).toBe(false); + + const directlyRevoked = await polar.subscriptions.create({ + externalCustomerId: "acct_main", + productId: freeProductId, + }); + expect((await polar.subscriptions.revoke({ id: directlyRevoked.id })).status).toBe("canceled"); + }); + + it("creates a customer portal session that renders", async () => { + const session = await polar.customerSessions.create({ + externalCustomerId: "acct_main", + returnUrl: "https://example.test/account", + }); + expect(session.customerId).toBe(customerId); + const portal = await localFetch(session.customerPortalUrl); + expect(portal.status).toBe(200); + expect(await portal.text()).toContain("main@example.com"); + }); + + it("faults state reads by operation ID and records the fault in the ledger", async () => { + const armed = await localFetch(`${BASE}/_emulate/faults`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + match: { operationId: "customers:get_state_external" }, + response: { status: 503 }, + times: 1, + }), + }); + expect(armed.status).toBe(200); + await expect(polar.customers.getStateExternal({ externalId: "acct_main" })).rejects.toBeTruthy(); + + const ledgerResponse = await localFetch(`${BASE}/_emulate/ledger`); + const ledger = (await ledgerResponse.json()) as { + entries: Array<{ operationId?: string; faulted?: boolean; response: { status: number } }>; + }; + expect( + ledger.entries.find((entry) => entry.operationId === "customers:get_state_external" && entry.faulted), + ).toMatchObject({ operationId: "customers:get_state_external", faulted: true, response: { status: 503 } }); + + const cleared = await localFetch(`${BASE}/_emulate/faults`, { method: "DELETE" }); + expect(cleared.status).toBe(200); + }); + + it("deleting a customer by external ID removes it and its subscriptions", async () => { + await polar.customers.deleteExternal({ externalId: "acct_main" }); + await expect(polar.customers.getStateExternal({ externalId: "acct_main" })).rejects.toBeInstanceOf( + ResourceNotFound, + ); + await expect(polar.subscriptions.get({ id: subscriptionId })).rejects.toBeInstanceOf(ResourceNotFound); + }); +}); diff --git a/packages/@emulators/polar/src/entities.ts b/packages/@emulators/polar/src/entities.ts new file mode 100644 index 00000000..0055e254 --- /dev/null +++ b/packages/@emulators/polar/src/entities.ts @@ -0,0 +1,189 @@ +import type { Entity } from "@emulators/core"; + +export type PolarMetadataValue = string | number | boolean; +export type PolarMetadata = Record; + +export type PolarFilterOperator = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like" | "not_like"; + +export interface PolarFilterClause { + property: string; + operator: PolarFilterOperator; + value: PolarMetadataValue; +} + +export interface PolarFilter { + conjunction: "and" | "or"; + clauses: Array; +} + +export type PolarAggregation = { func: "count" } | { func: "sum" | "max" | "min" | "avg" | "unique"; property: string }; + +export interface PolarCustomer extends Entity { + polar_id: string; + external_id: string | null; + email: string; + email_verified: boolean; + type: "individual" | "team"; + name: string | null; + billing_name: string | null; + billing_address: Record | null; + tax_id: string | null; + locale: string | null; + metadata: PolarMetadata; +} + +export interface PolarMeter extends Entity { + polar_id: string; + name: string; + unit: "scalar" | "token" | "custom"; + custom_label: string | null; + custom_multiplier: number | null; + filter: PolarFilter; + aggregation: PolarAggregation; + metadata: PolarMetadata; + archived_at: string | null; +} + +export interface PolarEvent extends Entity { + polar_id: string; + external_id: string | null; + timestamp: string; + name: string; + customer_id: string | null; + external_customer_id: string | null; + metadata: PolarMetadata; +} + +export interface PolarMeterCreditProperties { + meter_id: string; + units: number; + rollover: boolean; +} + +export interface PolarCustomBenefitProperties { + note: string | null; +} + +export interface PolarBenefit extends Entity { + polar_id: string; + type: "meter_credit" | "custom"; + description: string; + properties: PolarMeterCreditProperties | PolarCustomBenefitProperties; + metadata: PolarMetadata; + visibility: "draft" | "private" | "public"; +} + +export type PolarStoredPrice = + | { + id: string; + created_at: string; + amount_type: "fixed"; + price_amount: number; + price_currency: string; + } + | { + id: string; + created_at: string; + amount_type: "metered_unit"; + meter_id: string; + unit_amount: string; + cap_amount: number | null; + price_currency: string; + } + | { + id: string; + created_at: string; + amount_type: "custom"; + minimum_amount: number; + maximum_amount: number | null; + preset_amount: number | null; + price_currency: string; + }; + +export interface PolarProduct extends Entity { + polar_id: string; + name: string; + description: string | null; + recurring_interval: "day" | "week" | "month" | "year" | null; + recurring_interval_count: number | null; + meter_interval: "day" | "week" | "month" | "year" | null; + meter_interval_count: number | null; + trial_interval: "day" | "week" | "month" | "year" | null; + trial_interval_count: number | null; + prices: PolarStoredPrice[]; + benefit_ids: string[]; + metadata: PolarMetadata; + visibility: "draft" | "private" | "public"; + is_archived: boolean; +} + +export type PolarSubscriptionStatus = "incomplete" | "trialing" | "active" | "past_due" | "canceled"; + +export interface PolarPendingUpdate { + id: string; + created_at: string; + applies_at: string; + product_id: string | null; + seats: number | null; + units: number | null; +} + +export interface PolarSubscription extends Entity { + polar_id: string; + status: PolarSubscriptionStatus; + amount: number; + currency: string; + recurring_interval: "day" | "week" | "month" | "year"; + recurring_interval_count: number; + current_period_start: string; + current_period_end: string; + trial_start: string | null; + trial_end: string | null; + cancel_at_period_end: boolean; + canceled_at: string | null; + started_at: string | null; + ends_at: string | null; + ended_at: string | null; + customer_id: string; + product_id: string; + pending_update: PolarPendingUpdate | null; + checkout_id: string | null; + customer_cancellation_reason: string | null; + customer_cancellation_comment: string | null; + metadata: PolarMetadata; + pending: boolean; +} + +export interface PolarCheckout extends Entity { + polar_id: string; + client_secret: string; + status: "open" | "succeeded"; + expires_at: string; + success_url: string; + return_url: string | null; + product_ids: string[]; + customer_id: string | null; + customer_email: string | null; + customer_name: string | null; + external_customer_id: string | null; + subscription_id: string | null; + pending_subscription_id: string | null; + amount: number; + currency: string; + allow_discount_codes: boolean; + allow_trial: boolean; + trial_end: string | null; + metadata: PolarMetadata; + customer_metadata: PolarMetadata; + settle_delay_ms: number | null; + confirmed_at: string | null; + settled_at: string | null; +} + +export interface PolarCustomerSession extends Entity { + polar_id: string; + token: string; + expires_at: string; + customer_id: string; + return_url: string | null; +} diff --git a/packages/@emulators/polar/src/index.ts b/packages/@emulators/polar/src/index.ts new file mode 100644 index 00000000..605e4dbd --- /dev/null +++ b/packages/@emulators/polar/src/index.ts @@ -0,0 +1,242 @@ +import type { AppEnv, Hono, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; + +import type { + PolarAggregation, + PolarFilter, + PolarMetadata, + PolarProduct, + PolarStoredPrice, + PolarSubscriptionStatus, +} from "./entities.js"; +import { checkoutRoutes } from "./routes/checkout.js"; +import { openapiRoutes } from "./routes/openapi.js"; +import { polarApiRoutes } from "./routes/api.js"; +import { portalRoutes } from "./routes/portal.js"; +import { createSubscription, metadata, newUuid, productAmount, productCurrency } from "./serialize.js"; +import { getPolarStore, type PolarStore } from "./store.js"; + +export { getPolarStore, type PolarStore } from "./store.js"; +export * from "./entities.js"; +export { manifest } from "./manifest.js"; + +export type PolarSeedPrice = + | { amount_type: "fixed"; price_amount: number; price_currency?: string } + | { + amount_type: "metered_unit"; + meter_id: string; + unit_amount: number | string; + cap_amount?: number | null; + price_currency?: string; + } + | { + amount_type: "custom"; + minimum_amount?: number; + maximum_amount?: number | null; + preset_amount?: number | null; + price_currency?: string; + }; + +export interface PolarSeedConfig { + meters?: Array<{ + name: string; + filter: PolarFilter; + aggregation: PolarAggregation; + metadata?: PolarMetadata; + }>; + benefits?: Array< + | { + type: "meter_credit"; + description: string; + meter: string; + units: number; + rollover?: boolean; + metadata?: PolarMetadata; + } + | { type: "custom"; description: string; metadata?: PolarMetadata } + >; + products?: Array<{ + name: string; + description?: string; + recurring_interval?: "day" | "week" | "month" | "year"; + recurring_interval_count?: number; + prices: PolarSeedPrice[]; + trial_interval?: "day" | "week" | "month" | "year"; + trial_interval_count?: number; + benefits?: string[]; + metadata?: PolarMetadata; + }>; + customers?: Array<{ + external_id: string; + email: string; + name?: string; + subscriptions?: Array<{ product: string; status?: Extract }>; + }>; + checkout?: { settle_delay_ms?: number | null }; +} + +function seedMeters(ps: PolarStore, meters: NonNullable): void { + for (const meter of meters) { + const values = { + name: meter.name, + unit: "scalar" as const, + custom_label: null, + custom_multiplier: null, + filter: meter.filter, + aggregation: meter.aggregation, + metadata: metadata(meter.metadata), + archived_at: null, + }; + const existing = ps.meters.findOneBy("name", meter.name); + if (existing) ps.meters.update(existing.id, values); + else ps.meters.insert({ polar_id: newUuid(), ...values }); + } +} + +function seedBenefits(ps: PolarStore, benefits: NonNullable): void { + for (const benefit of benefits) { + const existing = ps.benefits.findOneBy("description", benefit.description); + const values = { + type: benefit.type, + description: benefit.description, + properties: + benefit.type === "meter_credit" + ? { + meter_id: + ps.meters.findOneBy("polar_id", benefit.meter)?.polar_id ?? + ps.meters.findOneBy("name", benefit.meter)?.polar_id ?? + benefit.meter, + units: benefit.units, + rollover: benefit.rollover ?? false, + } + : { note: null }, + metadata: metadata(benefit.metadata), + visibility: "public" as const, + }; + if (existing) ps.benefits.update(existing.id, values); + else ps.benefits.insert({ polar_id: newUuid(), ...values }); + } +} + +function seedPrice(ps: PolarStore, value: PolarSeedPrice): PolarStoredPrice { + const common = { id: newUuid(), created_at: new Date().toISOString(), price_currency: value.price_currency ?? "usd" }; + if (value.amount_type === "fixed") return { ...common, amount_type: "fixed", price_amount: value.price_amount }; + if (value.amount_type === "metered_unit") { + const meterId = + ps.meters.findOneBy("polar_id", value.meter_id)?.polar_id ?? + ps.meters.findOneBy("name", value.meter_id)?.polar_id ?? + value.meter_id; + return { + ...common, + amount_type: "metered_unit", + meter_id: meterId, + unit_amount: String(value.unit_amount), + cap_amount: value.cap_amount ?? null, + }; + } + return { + ...common, + amount_type: "custom", + minimum_amount: value.minimum_amount ?? 0, + maximum_amount: value.maximum_amount ?? null, + preset_amount: value.preset_amount ?? null, + }; +} + +function productValues(ps: PolarStore, product: NonNullable[number]) { + return { + name: product.name, + description: product.description ?? null, + recurring_interval: product.recurring_interval ?? "month", + recurring_interval_count: product.recurring_interval_count ?? 1, + meter_interval: null, + meter_interval_count: null, + trial_interval: product.trial_interval ?? null, + trial_interval_count: product.trial_interval ? (product.trial_interval_count ?? 1) : null, + prices: product.prices.map((price) => seedPrice(ps, price)), + benefit_ids: (product.benefits ?? []) + .map( + (reference) => + ps.benefits.findOneBy("polar_id", reference)?.polar_id ?? + ps.benefits.findOneBy("description", reference)?.polar_id, + ) + .filter((id): id is string => id !== undefined), + metadata: metadata(product.metadata), + visibility: "public" as const, + is_archived: false, + } satisfies Omit; +} + +function seedProducts(ps: PolarStore, products: NonNullable): void { + for (const product of products) { + const values = productValues(ps, product); + const existing = ps.products.findOneBy("name", product.name); + if (existing) ps.products.update(existing.id, values); + else ps.products.insert({ polar_id: newUuid(), ...values }); + } +} + +function seedCustomers(ps: PolarStore, customers: NonNullable): void { + for (const customer of customers) { + const existing = ps.customers.findOneBy("external_id", customer.external_id); + const values = { + external_id: customer.external_id, + email: customer.email, + email_verified: false, + type: "individual" as const, + name: customer.name ?? null, + billing_name: null, + billing_address: null, + tax_id: null, + locale: null, + metadata: {}, + }; + const saved = existing + ? ps.customers.update(existing.id, values)! + : ps.customers.insert({ polar_id: newUuid(), ...values }); + for (const seededSubscription of customer.subscriptions ?? []) { + const product = + ps.products.findOneBy("polar_id", seededSubscription.product) ?? + ps.products.findOneBy("name", seededSubscription.product); + if (!product) continue; + const subscription = ps.subscriptions + .findBy("customer_id", saved.polar_id) + .find((candidate) => candidate.product_id === product.polar_id && !candidate.pending); + if (subscription) { + ps.subscriptions.update(subscription.id, { + status: seededSubscription.status ?? "active", + amount: productAmount(product), + currency: productCurrency(product), + }); + } else { + createSubscription(ps, saved, product, { status: seededSubscription.status ?? "active" }); + } + } + } +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: PolarSeedConfig): void { + const ps = getPolarStore(store); + if (config.meters) seedMeters(ps, config.meters); + if (config.benefits) seedBenefits(ps, config.benefits); + if (config.products) seedProducts(ps, config.products); + if (config.customers) seedCustomers(ps, config.customers); + if (config.checkout && Object.hasOwn(config.checkout, "settle_delay_ms")) { + store.setData("polar.checkout.settle_delay_ms", config.checkout.settle_delay_ms ?? null); + } +} + +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 }; + polarApiRoutes(ctx); + checkoutRoutes(ctx); + portalRoutes(ctx); + openapiRoutes(ctx); + }, + seed(store: Store): void { + store.setData("polar.checkout.settle_delay_ms", 2500); + }, +}; + +export default polarPlugin; diff --git a/packages/@emulators/polar/src/manifest.ts b/packages/@emulators/polar/src/manifest.ts new file mode 100644 index 00000000..b8737936 --- /dev/null +++ b/packages/@emulators/polar/src/manifest.ts @@ -0,0 +1,178 @@ +import type { OperationCoverage, ServiceManifest } from "@emulators/core"; + +export const operations: OperationCoverage[] = [ + { operationId: "customers:create", method: "POST", path: "/v1/customers/", status: "hand-authored" }, + { operationId: "customers:list", method: "GET", path: "/v1/customers/", status: "hand-authored" }, + { operationId: "customers:get", method: "GET", path: "/v1/customers/{id}", status: "hand-authored" }, + { operationId: "customers:update", method: "PATCH", path: "/v1/customers/{id}", status: "hand-authored" }, + { operationId: "customers:delete", method: "DELETE", path: "/v1/customers/{id}", status: "hand-authored" }, + { + operationId: "customers:get_external", + method: "GET", + path: "/v1/customers/external/{external_id}", + status: "hand-authored", + }, + { + operationId: "customers:update_external", + method: "PATCH", + path: "/v1/customers/external/{external_id}", + status: "hand-authored", + }, + { + operationId: "customers:delete_external", + method: "DELETE", + path: "/v1/customers/external/{external_id}", + status: "hand-authored", + }, + { operationId: "customers:get_state", method: "GET", path: "/v1/customers/{id}/state", status: "hand-authored" }, + { + operationId: "customers:get_state_external", + method: "GET", + path: "/v1/customers/external/{external_id}/state", + status: "hand-authored", + }, + { operationId: "customer_meters:list", method: "GET", path: "/v1/customer-meters/", status: "hand-authored" }, + { operationId: "meters:create", method: "POST", path: "/v1/meters/", status: "hand-authored" }, + { operationId: "meters:list", method: "GET", path: "/v1/meters/", status: "hand-authored" }, + { operationId: "meters:get", method: "GET", path: "/v1/meters/{id}", status: "hand-authored" }, + { operationId: "meters:update", method: "PATCH", path: "/v1/meters/{id}", status: "hand-authored" }, + { operationId: "events:ingest", method: "POST", path: "/v1/events/ingest", status: "hand-authored" }, + { operationId: "events:list", method: "GET", path: "/v1/events/", status: "hand-authored" }, + { operationId: "benefits:create", method: "POST", path: "/v1/benefits/", status: "hand-authored" }, + { operationId: "benefits:list", method: "GET", path: "/v1/benefits/", status: "hand-authored" }, + { operationId: "benefits:get", method: "GET", path: "/v1/benefits/{id}", status: "hand-authored" }, + { operationId: "benefits:update", method: "PATCH", path: "/v1/benefits/{id}", status: "hand-authored" }, + { operationId: "benefits:delete", method: "DELETE", path: "/v1/benefits/{id}", status: "hand-authored" }, + { operationId: "products:create", method: "POST", path: "/v1/products/", status: "hand-authored" }, + { operationId: "products:list", method: "GET", path: "/v1/products/", status: "hand-authored" }, + { operationId: "products:get", method: "GET", path: "/v1/products/{id}", status: "hand-authored" }, + { operationId: "products:update", method: "PATCH", path: "/v1/products/{id}", status: "hand-authored" }, + { + operationId: "products:update_benefits", + method: "POST", + path: "/v1/products/{id}/benefits", + status: "hand-authored", + }, + { operationId: "subscriptions:create", method: "POST", path: "/v1/subscriptions/", status: "hand-authored" }, + { operationId: "subscriptions:list", method: "GET", path: "/v1/subscriptions/", status: "hand-authored" }, + { operationId: "subscriptions:get", method: "GET", path: "/v1/subscriptions/{id}", status: "hand-authored" }, + { + operationId: "subscriptions:update", + method: "PATCH", + path: "/v1/subscriptions/{id}", + status: "hand-authored", + }, + { + operationId: "subscriptions:revoke", + method: "DELETE", + path: "/v1/subscriptions/{id}", + status: "hand-authored", + }, + { operationId: "checkouts:create", method: "POST", path: "/v1/checkouts/", status: "hand-authored" }, + { operationId: "checkouts:get", method: "GET", path: "/v1/checkouts/{id}", status: "hand-authored" }, + { + operationId: "checkouts:client_get", + method: "GET", + path: "/v1/checkouts/client/{client_secret}", + status: "hand-authored", + }, + { + operationId: "customer_sessions:create", + method: "POST", + path: "/v1/customer-sessions/", + status: "hand-authored", + }, + { operationId: "organizations:list", method: "GET", path: "/v1/organizations/", status: "hand-authored" }, +]; + +export const manifest: ServiceManifest = { + id: "polar", + name: "Polar", + description: + "Stateful Polar merchant-of-record billing emulator for customers, products, subscriptions, usage meters, hosted checkout, and the customer portal.", + docsUrl: "https://docs.emulators.dev/polar", + surfaces: [ + { id: "rest", kind: "rest", title: "Polar v1 API", status: "partial", basePath: "/v1" }, + { id: "checkout", kind: "ui", title: "Hosted checkout", status: "partial", basePath: "/checkout" }, + { id: "portal", kind: "ui", title: "Customer portal", status: "partial", basePath: "/portal" }, + ], + auth: [ + { + id: "organization-access-token", + title: "Polar organization access token", + type: "bearer-token", + status: "supported", + notes: "Any non-empty polar_oat_... bearer token is accepted.", + }, + ], + specs: [ + { + kind: "openapi", + title: "Polar v1 subscription billing subset", + coverage: "hand-authored", + url: "/openapi.json", + operations, + }, + ], + seedSchema: { + description: "Seed meters, benefits, products, customers, subscriptions, and checkout settlement behavior.", + fields: [ + { key: "meters", title: "Meters", description: "Usage meters, upserted by name." }, + { key: "benefits", title: "Benefits", description: "Meter credit and custom benefits, upserted by description." }, + { + key: "products", + title: "Products", + description: "Subscription products, prices, and attached benefits, upserted by name.", + }, + { key: "customers", title: "Customers", description: "Customers and subscriptions, upserted by external_id." }, + { + key: "checkout", + title: "Checkout", + description: "Settle delay in milliseconds. Null disables automatic settlement.", + }, + ], + example: { + products: [ + { + name: "Free", + recurring_interval: "month", + prices: [{ amount_type: "fixed", price_amount: 0, price_currency: "usd" }], + }, + ], + customers: [{ external_id: "customer_123", email: "customer@example.com" }], + checkout: { settle_delay_ms: 2500 }, + }, + }, + scenarios: [ + { + id: "delayed-checkout-settlement", + title: "Delayed checkout settlement", + description: + "Confirm a checkout, observe stale customer state, then settle explicitly or after the configured delay.", + }, + ], + stateModel: { + description: "Entities mutated by Polar provider calls.", + collections: [ + { name: "polar.customers" }, + { name: "polar.meters" }, + { name: "polar.events" }, + { name: "polar.benefits" }, + { name: "polar.products" }, + { name: "polar.subscriptions" }, + { name: "polar.checkouts" }, + { name: "polar.customer_sessions" }, + ], + }, + connections: [ + { + id: "polar-sdk", + title: "@polar-sh/sdk", + kind: "sdk", + language: "typescript", + description: "Point the official Polar SDK at the emulator with serverURL.", + template: + 'import { Polar } from "@polar-sh/sdk";\n\nconst polar = new Polar({ accessToken: "{{token}}", serverURL: "{{baseUrl}}" });', + }, + ], +}; diff --git a/packages/@emulators/polar/src/routes/api.ts b/packages/@emulators/polar/src/routes/api.ts new file mode 100644 index 00000000..007a740b --- /dev/null +++ b/packages/@emulators/polar/src/routes/api.ts @@ -0,0 +1,1000 @@ +import { recordSideEffect, type AppEnv, type Context, type RouteContext, type Store } from "@emulators/core"; + +import type { + PolarAggregation, + PolarCustomer, + PolarFilter, + PolarMeterCreditProperties, + PolarProduct, + PolarStoredPrice, +} from "../entities.js"; +import { + POLAR_ORGANIZATION_ID, + addInterval, + createSubscription, + isFreeProduct, + liveSubscriptions, + matchesMeter, + metadata, + meterBalances, + newUuid, + paginate, + productAmount, + productCurrency, + rolloverSubscription, + serializeBenefit, + serializeCheckout, + serializeCustomer, + serializeCustomerState, + serializeEvent, + serializeMeter, + serializeProduct, + serializeSubscription, +} from "../serialize.js"; +import { getPolarStore, type PolarStore } from "../store.js"; + +type Body = Record; +const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +function notFound(c: Context) { + return c.json({ error: "ResourceNotFound", detail: "Not found" }, 404); +} + +function validation(c: Context, field: string, message: string, input: unknown) { + return c.json( + { + error: "RequestValidationError", + detail: [{ type: "value_error", loc: ["body", field], msg: message, input }], + }, + 422, + ); +} + +function bodyCustomer(ps: PolarStore, body: Body): PolarCustomer | undefined { + if (typeof body.customer_id === "string") return ps.customers.findOneBy("polar_id", body.customer_id); + if (typeof body.external_customer_id === "string") { + return ps.customers.findOneBy("external_id", body.external_customer_id); + } + return undefined; +} + +function insertCustomer(ps: PolarStore, body: Body): PolarCustomer { + return ps.customers.insert({ + polar_id: newUuid(), + external_id: typeof body.external_id === "string" ? body.external_id : null, + email: String(body.email), + email_verified: false, + type: body.type === "team" ? "team" : "individual", + name: typeof body.name === "string" ? body.name : null, + billing_name: null, + billing_address: + body.billing_address && typeof body.billing_address === "object" + ? (body.billing_address as Record) + : null, + tax_id: typeof body.tax_id === "string" ? body.tax_id : null, + locale: typeof body.locale === "string" ? body.locale : null, + metadata: metadata(body.metadata), + }); +} + +function validateCustomerUniqueness(ps: PolarStore, body: Body, current?: PolarCustomer) { + if (typeof body.email === "string") { + const duplicate = ps.customers.findOneBy("email", body.email); + if (duplicate && duplicate.id !== current?.id) { + return { + field: "email", + message: "A customer with this email address already exists.", + input: body.email, + }; + } + } + if (typeof body.external_id === "string") { + const duplicate = ps.customers.findOneBy("external_id", body.external_id); + if (duplicate && duplicate.id !== current?.id) { + return { + field: "external_id", + message: "A customer with this external ID already exists.", + input: body.external_id, + }; + } + } + return null; +} + +function updateCustomer(ps: PolarStore, customer: PolarCustomer, body: Body): PolarCustomer { + return ps.customers.update(customer.id, { + external_id: + body.external_id === null ? null : typeof body.external_id === "string" ? body.external_id : customer.external_id, + email: typeof body.email === "string" ? body.email : customer.email, + type: body.type === "team" || body.type === "individual" ? body.type : customer.type, + name: body.name === null ? null : typeof body.name === "string" ? body.name : customer.name, + billing_address: + body.billing_address === null + ? null + : body.billing_address && typeof body.billing_address === "object" + ? (body.billing_address as Record) + : customer.billing_address, + tax_id: body.tax_id === null ? null : typeof body.tax_id === "string" ? body.tax_id : customer.tax_id, + locale: body.locale === null ? null : typeof body.locale === "string" ? body.locale : customer.locale, + metadata: body.metadata === undefined ? customer.metadata : metadata(body.metadata), + })!; +} + +function revokeCustomerSubscriptions(ps: PolarStore, customer: PolarCustomer): void { + const now = new Date().toISOString(); + for (const subscription of ps.subscriptions.findBy("customer_id", customer.polar_id)) { + const revoked = ps.subscriptions.update(subscription.id, { + status: "canceled", + ended_at: now, + ends_at: now, + cancel_at_period_end: false, + }); + if (revoked) ps.subscriptions.delete(revoked.id); + } +} + +function queryValues(url: string, key: string): string[] { + return new URL(url).searchParams.getAll(key); +} + +function includesQuery(values: string[], value: string | null): boolean { + return values.length === 0 || (value !== null && values.includes(value)); +} + +function normalizePrice(value: unknown): PolarStoredPrice | null { + if (!value || typeof value !== "object") return null; + const price = value as Body; + const now = new Date().toISOString(); + const common = { + id: newUuid(), + created_at: now, + price_currency: typeof price.price_currency === "string" ? price.price_currency : "usd", + }; + if (price.amount_type === "fixed") { + return { ...common, amount_type: "fixed", price_amount: Number(price.price_amount ?? 0) }; + } + if (price.amount_type === "metered_unit" && typeof price.meter_id === "string") { + return { + ...common, + amount_type: "metered_unit", + meter_id: price.meter_id, + unit_amount: String(price.unit_amount ?? "0"), + cap_amount: typeof price.cap_amount === "number" ? price.cap_amount : null, + }; + } + if (price.amount_type === "custom") { + return { + ...common, + amount_type: "custom", + minimum_amount: Number(price.minimum_amount ?? 0), + maximum_amount: typeof price.maximum_amount === "number" ? price.maximum_amount : null, + preset_amount: typeof price.preset_amount === "number" ? price.preset_amount : null, + }; + } + return null; +} + +function insertProduct(ps: PolarStore, body: Body): PolarProduct { + const prices = (Array.isArray(body.prices) ? body.prices : []).map(normalizePrice).filter((price) => price !== null); + const interval = ["day", "week", "month", "year"].includes(String(body.recurring_interval)) + ? (body.recurring_interval as "day" | "week" | "month" | "year") + : null; + const meterInterval = ["day", "week", "month", "year"].includes(String(body.meter_interval)) + ? (body.meter_interval as "day" | "week" | "month" | "year") + : null; + const trialInterval = ["day", "week", "month", "year"].includes(String(body.trial_interval)) + ? (body.trial_interval as "day" | "week" | "month" | "year") + : null; + return ps.products.insert({ + polar_id: newUuid(), + name: String(body.name ?? "Product"), + description: typeof body.description === "string" ? body.description : null, + recurring_interval: interval, + recurring_interval_count: interval ? Number(body.recurring_interval_count ?? 1) : null, + meter_interval: meterInterval, + meter_interval_count: meterInterval ? Number(body.meter_interval_count ?? 1) : null, + trial_interval: trialInterval, + trial_interval_count: trialInterval ? Number(body.trial_interval_count ?? 1) : null, + prices, + benefit_ids: [], + metadata: metadata(body.metadata), + visibility: body.visibility === "draft" || body.visibility === "private" ? body.visibility : "public", + is_archived: false, + }); +} + +function serializeCustomerMeter( + ps: PolarStore, + customer: PolarCustomer, + row: ReturnType[number], +) { + const meter = ps.meters.findOneBy("polar_id", row.meter_id)!; + return { + ...row, + customer_id: customer.polar_id, + customer: serializeCustomer(customer), + meter: serializeMeter(meter), + }; +} + +function organization() { + const emailSettings = { + order_confirmation: false, + subscription_cancellation: false, + subscription_confirmation: false, + subscription_cycled: false, + subscription_cycled_after_trial: false, + subscription_past_due: false, + subscription_paused: false, + subscription_resumed: false, + subscription_renewal_reminder: false, + subscription_revoked: false, + subscription_trial_conversion_reminder: false, + subscription_uncanceled: false, + subscription_updated: false, + }; + return { + id: POLAR_ORGANIZATION_ID, + created_at: "2025-01-01T00:00:00.000Z", + modified_at: null, + name: "Emulate", + slug: "emulate", + avatar_url: null, + proration_behavior: "prorate", + allow_customer_updates: true, + email: null, + website: null, + socials: [], + status: "active", + details_submitted_at: null, + sso_enforced: false, + default_presentment_currency: "usd", + default_tax_behavior: "location", + feature_settings: null, + subscription_settings: { + allow_multiple_subscriptions: true, + proration_behavior: "prorate", + benefit_revocation_grace_period: 0, + prevent_trial_abuse: false, + allow_customer_updates: true, + }, + customer_email_settings: emailSettings, + customer_portal_settings: { + usage: { show: true }, + subscription: { update_seats: false, update_plan: false, pause: false }, + customer: { allow_email_change: true }, + }, + country: null, + account_id: null, + payout_account_id: null, + capabilities: { + checkout_payments: true, + subscription_renewals: true, + payouts: false, + refunds: false, + api_access: true, + dashboard_access: true, + }, + }; +} + +export function polarApiRoutes(ctx: RouteContext): void { + const { app, store, baseUrl } = ctx; + const ps = () => getPolarStore(store); + + app.use("/v1/*", async (c, next) => { + const authorization = c.req.header("Authorization") ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(authorization); + const token = match?.[1]?.trim(); + if (!token) return c.json({ error: "Unauthorized", detail: "A valid bearer token is required." }, 401); + let id = 0; + for (const character of token) id = (id * 31 + character.charCodeAt(0)) >>> 0; + c.set("authUser", { login: token, id, scopes: [] }); + c.set("authToken", token); + c.set("authScopes", []); + await next(); + }); + + app.post("/v1/customers/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + if (typeof body.email !== "string" || !EMAIL_PATTERN.test(body.email)) { + return validation(c, "email", "Input should be a valid email address", body.email); + } + const duplicate = validateCustomerUniqueness(ps(), body); + if (duplicate) return validation(c, duplicate.field, duplicate.message, duplicate.input); + const customer = insertCustomer(ps(), body); + recordSideEffect(c, { type: "create", collection: "polar.customers", id: customer.polar_id }); + return c.json(serializeCustomer(customer), 201); + }); + + app.get("/v1/customers/", (c) => { + const url = new URL(c.req.url); + const externalIds = queryValues(c.req.url, "external_id"); + const emails = queryValues(c.req.url, "email"); + const query = (url.searchParams.get("query") ?? "").toLowerCase(); + const items = ps() + .customers.all() + .filter( + (customer) => + includesQuery(externalIds, customer.external_id) && + includesQuery(emails, customer.email) && + (!query || + customer.email.toLowerCase().includes(query) || + customer.name?.toLowerCase().includes(query) || + customer.external_id?.toLowerCase().includes(query)), + ) + .map(serializeCustomer); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.get("/v1/customers/external/:externalId/state", (c) => { + const store = ps(); + const customer = store.customers.findOneBy("external_id", c.req.param("externalId")); + return customer ? c.json(serializeCustomerState(store, customer)) : notFound(c); + }); + + app.get("/v1/customers/external/:externalId", (c) => { + const customer = ps().customers.findOneBy("external_id", c.req.param("externalId")); + return customer ? c.json(serializeCustomer(customer)) : notFound(c); + }); + + app.patch("/v1/customers/external/:externalId", async (c) => { + const store = ps(); + const customer = store.customers.findOneBy("external_id", c.req.param("externalId")); + if (!customer) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + if (typeof body.email === "string" && !EMAIL_PATTERN.test(body.email)) { + return validation(c, "email", "Input should be a valid email address", body.email); + } + const duplicate = validateCustomerUniqueness(store, body, customer); + if (duplicate) return validation(c, duplicate.field, duplicate.message, duplicate.input); + const updated = updateCustomer(store, customer, body); + recordSideEffect(c, { type: "update", collection: "polar.customers", id: updated.polar_id }); + return c.json(serializeCustomer(updated)); + }); + + app.delete("/v1/customers/external/:externalId", (c) => { + const store = ps(); + const customer = store.customers.findOneBy("external_id", c.req.param("externalId")); + if (!customer) return notFound(c); + revokeCustomerSubscriptions(store, customer); + store.customers.delete(customer.id); + recordSideEffect(c, { type: "delete", collection: "polar.customers", id: customer.polar_id }); + return c.body(null, 204); + }); + + app.get("/v1/customers/:id/state", (c) => { + const store = ps(); + const customer = store.customers.findOneBy("polar_id", c.req.param("id")); + return customer ? c.json(serializeCustomerState(store, customer)) : notFound(c); + }); + + app.get("/v1/customers/:id", (c) => { + const customer = ps().customers.findOneBy("polar_id", c.req.param("id")); + return customer ? c.json(serializeCustomer(customer)) : notFound(c); + }); + + app.patch("/v1/customers/:id", async (c) => { + const store = ps(); + const customer = store.customers.findOneBy("polar_id", c.req.param("id")); + if (!customer) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + if (typeof body.email === "string" && !EMAIL_PATTERN.test(body.email)) { + return validation(c, "email", "Input should be a valid email address", body.email); + } + const duplicate = validateCustomerUniqueness(store, body, customer); + if (duplicate) return validation(c, duplicate.field, duplicate.message, duplicate.input); + const updated = updateCustomer(store, customer, body); + recordSideEffect(c, { type: "update", collection: "polar.customers", id: updated.polar_id }); + return c.json(serializeCustomer(updated)); + }); + + app.delete("/v1/customers/:id", (c) => { + const store = ps(); + const customer = store.customers.findOneBy("polar_id", c.req.param("id")); + if (!customer) return notFound(c); + revokeCustomerSubscriptions(store, customer); + store.customers.delete(customer.id); + recordSideEffect(c, { type: "delete", collection: "polar.customers", id: customer.polar_id }); + return c.body(null, 204); + }); + + app.get("/v1/customer-meters/", (c) => { + const url = new URL(c.req.url); + const customerIds = queryValues(c.req.url, "customer_id"); + const externalIds = queryValues(c.req.url, "external_customer_id"); + const meterIds = queryValues(c.req.url, "meter_id"); + const store = ps(); + const items = store.customers + .all() + .filter( + (customer) => includesQuery(customerIds, customer.polar_id) && includesQuery(externalIds, customer.external_id), + ) + .flatMap((customer) => meterBalances(store, customer).map((row) => serializeCustomerMeter(store, customer, row))) + .filter((row) => includesQuery(meterIds, row.meter_id)); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.post("/v1/meters/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const meter = ps().meters.insert({ + polar_id: newUuid(), + name: String(body.name ?? "Meter"), + unit: body.unit === "token" || body.unit === "custom" ? body.unit : "scalar", + custom_label: typeof body.custom_label === "string" ? body.custom_label : null, + custom_multiplier: typeof body.custom_multiplier === "number" ? body.custom_multiplier : null, + filter: body.filter as PolarFilter, + aggregation: body.aggregation as PolarAggregation, + metadata: metadata(body.metadata), + archived_at: null, + }); + recordSideEffect(c, { type: "create", collection: "polar.meters", id: meter.polar_id }); + return c.json(serializeMeter(meter), 201); + }); + + app.get("/v1/meters/", (c) => { + const url = new URL(c.req.url); + const query = (url.searchParams.get("query") ?? "").toLowerCase(); + const archived = url.searchParams.get("is_archived"); + const items = ps() + .meters.all() + .filter( + (meter) => + (!query || meter.name.toLowerCase().includes(query)) && + (archived === null || (meter.archived_at !== null) === (archived === "true")), + ) + .map(serializeMeter); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.get("/v1/meters/:id", (c) => { + const meter = ps().meters.findOneBy("polar_id", c.req.param("id")); + return meter ? c.json(serializeMeter(meter)) : notFound(c); + }); + + app.patch("/v1/meters/:id", async (c) => { + const store = ps(); + const meter = store.meters.findOneBy("polar_id", c.req.param("id")); + if (!meter) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + const updated = store.meters.update(meter.id, { + name: typeof body.name === "string" ? body.name : meter.name, + unit: body.unit === "scalar" || body.unit === "token" || body.unit === "custom" ? body.unit : meter.unit, + custom_label: + body.custom_label === null + ? null + : typeof body.custom_label === "string" + ? body.custom_label + : meter.custom_label, + custom_multiplier: + body.custom_multiplier === null + ? null + : typeof body.custom_multiplier === "number" + ? body.custom_multiplier + : meter.custom_multiplier, + filter: body.filter && typeof body.filter === "object" ? (body.filter as PolarFilter) : meter.filter, + aggregation: + body.aggregation && typeof body.aggregation === "object" + ? (body.aggregation as PolarAggregation) + : meter.aggregation, + metadata: body.metadata === undefined ? meter.metadata : metadata(body.metadata), + archived_at: + body.is_archived === true ? new Date().toISOString() : body.is_archived === false ? null : meter.archived_at, + })!; + recordSideEffect(c, { type: "update", collection: "polar.meters", id: updated.polar_id }); + return c.json(serializeMeter(updated)); + }); + + app.post("/v1/events/ingest", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const events = Array.isArray(body.events) ? body.events : []; + let inserted = 0; + let duplicates = 0; + const store = ps(); + for (const value of events) { + if (!value || typeof value !== "object") continue; + const event = value as Body; + const externalId = typeof event.external_id === "string" ? event.external_id : null; + if (externalId && store.events.findOneBy("external_id", externalId)) { + duplicates += 1; + continue; + } + const created = store.events.insert({ + polar_id: newUuid(), + external_id: externalId, + timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), + name: String(event.name ?? "event"), + customer_id: typeof event.customer_id === "string" ? event.customer_id : null, + external_customer_id: typeof event.external_customer_id === "string" ? event.external_customer_id : null, + metadata: metadata(event.metadata), + }); + inserted += 1; + recordSideEffect(c, { type: "create", collection: "polar.events", id: created.polar_id }); + } + return c.json({ inserted, duplicates }); + }); + + app.get("/v1/events/", (c) => { + const url = new URL(c.req.url); + const customerIds = queryValues(c.req.url, "customer_id"); + const externalIds = queryValues(c.req.url, "external_customer_id"); + const names = queryValues(c.req.url, "name"); + const meterId = url.searchParams.get("meter_id"); + const store = ps(); + const meter = meterId ? store.meters.findOneBy("polar_id", meterId) : undefined; + const items = store.events + .all() + .filter( + (event) => + includesQuery(customerIds, event.customer_id) && + includesQuery(externalIds, event.external_customer_id) && + includesQuery(names, event.name) && + (!meterId || (meter !== undefined && matchesMeter(event, meter))), + ) + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + .map((event) => serializeEvent(store, event)); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.get("/v1/events/names", (c) => { + const url = new URL(c.req.url); + const grouped = new Map(); + for (const event of ps().events.all()) { + const current = grouped.get(event.name); + grouped.set(event.name, { + occurrences: (current?.occurrences ?? 0) + 1, + first: current && current.first < event.timestamp ? current.first : event.timestamp, + last: current && current.last > event.timestamp ? current.last : event.timestamp, + }); + } + const items = [...grouped].map(([name, value]) => ({ + name, + label: name, + source: "user", + occurrences: value.occurrences, + first_seen: value.first, + last_seen: value.last, + })); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.post("/v1/benefits/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const type = body.type === "meter_credit" ? "meter_credit" : "custom"; + const properties = body.properties && typeof body.properties === "object" ? (body.properties as Body) : {}; + const benefit = ps().benefits.insert({ + polar_id: newUuid(), + type, + description: String(body.description ?? "Benefit"), + properties: + type === "meter_credit" + ? { + meter_id: String(properties.meter_id ?? ""), + units: Number(properties.units ?? 0), + rollover: properties.rollover === true, + } + : { note: typeof properties.note === "string" ? properties.note : null }, + metadata: metadata(body.metadata), + visibility: body.visibility === "draft" || body.visibility === "private" ? body.visibility : "public", + }); + recordSideEffect(c, { type: "create", collection: "polar.benefits", id: benefit.polar_id }); + return c.json(serializeBenefit(benefit), 201); + }); + + app.get("/v1/benefits/", (c) => { + const url = new URL(c.req.url); + const items = ps().benefits.all().map(serializeBenefit); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.get("/v1/benefits/:id", (c) => { + const benefit = ps().benefits.findOneBy("polar_id", c.req.param("id")); + return benefit ? c.json(serializeBenefit(benefit)) : notFound(c); + }); + + app.patch("/v1/benefits/:id", async (c) => { + const store = ps(); + const benefit = store.benefits.findOneBy("polar_id", c.req.param("id")); + if (!benefit) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + const props = body.properties && typeof body.properties === "object" ? (body.properties as Body) : null; + const updated = store.benefits.update(benefit.id, { + description: + body.description === null + ? benefit.description + : typeof body.description === "string" + ? body.description + : benefit.description, + metadata: body.metadata === undefined ? benefit.metadata : metadata(body.metadata), + visibility: + body.visibility === "draft" || body.visibility === "private" || body.visibility === "public" + ? body.visibility + : benefit.visibility, + properties: + props && benefit.type === "meter_credit" + ? { + meter_id: String(props.meter_id ?? (benefit.properties as PolarMeterCreditProperties).meter_id), + units: Number(props.units ?? (benefit.properties as PolarMeterCreditProperties).units), + rollover: + props.rollover === undefined + ? (benefit.properties as PolarMeterCreditProperties).rollover + : props.rollover === true, + } + : props && benefit.type === "custom" + ? { note: typeof props.note === "string" ? props.note : null } + : benefit.properties, + })!; + recordSideEffect(c, { type: "update", collection: "polar.benefits", id: updated.polar_id }); + return c.json(serializeBenefit(updated)); + }); + + app.delete("/v1/benefits/:id", (c) => { + const store = ps(); + const benefit = store.benefits.findOneBy("polar_id", c.req.param("id")); + if (!benefit) return notFound(c); + store.benefits.delete(benefit.id); + for (const product of store.products.all()) { + if (product.benefit_ids.includes(benefit.polar_id)) { + store.products.update(product.id, { benefit_ids: product.benefit_ids.filter((id) => id !== benefit.polar_id) }); + } + } + recordSideEffect(c, { type: "delete", collection: "polar.benefits", id: benefit.polar_id }); + return c.body(null, 204); + }); + + app.post("/v1/products/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const product = insertProduct(ps(), body); + recordSideEffect(c, { type: "create", collection: "polar.products", id: product.polar_id }); + return c.json(serializeProduct(ps(), product), 201); + }); + + app.get("/v1/products/", (c) => { + const url = new URL(c.req.url); + const archived = url.searchParams.get("is_archived"); + const recurring = url.searchParams.get("is_recurring"); + const store = ps(); + const items = store.products + .all() + .filter( + (product) => + (archived === null || product.is_archived === (archived === "true")) && + (recurring === null || (product.recurring_interval !== null) === (recurring === "true")), + ) + .map((product) => serializeProduct(store, product)); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.post("/v1/products/:id/benefits", async (c) => { + const store = ps(); + const product = store.products.findOneBy("polar_id", c.req.param("id")); + if (!product) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + const benefitIds = Array.isArray(body.benefits) + ? body.benefits.filter( + (id): id is string => typeof id === "string" && store.benefits.findOneBy("polar_id", id) !== undefined, + ) + : []; + const updated = store.products.update(product.id, { benefit_ids: benefitIds })!; + recordSideEffect(c, { type: "update", collection: "polar.products", id: updated.polar_id }); + return c.json(serializeProduct(store, updated)); + }); + + app.get("/v1/products/:id", (c) => { + const store = ps(); + const product = store.products.findOneBy("polar_id", c.req.param("id")); + return product ? c.json(serializeProduct(store, product)) : notFound(c); + }); + + app.patch("/v1/products/:id", async (c) => { + const store = ps(); + const product = store.products.findOneBy("polar_id", c.req.param("id")); + if (!product) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + const prices = Array.isArray(body.prices) + ? body.prices.map(normalizePrice).filter((price) => price !== null) + : product.prices; + const updated = store.products.update(product.id, { + name: typeof body.name === "string" ? body.name : product.name, + description: + body.description === null + ? null + : typeof body.description === "string" + ? body.description + : product.description, + metadata: body.metadata === undefined ? product.metadata : metadata(body.metadata), + is_archived: typeof body.is_archived === "boolean" ? body.is_archived : product.is_archived, + prices, + })!; + recordSideEffect(c, { type: "update", collection: "polar.products", id: updated.polar_id }); + return c.json(serializeProduct(store, updated)); + }); + + app.post("/v1/subscriptions/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const store = ps(); + const product = + typeof body.product_id === "string" ? store.products.findOneBy("polar_id", body.product_id) : undefined; + const customer = bodyCustomer(store, body); + if (!product || !customer) return notFound(c); + if (!isFreeProduct(product)) { + return c.json( + { + error: "SubscriptionCreationError", + detail: + "This endpoint only allows to create subscription on free products. For paid products, use the checkout flow.", + }, + 400, + ); + } + const subscription = createSubscription(store, customer, product, { metadata: metadata(body.metadata) }); + recordSideEffect(c, { type: "create", collection: "polar.subscriptions", id: subscription.polar_id }); + return c.json(serializeSubscription(store, subscription), 201); + }); + + app.get("/v1/subscriptions/", (c) => { + const url = new URL(c.req.url); + const customerIds = queryValues(c.req.url, "customer_id"); + const externalIds = queryValues(c.req.url, "external_customer_id"); + const productIds = queryValues(c.req.url, "product_id"); + const statuses = queryValues(c.req.url, "status"); + const active = url.searchParams.get("active"); + const store = ps(); + const customersByExternal = new Set( + store.customers + .all() + .filter((customer) => includesQuery(externalIds, customer.external_id)) + .map((customer) => customer.polar_id), + ); + const items = liveSubscriptions(store) + .map((subscription) => rolloverSubscription(store, subscription)) + .filter( + (subscription) => + includesQuery(customerIds, subscription.customer_id) && + (externalIds.length === 0 || customersByExternal.has(subscription.customer_id)) && + includesQuery(productIds, subscription.product_id) && + includesQuery(statuses, subscription.status) && + (active === null || ["active", "trialing"].includes(subscription.status) === (active === "true")), + ) + .map((subscription) => serializeSubscription(store, subscription)); + return c.json( + paginate(items, url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); + + app.post("/v1/subscriptions/:id/revoke", (c) => { + c.set("operationId", "subscriptions:revoke"); + const store = ps(); + const subscription = store.subscriptions.findOneBy("polar_id", c.req.param("id")); + if (!subscription || subscription.pending) return notFound(c); + const now = new Date().toISOString(); + const updated = store.subscriptions.update(subscription.id, { + status: "canceled", + ended_at: now, + ends_at: now, + cancel_at_period_end: false, + })!; + return c.json(serializeSubscription(store, updated)); + }); + + app.get("/v1/subscriptions/:id", (c) => { + const store = ps(); + const subscription = store.subscriptions.findOneBy("polar_id", c.req.param("id")); + return subscription && !subscription.pending ? c.json(serializeSubscription(store, subscription)) : notFound(c); + }); + + app.patch("/v1/subscriptions/:id", async (c) => { + const store = ps(); + const subscription = store.subscriptions.findOneBy("polar_id", c.req.param("id")); + if (!subscription || subscription.pending) return notFound(c); + const body = (await c.req.json().catch(() => ({}))) as Body; + let updated = subscription; + if (body.revoke === true) { + const now = new Date().toISOString(); + updated = store.subscriptions.update(subscription.id, { + status: "canceled", + ended_at: now, + ends_at: now, + cancel_at_period_end: false, + })!; + } else if (Object.hasOwn(body, "pending_update") && body.pending_update === null) { + updated = store.subscriptions.update(subscription.id, { pending_update: null })!; + } else if (typeof body.cancel_at_period_end === "boolean") { + const now = new Date().toISOString(); + updated = store.subscriptions.update(subscription.id, { + cancel_at_period_end: body.cancel_at_period_end, + canceled_at: body.cancel_at_period_end ? now : null, + ends_at: body.cancel_at_period_end ? subscription.current_period_end : null, + customer_cancellation_reason: + typeof body.customer_cancellation_reason === "string" ? body.customer_cancellation_reason : null, + customer_cancellation_comment: + typeof body.customer_cancellation_comment === "string" ? body.customer_cancellation_comment : null, + })!; + } else if (typeof body.product_id === "string") { + const product = store.products.findOneBy("polar_id", body.product_id); + if (!product) return notFound(c); + if (body.proration_behavior === "next_period") { + const now = new Date().toISOString(); + updated = store.subscriptions.update(subscription.id, { + pending_update: { + id: newUuid(), + created_at: now, + applies_at: subscription.current_period_end, + product_id: product.polar_id, + seats: null, + units: null, + }, + })!; + } else { + const now = new Date(); + updated = store.subscriptions.update(subscription.id, { + product_id: product.polar_id, + amount: productAmount(product), + currency: productCurrency(product), + recurring_interval: product.recurring_interval ?? "month", + recurring_interval_count: product.recurring_interval_count ?? 1, + pending_update: null, + ...(body.proration_behavior === "reset" + ? { + current_period_start: now.toISOString(), + current_period_end: addInterval( + now, + product.recurring_interval ?? "month", + product.recurring_interval_count ?? 1, + ).toISOString(), + } + : {}), + })!; + } + } + recordSideEffect(c, { type: "update", collection: "polar.subscriptions", id: updated.polar_id }); + return c.json(serializeSubscription(store, updated)); + }); + + app.delete("/v1/subscriptions/:id", (c) => { + c.set("operationId", "subscriptions:revoke"); + const store = ps(); + const subscription = store.subscriptions.findOneBy("polar_id", c.req.param("id")); + if (!subscription || subscription.pending) return notFound(c); + const now = new Date().toISOString(); + const updated = store.subscriptions.update(subscription.id, { + status: "canceled", + ended_at: now, + ends_at: now, + cancel_at_period_end: false, + })!; + recordSideEffect(c, { type: "update", collection: "polar.subscriptions", id: updated.polar_id }); + return c.json(serializeSubscription(store, updated)); + }); + + app.post("/v1/checkouts/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const store = ps(); + const productIds = Array.isArray(body.products) + ? body.products.filter((id): id is string => typeof id === "string") + : []; + const product = store.products.findOneBy("polar_id", productIds[0] ?? ""); + if (!product) return notFound(c); + const referencedSubscription = + typeof body.subscription_id === "string" + ? store.subscriptions.findOneBy("polar_id", body.subscription_id) + : undefined; + let customer = referencedSubscription + ? store.customers.findOneBy("polar_id", referencedSubscription.customer_id) + : bodyCustomer(store, body); + if (!customer && typeof body.external_customer_id === "string" && typeof body.customer_email === "string") { + const duplicate = validateCustomerUniqueness(store, { + email: body.customer_email, + external_id: body.external_customer_id, + }); + if (duplicate) return validation(c, duplicate.field, duplicate.message, duplicate.input); + customer = insertCustomer(store, { + email: body.customer_email, + name: body.customer_name, + external_id: body.external_customer_id, + metadata: body.customer_metadata, + }); + } + const allowTrial = body.allow_trial !== false; + const now = new Date(); + const checkout = store.checkouts.insert({ + polar_id: newUuid(), + client_secret: newUuid(), + status: "open", + expires_at: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(), + success_url: typeof body.success_url === "string" ? body.success_url : `${baseUrl}/checkout/success`, + return_url: typeof body.return_url === "string" ? body.return_url : null, + product_ids: productIds, + customer_id: customer?.polar_id ?? null, + customer_email: typeof body.customer_email === "string" ? body.customer_email : (customer?.email ?? null), + customer_name: typeof body.customer_name === "string" ? body.customer_name : (customer?.name ?? null), + external_customer_id: + typeof body.external_customer_id === "string" ? body.external_customer_id : (customer?.external_id ?? null), + subscription_id: referencedSubscription?.polar_id ?? null, + pending_subscription_id: null, + amount: productAmount(product), + currency: productCurrency(product), + allow_discount_codes: body.allow_discount_codes !== false, + allow_trial: allowTrial, + trial_end: + allowTrial && product.trial_interval && product.trial_interval_count + ? addInterval(now, product.trial_interval, product.trial_interval_count).toISOString() + : null, + metadata: metadata(body.metadata), + customer_metadata: metadata(body.customer_metadata), + settle_delay_ms: storeDataSettleDelay(ctx.store), + confirmed_at: null, + settled_at: null, + }); + recordSideEffect(c, { type: "create", collection: "polar.checkouts", id: checkout.polar_id }); + return c.json(serializeCheckout(store, checkout, baseUrl), 201); + }); + + app.get("/v1/checkouts/client/:clientSecret", (c) => { + const store = ps(); + const checkout = store.checkouts.findOneBy("client_secret", c.req.param("clientSecret")); + return checkout ? c.json(serializeCheckout(store, checkout, baseUrl)) : notFound(c); + }); + + app.get("/v1/checkouts/:id", (c) => { + const store = ps(); + const checkout = store.checkouts.findOneBy("polar_id", c.req.param("id")); + return checkout ? c.json(serializeCheckout(store, checkout, baseUrl)) : notFound(c); + }); + + app.post("/v1/customer-sessions/", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Body; + const store = ps(); + const customer = bodyCustomer(store, body); + if (!customer) return notFound(c); + const now = new Date(); + const session = store.customerSessions.insert({ + polar_id: newUuid(), + token: newUuid(), + expires_at: new Date(now.getTime() + 60 * 60 * 1000).toISOString(), + customer_id: customer.polar_id, + return_url: typeof body.return_url === "string" ? body.return_url : null, + }); + recordSideEffect(c, { type: "create", collection: "polar.customer_sessions", id: session.polar_id }); + return c.json( + { + id: session.polar_id, + created_at: session.created_at, + modified_at: null, + token: session.token, + expires_at: session.expires_at, + return_url: session.return_url, + customer_portal_url: `${baseUrl}/portal?customer_session_token=${session.token}`, + customer_id: customer.polar_id, + customer: serializeCustomer(customer), + }, + 201, + ); + }); + + app.get("/v1/organizations/", (c) => { + const url = new URL(c.req.url); + return c.json( + paginate([organization()], url.searchParams.get("page") ?? undefined, url.searchParams.get("limit") ?? undefined), + ); + }); +} + +function storeDataSettleDelay(store: Store): number | null { + const delay = store.getData("polar.checkout.settle_delay_ms"); + return delay === undefined ? 2500 : delay; +} diff --git a/packages/@emulators/polar/src/routes/checkout.ts b/packages/@emulators/polar/src/routes/checkout.ts new file mode 100644 index 00000000..ba765985 --- /dev/null +++ b/packages/@emulators/polar/src/routes/checkout.ts @@ -0,0 +1,130 @@ +import { escapeAttr, escapeHtml, renderCardPage, type RouteContext } from "@emulators/core"; + +import type { PolarCustomer } from "../entities.js"; +import { createSubscription, newUuid, productAmount, settleCheckout } from "../serialize.js"; +import { getPolarStore } from "../store.js"; + +const SERVICE_LABEL = "Polar"; + +function checkoutCustomer( + email: string, + externalId: string | null, + name: string | null, + customerMetadata: Record, +): Omit { + return { + polar_id: newUuid(), + external_id: externalId, + email, + email_verified: false, + type: "individual", + name, + billing_name: null, + billing_address: null, + tax_id: null, + locale: null, + metadata: customerMetadata, + }; +} + +function redirectUrl(successUrl: string, checkoutId: string): string { + return successUrl.replaceAll("{CHECKOUT_ID}", checkoutId); +} + +export function checkoutRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ps = () => getPolarStore(store); + + app.get("/checkout/:clientSecret", (c) => { + const polar = ps(); + const checkout = polar.checkouts.findOneBy("client_secret", c.req.param("clientSecret")); + if (!checkout) + return c.html(renderCardPage("Checkout not found", "This checkout does not exist.", "", SERVICE_LABEL), 404); + if (checkout.status === "succeeded") { + return c.html( + renderCardPage( + "Checkout complete", + "The checkout succeeded. Subscription settlement may still be in progress.", + '

Subscription pending

', + SERVICE_LABEL, + ), + ); + } + const product = polar.products.findOneBy("polar_id", checkout.product_ids[0] ?? ""); + if (!product) + return c.html(renderCardPage("Checkout unavailable", "The product no longer exists.", "", SERVICE_LABEL), 404); + const trial = checkout.allow_trial && product.trial_interval !== null && product.trial_interval_count !== null; + const amount = productAmount(product); + const price = `${(amount / 100).toFixed(2)} ${checkout.currency.toUpperCase()}`; + const trialLine = trial + ? `

${product.trial_interval_count} ${escapeHtml(product.trial_interval!)} free trial

` + : ""; + const email = checkout.customer_email ?? ""; + const body = `
+
${escapeHtml(product.name)}
+

${escapeHtml(price)} per ${escapeHtml(product.recurring_interval ?? "purchase")}

+ ${trialLine} +
+
+
+ + +
+ +
`; + return c.html(renderCardPage("Subscribe", "Complete this simulated Polar checkout.", body, SERVICE_LABEL)); + }); + + app.post("/checkout/:clientSecret/confirm", async (c) => { + const polar = ps(); + const checkout = polar.checkouts.findOneBy("client_secret", c.req.param("clientSecret")); + if (!checkout) + return c.html(renderCardPage("Checkout not found", "This checkout does not exist.", "", SERVICE_LABEL), 404); + if (checkout.status === "succeeded") return c.redirect(redirectUrl(checkout.success_url, checkout.polar_id), 303); + const product = polar.products.findOneBy("polar_id", checkout.product_ids[0] ?? ""); + if (!product) + return c.html(renderCardPage("Checkout unavailable", "The product no longer exists.", "", SERVICE_LABEL), 404); + const form = await c.req.parseBody(); + const email = typeof form.email === "string" ? form.email : checkout.customer_email; + if (!email) { + return c.html(renderCardPage("Email required", "Enter a customer email to continue.", "", SERVICE_LABEL), 422); + } + let customer = checkout.customer_id ? polar.customers.findOneBy("polar_id", checkout.customer_id) : undefined; + customer ??= polar.customers.findOneBy("email", email); + if (!customer) { + customer = polar.customers.insert( + checkoutCustomer(email, checkout.external_customer_id, checkout.customer_name, checkout.customer_metadata), + ); + } + const now = new Date(); + const appliesTrial = + checkout.allow_trial && product.trial_interval !== null && product.trial_interval_count !== null; + let pendingSubscriptionId = checkout.pending_subscription_id; + if (!checkout.subscription_id) { + const subscription = createSubscription(polar, customer, product, { + status: appliesTrial ? "trialing" : "active", + metadata: checkout.metadata, + pending: true, + checkoutId: checkout.polar_id, + now, + }); + pendingSubscriptionId = subscription.polar_id; + } + polar.checkouts.update(checkout.id, { + status: "succeeded", + customer_id: customer.polar_id, + customer_email: customer.email, + pending_subscription_id: pendingSubscriptionId, + confirmed_at: now.toISOString(), + }); + return c.redirect(redirectUrl(checkout.success_url, checkout.polar_id), 303); + }); + + app.post("/checkout/:clientSecret/settle", (c) => { + const polar = ps(); + const checkout = polar.checkouts.findOneBy("client_secret", c.req.param("clientSecret")); + if (!checkout) return c.json({ error: "ResourceNotFound", detail: "Not found" }, 404); + const settled = settleCheckout(polar, checkout); + return c.json({ settled: settled.settled_at !== null, checkout_id: checkout.polar_id }); + }); +} diff --git a/packages/@emulators/polar/src/routes/openapi.ts b/packages/@emulators/polar/src/routes/openapi.ts new file mode 100644 index 00000000..760b45d5 --- /dev/null +++ b/packages/@emulators/polar/src/routes/openapi.ts @@ -0,0 +1,62 @@ +import type { RouteContext } from "@emulators/core"; + +import { operations } from "../manifest.js"; + +export function openapiRoutes({ app, baseUrl }: RouteContext): void { + app.get("/openapi.json", (c) => c.json(buildSpec(baseUrl))); +} + +function buildSpec(baseUrl: string): Record { + const paths: Record> = {}; + for (const operation of operations) { + if (!operation.path || !operation.method) continue; + const method = operation.method.toLowerCase(); + const success = + operation.operationId === "subscriptions:revoke" + ? "200" + : method === "post" + ? operation.operationId.endsWith(":create") + ? "201" + : "200" + : method === "delete" + ? "204" + : "200"; + paths[operation.path] ??= {}; + paths[operation.path]![method] = { + operationId: operation.operationId, + summary: operation.operationId.replaceAll(":", " "), + responses: { + [success]: { description: "Successful response" }, + "401": { description: "Unauthorized" }, + "404": { description: "Resource not found" }, + "422": { description: "Request validation error" }, + }, + }; + } + return { + openapi: "3.1.0", + info: { + title: "Polar API (Emulated)", + version: "0.49.0", + description: "Hand-authored Polar subscription and usage billing subset implemented by the Polar emulator.", + }, + servers: [{ url: baseUrl }], + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "polar_oat_..." } }, + schemas: { + ResourceNotFound: { + type: "object", + required: ["error", "detail"], + properties: { error: { const: "ResourceNotFound" }, detail: { type: "string" } }, + }, + RequestValidationError: { + type: "object", + required: ["error", "detail"], + properties: { error: { const: "RequestValidationError" }, detail: { type: "array" } }, + }, + }, + }, + paths, + }; +} diff --git a/packages/@emulators/polar/src/routes/portal.ts b/packages/@emulators/polar/src/routes/portal.ts new file mode 100644 index 00000000..7f56506d --- /dev/null +++ b/packages/@emulators/polar/src/routes/portal.ts @@ -0,0 +1,85 @@ +import { + escapeAttr, + escapeHtml, + renderSettingsPage, + type AppEnv, + type Context, + type RouteContext, +} from "@emulators/core"; + +import { liveSubscriptions, rolloverSubscription } from "../serialize.js"; +import { getPolarStore } from "../store.js"; + +const SERVICE_LABEL = "Polar"; + +export function portalRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ps = () => getPolarStore(store); + + app.get("/portal", (c) => { + const polar = ps(); + const token = c.req.query("customer_session_token") ?? ""; + const session = polar.customerSessions.findOneBy("token", token); + const customer = session ? polar.customers.findOneBy("polar_id", session.customer_id) : undefined; + if (!session || !customer || Date.now() >= Date.parse(session.expires_at)) { + return c.html( + renderSettingsPage("Customer portal", "", '

Invalid or expired session.

', SERVICE_LABEL), + 401, + ); + } + const subscriptions = liveSubscriptions(polar) + .filter((subscription) => subscription.customer_id === customer.polar_id) + .map((subscription) => rolloverSubscription(polar, subscription)); + const rows = subscriptions.length + ? subscriptions + .map((subscription) => { + const product = polar.products.findOneBy("polar_id", subscription.product_id); + const action = subscription.cancel_at_period_end ? "resume" : "cancel"; + const label = subscription.cancel_at_period_end ? "Resume" : "Cancel at period end"; + return `
+
${escapeHtml((product?.name ?? "P").charAt(0).toUpperCase())}
+
+
${escapeHtml(product?.name ?? subscription.product_id)}
+
${escapeHtml(subscription.status)} ยท period ends ${escapeHtml(subscription.current_period_end)}
+
+
+ + +
+
`; + }) + .join("") + : '

No subscriptions

'; + const sidebar = `Subscriptions${ + session.return_url ? `Back` : "" + }`; + const body = `
+
+
${escapeHtml(customer.email.charAt(0).toUpperCase())}
+
Subscriptions
${escapeHtml(customer.email)}
+
+ ${rows} +
`; + return c.html(renderSettingsPage("Customer portal", sidebar, body, SERVICE_LABEL)); + }); + + const updateCancellation = (cancel: boolean) => async (c: Context) => { + const polar = ps(); + const form = await c.req.parseBody(); + const token = typeof form.customer_session_token === "string" ? form.customer_session_token : ""; + const session = polar.customerSessions.findOneBy("token", token); + const subscription = polar.subscriptions.findOneBy("polar_id", c.req.param("id")); + if (!session || !subscription || subscription.customer_id !== session.customer_id) { + return c.html(renderSettingsPage("Customer portal", "", '

Not found.

', SERVICE_LABEL), 404); + } + polar.subscriptions.update(subscription.id, { + cancel_at_period_end: cancel, + canceled_at: cancel ? new Date().toISOString() : null, + ends_at: cancel ? subscription.current_period_end : null, + }); + return c.redirect(`/portal?customer_session_token=${encodeURIComponent(token)}`, 303); + }; + + app.post("/portal/subscriptions/:id/cancel", updateCancellation(true)); + app.post("/portal/subscriptions/:id/resume", updateCancellation(false)); +} diff --git a/packages/@emulators/polar/src/serialize.ts b/packages/@emulators/polar/src/serialize.ts new file mode 100644 index 00000000..b76c60a7 --- /dev/null +++ b/packages/@emulators/polar/src/serialize.ts @@ -0,0 +1,725 @@ +import type { + PolarBenefit, + PolarCheckout, + PolarCustomer, + PolarEvent, + PolarFilter, + PolarFilterClause, + PolarMetadata, + PolarMeter, + PolarProduct, + PolarStoredPrice, + PolarSubscription, + PolarSubscriptionStatus, +} from "./entities.js"; +import type { PolarStore } from "./store.js"; + +export const POLAR_ORGANIZATION_ID = "00000000-0000-4000-8000-000000000001"; + +const ACTIVE_STATUSES = new Set(["active", "trialing"]); + +export function newUuid(): string { + return crypto.randomUUID(); +} + +function modifiedAt(entity: { created_at: string; updated_at: string }): string | null { + return entity.updated_at === entity.created_at ? null : entity.updated_at; +} + +export function serializeCustomer(customer: PolarCustomer): Record { + return { + id: customer.polar_id, + created_at: customer.created_at, + modified_at: modifiedAt(customer), + metadata: customer.metadata, + external_id: customer.external_id, + email: customer.email, + email_verified: customer.email_verified, + type: customer.type, + name: customer.name, + billing_name: customer.billing_name, + billing_address: customer.billing_address, + tax_id: customer.tax_id ? [customer.tax_id] : null, + locale: customer.locale, + organization_id: POLAR_ORGANIZATION_ID, + default_payment_method_id: null, + deleted_at: null, + avatar_url: null, + }; +} + +export function serializeMeter(meter: PolarMeter): Record { + return { + id: meter.polar_id, + created_at: meter.created_at, + modified_at: modifiedAt(meter), + metadata: meter.metadata, + name: meter.name, + unit: meter.unit, + custom_label: meter.custom_label, + custom_multiplier: meter.custom_multiplier, + filter: meter.filter, + aggregation: meter.aggregation, + organization_id: POLAR_ORGANIZATION_ID, + archived_at: meter.archived_at, + }; +} + +export function serializeBenefit(benefit: PolarBenefit): Record { + return { + id: benefit.polar_id, + created_at: benefit.created_at, + modified_at: modifiedAt(benefit), + type: benefit.type, + description: benefit.description, + selectable: true, + deletable: true, + is_deleted: false, + organization_id: POLAR_ORGANIZATION_ID, + metadata: benefit.metadata, + visibility: benefit.visibility, + properties: benefit.properties, + visibility_configurable: true, + }; +} + +function serializePublicBenefit(benefit: PolarBenefit): Record { + return { + id: benefit.polar_id, + created_at: benefit.created_at, + modified_at: modifiedAt(benefit), + type: benefit.type, + description: benefit.description, + selectable: true, + deletable: true, + is_deleted: false, + organization_id: POLAR_ORGANIZATION_ID, + }; +} + +export function serializePrice( + ps: PolarStore, + product: PolarProduct, + price: PolarStoredPrice, +): Record { + const common = { + id: price.id, + created_at: price.created_at, + modified_at: null, + source: "catalog", + amount_type: price.amount_type, + type: product.recurring_interval ? "recurring" : "one_time", + recurring_interval: product.recurring_interval, + price_currency: price.price_currency, + tax_behavior: null, + is_archived: false, + product_id: product.polar_id, + }; + + if (price.amount_type === "fixed") { + return { ...common, price_amount: price.price_amount }; + } + if (price.amount_type === "metered_unit") { + const meter = ps.meters.findOneBy("polar_id", price.meter_id); + return { + ...common, + meter_id: price.meter_id, + unit_amount: price.unit_amount, + cap_amount: price.cap_amount, + meter: meter + ? { + id: meter.polar_id, + name: meter.name, + unit: meter.unit, + custom_label: meter.custom_label, + custom_multiplier: meter.custom_multiplier, + } + : { + id: price.meter_id, + name: "Unknown meter", + unit: "scalar", + custom_label: null, + custom_multiplier: null, + }, + }; + } + return { + ...common, + minimum_amount: price.minimum_amount, + maximum_amount: price.maximum_amount, + preset_amount: price.preset_amount, + }; +} + +export function serializeProduct( + ps: PolarStore, + product: PolarProduct, + options: { checkout?: boolean } = {}, +): Record { + const benefits = product.benefit_ids + .map((id) => ps.benefits.findOneBy("polar_id", id)) + .filter((benefit): benefit is PolarBenefit => benefit !== undefined) + .map((benefit) => (options.checkout ? serializePublicBenefit(benefit) : serializeBenefit(benefit))); + return { + id: product.polar_id, + created_at: product.created_at, + modified_at: modifiedAt(product), + trial_interval: product.trial_interval, + trial_interval_count: product.trial_interval_count, + name: product.name, + description: product.description, + visibility: product.visibility, + recurring_interval: product.recurring_interval, + recurring_interval_count: product.recurring_interval_count, + meter_interval: product.meter_interval, + meter_interval_count: product.meter_interval_count, + is_recurring: product.recurring_interval !== null, + is_archived: product.is_archived, + organization_id: POLAR_ORGANIZATION_ID, + metadata: product.metadata, + prices: product.prices.map((price) => serializePrice(ps, product, price)), + benefits, + medias: [], + attached_custom_fields: [], + }; +} + +export function productAmount(product: PolarProduct): number { + return product.prices.reduce((total, price) => { + if (price.amount_type === "fixed") return total + price.price_amount; + if (price.amount_type === "custom") return total + (price.preset_amount ?? price.minimum_amount); + return total; + }, 0); +} + +export function productCurrency(product: PolarProduct): string { + return product.prices[0]?.price_currency ?? "usd"; +} + +export function isFreeProduct(product: PolarProduct): boolean { + return ( + product.prices.length > 0 && + product.prices.every((price) => price.amount_type === "fixed" && price.price_amount === 0) + ); +} + +export function addInterval(value: Date, interval: "day" | "week" | "month" | "year", count = 1): Date { + const next = new Date(value); + if (interval === "day") next.setUTCDate(next.getUTCDate() + count); + if (interval === "week") next.setUTCDate(next.getUTCDate() + count * 7); + if (interval === "month") next.setUTCMonth(next.getUTCMonth() + count); + if (interval === "year") next.setUTCFullYear(next.getUTCFullYear() + count); + return next; +} + +function trialEnd(product: PolarProduct, now: Date): string | null { + if (!product.trial_interval || !product.trial_interval_count) return null; + return addInterval(now, product.trial_interval, product.trial_interval_count).toISOString(); +} + +export function createSubscription( + ps: PolarStore, + customer: PolarCustomer, + product: PolarProduct, + options: { + status?: "active" | "trialing"; + metadata?: PolarMetadata; + pending?: boolean; + checkoutId?: string | null; + now?: Date; + } = {}, +): PolarSubscription { + const now = options.now ?? new Date(); + const status = options.status ?? "active"; + const end = status === "trialing" ? trialEnd(product, now) : null; + const interval = product.recurring_interval ?? "month"; + const intervalCount = product.recurring_interval_count ?? 1; + return ps.subscriptions.insert({ + polar_id: newUuid(), + status, + amount: productAmount(product), + currency: productCurrency(product), + recurring_interval: interval, + recurring_interval_count: intervalCount, + current_period_start: now.toISOString(), + current_period_end: end ?? addInterval(now, interval, intervalCount).toISOString(), + trial_start: status === "trialing" ? now.toISOString() : null, + trial_end: end, + cancel_at_period_end: false, + canceled_at: null, + started_at: now.toISOString(), + ends_at: null, + ended_at: null, + customer_id: customer.polar_id, + product_id: product.polar_id, + pending_update: null, + checkout_id: options.checkoutId ?? null, + customer_cancellation_reason: null, + customer_cancellation_comment: null, + metadata: options.metadata ?? {}, + pending: options.pending ?? false, + }); +} + +function applyProduct(ps: PolarStore, subscription: PolarSubscription, productId: string): PolarSubscription { + const product = ps.products.findOneBy("polar_id", productId); + if (!product) return subscription; + return ps.subscriptions.update(subscription.id, { + product_id: product.polar_id, + amount: productAmount(product), + currency: productCurrency(product), + recurring_interval: product.recurring_interval ?? "month", + recurring_interval_count: product.recurring_interval_count ?? 1, + })!; +} + +export function rolloverSubscription( + ps: PolarStore, + subscription: PolarSubscription, + now = new Date(), +): PolarSubscription { + let current = subscription; + if (current.pending || current.status === "canceled") return current; + let periodEnd = new Date(current.current_period_end); + if (now < periodEnd) return current; + + if (current.cancel_at_period_end) { + return ps.subscriptions.update(current.id, { + status: "canceled", + ended_at: now.toISOString(), + ends_at: current.current_period_end, + })!; + } + + if (current.pending_update?.product_id) { + current = applyProduct(ps, current, current.pending_update.product_id); + } + let periodStart = periodEnd; + if (current.status === "trialing") current = ps.subscriptions.update(current.id, { status: "active" })!; + do { + periodEnd = addInterval(periodStart, current.recurring_interval, current.recurring_interval_count); + if (now < periodEnd) break; + periodStart = periodEnd; + } while (true); + return ps.subscriptions.update(current.id, { + current_period_start: periodStart.toISOString(), + current_period_end: periodEnd.toISOString(), + pending_update: null, + })!; +} + +export function liveSubscriptions(ps: PolarStore): PolarSubscription[] { + settleDueCheckouts(ps); + return ps.subscriptions.all().filter((subscription) => !subscription.pending); +} + +export function settleCheckout(ps: PolarStore, checkout: PolarCheckout, now = new Date()): PolarCheckout { + if (checkout.status !== "succeeded" || checkout.settled_at) return checkout; + const product = ps.products.findOneBy("polar_id", checkout.product_ids[0] ?? ""); + const customer = ps.customers.findOneBy("polar_id", checkout.customer_id ?? ""); + if (!product || !customer) return checkout; + const appliesTrial = checkout.allow_trial && product.trial_interval !== null && product.trial_interval_count !== null; + + if (checkout.subscription_id) { + const subscription = ps.subscriptions.findOneBy("polar_id", checkout.subscription_id); + if (subscription) { + const end = appliesTrial ? trialEnd(product, now) : null; + ps.subscriptions.update(subscription.id, { + status: appliesTrial ? "trialing" : "active", + product_id: product.polar_id, + amount: productAmount(product), + currency: productCurrency(product), + recurring_interval: product.recurring_interval ?? "month", + recurring_interval_count: product.recurring_interval_count ?? 1, + current_period_start: now.toISOString(), + current_period_end: + end ?? + addInterval(now, product.recurring_interval ?? "month", product.recurring_interval_count ?? 1).toISOString(), + trial_start: appliesTrial ? now.toISOString() : null, + trial_end: end, + checkout_id: checkout.polar_id, + cancel_at_period_end: false, + canceled_at: null, + ends_at: null, + ended_at: null, + pending_update: null, + }); + } + } else if (checkout.pending_subscription_id) { + const subscription = ps.subscriptions.findOneBy("polar_id", checkout.pending_subscription_id); + if (subscription) { + const end = appliesTrial ? trialEnd(product, now) : null; + ps.subscriptions.update(subscription.id, { + pending: false, + status: appliesTrial ? "trialing" : "active", + current_period_start: now.toISOString(), + current_period_end: + end ?? + addInterval(now, product.recurring_interval ?? "month", product.recurring_interval_count ?? 1).toISOString(), + trial_start: appliesTrial ? now.toISOString() : null, + trial_end: end, + }); + } + } + + return ps.checkouts.update(checkout.id, { settled_at: now.toISOString() })!; +} + +export function settleDueCheckouts(ps: PolarStore, now = new Date()): void { + for (const checkout of ps.checkouts.all()) { + if (!checkout.confirmed_at || checkout.settled_at || checkout.settle_delay_ms === null) continue; + if (now.getTime() >= Date.parse(checkout.confirmed_at) + checkout.settle_delay_ms) { + settleCheckout(ps, checkout, now); + } + } +} + +export function serializeSubscription(ps: PolarStore, subscription: PolarSubscription): Record { + const current = rolloverSubscription(ps, subscription); + const customer = ps.customers.findOneBy("polar_id", current.customer_id); + const product = ps.products.findOneBy("polar_id", current.product_id); + return { + id: current.polar_id, + created_at: current.created_at, + modified_at: modifiedAt(current), + amount: current.amount, + currency: current.currency, + recurring_interval: current.recurring_interval, + recurring_interval_count: current.recurring_interval_count, + status: current.status, + current_period_start: current.current_period_start, + current_period_end: current.current_period_end, + current_meter_period_start: null, + current_meter_period_end: null, + trial_start: current.trial_start, + trial_end: current.trial_end, + cancel_at_period_end: current.cancel_at_period_end, + canceled_at: current.canceled_at, + started_at: current.started_at, + ends_at: current.ends_at, + ended_at: current.ended_at, + past_due_at: null, + pause_at_period_end: false, + paused_at: null, + resumes_at: null, + customer_id: current.customer_id, + product_id: current.product_id, + discount_id: null, + checkout_id: current.checkout_id, + seats: null, + units: null, + customer_cancellation_reason: current.customer_cancellation_reason, + customer_cancellation_comment: current.customer_cancellation_comment, + metadata: current.metadata, + custom_field_data: {}, + customer: customer ? serializeCustomer(customer) : null, + product: product ? serializeProduct(ps, product) : null, + discount: null, + prices: product ? product.prices.map((price) => serializePrice(ps, product, price)) : [], + meters: [], + pending_update: current.pending_update ? { ...current.pending_update, modified_at: null } : null, + }; +} + +function eventProperty(event: PolarEvent, property: string): unknown { + if (property === "name") return event.name; + if (property === "external_customer_id") return event.external_customer_id; + if (property === "customer_id") return event.customer_id; + return event.metadata[property]; +} + +function numericCompare(left: unknown, right: unknown, compare: (a: number, b: number) => boolean): boolean { + const a = Number(left); + const b = Number(right); + return Number.isFinite(a) && Number.isFinite(b) && compare(a, b); +} + +function like(left: unknown, right: unknown): boolean { + const pattern = String(right) + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/%/g, ".*") + .replace(/_/g, "."); + return new RegExp(`^${pattern}$`, "i").test(String(left ?? "")); +} + +function matchesClause(event: PolarEvent, clause: PolarFilterClause): boolean { + const value = eventProperty(event, clause.property); + if (clause.operator === "eq") return value === clause.value; + if (clause.operator === "ne") return value !== clause.value; + if (clause.operator === "gt") return numericCompare(value, clause.value, (a, b) => a > b); + if (clause.operator === "gte") return numericCompare(value, clause.value, (a, b) => a >= b); + if (clause.operator === "lt") return numericCompare(value, clause.value, (a, b) => a < b); + if (clause.operator === "lte") return numericCompare(value, clause.value, (a, b) => a <= b); + if (clause.operator === "like") return like(value, clause.value); + return !like(value, clause.value); +} + +function isFilter(value: PolarFilterClause | PolarFilter): value is PolarFilter { + return "conjunction" in value; +} + +export function matchesMeter(event: PolarEvent, meter: PolarMeter): boolean { + const results = meter.filter.clauses.map((clause) => + isFilter(clause) ? matchesFilter(event, clause) : matchesClause(event, clause), + ); + return meter.filter.conjunction === "and" ? results.every(Boolean) : results.some(Boolean); +} + +function matchesFilter(event: PolarEvent, filter: PolarFilter): boolean { + const results = filter.clauses.map((clause) => + isFilter(clause) ? matchesFilter(event, clause) : matchesClause(event, clause), + ); + return filter.conjunction === "and" ? results.every(Boolean) : results.some(Boolean); +} + +function aggregate(events: PolarEvent[], meter: PolarMeter): number { + if (meter.aggregation.func === "count") return events.length; + const property = meter.aggregation.property; + const values = events + .map((event) => eventProperty(event, property)) + .filter((value) => value !== undefined && value !== null); + if (meter.aggregation.func === "unique") return new Set(values.map(String)).size; + const numbers = values.map(Number).filter(Number.isFinite); + if (numbers.length === 0) return 0; + if (meter.aggregation.func === "sum") return numbers.reduce((sum, value) => sum + value, 0); + if (meter.aggregation.func === "max") return Math.max(...numbers); + if (meter.aggregation.func === "min") return Math.min(...numbers); + return numbers.reduce((sum, value) => sum + value, 0) / numbers.length; +} + +function eventsForCustomer(ps: PolarStore, customer: PolarCustomer, since: string | null): PolarEvent[] { + return ps.events.all().filter((event) => { + const belongs = + event.customer_id === customer.polar_id || + (customer.external_id !== null && event.external_customer_id === customer.external_id); + return belongs && (since === null || Date.parse(event.timestamp) >= Date.parse(since)); + }); +} + +export interface MeterBalance { + id: string; + created_at: string; + modified_at: string | null; + meter_id: string; + consumed_units: number; + credited_units: number; + balance: number; +} + +export function meterBalances(ps: PolarStore, customer: PolarCustomer): MeterBalance[] { + const subscriptions = liveSubscriptions(ps) + .filter((subscription) => subscription.customer_id === customer.polar_id) + .map((subscription) => rolloverSubscription(ps, subscription)) + .filter((subscription) => ACTIVE_STATUSES.has(subscription.status)); + const since = subscriptions.length + ? subscriptions.map((subscription) => subscription.current_period_start).sort()[0]! + : null; + const events = eventsForCustomer(ps, customer, since); + const credits = new Map(); + for (const subscription of subscriptions) { + const product = ps.products.findOneBy("polar_id", subscription.product_id); + for (const benefitId of product?.benefit_ids ?? []) { + const benefit = ps.benefits.findOneBy("polar_id", benefitId); + if (benefit?.type !== "meter_credit") continue; + const props = benefit.properties as { meter_id: string; units: number }; + credits.set(props.meter_id, (credits.get(props.meter_id) ?? 0) + props.units); + } + } + const rows: MeterBalance[] = []; + for (const meter of ps.meters.all()) { + const matching = events.filter((event) => matchesMeter(event, meter)); + const credited = credits.get(meter.polar_id) ?? 0; + if (credited === 0 && matching.length === 0) continue; + const consumed = aggregate(matching, meter); + rows.push({ + id: meter.polar_id, + created_at: meter.created_at, + modified_at: modifiedAt(meter), + meter_id: meter.polar_id, + consumed_units: consumed, + credited_units: credited, + balance: credited - consumed, + }); + } + return rows; +} + +function stateSubscription(subscription: PolarSubscription): Record { + return { + id: subscription.polar_id, + created_at: subscription.created_at, + modified_at: modifiedAt(subscription), + custom_field_data: {}, + metadata: subscription.metadata, + status: subscription.status, + amount: subscription.amount, + currency: subscription.currency, + recurring_interval: subscription.recurring_interval, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + trial_start: subscription.trial_start, + trial_end: subscription.trial_end, + cancel_at_period_end: subscription.cancel_at_period_end, + canceled_at: subscription.canceled_at, + started_at: subscription.started_at, + ends_at: subscription.ends_at, + product_id: subscription.product_id, + discount_id: null, + meters: [], + }; +} + +export function serializeCustomerState(ps: PolarStore, customer: PolarCustomer): Record { + const subscriptions = liveSubscriptions(ps) + .filter((subscription) => subscription.customer_id === customer.polar_id) + .map((subscription) => rolloverSubscription(ps, subscription)) + .filter((subscription) => ACTIVE_STATUSES.has(subscription.status)); + const benefitIds = new Set(); + for (const subscription of subscriptions) { + const product = ps.products.findOneBy("polar_id", subscription.product_id); + for (const benefitId of product?.benefit_ids ?? []) benefitIds.add(benefitId); + } + const grantedBenefits = [...benefitIds] + .map((id) => ps.benefits.findOneBy("polar_id", id)) + .filter((benefit): benefit is PolarBenefit => benefit !== undefined) + .map((benefit) => ({ + id: benefit.polar_id, + created_at: benefit.created_at, + modified_at: modifiedAt(benefit), + granted_at: benefit.created_at, + benefit_id: benefit.polar_id, + benefit_type: benefit.type, + benefit_metadata: benefit.metadata, + properties: benefit.properties, + })); + return { + ...serializeCustomer(customer), + active_subscriptions: subscriptions.map(stateSubscription), + granted_benefits: grantedBenefits, + active_meters: meterBalances(ps, customer), + }; +} + +export function serializeEvent(ps: PolarStore, event: PolarEvent): Record { + const customer = event.customer_id + ? ps.customers.findOneBy("polar_id", event.customer_id) + : event.external_customer_id + ? ps.customers.findOneBy("external_id", event.external_customer_id) + : undefined; + return { + id: event.polar_id, + external_id: event.external_id, + timestamp: event.timestamp, + name: event.name, + label: event.name, + source: "user", + organization_id: POLAR_ORGANIZATION_ID, + customer_id: customer?.polar_id ?? event.customer_id, + external_customer_id: event.external_customer_id ?? customer?.external_id ?? null, + customer: customer ? serializeCustomer(customer) : null, + child_count: 0, + parent_id: null, + metadata: event.metadata, + }; +} + +export function serializeCheckout(ps: PolarStore, checkout: PolarCheckout, baseUrl: string): Record { + settleDueCheckouts(ps); + const current = ps.checkouts.get(checkout.id) ?? checkout; + const products = current.product_ids + .map((id) => ps.products.findOneBy("polar_id", id)) + .filter((item): item is PolarProduct => item !== undefined); + const product = products[0]; + const customer = ps.customers.findOneBy("polar_id", current.customer_id ?? ""); + const price = product?.prices[0]; + const appliesTrial = current.allow_trial && product?.trial_interval != null && product.trial_interval_count != null; + return { + id: current.polar_id, + created_at: current.created_at, + modified_at: modifiedAt(current), + custom_field_data: {}, + payment_processor: "stripe", + status: current.status, + client_secret: current.client_secret, + url: `${baseUrl}/checkout/${current.client_secret}`, + expires_at: current.expires_at, + success_url: current.success_url, + return_url: current.return_url, + embed_origin: null, + amount: current.amount, + seats: null, + min_seats: null, + max_seats: null, + discount_amount: 0, + net_amount: current.amount, + tax_amount: 0, + tax_behavior: null, + total_amount: current.amount, + currency: current.currency, + allow_trial: current.allow_trial, + active_trial_interval: appliesTrial ? product?.trial_interval : null, + active_trial_interval_count: appliesTrial ? product?.trial_interval_count : null, + trial_end: current.trial_end, + organization_id: POLAR_ORGANIZATION_ID, + product_id: product?.polar_id ?? null, + product_price_id: price?.id ?? null, + discount_id: null, + allow_discount_codes: current.allow_discount_codes, + require_billing_address: false, + is_discount_applicable: false, + is_free_product_price: product ? isFreeProduct(product) : false, + is_payment_required: current.amount > 0 && !appliesTrial, + is_payment_setup_required: appliesTrial && current.amount > 0, + is_payment_form_required: current.amount > 0, + customer_id: customer?.polar_id ?? null, + is_business_customer: customer?.type === "team", + customer_name: current.customer_name ?? customer?.name ?? null, + customer_email: current.customer_email ?? customer?.email ?? null, + customer_ip_address: null, + customer_billing_name: null, + customer_billing_address: null, + customer_tax_id: null, + locale: null, + payment_processor_metadata: {}, + billing_address_fields: { + country: "disabled", + state: "disabled", + city: "disabled", + postal_code: "disabled", + line1: "disabled", + line2: "disabled", + }, + trial_interval: product?.trial_interval ?? null, + trial_interval_count: product?.trial_interval_count ?? null, + metadata: current.metadata, + customer_external_id: current.external_customer_id, + external_customer_id: current.external_customer_id, + products: products.map((item) => serializeProduct(ps, item, { checkout: true })), + product: product ? serializeProduct(ps, product, { checkout: true }) : null, + product_price: product && price ? serializePrice(ps, product, price) : null, + prices: null, + discount: null, + subscription_id: current.subscription_id, + attached_custom_fields: [], + customer_metadata: current.customer_metadata, + }; +} + +export function paginate(items: T[], pageValue: string | undefined, limitValue: string | undefined) { + const page = Math.max(1, Number.parseInt(pageValue ?? "1", 10) || 1); + const limit = Math.min(100, Math.max(1, Number.parseInt(limitValue ?? "10", 10) || 10)); + const total = items.length; + return { + items: items.slice((page - 1) * limit, page * limit), + pagination: { total_count: total, max_page: Math.max(1, Math.ceil(total / limit)) }, + }; +} + +export function metadata(value: unknown): PolarMetadata { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string | number | boolean] => + ["string", "number", "boolean"].includes(typeof entry[1]), + ), + ); +} diff --git a/packages/@emulators/polar/src/store.ts b/packages/@emulators/polar/src/store.ts new file mode 100644 index 00000000..2f425185 --- /dev/null +++ b/packages/@emulators/polar/src/store.ts @@ -0,0 +1,50 @@ +import { Store, type Collection } from "@emulators/core"; + +import type { + PolarBenefit, + PolarCheckout, + PolarCustomer, + PolarCustomerSession, + PolarEvent, + PolarMeter, + PolarProduct, + PolarSubscription, +} from "./entities.js"; + +export interface PolarStore { + customers: Collection; + meters: Collection; + events: Collection; + benefits: Collection; + products: Collection; + subscriptions: Collection; + checkouts: Collection; + customerSessions: Collection; +} + +export function getPolarStore(store: Store): PolarStore { + return { + customers: store.collection("polar.customers", ["polar_id", "external_id", "email"]), + meters: store.collection("polar.meters", ["polar_id", "name"]), + events: store.collection("polar.events", [ + "polar_id", + "external_id", + "customer_id", + "external_customer_id", + "name", + ]), + benefits: store.collection("polar.benefits", ["polar_id", "description"]), + products: store.collection("polar.products", ["polar_id", "name"]), + subscriptions: store.collection("polar.subscriptions", [ + "polar_id", + "customer_id", + "product_id", + ]), + checkouts: store.collection("polar.checkouts", ["polar_id", "client_secret", "customer_id"]), + customerSessions: store.collection("polar.customer_sessions", [ + "polar_id", + "token", + "customer_id", + ]), + }; +} diff --git a/packages/@emulators/polar/tsconfig.json b/packages/@emulators/polar/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/polar/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/polar/tsup.config.ts b/packages/@emulators/polar/tsup.config.ts new file mode 100644 index 00000000..31a49abb --- /dev/null +++ b/packages/@emulators/polar/tsup.config.ts @@ -0,0 +1,19 @@ +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { defineConfig } from "tsup"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/@emulators/polar/vitest.config.ts b/packages/@emulators/polar/vitest.config.ts new file mode 100644 index 00000000..e2ec3329 --- /dev/null +++ b/packages/@emulators/polar/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/@emulators/posthog/package.json b/packages/@emulators/posthog/package.json index d346f0af..f6556c5f 100644 --- a/packages/@emulators/posthog/package.json +++ b/packages/@emulators/posthog/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/posthog", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/resend/package.json b/packages/@emulators/resend/package.json index e334e51e..4134d0a4 100644 --- a/packages/@emulators/resend/package.json +++ b/packages/@emulators/resend/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/resend", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/slack/package.json b/packages/@emulators/slack/package.json index 765c3f88..02608602 100644 --- a/packages/@emulators/slack/package.json +++ b/packages/@emulators/slack/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/slack", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/spotify/package.json b/packages/@emulators/spotify/package.json index db08c40c..b74db701 100644 --- a/packages/@emulators/spotify/package.json +++ b/packages/@emulators/spotify/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/spotify", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/stripe/package.json b/packages/@emulators/stripe/package.json index 28678cea..92573c38 100644 --- a/packages/@emulators/stripe/package.json +++ b/packages/@emulators/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/stripe", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/vercel/package.json b/packages/@emulators/vercel/package.json index 7a9c0a44..096a1307 100644 --- a/packages/@emulators/vercel/package.json +++ b/packages/@emulators/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/vercel", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/workos/package.json b/packages/@emulators/workos/package.json index 0e452d16..7115636a 100644 --- a/packages/@emulators/workos/package.json +++ b/packages/@emulators/workos/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/workos", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/x/package.json b/packages/@emulators/x/package.json index 8a2d40d4..cbea34ea 100644 --- a/packages/@emulators/x/package.json +++ b/packages/@emulators/x/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/x", - "version": "0.14.1", + "version": "0.15.0", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/emulate/package.json b/packages/emulate/package.json index c0469e77..8c41374d 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/emulate", - "version": "0.14.1", + "version": "0.15.0", "description": "Local drop-in replacement services for CI and no-network sandboxes", "license": "Apache-2.0", "type": "module", @@ -92,6 +92,7 @@ "@emulators/stripe": "workspace:*", "@emulators/clerk": "workspace:*", "@emulators/posthog": "workspace:*", + "@emulators/polar": "workspace:*", "@emulators/spotify": "workspace:*", "@emulators/x": "workspace:*", "tsup": "^8", diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index f97c8b69..bd50dfbb 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -63,7 +63,7 @@ Global catalog: Hosted services: Available services include vercel, github, gitlab, google, slack, apple, microsoft, okta, aws, resend, stripe, mongoatlas, clerk, spotify, x, workos, - autumn, posthog, and mcp. + autumn, posthog, mcp, and polar. MCP OAuth compliance scenarios are configured under mcp.oauth in seed data; see the MCP manifest seed schema for issuer, resource, DCR, and token-auth knobs. Microsoft Graph includes OneDrive file content upload/download routes under diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 25c38162..63536c35 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -56,9 +56,10 @@ const SERVICE_NAME_LIST = [ "autumn", "posthog", "mcp", - // gitlab is appended last so adding it leaves every other service's default - // multi-service port (basePort + index) unchanged. + // New services are appended so existing default multi-service ports remain + // unchanged. "gitlab", + "polar", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -118,6 +119,7 @@ function defaultToken(service: ServiceName, type: string): string { // GitLab personal access tokens are prefixed glpat- so the issued credential // reads like a real one, even though the emulator does not validate it. if (service === "gitlab") return `glpat-${randomId().slice(0, 20)}`; + if (service === "polar") return `polar_oat_${randomId()}`; const prefix = type === "api-key" ? apiKeyPrefix(service) : `emu_${service}`; return `${prefix}_${randomId()}`; } @@ -994,6 +996,33 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + polar: { + label: "Polar billing emulator", + endpoints: "customers, meters, events, benefits, products, subscriptions, hosted checkout, and customer portal", + async load() { + const mod = await import("@emulators/polar"); + return { + plugin: mod.polarPlugin, + manifest: mod.manifest, + seedFromConfig: mod.seedFromConfig, + }; + }, + defaultFallback() { + return { login: "polar_oat_emulate", id: 1, scopes: [] }; + }, + initConfig: { + polar: { + products: [ + { + name: "Free", + recurring_interval: "month", + prices: [{ amount_type: "fixed", price_amount: 0, price_currency: "usd" }], + }, + ], + customers: [{ external_id: "customer_123", email: "customer@example.com" }], + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80342cff..6fe3d696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,6 +719,25 @@ 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)(terser@5.16.9)(yaml@2.9.0)) + packages/@emulators/polar: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@polar-sh/sdk': + specifier: 0.49.0 + version: 0.49.0 + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.9.0) + 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)(terser@5.16.9)(yaml@2.9.0)) + packages/@emulators/posthog: dependencies: '@emulators/core': @@ -964,6 +983,9 @@ importers: '@emulators/posthog': specifier: workspace:* version: link:../@emulators/posthog + '@emulators/polar': + specifier: workspace:* + version: link:../@emulators/polar '@emulators/resend': specifier: workspace:* version: link:../@emulators/resend @@ -2564,6 +2586,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@polar-sh/sdk@0.49.0': + resolution: {integrity: sha512-9UYb70iKjJCtWYlu0OF5HLYBLmkxHwqr2RlXwuxXQgRGqq56IQWlVG+NO7e1YJ7I5GW0CBHhGIjRbQ9hYM6ycQ==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -10185,6 +10210,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@polar-sh/sdk@0.49.0': + dependencies: + standardwebhooks: 1.0.0 + zod: 4.3.6 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 diff --git a/skills/polar/SKILL.md b/skills/polar/SKILL.md new file mode 100644 index 00000000..78212e8a --- /dev/null +++ b/skills/polar/SKILL.md @@ -0,0 +1,102 @@ +--- +name: polar +description: Emulated Polar billing API for customers, subscription products, usage meters, benefits, hosted checkout, and customer portal flows. Use when the user needs Polar billing behavior without calling real Polar. +allowed-tools: Bash(npx emulate:*), Bash(curl:*) +--- + +# Polar Emulator + +Use the Polar emulator for stateful subscription and usage-based billing tests through the official `@polar-sh/sdk` client. It supports customers, meters, events, benefits, products, subscriptions, hosted checkout, customer sessions, and customer state. + +## Start + +```bash +npx emulate --service polar +``` + +When all services run together, Polar uses `http://localhost:4019`. Any non-empty bearer token is accepted. + +## Connect the official SDK + +```ts +import { Polar } from "@polar-sh/sdk"; + +const polar = new Polar({ + accessToken: "polar_oat_test", + serverURL: "http://localhost:4019", +}); + +const state = await polar.customers.getStateExternal({ externalId: "customer_123" }); +``` + +## Seed billing state + +Seed meters, benefits, and products before customers so references can resolve by name. Customers can include subscriptions that refer to products by name or ID. + +```bash +curl -X POST "$POLAR_EMULATOR_URL/_emulate/seed" -H "Content-Type: application/json" -d '{ + "meters": [{ + "name": "API calls", + "filter": { "conjunction": "and", "clauses": [{ "property": "name", "operator": "eq", "value": "api.call" }] }, + "aggregation": { "func": "sum", "property": "count" } + }], + "benefits": [{ + "type": "meter_credit", "description": "100 API calls", "meter": "API calls", "units": 100 + }], + "products": [{ + "name": "Free", "recurring_interval": "month", + "prices": [{ "amount_type": "fixed", "price_amount": 0, "price_currency": "usd" }], + "benefits": ["100 API calls"] + }], + "customers": [{ + "external_id": "customer_123", "email": "customer@example.com", + "subscriptions": [{ "product": "Free", "status": "active" }] + }] +}' +``` + +Meters, benefits, and products are upserted by name or description. Customers are upserted by external ID. + +## Exercise usage billing + +Ingest events with a Polar customer ID or your external customer ID. Metadata values feed `sum`, `max`, `min`, `avg`, and `unique` aggregations. + +```ts +await polar.events.ingest({ + events: [ + { + name: "api.call", + externalCustomerId: "customer_123", + metadata: { count: 3 }, + }, + ], +}); + +const state = await polar.customers.getStateExternal({ externalId: "customer_123" }); +``` + +Events for an unknown external customer remain stored and begin counting after that customer is created. + +## Complete a checkout + +Create paid subscriptions with `polar.checkouts.create`. Open the returned `url`, submit the hosted form, then account for the deliberate state delay. Call the settle route for deterministic tests: + +```bash +curl -X POST "$POLAR_CHECKOUT_URL/settle" +``` + +Seed `checkout.settle_delay_ms` to control automatic settlement. The default is 2500 milliseconds. A value of `null` disables automatic settlement. + +## Inspect calls and inject faults + +Inspect requests at `GET /_emulate/ledger`. Arm a failure by the official operation ID: + +```bash +curl -X POST "$POLAR_EMULATOR_URL/_emulate/faults" -H "Content-Type: application/json" -d '{ + "match": { "operationId": "customers:get_state_external" }, + "response": { "status": 503 }, + "times": 1 +}' +``` + +Clear faults with `DELETE /_emulate/faults`. Reset state and the ledger with `POST /_emulate/reset`.