architecture/polar-integration-plan.md

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 + :provider webhook 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, flat flatPriceMinor (NOK).
  • Plan catalog: keep schema/code as-is; data only — old plans (solo_*, pro_*, enterprise) → isPublic=false; one premium row isPublic=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 + new Plan/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 our pro_*_per_seat plans 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.getcustomerSessions.create({ customerId })
cancelSubscription subscriptions.update({ cancelAtPeriodEnd }) / subscriptions.revoke
updateSubscriptionQuantity subscriptions.update({ seats })
parseWebhook standardwebhooks verify → mapPolarWebhookDomainSubscriptionEvent
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 → SDK server: 'sandbox', storeId (optional) = Polar org id.
  • PlatformBillingPlanMapping: providerVariantId stores the Polar product id.
  • POST /webhooks/subscription/:providerparseProvider already accepts any BillingProvider enum value, so /webhooks/subscription/polar works once the enum has POLAR.
  • SubscriptionWebhookInbox idempotency, 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 — SchemaPOLAR added to enum BillingProvider; migration 20260608000000_add_polar_billing_provider (ALTER TYPE … ADD VALUE). Prisma client regenerated.

  • P2 — Adapter skeletoninfrastructure/providers/polar/: polar.config.ts, polar.signature.ts (standardwebhooks), polar.event-mapper.ts, polar.adapter.ts. Registered in subscription.module.ts alongside LemonSqueezy. @polar-sh/sdk installed. Build + lint green.

  • P3 — Config wiring ⬜ Confirm credentials shape (apiKey=access token, webhookSecret) flows through PlatformBillingConfigService; wire healthCheck button; verify server toggle (sandbox/prod) from isTest.

  • 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.updatedsubscription.renewed) and payment recovered; confirm inbox idempotency + Tenant sync.

  • P4.5 — Premium plan data ⬜ Set solo_*/pro_*/enterpriseisPublic=false; create/enable one premium plan (isPublic=true, monthly, flatPriceMinor); add plan mapping premium → Polar Premium product id. Via super-admin Plans UI or a seed (upsert, no delete).

  • P5 — FrontendBillingSection CreateConfigModal: replace hardcoded provider:'LEMONSQUEEZY' with provider:'POLAR' (single provider for now — selector optional later); un-hide the Billing tab in PlatformSettingsContent.tsx (re-add 'billing' to TABS/VALID_TABS, re-import CreditCard). 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 + signed parseWebhook + 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.md once the provider is verified live (P3/P4).
  • 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.billingExempt super-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 one premium (Option B).
  • Drop LS or keep dual? Keep LS code; only the active PlatformBillingConfig row 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.