Skip to content

feat(billing): companion monthly usage subscription for yearly plans (flag off) - #415

Merged
ABB65 merged 4 commits into
mainfrom
feat/polar-companion-usage
Oct 5, 2026
Merged

ABB65 merged 4 commits into
mainfrom
feat/polar-companion-usage

Conversation

@ABB65

@ABB65 ABB65 commented Oct 4, 2026

Copy link
Copy Markdown
Member

What

Yearly plans bill usage once a year in Polar. This adds a companion subscription: a second, monthly, $0-base subscription that carries the metered prices and monthly meter credits beside the yearly plan sub. Same limits as the monthly tier, usage tracked and reset monthly, overage invoiced monthly (founder rule 2026-10-04).

Flag off by default. With it off nothing changes: ensureCompanionSubscription returns null, no companion is created, the reconciler is a no-op.

Config (env names only)

  • NUXT_POLAR_COMPANION_USAGE (default false)
  • NUXT_POLAR_STARTER_COMPANION_PRODUCT_ID, NUXT_POLAR_PRO_COMPANION_PRODUCT_ID

polar:sync now has a step 4/4 that creates the companion products. Dry-run only; not applied to any sandbox/prod from this PR.

Design

  • Companion created on subscription.created / updated for yearly plans (and Migrate bundle year-1 → yearly) when none is recorded. Tagged metadata.contentrain_companion = 'true'.
  • Recorded on payment_accounts.plugin_metadata (companion_subscription_id, companion_billable_meters) via single-key writes; plan-subscription upserts preserve both keys (preserveMetadataKeys).
  • Webhook isolation: companion events never overwrite payment_accounts plan fields; applyCompanionEvent only updates the metadata keys.
  • Overage lock reads the union of own + companion billable meters; yearly_plan reason only when nothing is priced.
  • Cancel/revoke: plan canceled → companion cancelled first; Migrate revoke and workspace delete cancel the companion before the parent (a companion failure → 502, grant stays live, parent untouched, Migrate can retry).
  • Reconciler (server/plugins/companion-reconciler.ts, 6-hourly) opens missing companions and covers accounts the webhook missed.
  • Known risk: if the companion cancel succeeds and the parent cancel then fails, an orphan-free state is kept (parent still live); the reverse order would leave an orphan companion, hence this order.

Tests

Unit: overage-lock (+6), companion plugin, companion util, migrate-revoke (+2). Integration (mocked Polar): new billing-webhook-companion (10 cases); existing webhook test updated for the extra preserved keys. Typecheck and eslint clean. pnpm test:ci green with a raised unit timeout; at the default 5s a few unrelated import-heavy files (agent/brain-query/conversation-engine) time out under load and pass alone.

Open questions (not proven in the sandbox — no card, cannot advance a billing cycle)

A. Is the companion's overage actually invoiced at cycle end?
B. Is it charged to the card saved at the bundle checkout?
C. Companion beside a paid yearly sub / checkout interplay — only two free subs were tested; allow_multiple_subscriptions was not touched.

Also unverified: alignment of the yearly and companion billing cycles, and whether the companion invoice uses the bundle card.

Don't merge before qa ONAY. Not to be enabled anywhere until A/B are checked with a test-card yearly-bundle checkout.

… flag off by default

A second free-base monthly subscription carries the metered prices
and monthly meter credits beside a yearly plan, so overage is invoiced
monthly. Behind NUXT_POLAR_COMPANION_USAGE (default off).
@ABB65

ABB65 commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

qa review: CHANGES @ 23cc81b (test merge d7725a12 = origin/main 1b39285 + head)

The flag is off, so prod is not at risk, but two items in the PR's own spec fail.

Blocker (before the flag is enabled)

  1. Duplicate companions. polar.ts ensureCompanionSubscription runs subscriptions.list and then subscriptions.create with no claim or lock in between. Polar sends subscription.created, .active and .updated together at checkout, and webhook/[provider].post.ts opens a companion from both the created and the updated branch; the reconciler can race too. Whichever companion is recorded first wins. The other one's events are dropped by the recorded !== subscriptionId check, so it is never cancelled, and usage is metered twice. Fix: claim a metadata key with setPaymentAccountMetadataKey(when:'absent') before create, or take a lock.

Major
2. Workspace delete. index.delete.ts calls cancelCompanionSubscription without throwOnFailure, and the outer catch swallows every error. If the companion cancel fails, the parent is still cancelled, no 502 is returned, and the CASCADE drops the only record of the companion. This contradicts the PR body. Migrate revoke does it correctly.
3. Plan-cancel webhook. A failed companion cancel is only logged. The webhook returns 200, so Polar does not retry, and the account is archived, so the reconciler never revisits it. The orphaned companion then bills usage as overage.
4. Overwrite race. applyCompanionEvent upserts the whole row (status, period, plan, subscription_id) from a snapshot. A parent updated event that lands between its read and its write is rolled back. Write only the companion and suspended-overage keys.
5. Product switch. If the portal allows switching products, a starter→pro or yearly→monthly change keeps the old companion, so credits and prices are for the wrong tier, or metered usage is billed twice on monthly.

Minor
6. The archived row keeps companion_subscription_id and its meters. A resubscribe reuses that row, so the stale meters unlock overage that nothing bills, and the stale id makes the reconciler skip that account.
7. With the flag off:

  • revoke now reads the account before the try, so a DB error returns 500;
  • the reconciler's DB list runs anyway;
  • listActivePaymentAccounts(…, 500) applies its filter after the limit, so accounts past the first 500 are never reached, and the method has no contract test.
  1. A replayed companion created that arrives after its canceled records the id and meters again.

Before enabling: run the yearly bundle once in the sandbox with a test card. It needs to cover:

  • whether the org allows multiple subscriptions per customer at all (this gates the whole feature);
  • whether revoke invoices the last partial month;
  • whether the companion's billing cycle lines up with the yearly window.

Evidence

  • pnpm typecheck: 0 errors (after contentrain-query generate, as CI does).
  • pnpm lint: 0 errors.
  • Unit: 2079 passed, 7 failed, all 5 s timeouts under load average 50–90. Those 4 files pass 60/60 when rerun with a 60 s timeout.
  • Integration: 498/498.
  • Nuxt: 274/274.
  • test:contract on a fresh Postgres: 166 passed, 1 skipped.
  • CI: green.

…lace on product switch

One conditional claim per workspace decides who opens the companion, so
created, updated and reconciler cannot open two. Workspace delete and the
plan-cancel webhook now fail when the companion cannot be cancelled. The
overage toggle write touches only its own key. A plan moved to another
product replaces its companion; an ended one is forgotten on the row.
@ABB65

ABB65 commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

qa re-review: CHANGES @ 3dace87 (test merge a7b158b1 = origin/main 1b39285 + head)

Fixed: workspace delete (2), plan-cancel webhook (3), the applyCompanionEvent partial write (4), flag-off and page-cap handling (7), and replays of an ended companion (8). The claim is atomic on both providers, a stale opening is taken over, and failed is retried.

Blocker A: a companion opened by the reconciler is cancelled and re-created on the next plan webhook.

  • The reconciler passes productId: null (companion-subscription.ts:199), so its claim is stored as done: with an empty product.
  • In claimOpening, line 68 then gives settledFor = '', which is not null. Line 69 then evaluates productChanged = '' !== productId to true for any real product, and every Polar plan event carries one.
  • The next subscription.updated, a renewal for example, therefore revokes the healthy companion and opens a new one. A unit probe confirmed it: after reconcile the claim is done: and the id is sub_c1; after a plan update the outcome is opened, the id is sub_c2, and sub_c1 is cancelled.
  • This hits every account that predates the flag and every account that failed before.
  • Fix: have the reconciler pass the real product (it already reads the parent subscription), or treat an empty settled product as unknown and re-stamp it without cancelling.

Blocker B: the created/updated race from finding 1 is still open.

  • The subscription.created upsert passes pluginMetadata without preserveMetadataKeys. Postgres metadataOnUpdate then replaces plugin_metadata wholesale and wipes companion_claim.
  • Sequence: updated arrives first and holds opening:T while its Polar create is in flight. created then wipes the claim and wins its own when:'absent' claim. Two creates now run at once, and list-before-create cannot see an in-flight create.
  • A contract probe on real Postgres confirmed it: after the created-style upsert the metadata is {"overage_suspended":[]}.
  • Fix: preserve the companion_* keys, including the claim, on the created upsert.

Medium: a companion canceled event can overwrite an in-flight opening: claim.

  • applyCompanionEvent lets the event through when no id is recorded (recorded && recorded !== id). That is exactly the state during a product switch.
  • It then writes the claim as failed with when:'different', which allows a parallel open; its id clear can also wipe the new id.
  • Fix: apply the event only when recorded === result.subscriptionId, and write the claim conditionally.

Low

  • Supabase setPaymentAccountMetadataJson depends on updated_at changing on every write. Prod is Postgres.
  • Pre-existing in the delete route: if the account read throws, the delete still goes ahead.
  • A move to a plan other than starter or pro returns skipped before the claim, so the old companion stays.

Tests: the 4 touched files pass 80/80. The payment-accounts contract test passes 6/6 on a fresh postgres:16. CI: postgres-lineage green; ci was pending at review time.

ABB65 added 2 commits October 5, 2026 03:34
…mp is learned

The plan created upsert now preserves the companion keys, claim included,
so a racing open keeps its claim. A claim stamped without a product by the
reconciler is learned on the next plan event instead of cancelling a
healthy companion. An ending companion event with none recorded is ignored.
…ed for

The provider returns the plan product it read from the plan subscription,
so a reconciler-opened companion is stamped with its real product and a
switch as the first later plan event is still replaced.
@ABB65

ABB65 commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

ONAY @ ceebcbb

Three review rounds; every finding is fixed at this head.

Evidence (qa, local): head ceebcbb already contains origin/main 1b39285 (fast-forward, no merge needed).

  • pnpm install --frozen-lockfile, then pnpm exec contentrain-query generate, then pnpm test:ci: exit 0
    • unit: 186 files, 2093 passed, 1 skipped
    • integration: 53 files, 504 passed
    • nuxt: 43 files, 274 passed
  • CI is green at this head: ci (11m) and postgres-lineage.

@ABB65
ABB65 merged commit aea5e71 into main Oct 5, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/polar-companion-usage branch October 5, 2026 01:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant