Feat/subscription management - #39
Open
TheTrunk wants to merge 7 commits into
Open
Conversation
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.
Deploying with
|
| 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 |
Deploying with
|
| 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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
cumulusvpn-dashboard | fdca65e | Aug 14 2026, 03:30 PM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.