Skip to content

fix(billing): sell a VPN plan inactive, and purge abandoned orders - #413

Merged
v0l merged 1 commit into
masterfrom
fix/unpaid-subscription-cleanup
Sep 1, 2026
Merged

fix(billing): sell a VPN plan inactive, and purge abandoned orders#413
v0l merged 1 commit into
masterfrom
fix/unpaid-subscription-cleanup

Conversation

@v0l

@v0l v0l commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Two bugs, both in how an order that has not been paid for is represented.

A VPN plan was reported active before it was paid for

create_vpn_plan built its subscription with is_active: true. Every other creation path in the codebase sets it false with the comment "Inactive until first payment" (api/subscriptions.rs, api/apps.rs, api/marketplace.rs), and payment sets is_active and is_setup together in subscription_payment_paid.

Nothing leaked: the peer set query joins through is_setup as well, so no device reached a route server. What was wrong was the reporting. An unpaid plan appeared in GET /api/v1/subscriptions and serialised is_active: true while billing_state on the same object said unpaid.

Abandoned orders were never cleaned up

check_subscriptions reads list_lifecycle_subscriptions, which is is_active = 1 AND expires IS NOT NULL. A never-paid subscription has a NULL expiry and (now) is_active = 0, so both filters exclude it and nothing ever looked at it again. Only VMs escaped this, because check_vms scans the vm table and deletes never-paid ones after an hour.

For VPN it also pinned the customer: create_vpn_plan returns an existing plan whenever its state is not Expired, and Unpaid is not Expired, so an abandoned plan was the only one they could ever have, at the price it was created at.

Worker::check_never_paid_subscriptions runs at the tail of check_subscriptions over a new list_never_paid_subscriptions(older_than_seconds) — the complement of the lifecycle query. Each candidate is re-read (a payment may have settled since the snapshot) and skipped while an invoice is unexpired or an on-chain deposit has been detected but not confirmed, reusing the #194 helper. It is renamed payment_blocks_unpaid_deletion since it is no longer VM-only.

Never paid means never provisioned for every product — provisions_cluster_objects gives a BillingState::Unpaid deployment no Kubernetes objects at all — so there is nothing running to tear down, only rows:

line item what happens
Vps skipped; check_vms owns it, on a shorter clock and with a hypervisor to talk to
Vpn plan, devices and their tunnels deleted (new delete_vpn_subscription)
App hard_delete_app_deployment, which takes the subscription with it
MarketplaceNodeFee billing link cleared only; the node registration survives, the unpaid order being the fee rather than the node
IpRange / AsnSponsoring / DnsHosting nothing to detach, the rows cascade off the line item

TTL is 24h. A lightning invoice has expired many times over by then, but a bank transfer or a late on-chain send has not, and being early means deleting an order the customer is still paying for.

Tests

Nine added: the is_active regression, and eight covering the sweep (purge past the TTL, kept within it, kept with a pending payment, VM left alone, VPN plan purged with its devices and tunnels, marketplace node kept and unlinked, app deployment purged, and the re-read that lets a payment settling mid-sweep win).

Full workspace suite passes; clippy warning count unchanged. Note the two new MySQL queries are exercised only through MockDb in unit tests, as is the rest of mysql.rs.

A VPN plan's subscription was created is_active = 1, so an unpaid plan
was reported as an active subscription while billing_state on the same
object said unpaid. Every other product creates its subscription
inactive and lets payment set both flags. No service was configured
early -- the peer set joins through is_setup -- but two fields
describing one fact were free to disagree, and one of them was wrong.

Nothing ever revisited a subscription whose first payment never
arrived. The lifecycle sweep reads list_lifecycle_subscriptions, which
is active-with-an-expiry, and an abandoned checkout is neither, so it
lived in the database for good. For a VPN plan that is worse than
clutter: create_vpn_plan returns an existing plan whenever its state is
not Expired, and Unpaid is not Expired, so the customer stayed pinned to
it at the price it was created at.

check_never_paid_subscriptions purges them 24h after creation, keeping
any whose invoice is unexpired or whose on-chain deposit has been
detected but not confirmed (#194). Never paid means never provisioned
for every product -- the operator's gate gives an Unpaid deployment no
cluster objects at all -- so the product rows go with the order: an app
deployment, and a VPN plan with its devices and keys. A marketplace node
keeps its registration and loses only the billing link, the unpaid order
being the listing fee rather than the node. VMs are left alone; check_vms
already deletes never-paid ones on its own shorter clock, and it has a
hypervisor to talk to.
@v0l
v0l merged commit 9666c03 into master Sep 1, 2026
10 checks passed
@v0l
v0l deleted the fix/unpaid-subscription-cleanup branch September 1, 2026 12:13
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