Skip to content

Feat/subscription management - #39

Open
TheTrunk wants to merge 7 commits into
mainfrom
feat/subscription-management
Open

Feat/subscription management#39
TheTrunk wants to merge 7 commits into
mainfrom
feat/subscription-management

Conversation

@TheTrunk

Copy link
Copy Markdown
Member

No description provided.

Card subscribers had no way to cancel, change card, or switch plan — the web
copy pointed them at their Stripe receipt email and that was the whole story.
Adds POST /v1/stripe/portal.

The Checkout Session id is the capability, deliberately not the payment code.
The code is base58(sha256(device pubkey)) and every gateway a client enrolls
with receives that pubkey, so authorizing on the code alone would hand any node
operator their users' billing email, card last-4, invoice history and a cancel
button. The session id only ever reaches the buyer's own browser; the code is
checked alongside it purely to bind the two.

Plan changes then have to be paid for correctly. Stripe puts the credit for the
unused old plan and the charge for the new one on one invoice, but the old
plan's days are already settled on chain and cannot be clawed back, so a full
new grant would make the treasury pay for that overlap twice. An invoice
carrying proration lines now grants PLAN_DAYS * total_excluding_tax / list
price. Invoices without proration lines are untouched: a full plan, discounted
or not, because a promo buys a cheaper month and never a shorter one.

Three traps, each pinned by a test:

- The plan is read from the invoice line's price id, never from cvpn_plan
  metadata. Stripe writes that metadata once at Checkout and never rewrites it
  when a price changes, so after a switch it names the plan the customer LEFT.
  Sizing from it gives an upgrader 30 days for a year's payment and a
  downgrader a fresh 360 days every month.
- The numerator is total_excluding_tax, not subtotal. Stripe's subtotal is
  pre-invoice-discount, so a discounted switch would over-grant.
- Invoices settled from a credit balance grant nothing. The only source of
  customer credit is a downgrade, whose unused value we already paid out as
  irrevocable chain days.

Also stores the Stripe customer id (migration 3, COALESCEd so a renewal without
one cannot wipe it) and adds GET /internal/subscriptions?code= so support can
answer "which subscription is this device?" without mirroring any card data.

README documents the two Stripe Dashboard toggles that have no env var and are
easy to miss: Apple Pay / Google Pay on hosted Checkout, and activating the
customer portal with proration left ON.
Adds a "Your subscription" card that opens the Stripe billing portal, plus the
six manage_* strings across all 26 locales.

The checkout session id is what authorizes the portal, so it is persisted per
payment code in localStorage — keyed by code because the desktop hand-off
(?code=) manages a different device's subscription from the same browser. It is
validated as a cs_-prefixed id before being stored or used: an empty ?session=
otherwise rendered a button whose request the bridge rejects, and a crafted link
could permanently overwrite a subscriber's real id with junk, which is
unrecoverable because there is no account to restore it from.

The section always renders, because upgrade_card_note now names it. Without a
session on file it explains where to go instead — the Stripe receipt email, or
the App Store / Play for in-app subscribers — which also covers the case of a
subscriber arriving on a fresh browser, who previously saw no management UI and
no explanation at all.
Support had no way to answer "which subscription belongs to this device, and
did its settlements land?". Adds a lookup panel over the bridge's
/internal/subscriptions, through the same authenticated proxy the vouchers
admin already uses.

Shows subscriptions and settlement rows only. Card details, emails and names
stay at Stripe/Apple/Google, so refunds and manual cancels are still done in
the provider console — external_id is the handle for that.
The FAQ answered "Do you take credit cards?" with "No", and the privacy policy
asserted "we do not take card details". The Stripe, Apple IAP and Play Billing
rails have shipped, so both were false.

Rewrites 12 keys across all 26 locales to the claim that is still true and is
stronger for being narrower: we never see payment details — the processor holds
them — there is still no account, and entitlement is still computed from chain
against a hash of the public key. Also discloses what the bridge does keep for
fiat renewals (payment code + processor transaction id) and drops the "should we
add one" hedge about app-store billing.

privacy.doc_meta moves to 14 August 2026, which section 13 promises for material
changes. The date is substituted into each locale's existing line rather than
rewritten, so per-locale label wording survives and Thai keeps its Buddhist-era
year (2569, not 2026).

Regenerates the built pages under public/.
…m the node

Two cold-start costs, neither a correctness problem.

Every restart replayed the payment address from height 0 — thousands of
sequential explorer pages on a busy address, serving free-only throughout — and
a Flux app update redeploys every container. The paid_until map and its block
cursor are now checkpointed to /data/entitle.state: immediately when a grant
lands, otherwise at most once a minute, and on shutdown.

Unlike the peer cache this is purely a cache — every value is recomputable from
chain — so a missing, corrupt or mismatched file just means a full rescan and is
never fatal. The file records the payment address and price it was derived under
and is discarded if either changed, because repricing or repointing the fleet
changes what every historical tx granted. Expired codes are pruned on write:
stack() treats a past paid_until exactly like an absent entry, so keeping them
would only grow the file for the life of the deployment.

Address history now prefers the host node's insight mirror on :16127, with the
public explorer as fallback, so a fleet redeploy no longer has every gateway
hammering a single flaky third party for entitlement data. The source is probed
once and remembered — a node lacking the route would otherwise cost a failed
request on every page of every poll — and a node that starts failing drops back
and gets re-probed. A page that decodes but reports nothing is treated as "route
absent" rather than "no payments": guessing wrong there would silently grant
nobody premium.
The per-IP limit was a fixed one-enroll-per-2s window. Mobile carriers front
thousands of subscribers behind a single CGNAT address, so the effective limit
was half an enroll per second for an entire carrier on a given gateway: the
second person to open the app within two seconds was turned away, on the
platform most of our users are on.

Replaces it with a token bucket — burst 8, then one per 2s. The sustained rate
is unchanged, so an abuser gains only the bounded burst, still pays a fresh ~1s
PoW per attempt, and the peer-table ceiling is enforced separately.

A rejected call stays free: tokens accrue continuously, so being turned away
never pushes back the moment the next enroll succeeds. The GC threshold is
derived rather than constant — a bucket refilled to full is indistinguishable
from an absent one, since allowEnroll starts unseen IPs full, so sweeping
exactly at full remains provably behaviour-neutral.
07 and 08 still framed IAP/Play Billing as future work and left the fiat-rail
timing question open; all three rails ship through the payments bridge, so the
remaining work there is operator setup, not design.

03 documents the entitlement snapshot and the node-first address paging, and
why the enroll rate limit is a bucket rather than a window.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cumulusvpn fdca65e Commit Preview URL

Branch Preview URL
Aug 14 2026, 03:30 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cumulusvpn-landing fdca65e Commit Preview URL

Branch Preview URL
Aug 14 2026, 03:31 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
cumulusvpn-dashboard fdca65e Aug 14 2026, 03:30 PM

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