Skip to content

Migrate vm_payment to subscriptions payment system#114

Merged
v0l merged 58 commits into
masterfrom
feat/vm-payment-to-subscription
Jun 17, 2026
Merged

Migrate vm_payment to subscriptions payment system#114
v0l merged 58 commits into
masterfrom
feat/vm-payment-to-subscription

Conversation

@v0l

@v0l v0l commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Consolidate vm_payment into subscription_payment so there is a single unified payment table
  • VMs will link to subscriptions via vm.subscription_id
  • Full migration plan tracked in work/vm-payment-to-subscription.md

This PR will be built up incrementally. First commit: rename VmCostPlanIntervalTypeIntervalType and ApiVmCostPlanIntervalTypeApiIntervalType to decouple interval types from vm_cost_plan.

v0l added 30 commits March 2, 2026 15:08
Rename VmCostPlanIntervalType → IntervalType and
ApiVmCostPlanIntervalType → ApiIntervalType across the codebase
to decouple interval types from vm_cost_plan in preparation for
the subscription payment migration.
- Migration 20260302151134: re-add interval_amount/interval_type to
  subscription, add time_value/metadata to subscription_payment, add
  subscription_id FK to vm table
- Add VmRenewal=3 and VmUpgrade=4 to SubscriptionType
- Add Upgrade=2 to SubscriptionPaymentType
- Add interval_amount/interval_type to Subscription struct
- Add time_value/metadata to SubscriptionPayment and SubscriptionPaymentWithCompany
- Add subscription_id to Vm struct
- Fix subscription_payment_paid() transaction bug; implement VM path
  (extend by time_value seconds) and regular path (read interval from subscription)
- Add get_vm_by_subscription() and list_vm_subscription_payments() to
  LNVpsDbBase trait, MySQL impl, and MockDb
- Update insert_vm/update_vm SQL to include subscription_id
- Propagate new fields through admin and user-facing API models
- Add interval_amount/interval_type to AdminCreateSubscriptionRequest
  and AdminSubscriptionInfo
- Add migrate_vm_subscriptions binary: creates a Subscription +
  SubscriptionLineItem(VmRenewal) for each VM without subscription_id.
  Standard VMs use cost plan interval; custom VMs default to 1 Month.
  Supports --dry-run for preview. Idempotent: already-linked VMs skipped.
- Fix insert_subscription, insert_subscription_with_line_items, and
  update_subscription SQL to bind interval_amount and interval_type.
- Register migrate_vm_subscriptions in lnvps_api_admin/Cargo.toml.
- vm.subscription_id is now u64 (not Option); migration added to make
  it NOT NULL after data backfill
- provision() and provision_custom() create Subscription +
  SubscriptionLineItem(VmRenewal) before inserting the VM
- CostResult::Existing now holds SubscriptionPayment; deduplication
  checks list_vm_subscription_payments instead of list_vm_payment
- price_to_payment_with_type creates SubscriptionPayment using
  vm.subscription_id; inserts via insert_subscription_payment
- renew(), renew_intervals(), renew_amount() return SubscriptionPayment
- create_upgrade_payment() uses SubscriptionPaymentType::Upgrade and
  stores UpgradeConfig in metadata as JSON Value
- auto_renew_via_nwc() returns SubscriptionPayment
- handle_upgrade() accepts SubscriptionPayment, reads metadata field
- Lightning and Revolut webhook handlers updated to look up
  SubscriptionPayment by ID, mark paid via subscription_payment_paid,
  and find VM via get_vm_by_subscription
- API routes v1_renew_vm, v1_get_payment, v1_get_payment_invoice,
  v1_payment_history, v1_vm_upgrade all use SubscriptionPayment
- ApiVmPayment::from_subscription_payment and
  ApiInvoiceItem::from_subscription_payment added
- Mock insert_subscription and insert_subscription_with_line_items
  fixed to actually persist data
- Test helpers create proper subscriptions for VM fixture data
Instead of vm.subscription_id (VM -> subscription), VMs now carry
subscription_line_item_id (VM -> line item), matching the existing
ip_range_subscription -> subscription_line_item pattern.

This allows a single subscription to contain a VM renewal line item,
additional IP allocations, and other products, all billed together.
The subscription is reached by traversing: vm -> line_item -> subscription.

- Rename vm.subscription_id -> vm.subscription_line_item_id (NOT NULL)
- Merge 20260302154256 NOT NULL migration into 20260302151134
- Add fk_vm_subscription_line_item FK to subscription_line_item(id)
- Add get_vm_by_line_item() to DB trait (primary lookup)
- Add get_vm_by_subscription() joining through line_item for payment handlers
- insert_subscription_with_line_items() now returns (subscription_id, Vec<line_item_id>)
- provision() and provision_custom() store the line_item_id on the VM
- price_to_payment_with_type() resolves subscription_id via line_item lookup
- SubscriptionType::VmRenewal/VmUpgrade kept as discriminants on line items
- MockDb default now pre-populates subscription id=1 + line_item id=1
- migrate_vm_subscriptions binary updated to set subscription_line_item_id
Instead of calling list_companies() and picking the first result,
look up the region (template.region_id / pricing.region_id) and use
region.company_id. This is the correct relationship: template -> region -> company.
…st live

- Add is_setup BIT(1) NOT NULL DEFAULT 0 to subscription table (in
  20260302151134 migration) — set to 1 when first payment is confirmed
- Replace payment history scan (has_paid) with subscription.is_setup
  to determine Purchase vs Renewal payment type
- In renew_subscription(), always compute VmRenewal line item cost via
  PricingEngine::get_vm_cost_for_intervals — never use the stored amount,
  which is stale for standard VMs and zero for custom VMs
- subscription_payment_paid() now sets is_setup = 1 alongside is_active = 1
- Add is_setup to AdminSubscriptionInfo and all Subscription struct literals
- renew_subscription() now takes intervals: u32 parameter
- VmRenewal line items use get_vm_cost_for_intervals(vm.id, method, intervals)
- Non-VM line items multiply stored amount by intervals
- renew_intervals() passes its intervals arg through instead of ignoring it
- v1_renew_subscription API endpoint passes intervals=1 (non-VM subscriptions
  don't support multi-interval renewal via that endpoint yet)
…ements

- Add 20260303114230_fix_referral_constraint_names.sql to rename anonymous
  FK constraints on referral/referral_payout tables to explicit names,
  fixing mysqldump re-import collisions
- Add VmForMigration struct and LNVpsDbMysql helper methods
  (list_vm_ids_without_subscription, get_vm_for_migration,
  set_vm_subscription_line_item, pool) to allow migration binary to
  operate on VMs with NULL subscription_line_item_id
- Rework migrate_vm_subscriptions binary to use concrete DB type for
  VM reads/updates, avoiding Vm struct decode failures on NULL columns
…ment records

- Phase 1 now migrates ALL VMs including deleted ones (was previously
  skipping deleted VMs, leaving 863 VMs without subscriptions)
- Phase 2 backfills every vm_payment row into subscription_payment,
  preserving all fields including encrypted external_data (copied
  as raw string without decryption via new VmPaymentRaw struct)
- PaymentType::Renewal → SubscriptionPaymentType::Renewal
- PaymentType::Upgrade → SubscriptionPaymentType::Upgrade
- upgrade_params JSON string → metadata serde_json::Value
- time_value u64 (0=none) → Option<u64>
- Both phases are idempotent (re-run is safe)
- Validated against production backup: 1078 subscriptions created,
  2180 payments backfilled, 0 errors
…und-trip

The previous approach went through insert_subscription_payment which re-encrypted
external_data using the local key. On production the data is already encrypted
with the production key and must be copied verbatim.

- Add insert_subscription_payment_raw() to LNVpsDbMysql for raw byte copy
- Add list_subscription_payment_ids_for_subscription() for idempotency
  checking without decrypting external_data
- Add VmPaymentRaw struct with external_data as plain String
- Migration binary now uses raw insert for all payment backfilling
- renew_subscription: use NewPaymentInfo directly from get_vm_cost_for_intervals
  instead of re-passing already-converted BTC amounts through get_amount_and_rate
  (double-conversion bug causing ~65000x inflated BTC amounts)
- renew_subscription: set time_value on created SubscriptionPayment from summed
  NewPaymentInfo.time_value (was always None, so vm.expires was never extended)
- insert_subscription_payment / update_subscription_payment: add missing
  time_value, processing_fee, metadata columns to SQL (were silently dropped)
- subscription_payment_paid: also UPDATE vm.expires by time_value seconds, not
  only subscription.expires (vm expiry was never extended on payment confirmation)
- get_vm_cost_for_intervals: drop time_value from dedup key; match unpaid renewal
  by subscription+method+type only (time_value changes each call as vm.expires
  advances, so the old match always missed and created duplicate payments)
- next_template_expire / get_custom_vm_cost / get_template_vm_cost: clamp base
  to max(vm.expires, now) so expired VMs get correct time_value and new_expiry
- Skip data migrations when running in API-only mode (--mode api)
- Add time_value and amount sanity assertions to all renew tests
- Mock subscription_payment_paid now also updates vm.expires; test updated to
  insert VM and assert both subscription and VM expiry are extended
…e dedup

- Add list_pending_vm_subscription_payments to DB trait, MySQL impl, and mock:
  returns only unpaid payments whose expires > NOW(), so callers never see
  stale expired invoices
- get_vm_cost_for_intervals: use list_pending_vm_subscription_payments instead
  of list_vm_subscription_payments + in-memory filter; removes the redundant
  p.expires > Utc::now() check from the find predicate
- invoice.rs / revolut.rs: use list_pending_vm_subscription_payments when
  cancelling other pending upgrade payments; drop now-redundant !p.is_paid filter
- Add two tests: dedup reuses a valid unpaid payment (Existing), and dedup
  ignores an expired unpaid payment and returns New instead
After a VM upgrade (both standard→custom and custom→custom paths), the
SubscriptionLineItem.amount was never updated, leaving the displayed
subscription renewal cost stale.

- In convert_to_custom_template: compute new base-currency cost via
  PricingEngine::get_custom_vm_cost_amount and set it on the line item
- Add update_line_item_cost_for_custom_vm helper on LNVpsProvisioner
- Call the helper from the process_vm_upgrade worker step after updating
  an existing custom template's specs
- Add regression tests for both code paths
The migration tool created subscriptions with is_active=true for all VMs
including deleted ones. Add the deleted column to VmForMigration and its
backing SQL query, then set is_active=!vm.deleted when building the
Subscription record.
All 12 list endpoints that previously fetched the full table into memory
and paginated in Rust (skip/take) now use LIMIT/OFFSET in SQL with a
separate COUNT(*) for the total.

New paginated DB trait methods added:
- list_cost_plans_paginated
- list_custom_pricing_paginated (optional region_id + enabled filters)
- list_subscriptions_paginated (optional user_id filter)
- list_subscription_payments_paginated
- list_available_ip_space_paginated (optional is_available/is_reserved/registry filters)
- list_ip_space_pricing_by_space_paginated
- list_ip_range_subscriptions_by_space_paginated (optional user_id + is_active filters)
- list_payment_method_configs_paginated
- list_roles_paginated

Endpoints updated: v1_list_ip_space, v1_list_subscriptions,
v1_list_subscription_payments, admin_list_cost_plans,
admin_list_custom_pricing, admin_list_ip_space, admin_list_ip_space_pricing,
admin_list_ip_space_subscriptions, admin_list_payment_methods,
admin_list_subscriptions, admin_list_subscription_payments, admin_list_roles
…1-13)

- Add list_expiring_subscriptions, list_expired_subscriptions, deactivate_subscription to DB trait/MySQL/mock
- Implement all ip_range_subscription mock methods; add ip_range_subscriptions field to MockDb
- Add WorkJob::CheckSubscriptions; schedule at 30s interval alongside CheckVms
- Add check_subscriptions() + handle_subscription_state() to Worker:
  expiring-soon NWC auto-renewal, non-VM deactivation on expiry, grace-period notification
- Add vm_expires() helper: resolves subscription.expires via line item; falls back to vm.expires
- handle_vm_state now uses vm_expires() for stop/delete decisions
- Remove VM-specific NWC auto-renewal from handle_vm_state (now owned by handle_subscription_state)
v0l added 15 commits March 10, 2026 10:57
…missing revolut config

Also handle graceful error when get_vm_by_subscription fails during upgrade
payment completion — log a warning instead of propagating an error that
would mislead callers into thinking the payment was not completed.
…-> = 3)

subscription_type 4 was incorrectly included in several VM-related joins,
causing ambiguous or wrong results when fetching VMs by subscription.
…ination

Avoids fetching all payments just to get the total count in the admin
VM payments endpoint.
… errors

Replaces unwrap() calls when deserialising Revolut/Stripe external_data.
Payment history endpoint now always uses the paginated query path.
Previously the early return inside the nostr-nwc cfg block caused the
expiry notification to be skipped for users without NWC configured.
Now uses an auto_renewed flag so the fallback notification always fires
when auto-renewal was not attempted.
…xpiry, grace period, renewal)

- test_payment_activates_subscription_and_queues_vm
- test_on_expired_stops_vm
- test_on_grace_period_exceeded_deletes_vm
- test_renew_after_expiry_extends_expires

Also exposes DummyVmHost as pub(crate) and adds MockVmHost type alias.
…est docs

build.yml conditions were inverted — builds only ran on workflow_dispatch.
Now triggers on push and pull_request as well.
build-and-test.md clarifies docker compose requirement before running tests.
…er infra

- API_CHANGELOG.md: add entries for post-2026-03-03 changes (VmStatus.expires
  nullable, Stripe support, LNURL payment method, SubscriptionPayment.processing_fee
  and Upgrade payment type, console WebSocket, VM payment pagination, expiry
  notification fix, and retract false admin VM renew claim)
- API_DOCUMENTATION.md: fix VmStatus.expires to optional; add processing_fee and
  Upgrade variant to SubscriptionPayment; add lnurl to PaymentMethod; document
  WebSocket console endpoint; document VM payment pagination; remove duplicate
  incorrect Price definition in IP space section
- ADMIN_API_ENDPOINTS.md: document subscription field on AdminVmInfo; add
  company_base_currency, time_value, metadata to AdminSubscriptionPaymentInfo;
  add upgrade to SubscriptionPaymentType enum inline schemas; fix AdminVmInfo
  expires to nullable
- lnvps_e2e: add LND integration, worker lifecycle tests, expanded DB helpers
- lnvps_db: migration to drop legacy vm.created/expires columns
- various: e2e infra improvements (wait-for-lnd, docker-compose, run-e2e script)
- Add 'creating' state for VMs during initial provisioning (closes #119)
- Simplify VmRunningStates enum: remove 'starting' and 'deleting'
- Introduce VmStateCache for caching VM states
- Implement persistent dummy host state in dev environment
- Refactor worker to use handle_vm_state helper
- Add VM state cache to subscription and provisioner handlers
- Remove deprecated VmState enum from model
- Mark referral test as ignore for network dependencies
Add SO_REUSEADDR socket option to allow binding even when sockets are
in TIME_WAIT state. This resolves 'Address already in use' errors in CI
where previous runs may not have fully released ports.

Also improved cleanup in run-e2e.sh to wait for processes to terminate.
@knocte

knocte commented May 25, 2026

Copy link
Copy Markdown

This would be great, any ETA?

@v0l

v0l commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@knocte this PR is gone stale, but im going to focus back on LNVPS since my other work is finishing soon, i need to do E2E migration test because this PR replaces all the existing billing system completely, its high risk change

knocte pushed a commit to nodeeffect/pulumi-lnvps that referenced this pull request Jun 16, 2026
In AsyncWaitForVMToBeRunning method. The new status is
expected to be added in [1]. Otherwise the code would fail
with "Unknown VM status" error.

[1] LNVPS/api#114
v0l added 2 commits June 16, 2026 13:52
…subscription

# Conflicts:
#	Cargo.lock
#	lnvps_api/src/worker.rs
#	lnvps_api_common/src/mock.rs
#	lnvps_db/src/mysql.rs
- Replace todo!()/unimplemented!() with bail!() in payment creation paths
  (Stripe/PayPal in price_to_payment_with_type, unhandled subscription
  types in make_line_item_handler) so unsupported methods return a clean
  error instead of panicking the server thread
- Remove accidentally committed log.txt and add it to .gitignore
- Correct API_CHANGELOG: Stripe completion (webhooks) is implemented;
  Stripe payment creation is not yet wired up
- Fix migrations doc: drop reference to deleted vm_subscription_not_null
  migration; document nullable column + deferred NOT NULL finalization
@knocte

knocte commented Jun 16, 2026

Copy link
Copy Markdown

its high risk change

We don't mind monitoring the situation from customer perspective and testing it very thouroughly.

BTW, after this is merged, would you allow VM-destroy-creation cycles that don't generate new invoices? E.g. if after 2 days of creating a VM, I create a new one, but after successful deployment of the new VM, I destroy the old one, will I be charged for 2 VMs? I mean, ideally this new payment system measures with better granularity (e.g. per hour of VM use?). This would allow truly ephmeral-VM-based deployments, very useful for our infrastructure reproducibility.

@v0l

v0l commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

with better granularity (e.g. per hour of VM use?)

You can generate invoice using sats amount (zap), so you could have a vm for 1s in theory, but i advise against this because then somebody could come along and buy up all the free ips and those vms wont be deleted until after 7 days

v0l added 2 commits June 16, 2026 20:52
The vm→subscription migration had two deploy-blocking bugs, both
reproduced against a full prod DB copy (1577 VMs, 3429 payments):

1. Fatal ordering bug: the drop_vm_expires / drop_vm_created migrations
   ran (via db.migrate()) BEFORE the backfill read vm.expires /
   auto_renewal_enabled, so the backfill failed for 100% of VMs
   (Unknown column 'expires'). Result: all VMs left unlinked, the new
   non-nullable subscription_line_item_id NULL-decodes and breaks every
   VM read (get_vm, list_vms, worker check_vms, ip6_init/dns data
   migrations) — the API is dead for existing VMs until backfilled.

2. Data loss: the backfill created subscriptions with expires = None and
   discarded each VM's real expiry, pausing renewal/suspension/auto-renew.

Fixes:
- Backfill now runs automatically during app startup, after schema
  migrations and before run_data_migrations / any VM read. Idempotent,
  aborts startup on error. No manual step, no broken deploy window.
- Backfill copies vm.expires → subscription.expires and
  vm.auto_renewal_enabled → subscription.auto_renewal_enabled.
- Link migration relaxes legacy vm.expires (nullable) and
  auto_renewal_enabled (DEFAULT 0) so new inserts succeed while the data
  is preserved for the backfill.
- Removed premature drop_vm_expires / drop_vm_created migrations; moved
  the column drops + NOT NULL + DROP TABLE vm_payment to finalization.
- Removed the standalone migrate_vm_subscriptions binary; its logic now
  lives in data_migration/vm_subscription_backfill.rs.

Verified on prod copy: 1577 subs created (0 errors), 3429 payments
copied, all expiry/auto-renewal values preserved, idempotent across
reboots, all unit tests pass.
CI e2e runs failed at infra setup with "MariaDB did not become ready
within 600s" — the tests never ran. Root cause: mysql_exec probed the DB
via a host mysql/mariadb client on localhost:3377, but the GitHub runner
has no such client installed and the docker-published port probe was
unreliable, so the readiness loop silently retried until timeout.

- mysql_exec now runs SQL inside the db container via `docker compose
  exec` first (deterministic; no host client / published-port
  dependency), falling back to host clients only for local dev.
- Start infra with `up -d --wait` so compose blocks until the db/bitcoind
  healthchecks pass before probing.
- On readiness timeout, dump `compose ps`, db logs, and the last connect
  attempt's stderr so failures are diagnosable instead of opaque.
- Reduce the silent timeout 600s→300s.

Verified locally: full harness brings the stack up and the lifecycle
suite passes (MariaDB ready after 1s via compose exec).
@v0l v0l merged commit 3372fb6 into master Jun 17, 2026
7 checks passed
@v0l v0l deleted the feat/vm-payment-to-subscription branch June 17, 2026 09:31
@knocte

knocte commented Jun 17, 2026

Copy link
Copy Markdown

You can generate invoice using sats amount (zap), so you could have a vm for 1s

But you seem to be talking about the old/current way of paying: an invoice per VM. What I was talking about is this new subscription model (which I guess is kind of "pay-as-you-go": you top up X amount, and then with that X amount you can create many VMs).

@v0l

v0l commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

You can generate invoice using sats amount (zap), so you could have a vm for 1s

But you seem to be talking about the old/current way of paying: an invoice per VM. What I was talking about is this new subscription model (which I guess is kind of "pay-as-you-go": you top up X amount, and then with that X amount you can create many VMs).

Yes in the new system you can have 1 subscription with multiple services like 3 vms + extras, all thats needed to make that work is the UX changes afair, im probably going to deploy this today so expect some downtime

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.

3 participants