Migrate vm_payment to subscriptions payment system#114
Conversation
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)
…and provision subscription creation
…ion_payment migration
…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)
…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)
…yment-to-subscription
- 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.
|
This would be great, any ETA? |
|
@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 |
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
…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
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. |
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 |
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).
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 |
Summary
vm_paymentintosubscription_paymentso there is a single unified payment tablevm.subscription_idwork/vm-payment-to-subscription.mdThis PR will be built up incrementally. First commit: rename
VmCostPlanIntervalType→IntervalTypeandApiVmCostPlanIntervalType→ApiIntervalTypeto decouple interval types fromvm_cost_plan.