Skip to main content

Billing

Subscription billing for a deployment. Polar.sh is the provider today; the module is written against a PaymentProvider port so Paddle/PayPal/PayMongo can be added as adapters.

How it fits together

Browser ──► POST /api/billing/checkout (authed, system.admin.billing.manage)


BillingService ──► PaymentProvider (port)
├── PolarPaymentProvider ──► sandbox-api.polar.sh
└── StubPaymentProvider (dev only, no network)

│ canonical BillingEvent
POST /api/webhooks/{provider} (public, signature-verified)


billing_subscription ◄── the only writer of paid state

The frontend never decides paid state. /checkout hands back a URL and nothing else. The subscription only changes when a signed webhook arrives. The post-checkout redirect is a UX hint — /billing/success polls the backend rather than assuming payment succeeded, because the webhook can (and does) land after the browser gets back.

One deployment holds exactly one subscription — its own — because each client gets an isolated stack. billing_subscription is a single-row table, like company_profile.

Nothing is enforced. A lapsed subscription is shown, not blocked. For a payroll system, a webhook bug or a Polar outage must never lock a client out of their own payroll during a cutoff. Enforcement is a product decision to make deliberately, not a TODO.

Key files

ConcernFile
The portbackend/.../service/billing/provider/PaymentProvider.java
Canonical eventbackend/.../service/billing/provider/BillingEvent.java
Polar adapterbackend/.../service/billing/provider/PolarPaymentProvider.java
Signature checkbackend/.../service/billing/provider/PolarWebhookVerifier.java
Polar → canonicalbackend/.../service/billing/provider/PolarEventMapper.java
Idempotency + applybackend/.../service/impl/BillingServiceImpl.java
Schemabackend/src/main/resources/db/migration/V52__billing.sql
UIfrontend/src/pages/billing/

Local testing — stub provider (no network)

The default. BILLING_PROVIDER=stub in .env, then:

docker compose up -d --build

Log in as a System Administrator, open http://localhost:5173/billing, pick a plan. The stub "checkout" returns straight to /billing/success. Drive the webhook by hand:

curl -X POST http://localhost:8081/api/webhooks/stub \
-H 'Content-Type: application/json' \
-d '{"type":"subscription.active","plan_code":"growth"}'

Then confirm the state flipped:

curl -s http://localhost:8081/api/billing/subscription -H "Authorization: Bearer $TOKEN"

Other type values the stub understands: subscription.canceled, subscription.revoked, subscription.past_due.

Local testing — real Polar sandbox

  1. Sign up at sandbox.polar.sh — a separate account from production.

  2. Create an organization and a recurring product. Copy the product UUID.

  3. Create an Organization Access Token in the sandbox dashboard. Grant it at least the checkouts:write scope — without it checkout creation fails with 403 insufficient_scope. Sandbox tokens do not work against production and vice versa.

  4. Expose your local backend. The Polar CLI can tunnel (polar listen), or use ngrok http 8081 / cloudflared tunnel. Port 8081 is the host-mapped backend from docker-compose.yml; under dev.sh (mvn spring-boot:run) it is 8080 instead.

  5. In the sandbox dashboard, add a webhook endpoint pointing at https://<your-tunnel>/api/webhooks/polar, format Raw, subscribing to subscription.* and order.*. Set a secret (or copy the generated one).

  6. Fill in .env and restart the backend:

    BILLING_PROVIDER=polar
    POLAR_SERVER=sandbox
    POLAR_ACCESS_TOKEN=polar_oat_...
    POLAR_WEBHOOK_SECRET=<the endpoint's secret, verbatim>
    POLAR_PRODUCT_ID_GROWTH=<product uuid>
  7. /billing → pick a plan → pay with test card 4242 4242 4242 4242, any future expiry, any CVC.

  8. You should land on /billing/success, see the delivery in the tunnel/dashboard, and watch GET /api/billing/subscription turn Active.

If the CLI provisions a fresh secret per session, POLAR_WEBHOOK_SECRET changes and the backend needs a restart.

The public /pricing page can take money from prospects who don't have an account yet. This uses Polar Checkout Links — static, dashboard-created URLs — so no payment code runs in the app for these orders and no API token is involved.

Setup (once per plan, in the Polar dashboard):

  1. Products → your product → Checkout Links → create one.

  2. Set its success URL to https://<your-marketing-domain>/order-received — a public page that tells the buyer their instance will be ready within 24 hours.

  3. Put the link URLs in the env of whichever deployment serves the marketing site:

    BILLING_CHECKOUT_LINK_STARTER=https://buy.polar.sh/polar_cl_...
    BILLING_CHECKOUT_LINK_GROWTH=https://buy.polar.sh/polar_cl_...
  4. In Polar → Settings → Notifications, make sure you get an email on every new order — that email is what triggers the onboarding runbook in ../deployment/vps-guide.md.

The frontend reads the links from the public endpoint GET /api/public/billing/checkout-links (PublicBillingController). Plans with a blank link fall back to a mailto: lead-capture CTA, so a deployment with no links configured degrades gracefully. Signed-in users never see the links — their CTA goes to the in-app /billing page instead.

Known limitation: a subscription bought this way predates the client's stack, so after provisioning, that stack's /billing page shows None — its webhook endpoint didn't exist at purchase time and billing state only ever arrives via webhooks. Harmless today (billing is display-only; nothing is enforced). If it starts to matter, the fix is a boot-time sync that pulls current subscription state from the Polar API.

The webhook secret gotcha

Polar follows Standard Webhooks, but not in the way a stock Standard Webhooks library expects. The spec treats the secret as base64 and libraries decode it before signing. Polar's SDKs base64-encode the configured secret and pass that to such a library, which decodes it right back — so the real HMAC key is the secret's raw UTF-8 bytes. Polar's docs say the same from the other side: "base64 encode the secret you configured on Polar in your code before generating the signature to validate against."

PolarWebhookVerifier therefore signs with secret.getBytes(UTF_8) directly. Getting this wrong produces a signature mismatch on every delivery and no other symptom. PolarWebhookVerifierTest pins both the correct derivation and the naive one, so a "fix" toward the intuitive reading fails a test instead of failing in production.

Adding another provider (Paddle, PayPal, PayMongo)

  1. Implement PaymentProvider in service/billing/provider/, returning a new name().
  2. Map that provider's events onto BillingEventType in its own mapper. Map anything you don't act on to IGNORED — never throw, or the provider retries forever.
  3. Add its config block to BillingProperties.
  4. Set BILLING_PROVIDER=<name>.

BillingServiceImpl, the entity, the schema and the entire frontend stay untouched. If they don't, the abstraction has sprung a leak worth fixing.

Webhooks resolve their provider from the URL path, so /api/webhooks/polar and /api/webhooks/paddle can both be live while migrating.

Why the stub is guarded

StubPaymentProvider verifies no signatures, and /api/webhooks/** is public. If the bean existed in production, anyone could POST a forged activation to /api/webhooks/stub. It carries @ConditionalOnProperty(billing.provider=stub), so outside dev the bean doesn't exist and the path 404s. Don't relax that.

Production notes

Each client stack is its own deployment, so each needs its own webhook endpoint registered in Polar, pointing at https://<slug>.motorphenterprise.com/api/webhooks/polar. scripts/onboard-client.sh prints this reminder; it is a manual step.

Be aware of where this goes: Polar delivers every event to every endpoint on the organization, so once several client stacks are registered, each receives other clients' events (they're recorded and ignored — an event for another subscription won't match this deployment's state), and the Polar token sits in every stack. That's fine for the first handful of clients and is the thing that eventually argues for a central control-plane service that owns billing and provisioning. The port and canonical event model are what make that a move of a self-contained module rather than a rewrite.