Polar.sh Integration Plan
Migrate platform SaaS billing from LemonSqueezy (hard to verify) to Polar.sh. The subscription context is already provider-agnostic (
BillingProviderPort+ registry +:providerwebhook route +ParsedWebhook), so this is mostly one new adapter — domain, aggregate and Tenant read-model sync stay untouched.Status legend: ✅ done · 🚧 in progress · ⬜ pending.
0. MVP scope (locked 2026-06-08)
Launch with one plan only — "Premium", monthly, flat price per salon (not per-seat). Owner already has a Polar account + a "Public subscription" product with the Premium option. Keep it minimal to get paying customers fast.
planKey = premium, billing cycle monthly, flatflatPriceMinor(NOK).- Plan catalog: keep schema/code as-is; data only — old plans
(
solo_*,pro_*,enterprise) →isPublic=false; onepremiumrowisPublic=true. No migration, fully reversible (never delete DB rows). - Flat → per-seat later is contained, not a rewrite: adapter already
supports seats (
updateSubscriptionQuantity) and schema is multi-plan. Adding per-seat = new Polar seat price + newPlan/mapping + seat-sync trigger on staff add/remove + migrate existing flat customers (~0.5–1 day).
1. Why Polar
- Merchant of Record — Polar handles EU/global VAT & sales tax (LemonSqueezy does too, but Polar's onboarding/verification is lighter).
- Native seat-based pricing (
ProductPriceSeatBased+subscriptions.update({ seats })) — maps cleanly to ourpro_*_per_seatplans instead of LS quantity hacks. - Sandbox environment (
https://sandbox-api.polar.sh) — easy end-to-end testing without a verified production account. - Type-safe TS SDK
@polar-sh/sdk+ official hosted MCP for AI ops.
2. Polar primitives (reference)
| Need | Polar |
|---|---|
| Hosted checkout | polar.checkouts.create({ products:[productId], externalCustomerId, customerEmail, successUrl, metadata }) → { id, url } |
| Customer portal | polar.customerSessions.create({ customerId }) → { customerPortalUrl } |
| Cancel at period end | polar.subscriptions.update({ id, subscriptionUpdate:{ cancelAtPeriodEnd:true } }) |
| Cancel immediately | polar.subscriptions.revoke({ id }) |
| Update seats | polar.subscriptions.update({ id, subscriptionUpdate:{ seats } }) |
| Verify webhook | validateEvent(rawBody, headers, secret) / standardwebhooks — Standard Webhooks (webhook-id/webhook-timestamp/webhook-signature, secret base64-encoded) |
| Health check | any authenticated call, e.g. polar.products.list({}) |
| SDK init | new Polar({ accessToken, server: 'sandbox' | 'production' }) |
Webhook events (payload { type, data }, raw snake_case):
subscription.created, subscription.active, subscription.updated,
subscription.canceled, subscription.uncanceled, subscription.past_due,
subscription.revoked; order.created (with billing_reason:
subscription_create / subscription_cycle = renewal / subscription_update),
order.paid, order.refunded.
3. Mapping to BillingProviderPort
| Port method | Polar call |
|---|---|
createCheckoutSession |
checkouts.create (productId from plan mapping, externalCustomerId=tenantId, metadata={tenantId,planKey}) |
getCustomerPortalUrl |
subscriptions.get → customerSessions.create({ customerId }) |
cancelSubscription |
subscriptions.update({ cancelAtPeriodEnd }) / subscriptions.revoke |
updateSubscriptionQuantity |
subscriptions.update({ seats }) |
parseWebhook |
standardwebhooks verify → mapPolarWebhook → DomainSubscriptionEvent |
healthCheck |
products.list({}) |
Domain event mapping (see polar.event-mapper.ts):
| Polar event | Domain event |
|---|---|
subscription.created |
subscription.created |
subscription.updated / subscription.uncanceled |
subscription.updated |
subscription.canceled |
subscription.canceled (scheduled) |
subscription.revoked |
subscription.expired |
subscription.past_due |
subscription.payment_failed |
subscription.active, order.*, others |
null (inbox-recorded, ignored) |
4. Reused infrastructure (no change)
PlatformBillingConfig(DB-backed, AES-GCM encrypted credentials):apiKey= Polar Organization Access Token,webhookSecret= endpoint secret,isTest→ SDKserver: 'sandbox',storeId(optional) = Polar org id.PlatformBillingPlanMapping:providerVariantIdstores the Polar product id.POST /webhooks/subscription/:provider—parseProvideralready accepts anyBillingProviderenum value, so/webhooks/subscription/polarworks once the enum hasPOLAR.SubscriptionWebhookInboxidempotency,SyncFromWebhookHandler, Tenant read-model sync — provider-neutral, untouched.
5. Phases
P0 — Polar setup (manual, owner) 🚧 Product "Premium" already created (monthly flat price). Remaining: grab the product id + price (NOK), generate an Organization Access Token, and add a webhook endpoint →
…/webhooks/subscription/polar(capture the signing secret). Sandbox first, then mirror in production. → Step-by-step guide (token + webhook + super-admin, with links/options):../operations/billing-providers/polar-setup.md.P1 — Schema ✅
POLARadded toenum BillingProvider; migration20260608000000_add_polar_billing_provider(ALTER TYPE … ADD VALUE). Prisma client regenerated.P2 — Adapter skeleton ✅
infrastructure/providers/polar/:polar.config.ts,polar.signature.ts(standardwebhooks),polar.event-mapper.ts,polar.adapter.ts. Registered insubscription.module.tsalongside LemonSqueezy.@polar-sh/sdkinstalled. Build + lint green.P3 — Config wiring ⬜ Confirm credentials shape (
apiKey=access token,webhookSecret) flows throughPlatformBillingConfigService; wirehealthCheckbutton; verifyservertoggle (sandbox/prod) fromisTest.P4 — Webhook E2E (sandbox) ⬜ Drive a real sandbox subscription; verify exact payload field names in
polar.event-mapper.ts(snake_case,metadata.tenantId/planKey, period fields); wire renewal (order.*/subscription.updated→subscription.renewed) and payment recovered; confirm inbox idempotency + Tenant sync.P4.5 — Premium plan data ⬜ Set
solo_*/pro_*/enterprise→isPublic=false; create/enable onepremiumplan (isPublic=true, monthly,flatPriceMinor); add plan mappingpremium→ Polar Premium product id. Via super-admin Plans UI or a seed (upsert, no delete).P5 — Frontend ⬜
BillingSectionCreateConfigModal: replace hardcodedprovider:'LEMONSQUEEZY'withprovider:'POLAR'(single provider for now — selector optional later); un-hide the Billing tab inPlatformSettingsContent.tsx(re-add'billing'toTABS/VALID_TABS, re-importCreditCard). Pricing/checkout flow is provider-agnostic — pricing page renders the single Premium card automatically.P6 — Tests + docs 🚧
- ✅ Unit tests (45, 2026-06-09):
polar.signature.spec.ts(7 — Standard-Webhooks sign/verify, tamper + replay-window guards),polar.event-mapper.spec.ts(24 — every mapped + ignored event, snake_case and camelCase aliases, planKey reverse-lookup, missing-field throws),polar.adapter.spec.ts(14 — checkout/portal/cancel/seats/health + signedparseWebhook+ graceful no-active-config). Full subscription suite 202/202, lint clean. - ⬜ e2e webhook — deferred until P4 confirms the real sandbox payload
field names; an e2e built on the inferred snake_case contract would bake in
guesses. There is also no existing subscription-webhook e2e to mirror (current
coverage is controller-level unit specs under
src/). - ⬜ docs — update
subscription-architecture.md(decision log) +subscription-flow.mdonce the provider is verified live (P3/P4).
- ✅ Unit tests (45, 2026-06-09):
Phase A — owner checkout flow ✅ (shipped uncommitted 2026-06-10)
POST /subscription/checkout+ owner "Subscription" nav page + plan picker.Phase B — status / portal / cancel ✅ (shipped uncommitted 2026-06-10)
GET /subscription,GET /subscription/portal,POST /subscription/cancel+ current-subscription card (status / manage / cancel).Phase D — trial-on-signup + enforcement ⬜ PLANNED — system-managed 14-day trial, BillingGuard,
Tenant.billingExemptsuper-admin comp flag. Polar product trial must be OFF. Full plan:subscription-enforcement-plan.md.
6. Decisions
- ✅ Single plan: "Premium", monthly, flat per salon (2026-06-08).
- ✅ Old plans: keep code, data-only hide (
isPublic=false); seed onepremium(Option B). - ✅ Drop LS or keep dual? Keep LS code; only the active
PlatformBillingConfigrow decides which provider is live → switch by activating the Polar config. - ⬜ Polar MCP in Claude Code (sandbox) for ops (refund / cancel / lookup via chat) — added, needs auth (see §7).
- 🔭 Per-seat deferred — easy to add later (see §0).
7. Polar over MCP (AI ops)
Official hosted remote MCP (HTTP):
- Production:
https://mcp.polar.sh/mcp/polar-mcp - Sandbox:
https://mcp.polar.sh/mcp/polar-sandbox
Add to Claude Code:
claude mcp add --transport http "Polar" https://mcp.polar.sh/mcp/polar-sandbox
Auth uses a Polar Organization Access Token (OAuth/login on first connect).
Lets an agent process refunds, manage subscriptions, look up customers/orders,
and pull analytics via natural language. @polar-sh/sdk can also act as an MCP
server. Full docs for AI: polar.sh/docs/llms.txt; Context7 ids
/polarsource/polar-js, /llmstxt/polar_sh_llms-full_txt.
8. Interim: LemonSqueezy hidden
The super-admin Billing tab (the only LS config surface, hardcoded to
LEMONSQUEEZY) is hidden in PlatformSettingsContent.tsx — removed from nav +
?tab=billing falls back to branding. BillingSection + the entire LS
backend (adapter, config, webhook) stay intact (unreachable from UI). Restore by
re-adding 'billing' to TABS/VALID_TABS and re-importing CreditCard.