Skip to content
Open
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
c99b62b
refactor: rename VmCostPlanIntervalType to IntervalType
v0l Mar 2, 2026
b7fdcdf
feat: schema migration + DB layer for VM subscription link (increment 1)
v0l Mar 2, 2026
7f66382
feat: data migration tool + fix subscription interval SQL (increment 2)
v0l Mar 2, 2026
1634833
feat: migrate VM payments to subscription system (increments 3+4)
v0l Mar 2, 2026
f784ba3
refactor: link vm to subscription_line_item instead of subscription
v0l Mar 2, 2026
d6eb9da
chore: update agents-common submodule
v0l Mar 2, 2026
913298d
fix: derive company_id from template/pricing region in provision()
v0l Mar 2, 2026
e9e0d6f
feat: add is_setup flag to subscription; always compute VM renewal co…
v0l Mar 2, 2026
129e893
feat: pass intervals through renew_subscription
v0l Mar 2, 2026
222eb54
feat: add paginated list_vm_subscription_payments and use in API endp…
v0l Mar 3, 2026
f5cca92
feat: migrate reporting queries from vm_payment to subscription_payment
v0l Mar 3, 2026
3dececd
test: add unit tests for subscription_payment_paid, invoice handler, …
v0l Mar 3, 2026
098ad8d
docs: update changelog and migration notes for vm_payment → subscript…
v0l Mar 3, 2026
27bfcab
fix: add referral FK constraint migration and migration binary improv…
v0l Mar 3, 2026
d016b45
feat: extend migration to include deleted VMs and backfill all vm_pay…
v0l Mar 3, 2026
b1e34fc
fix: copy external_data raw in migration, avoiding encrypt/decrypt ro…
v0l Mar 3, 2026
ee197e3
fix: correct VM renewal payment creation and expiry extension
v0l Mar 3, 2026
2f186f6
fix: add list_pending_vm_subscription_payments and fix expired invoic…
v0l Mar 3, 2026
e3fcf85
fix: remove referral FK rename migration that fails on clean DB
v0l Mar 3, 2026
42f93cd
fix: update subscription line item cost after VM upgrade
v0l Mar 3, 2026
ae0fc20
fix: do not mark subscriptions active for deleted VMs during migration
v0l Mar 3, 2026
2a92da6
fix: return subscriptions in descending order in admin API
v0l Mar 3, 2026
8c3ce03
feat: include subscription details in admin VM info response
v0l Mar 3, 2026
21daf6b
feat: include company_base_currency in admin subscription payment res…
v0l Mar 3, 2026
3c66a1c
docs: update API_CHANGELOG with changes from this session
v0l Mar 3, 2026
7b1134b
fix: return subscription payments in descending order
v0l Mar 3, 2026
867c823
docs: require database-level pagination for all list APIs
v0l Mar 3, 2026
d2e29db
refactor: use database-level pagination for all list APIs
v0l Mar 3, 2026
0c2740a
plan: add subscription lifecycle + generic payment pipeline increment…
v0l Mar 3, 2026
5f25555
feat: generic subscription lifecycle + payment pipeline (increments 1…
v0l Mar 3, 2026
f956ad6
test: lifecycle DB tests for expiry queries and deactivate_subscripti…
v0l Mar 3, 2026
846ae74
feat: PaymentCompletionHandler trait + centralised complete_payment p…
v0l Mar 3, 2026
0caa203
feat: Stripe payment handler + non-VM pipeline tests (increments 18-19)
v0l Mar 3, 2026
c4a42b3
refactor: per-line-item payment completion dispatch
v0l Mar 3, 2026
da38d7d
refactor: SubscriptionLineItemHandler trait unifies payment + lifecycle
v0l Mar 3, 2026
be6bb03
feat: remove vm.expires and vm.auto_renewal_enabled — single source o…
v0l Mar 3, 2026
f243bf1
Add workflow_dispatch with docker label arg
v0l Mar 6, 2026
f104ada
refactor: introduce SubscriptionHandler and rename provisioner types
v0l Mar 10, 2026
115061d
ci: add run-e2e.sh script with per-run DB isolation and non-conflicti…
v0l Mar 10, 2026
67d9ee8
fix: SubscriptionHandler::new returns Result instead of panicking on …
v0l Mar 10, 2026
f135365
fix: correct subscription_type filter in VM lookup queries (IN (3,4) …
v0l Mar 10, 2026
faa10f9
feat: add count_vm_subscription_payments DB method and use it for pag…
v0l Mar 10, 2026
0e1457d
fix: from_subscription_payment returns Result to propagate JSON parse…
v0l Mar 10, 2026
024a777
fix: always send expiry notification when NWC auto-renewal is not active
v0l Mar 10, 2026
ed10a02
test: add VM/subscription lifecycle unit tests (payment activation, e…
v0l Mar 10, 2026
2f11786
ci: fix build.yml to trigger on push/PR events and update build-and-t…
v0l Mar 10, 2026
1ca231b
chore: dba review
v0l Mar 10, 2026
96289fe
fix: increase MariaDB readiness timeout from 30s to 90s in e2e script
v0l Mar 10, 2026
c234bde
chore: increase e2e timeout
v0l Mar 10, 2026
125cb79
chore: increase e2e timeout
v0l Mar 10, 2026
51c2c59
docs: audit and update API docs; add e2e lifecycle tests and LND/work…
v0l Mar 10, 2026
7ebf7bf
Merge branch 'master' of https://github.com/LNVPS/api into feat/vm-pa…
v0l Mar 10, 2026
9633726
feat: implement creating VM state for first-provision UX
v0l Mar 19, 2026
5b03633
fix: add SO_REUSEADDR to API servers and improve cleanup for e2e tests
v0l Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ on:
branches:
- master
pull_request:
workflow_dispatch:
inputs:
docker:
description: 'Docker image to build'
required: false
default: 'all'
type: choice
options:
- all
- lnvps-api
- lnvps-api-admin
- lnvps-operator
- lnvps-nostr
- lnvps-host-info

env:
REGISTRY: registry.v0l.io
Expand All @@ -14,6 +28,7 @@ jobs:
# Build host-info as a multi-arch image first
build-host-info:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && github.event.inputs.docker != 'lnvps-host-info' && github.event.inputs.docker != 'all'
Comment thread
v0l marked this conversation as resolved.
Outdated
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down Expand Up @@ -81,6 +96,7 @@ jobs:
password: ${{ secrets.REGISTRY_TOKEN }}

- name: Build and push ${{ matrix.name }}
if: github.event_name == 'workflow_dispatch' && github.event.inputs.docker != matrix.name && github.event.inputs.docker != 'all'
Comment thread
v0l marked this conversation as resolved.
Outdated
uses: docker/build-push-action@v5
with:
context: .
Expand Down
53 changes: 53 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,59 @@ All notable changes to the LNVPS APIs are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed

- **2026-03-03** - VM upgrade no longer leaves subscription renewal cost stale
- `POST /api/v1/vm/{id}/upgrade` — After payment confirmation, `SubscriptionLineItem.amount` is now updated to the new base-currency cost of the upgraded template for both standard→custom and custom→custom upgrade paths
- `GET /api/v1/subscriptions/{id}` and admin equivalents — `line_items[].price` now reflects the post-upgrade renewal cost immediately after an upgrade completes

- **2026-03-03** - Migration tool no longer marks subscriptions active for deleted VMs
- `migrate_vm_subscriptions` — Subscriptions created for deleted VMs are now inserted with `is_active = false`

### Changed

- **2026-03-03** - Admin subscription list now returns results in descending order
- `GET /api/admin/v1/subscriptions` — Results ordered by `id DESC` (newest first); applies to both the all-subscriptions list and the `?user_id=N` filtered list

- **2026-03-03** - Admin VM info response now includes subscription details
- `GET /api/admin/v1/vms/{id}` — Response now includes a `subscription` object with the full `AdminSubscriptionInfo` (id, status, interval, currency, line items, payment count); omitted if no subscription is linked

- **2026-03-03** - Admin subscription payment response now includes `company_base_currency`
- `GET /api/admin/v1/subscriptions/{id}/payments` — Each payment now includes `company_base_currency`
- `GET /api/admin/v1/subscription_payments/{id}` — Response now includes `company_base_currency`
- `POST /api/admin/v1/subscription_payments/{id}/complete` — Response now includes `company_base_currency`

- **2026-03-03** - VM payments now use the unified `subscription_payment` table
- All VM renewal, purchase, and upgrade payments are now stored in `subscription_payment` instead of `vm_payment`
- `GET /api/v1/vm/{id}/payments` — Response format unchanged; now backed by `subscription_payment`; supports pagination via `?limit=N&offset=N` query params
- `GET /api/v1/vm/{id}/payments/{payment_id}` — Now looks up by `subscription_payment.id`
- `GET /api/v1/vm/{id}/payments/{payment_id}/invoice` — Now backed by `subscription_payment`
- `POST /api/v1/vm/{id}/renew` — Returns payment from `subscription_payment`
- `POST /api/v1/vm/{id}/upgrade` — Returns payment from `subscription_payment`; upgrade parameters stored in `metadata` JSON field
- `GET /api/admin/v1/vms/{id}/payments` — Now backed by `subscription_payment`; uses real DB-level pagination
- `GET /api/admin/v1/vms/{id}/payments/{payment_id}` — Now looks up by `subscription_payment.id`
- `POST /api/admin/v1/vms/{id}/payments/{payment_id}/complete` — Now completes a `subscription_payment`
- `GET /api/admin/v1/reports/time-series` — Revenue data now sourced from `subscription_payment`
- `GET /api/admin/v1/reports/referral-usage/time-series` — Referral data now sourced from `subscription_payment`
- **Requires data migration**: Run `migrate_vm_subscriptions` binary to backfill existing VMs with subscriptions before upgrading
- **Schema migrations**: `20260302151134_vm_subscription_link.sql` and `20260302154256_vm_subscription_not_null.sql`

- **2026-03-03** - Every VM is now linked to a `subscription` and `subscription_line_item`
- `vm` table has a new `subscription_line_item_id` column (NOT NULL) linking it to the subscriptions system
- New VMs provisioned via `POST /api/v1/vm` or `POST /api/v1/vm/custom` automatically get a subscription created
- The subscription interval is copied from the cost plan (standard VMs) or defaults to 1 month (custom VMs)

- **2026-03-03** - `IntervalType` enum renamed from `VmCostPlanIntervalType`
- Affects admin responses that include cost plan or subscription interval information

### Added

- **2026-03-03** - Multi-interval VM renewal support
- `POST /api/v1/vm/{id}/renew` — Accepts optional `intervals` query parameter to pre-pay multiple billing periods at once
- `POST /api/admin/v1/vms/{id}/renew` — Same `intervals` support in admin renewal endpoint

## [v0.2.0] - 2026-02-22

### Changed
Expand Down
2 changes: 1 addition & 1 deletion docs/agents-common
Submodule agents-common updated 1 files
+3 −1 common.md
1 change: 1 addition & 0 deletions docs/agents/api-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- **Always return amounts in API responses as cents / milli-sats**
- **Never add JavaScript code examples to API documentation**
- **Never expose secrets in admin API responses** — tokens, API keys, webhook secrets, and other sensitive values must never be returned in GET/list responses. Use sanitized structs with boolean indicators (e.g., `has_token: true`) instead of actual values.
- **All `list_*` APIs must use database-level pagination** — never fetch all rows and paginate in Rust (skip/take). Use `LIMIT ? OFFSET ?` in the SQL query, and return a separate `COUNT(*)` or equivalent for the `total` field in the paginated response. Results must be ordered deterministically (typically `ORDER BY id DESC` or `ORDER BY created DESC`) so pagination is stable across requests.

## Documentation Requirements

Expand Down
36 changes: 36 additions & 0 deletions docs/agents/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,39 @@ Fix by using a completely unique timestamp:
- Use `NOT NULL DEFAULT <value>` for new columns to avoid breaking existing rows
- Test migrations against a database with production-like data
- Never modify a migration that has already been applied to any environment

## Notable Migrations

### vm_payment → subscription_payment (2026-03-02)

Two schema migrations and a data migration binary were added as part of migrating VM payments
from the legacy `vm_payment` table to the unified `subscription_payment` table.

**Schema migrations** (applied automatically by sqlx at startup):

- `20260302151134_vm_subscription_link.sql` — Adds `subscription_line_item_id` to `vm`; adds
`interval_amount`/`interval_type` back to `subscription`; adds `time_value`/`metadata` to
`subscription_payment`. All new columns have safe defaults so existing rows are unaffected.
- `20260302154256_vm_subscription_not_null.sql` — Makes `vm.subscription_line_item_id` NOT NULL
after the data migration has been run.

**Data migration** (must be run manually before the NOT NULL migration):

```bash
cargo run --bin migrate_vm_subscriptions -- --database-url <URL>
# Dry-run first:
cargo run --bin migrate_vm_subscriptions -- --database-url <URL> --dry-run
```

The binary iterates all VMs that do not yet have a `subscription_line_item_id` set, creates a
`subscription` + `subscription_line_item` (type `VmRenewal`) for each, and links the VM. It is
idempotent — VMs that already have a subscription are skipped.

**Finalization** (after production verification — do not run until confirmed):

Once the data migration has been verified in production and all new VMs are going through the
subscription path, `vm_payment` can be dropped:

```sql
DROP TABLE vm_payment;
```
27 changes: 10 additions & 17 deletions lnvps_api/src/api/ip_space.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,16 @@ async fn v1_list_ip_space(
let limit = q.limit.unwrap_or(50).min(100);
let offset = q.offset.unwrap_or(0);

// Get all available IP spaces
let all_spaces = this.db.list_available_ip_space().await?;

// Filter to only show available ones (not reserved)
let available_spaces: Vec<_> = all_spaces
.into_iter()
.filter(|space| space.is_available && !space.is_reserved)
.collect();

let total = available_spaces.len() as u64;

// Paginate
let paginated_spaces: Vec<_> = available_spaces
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
let (paginated_spaces, total) = this
.db
.list_available_ip_space_paginated(
Some(true), // is_available = true
Some(false), // is_reserved = false
None,
limit,
offset,
)
.await?;

// Convert to API format with pricing
let mut ip_spaces = Vec::new();
Expand Down
11 changes: 3 additions & 8 deletions lnvps_api/src/api/legal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,22 +228,17 @@ async fn v1_generate_lir_agreement_from_subscription(
.map(|li| {
let resource_type = match li.subscription_type {
lnvps_db::SubscriptionType::IpRange => {
// Try to extract IP range info from configuration
li.configuration
.as_ref()
.and_then(|cfg| cfg.get("cidr").and_then(|c| c.as_str()))
.map(|cidr| {
if cidr.contains(':') {
"IPv6 PI"
} else {
"IPv4 PI"
}
})
.map(|cidr| if cidr.contains(':') { "IPv6 PI" } else { "IPv4 PI" })
.unwrap_or("IP Range")
.to_string()
}
lnvps_db::SubscriptionType::AsnSponsoring => "AS Number".to_string(),
lnvps_db::SubscriptionType::DnsHosting => "DNS Hosting".to_string(),
lnvps_db::SubscriptionType::VmRenewal => "VM Renewal".to_string(),
lnvps_db::SubscriptionType::VmUpgrade => "VM Upgrade".to_string(),
};

let quantity = li
Expand Down
71 changes: 71 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,75 @@ impl ApiInvoiceItem {
payment.time_value,
)
}

/// Creates a formatted invoice item from a SubscriptionPayment
pub fn from_subscription_payment(
payment: &lnvps_db::SubscriptionPayment,
) -> Result<Self, anyhow::Error> {
Self::from_payment_data(
payment.amount,
payment.tax,
payment.processing_fee,
&payment.currency,
payment.time_value.unwrap_or(0),
)
}
}

impl ApiVmPayment {
/// Convert a `SubscriptionPayment` to an `ApiVmPayment`.
/// The `vm_id` must be provided because `SubscriptionPayment` only knows the subscription.
pub fn from_subscription_payment(
value: lnvps_db::SubscriptionPayment,
vm_id: u64,
) -> Self {
let upgrade_params = value
.metadata
.as_ref()
.map(|m| serde_json::to_string(m).unwrap_or_default());
let is_upgrade =
value.payment_type == lnvps_db::SubscriptionPaymentType::Upgrade;
let data = match &value.payment_method {
PaymentMethod::Lightning => ApiPaymentData::Lightning(value.external_data.into()),
PaymentMethod::Revolut => {
#[derive(Deserialize)]
struct RevolutData {
pub token: String,
}
let data: RevolutData =
serde_json::from_str(value.external_data.as_str()).unwrap();
Comment thread
v0l marked this conversation as resolved.
Outdated
ApiPaymentData::Revolut { token: data.token }
}
PaymentMethod::Paypal => todo!(),
PaymentMethod::Stripe => {
#[derive(Deserialize)]
struct StripeData {
pub session_id: String,
}
let data: StripeData =
serde_json::from_str(value.external_data.as_str()).unwrap();
Comment thread
v0l marked this conversation as resolved.
Outdated
ApiPaymentData::Stripe {
session_id: data.session_id,
}
}
};
Self {
id: hex::encode(&value.id),
vm_id,
created: value.created,
expires: value.expires,
amount: value.amount,
tax: value.tax,
processing_fee: value.processing_fee,
currency: value.currency,
is_paid: value.is_paid,
paid_at: value.paid_at,
time: value.time_value.unwrap_or(0),
is_upgrade,
upgrade_params,
data,
}
}
}

impl From<lnvps_db::VmPayment> for ApiVmPayment {
Expand Down Expand Up @@ -621,13 +690,15 @@ pub struct ApiSubscriptionPayment {
pub enum ApiSubscriptionPaymentType {
Purchase,
Renewal,
Upgrade,
}

impl From<lnvps_db::SubscriptionPaymentType> for ApiSubscriptionPaymentType {
fn from(payment_type: lnvps_db::SubscriptionPaymentType) -> Self {
match payment_type {
lnvps_db::SubscriptionPaymentType::Purchase => ApiSubscriptionPaymentType::Purchase,
lnvps_db::SubscriptionPaymentType::Renewal => ApiSubscriptionPaymentType::Renewal,
lnvps_db::SubscriptionPaymentType::Upgrade => ApiSubscriptionPaymentType::Upgrade,
}
}
}
Expand Down
Loading
Loading