Skip to content

Hosted: adopt pgstencil 0.4.2's billing helpers - #1426

Merged
nedtwigg merged 4 commits into
mainfrom
hosted-pgstencil-042
Oct 29, 2026
Merged

nedtwigg merged 4 commits into
mainfrom
hosted-pgstencil-042

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Moves Hosted billing onto pgstencil 0.4.2 (diffplug/pgstencil#81), so Hosted no longer copies pgstencil's rules or reads its private tables. The last of the billing-review fixups. Billing still ships dark.

  • Entitlement, the founders row and the cohort row read pgstencil_billing.owner_access (migration 007), the one definition status() also reads. Relay and voice get SELECT on the view's owner_id and access_until only, and nothing on subscriptions. The resync is reconcileStale; held checks use pgstencil's SUBSCRIBED.
  • Checkout uses replace and expectPrice and keeps the returned priceId; unexpected-price answers the existing PRICE_CHANGED 409. A price the page didn't show is now refused before Stripe opens a session, instead of opening one and expiring it.
  • Refund, withdrawal and closure end through billing.end with a recorded reason (migration 008); the webhook counts an end as refunded inside the refund window or by reason refund, the same results as before. endForRefund no longer reconciles.
  • customerOf / ownerOfCustomer replace the accounts reads; the event is verified once; a dispute's owner comes from pgstencil's webhook context; one purchaseCounts query for gross and net.
  • Specs: hosted.md, security-hosted.md S282 and S110.

Direct pgstencil_billing.subscriptions reads that remain, each with no pgstencil helper: closure listing every current subscription to end it (the view omits an owner with two) and its held check while billing is closed, and the notices' renewal and price-change queries.

Deploy: db:migrate applies migrations 007 and 008 and reapplies the role grants before the new code runs.

Tests: hosted test:deploy 46/46, vitest 302; hosted and lib tsc, spec-lint, md-unwrap, lint:public-docs; new tests mutation-checked.

🤖 Generated with Claude Code

nedtwigg and others added 3 commits October 28, 2026 20:01
Bumps pgstencil, @pgstencil/auth, and @pgstencil/stripe together, as
production's provenance check reads all three. Its migrations 007 (the
owner_access view) and 008 (subscriptions.end_reason) join through the
billing migrations directory Hosted already composes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The entitlement, the founders and cohort rows read pgstencil's
  owner_access view instead of restating its access rule; the relay and
  voice roles get SELECT (owner_id, access_until) on the view and nothing
  on subscriptions. The resync is reconcileStale, the held-subscription
  checks use the exported SUBSCRIBED.
- Checkout passes replace and expectPrice and records the priceId it
  returns, so a Price the page did not show is refused before Stripe opens
  a session; unexpected-price answers the existing PRICE_CHANGED 409.
- A refund, a withdrawal, and a closure end through billing.end with a
  recorded reason; the webhook classifies subscription.refunded by that
  reason instead of Stripe's cancellation comment, which a customer can
  write. endForRefund drops its reconciles.
- customerOf and ownerOfCustomer replace the reads of pgstencil's
  accounts table; the webhook is verified once, and a dispute's owner is
  pgstencil's, resolved through its charge.
- foundingPurchases counts both windows in one purchaseCounts query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2a133e4
Status: ✅  Deploy successful!
Preview URL: https://2b9b1a5c.mouseterm.pages.dev
Branch Preview URL: https://hosted-pgstencil-042.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A redelivered refund or lost-dispute event no longer ends the subscription, so S285 and "Billing"'s "on every delivery until ended" no longer hold. owner is set only inside apply. pgstencil 0.4.2's webhook() returns before calling apply when the event row already has processed_at (if (saved?.processed_at) return; in @pgstencil/stripe's dist/index.js). Here is the path:

  1. A full charge.refunded commits.
  2. billing.end fails on a Stripe error and the route answers 503.
  3. Stripe redelivers. owner stays null, endForRefund is skipped, and the route answers 200. The subscription stays current and keeps its entitlement.

Nothing else picks it up: a refund has no cron path, and a lost dispute's dormouse_billing_disputes row was already deleted in the committed transaction, so resyncDisputes never sees it. Before this PR, ownerOf ran outside the transaction, so it covered this case. No test covers a failed end followed by a redelivery. f.failing.cancels is only exercised for a withdrawal.

This PR overlaps with #1425 (the Renovate bump of @pgstencil/stripe to 0.4.2), which this one supersedes.

Comment thread hosted/server/billing-routes.ts Resolved
pgstencil's webhook() skips apply for an event already processed, so a
full refund or a lost dispute whose billing.end failed after the commit
answered 200 on Stripe's redelivery without ending the subscription. The
owner now falls back to the reversed charge's customer, as before 0.4.2,
so every delivery ends it until it has, and a dispute's notices still go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants