Total: {{total_formatted}}
diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs
index 7a45bd35..2e2b65b9 100644
--- a/lnvps_api/src/api/mod.rs
+++ b/lnvps_api/src/api/mod.rs
@@ -16,7 +16,9 @@ pub use contact::router as contacts_router;
pub use docs::router as docs_router;
pub use ip_space::router as ip_space_router;
pub use legal::router as legal_router;
-use lnvps_api_common::{ExchangeRateService, VmHistoryLogger, VmStateCache, WorkCommander};
+use lnvps_api_common::{
+ CountryResolver, ExchangeRateService, VmHistoryLogger, VmStateCache, WorkCommander,
+};
use lnvps_db::LNVpsDb;
#[cfg(feature = "nostr-domain")]
pub use nostr_domain::router as nostr_domain_router;
@@ -60,4 +62,7 @@ pub struct RouterState {
pub settings: Settings,
pub rates: Arc,
pub work_sender: Arc,
+ /// Resolves client IPs to a country for VAT place-of-supply evidence.
+ /// `None` when no geolocation database is configured.
+ pub geoip: Option>,
}
diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs
index faef666d..b1a7af6a 100644
--- a/lnvps_api/src/api/model.rs
+++ b/lnvps_api/src/api/model.rs
@@ -6,7 +6,7 @@ use anyhow::Result;
use chrono::{DateTime, Utc};
use humantime::format_duration;
use lnvps_api_common::{ApiDiskInterface, ApiDiskType};
-use lnvps_db::{PaymentMethod, PaymentType, VmCustomTemplate};
+use lnvps_db::{PaymentMethod, VmCustomTemplate};
use payments_rs::currency::{Currency, CurrencyAmount};
use serde::{Deserialize, Serialize};
@@ -309,17 +309,6 @@ impl ApiInvoiceItem {
})
}
- /// Creates a formatted invoice item from a VmPayment
- pub fn from_vm_payment(payment: &lnvps_db::VmPayment) -> Result {
- Self::from_payment_data(
- payment.amount,
- payment.tax,
- payment.processing_fee,
- &payment.currency,
- payment.time_value,
- )
- }
-
/// Creates a formatted invoice item from a SubscriptionPayment
pub fn from_subscription_payment(
payment: &lnvps_db::SubscriptionPayment,
@@ -389,52 +378,6 @@ impl ApiVmPayment {
}
}
-impl From for ApiVmPayment {
- fn from(value: lnvps_db::VmPayment) -> Self {
- Self {
- id: hex::encode(&value.id),
- vm_id: value.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,
- is_upgrade: value.payment_type == PaymentType::Upgrade,
- upgrade_params: value.upgrade_params.clone(),
- 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();
- 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();
- ApiPaymentData::Stripe {
- session_id: data.session_id,
- }
- }
- },
- }
- }
-}
-
#[derive(Serialize, Deserialize)]
pub struct ApiPaymentInfo {
pub name: ApiPaymentMethod,
@@ -1083,10 +1026,9 @@ impl From for ApiSubscriptionPayment {
struct StripeData {
pub session_id: String,
}
- let session_id =
- serde_json::from_str::(payment.external_data.as_str())
- .map(|d| d.session_id)
- .unwrap_or_default();
+ let session_id = serde_json::from_str::(payment.external_data.as_str())
+ .map(|d| d.session_id)
+ .unwrap_or_default();
ApiPaymentData::Stripe { session_id }
}
PaymentMethod::Paypal => ApiPaymentData::Lightning(String::new()),
@@ -1300,36 +1242,6 @@ pub enum ApiCreateSubscriptionLineItemRequest {
#[cfg(test)]
mod tests {
use super::*;
- use chrono::Utc;
- use lnvps_db::{EncryptedString, PaymentMethod, PaymentType, VmPayment};
-
- fn make_payment(
- currency: &str,
- amount: u64,
- tax: u64,
- processing_fee: u64,
- time_value: u64,
- ) -> VmPayment {
- VmPayment {
- id: vec![0u8; 32],
- vm_id: 1,
- created: Utc::now(),
- expires: Utc::now(),
- amount,
- currency: currency.to_string(),
- payment_method: PaymentMethod::Lightning,
- payment_type: PaymentType::Renewal,
- external_data: EncryptedString::from("test"),
- external_id: None,
- is_paid: true,
- rate: 1.0,
- time_value,
- tax,
- processing_fee,
- upgrade_params: None,
- paid_at: Some(Utc::now()),
- }
- }
#[test]
fn test_from_payment_data_fiat() {
@@ -1369,21 +1281,6 @@ mod tests {
assert!(result.is_err());
}
- #[test]
- fn test_from_vm_payment() {
- let payment = make_payment("EUR", 500, 115, 6, 86400);
- let item = ApiInvoiceItem::from_vm_payment(&payment).expect("should succeed");
-
- assert_eq!(item.amount, 500);
- assert_eq!(item.tax, 115);
- assert_eq!(item.processing_fee, 6);
- assert_eq!(item.currency, "EUR");
- assert_eq!(item.time, 86400);
- assert_eq!(item.formatted_amount, "EUR 5.00");
- assert_eq!(item.formatted_tax, "EUR 1.15");
- assert!(!item.formatted_duration.is_empty());
- }
-
#[test]
fn test_validate_firewall_cidr() {
assert!(validate_firewall_cidr("1.2.3.0/24").is_ok());
diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs
index 06db0e94..31e89e2a 100644
--- a/lnvps_api/src/api/routes.rs
+++ b/lnvps_api/src/api/routes.rs
@@ -21,17 +21,17 @@ use std::str::FromStr;
use lnvps_api_common::retry::{OpError, Pipeline, RetryPolicy};
use lnvps_api_common::{
ApiCurrency, ApiData, ApiError, ApiPrice, ApiResult, ApiUserSshKey, ApiVmOsImage,
- ApiVmTemplate, EuVatClient, Nip98Auth, PageQuery, UpgradeConfig, WorkJob,
+ ApiVmTemplate, ClientIp, Nip98Auth, PageQuery, UpgradeConfig, VatClient, WorkJob,
};
use lnvps_db::{
PaymentMethod, Vm, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHostRegion,
};
use crate::api::model::{
- AccountPatchRequest, ApiCompany, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest,
- ApiInvoiceItem, ApiPaymentInfo, ApiPaymentMethod, ApiTemplatesResponse, ApiVmFirewallPolicy,
- ApiVmFirewallRule, ApiVmHistory, ApiVmPayment, ApiVmStatus, ApiVmUpgradeQuote,
- ApiVmUpgradeRequest, AddNwcPaymentMethodRequest, CreateSshKey, CreateVmFirewallRule,
+ AccountPatchRequest, AddNwcPaymentMethodRequest, ApiCompany, ApiCustomTemplateParams,
+ ApiCustomVmOrder, ApiCustomVmRequest, ApiInvoiceItem, ApiPaymentInfo, ApiPaymentMethod,
+ ApiTemplatesResponse, ApiVmFirewallPolicy, ApiVmFirewallRule, ApiVmHistory, ApiVmPayment,
+ ApiVmStatus, ApiVmUpgradeQuote, ApiVmUpgradeRequest, CreateSshKey, CreateVmFirewallRule,
CreateVmRequest, PatchPaymentMethodRequest, PatchVmFirewallPolicy, PatchVmFirewallRule,
PaymentMethodResponse, VMPatchRequest, validate_firewall_cidr, validate_firewall_ports,
vm_to_status,
@@ -134,9 +134,29 @@ pub fn routes() -> Router {
)
}
+/// Capture IP-derived geolocation for a user as an independent place-of-supply
+/// evidence signal for EU VAT. Best-effort: never blocks or fails the caller.
+///
+/// Invoked on every path where a user acts (account edits *and* VM orders) so
+/// that a customer who never touches the account API still has a resolved
+/// country recorded at purchase time.
+async fn capture_client_geo(this: &RouterState, uid: u64, client_ip: ClientIp) {
+ if let (Some(ip), Some(geoip)) = (client_ip.0, this.geoip.as_ref()) {
+ let country = geoip.resolve(ip);
+ if let Err(e) = this
+ .db
+ .set_user_geo(uid, country.as_deref(), &ip.to_string())
+ .await
+ {
+ error!("Failed to store geolocation for user {}: {}", uid, e);
+ }
+ }
+}
+
/// Update user account
async fn v1_patch_account(
auth: Nip98Auth,
+ client_ip: ClientIp,
State(this): State,
req: Json,
) -> ApiResult<()> {
@@ -144,9 +164,11 @@ async fn v1_patch_account(
let uid = this.db.upsert_user(&pubkey).await?;
let mut user = this.db.get_user(uid).await?;
+ capture_client_geo(&this, uid, client_ip).await;
+
// validate tax_id if provided
if let Some(Some(tax_id)) = &req.tax_id {
- let vat_client = EuVatClient::new();
+ let vat_client = VatClient::new();
let result = vat_client
.validate_vat_number(tax_id, None)
.await
@@ -383,7 +405,11 @@ async fn v1_add_nwc_payment_method(
user_id: uid,
created: chrono::Utc::now(),
provider: "nwc".to_string(),
- name: req.name.as_ref().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()),
+ name: req
+ .name
+ .as_ref()
+ .map(|n| n.trim().to_string())
+ .filter(|n| !n.is_empty()),
external_customer_id: None,
external_id: nwc.into(),
card_brand: None,
@@ -824,12 +850,16 @@ async fn v1_custom_template_calc(
/// Unpaid VM orders will be deleted after 1 hour
async fn v1_create_custom_vm_order(
auth: Nip98Auth,
+ client_ip: ClientIp,
State(this): State,
Json(req): Json,
) -> ApiResult {
let pubkey = auth.event.pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey).await?;
+ // Capture place-of-supply evidence at purchase time (see capture_client_geo).
+ capture_client_geo(&this, uid, client_ip).await;
+
let user = this.db.get_user(uid).await?;
// Email verification is only enforced when SMTP is configured; otherwise
// there's no way to send the verification email, so the requirement is
@@ -948,12 +978,16 @@ async fn v1_delete_ssh_key(
/// Unpaid VM orders will be deleted after 1 hour
async fn v1_create_vm_order(
auth: Nip98Auth,
+ client_ip: ClientIp,
State(this): State,
Json(req): Json,
) -> ApiResult {
let pubkey = auth.event.pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey).await?;
+ // Capture place-of-supply evidence at purchase time (see capture_client_geo).
+ capture_client_geo(&this, uid, client_ip).await;
+
let user = this.db.get_user(uid).await?;
// Email verification is only enforced when SMTP is configured (see
// v1_create_custom_vm_order for rationale).
@@ -1452,7 +1486,54 @@ async fn v1_get_payment(
ApiData::ok(ApiVmPayment::from_subscription_payment(payment, vm.id)?)
}
-/// Print payment invoice
+/// Map a payment's stored tax fields to invoice display fields:
+/// `(rate_label, is_reverse_charge, is_out_of_scope, note)`.
+///
+/// `rate_label` (e.g. `"23% (IRL)"`) is produced for lines that carried tax. The
+/// reverse-charge and out-of-scope notes are EU-specific and only emitted when
+/// the seller is established in the EU VAT area (`seller_in_eu`); a non-EU
+/// seller shows no such note.
+fn invoice_vat_display(
+ treatment: Option<&str>,
+ tax: u64,
+ rate: Option,
+ country: Option<&str>,
+ seller_in_eu: bool,
+) -> (Option, bool, bool, Option) {
+ match treatment {
+ Some("reverse_charge") if seller_in_eu => (
+ None,
+ true,
+ false,
+ Some(
+ "VAT reverse charged — the recipient is liable to account for VAT \
+ (Article 196, Council Directive 2006/112/EC)."
+ .to_string(),
+ ),
+ ),
+ Some("out_of_scope") if seller_in_eu => (
+ None,
+ false,
+ true,
+ Some("Outside the scope of EU VAT.".to_string()),
+ ),
+ // Any taxed line (domestic / oss_b2c / undetermined_default, or a mixed
+ // payment whose summary is null but which carried tax): show the rate.
+ _ => {
+ let label = if tax > 0 {
+ Some(match (rate, country) {
+ (Some(r), Some(cc)) => format!("{:.0}% ({})", r, cc),
+ (Some(r), None) => format!("{:.0}%", r),
+ _ => "VAT".to_string(),
+ })
+ } else {
+ None
+ };
+ (label, false, false, None)
+ }
+ }
+}
+
async fn v1_get_payment_invoice(
State(this): State,
Path(id): Path,
@@ -1510,6 +1591,23 @@ async fn v1_get_payment_invoice(
company: Option,
#[serde(skip_serializing_if = "Option::is_none")]
upgrade_details: Option,
+ vat: InvoiceVat,
+ }
+
+ /// VAT presentation derived from the payment's frozen determination.
+ #[derive(Serialize, Default)]
+ struct InvoiceVat {
+ /// Human label for the applied rate line, e.g. "23% (IRL)". Present only
+ /// when VAT was actually charged.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ rate_label: Option,
+ /// True for an EU B2B reverse-charge supply (0%, recipient accounts).
+ is_reverse_charge: bool,
+ /// True for a supply outside the scope of EU VAT (non-EU customer).
+ is_out_of_scope: bool,
+ /// Legal note to print under the totals (reverse charge / out of scope).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ note: Option,
}
#[derive(Serialize)]
@@ -1560,6 +1658,27 @@ async fn v1_get_payment_invoice(
let invoice_item = ApiInvoiceItem::from_subscription_payment(&payment)
.map_err(|_| "Failed to create formatted invoice item")?;
+ // Present the tax fields stored on the payment. EU-specific notes are only
+ // shown for a seller established in the EU VAT area.
+ let seller_in_eu = company
+ .as_ref()
+ .and_then(|c| c.country_code.as_deref())
+ .map(lnvps_api_common::is_eu_vat_country)
+ .unwrap_or(false);
+ let (rate_label, is_reverse_charge, is_out_of_scope, note) = invoice_vat_display(
+ payment.tax_treatment.as_deref(),
+ payment.tax,
+ payment.tax_rate,
+ payment.tax_country_code.as_deref(),
+ seller_in_eu,
+ );
+ let vat = InvoiceVat {
+ rate_label,
+ is_reverse_charge,
+ is_out_of_scope,
+ note,
+ };
+
let mut html = Cursor::new(Vec::new());
template
.render(
@@ -1586,6 +1705,7 @@ async fn v1_get_payment_invoice(
user: user.into(),
company: company.map(|c| c.into()),
upgrade_details,
+ vat,
},
)
.map_err(|_| "Failed to generate invoice")?;
@@ -1860,7 +1980,9 @@ async fn v1_patch_firewall_rule(
let mut rule = this.db.get_vm_firewall_rule(rule_id).await?;
if rule.vm_id != vm.id {
- return Err(ApiError::not_found("Firewall rule does not belong to this VM"));
+ return Err(ApiError::not_found(
+ "Firewall rule does not belong to this VM",
+ ));
}
if let Some(p) = req.priority {
@@ -1917,7 +2039,9 @@ async fn v1_delete_firewall_rule(
let rule = this.db.get_vm_firewall_rule(rule_id).await?;
if rule.vm_id != vm.id {
- return Err(ApiError::not_found("Firewall rule does not belong to this VM"));
+ return Err(ApiError::not_found(
+ "Firewall rule does not belong to this VM",
+ ));
}
this.db.delete_vm_firewall_rule(rule_id).await?;
@@ -2207,4 +2331,63 @@ mod tests {
let err = ApiError::payment_required("Cannot re-install an expired VM");
assert_eq!(err.code, axum::http::StatusCode::PAYMENT_REQUIRED);
}
+
+ #[test]
+ fn invoice_vat_display_domestic_shows_rate_line() {
+ let (label, rc, oos, note) =
+ invoice_vat_display(Some("domestic"), 230, Some(23.0), Some("IRL"), true);
+ assert_eq!(label.as_deref(), Some("23% (IRL)"));
+ assert!(!rc && !oos);
+ assert!(note.is_none());
+ }
+
+ #[test]
+ fn invoice_vat_display_oss_shows_destination_rate() {
+ let (label, rc, oos, _note) =
+ invoice_vat_display(Some("oss_b2c"), 1900, Some(19.0), Some("DEU"), true);
+ assert_eq!(label.as_deref(), Some("19% (DEU)"));
+ assert!(!rc && !oos);
+ }
+
+ #[test]
+ fn invoice_vat_display_reverse_charge_has_note_no_rate() {
+ let (label, rc, oos, note) =
+ invoice_vat_display(Some("reverse_charge"), 0, None, Some("DEU"), true);
+ assert!(label.is_none());
+ assert!(rc && !oos);
+ assert!(note.unwrap().contains("Article 196"));
+ }
+
+ #[test]
+ fn invoice_vat_display_out_of_scope_note_only_for_eu_seller() {
+ // EU seller selling to a non-EU customer: out-of-scope note shown.
+ let (label, rc, oos, note) =
+ invoice_vat_display(Some("out_of_scope"), 0, None, Some("USA"), true);
+ assert!(label.is_none());
+ assert!(!rc && oos);
+ assert_eq!(note.as_deref(), Some("Outside the scope of EU VAT."));
+
+ // Non-EU (e.g. US) seller: no EU note at all.
+ let (label, rc, oos, note) =
+ invoice_vat_display(Some("out_of_scope"), 0, None, Some("USA"), false);
+ assert!(label.is_none());
+ assert!(!rc && !oos);
+ assert!(note.is_none());
+ }
+
+ #[test]
+ fn invoice_vat_display_no_tax_no_line() {
+ // No treatment recorded and no tax -> nothing shown.
+ let (label, rc, oos, note) = invoice_vat_display(None, 0, None, None, true);
+ assert!(label.is_none());
+ assert!(!rc && !oos);
+ assert!(note.is_none());
+ }
+
+ #[test]
+ fn invoice_vat_display_mixed_summary_null_but_taxed() {
+ // Mixed payment: summary rate/country are null but tax was charged.
+ let (label, _rc, _oos, _note) = invoice_vat_display(None, 500, None, None, true);
+ assert_eq!(label.as_deref(), Some("VAT"));
+ }
}
diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs
index fd332617..a755c3a2 100644
--- a/lnvps_api/src/bin/api.rs
+++ b/lnvps_api/src/bin/api.rs
@@ -6,8 +6,11 @@ use lnvps_api::dvm::start_dvms;
use lnvps_api::payments::listen_all_payments;
use lnvps_api::settings::Settings;
use lnvps_api::worker::Worker;
-use lnvps_api_common::{ChannelWorkCommander, RedisWorkCommander, VmHistoryLogger, WorkCommander};
-use lnvps_api_common::{VmStateCache, WorkJob, make_exchange_service};
+use lnvps_api_common::{
+ ChannelWorkCommander, CountryResolver, MaxmindCountryResolver, RedisWorkCommander,
+ VmHistoryLogger, WorkCommander,
+};
+use lnvps_api_common::{VatClient, VmStateCache, WorkJob, make_exchange_service};
use std::fmt::{Display, Formatter};
use lnvps_db::{EncryptionContext, LNVpsDb, LNVpsDbBase, LNVpsDbMysql};
@@ -108,15 +111,7 @@ async fn main() -> Result<(), Error> {
db.execute(setup_script).await?;
info!("Executed dev_setup.sql");
}
- // Backfill VMs/payments into the subscription system. Must run AFTER schema
- // migrations and BEFORE the Arc is used anywhere (the worker data
- // migrations and all VM reads decode the non-nullable subscription_line_item_id,
- // which is NULL for pre-migration rows until this backfill links them).
- // Idempotent — a no-op once every VM is linked and every payment copied.
- let db = Arc::new(db);
- lnvps_api::data_migration::vm_subscription_backfill::run_vm_subscription_backfill(db.clone())
- .await?;
- let db: Arc = db;
+ let db: Arc = Arc::new(db);
let nostr_client = if let Some(ref c) = settings.nostr {
let cx = Client::builder().signer(Keys::parse(&c.nsec)?).build();
for r in &c.relays {
@@ -131,6 +126,21 @@ async fn main() -> Result<(), Error> {
let exchange = make_exchange_service(&settings.redis);
let node = settings.get_node().await?;
+ // Optional IP -> country geolocation for VAT place-of-supply evidence.
+ let geoip: Option> = match &settings.geoip_database {
+ Some(path) => match MaxmindCountryResolver::open(path) {
+ Ok(r) => {
+ info!("Loaded GeoIP database from {}", path.display());
+ Some(Arc::new(r))
+ }
+ Err(e) => {
+ error!("Failed to load GeoIP database {}: {}", path.display(), e);
+ None
+ }
+ },
+ None => None,
+ };
+
let status = if let Some(redis_config) = &settings.redis {
VmStateCache::new_with_redis(redis_config.clone()).await?
} else {
@@ -144,11 +154,39 @@ async fn main() -> Result<(), Error> {
Arc::new(ChannelWorkCommander::new())
};
+ // One shared VAT rate cache for the whole process. It is populated now and
+ // refreshed periodically; the same instance is handed to the subscription
+ // handler (and thus every PricingEngine clone) so rate updates are visible
+ // everywhere without restarting. Until the first successful refresh no rates
+ // are known and tax falls back to 0%.
+ let vat = VatClient::new();
+ match vat.refresh_rates().await {
+ Ok(n) => info!("Loaded {} VAT rates", n),
+ Err(e) => warn!(
+ "Failed to load VAT rates (tax will be 0% until refreshed): {}",
+ e
+ ),
+ }
+ tasks.push(tokio::spawn({
+ let vat = vat.clone();
+ async move {
+ loop {
+ // Refresh once a day - standard rates change rarely.
+ tokio::time::sleep(Duration::from_secs(24 * 60 * 60)).await;
+ match vat.refresh_rates().await {
+ Ok(n) => info!("Refreshed {} VAT rates", n),
+ Err(e) => error!("Failed to refresh VAT rates: {}", e),
+ }
+ }
+ }
+ }));
+
let sub_handler = SubscriptionHandler::new(
settings.clone(),
db.clone(),
node.clone(),
exchange.clone(),
+ vat.clone(),
work_commander.clone(),
status.clone(),
)?;
@@ -292,6 +330,7 @@ async fn main() -> Result<(), Error> {
settings,
rates: exchange,
work_sender: worker.commander(),
+ geoip: geoip.clone(),
}),
)
.await
diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs
index 7bf184e8..cb53723c 100644
--- a/lnvps_api/src/data_migration/mod.rs
+++ b/lnvps_api/src/data_migration/mod.rs
@@ -21,7 +21,6 @@ mod encryption_migration;
mod ip6_init;
mod payment_method_config;
mod ssh_key_migration;
-pub mod vm_subscription_backfill;
/// Basic data migration to run at startup
pub trait DataMigration: Send + Sync {
diff --git a/lnvps_api/src/data_migration/vm_subscription_backfill.rs b/lnvps_api/src/data_migration/vm_subscription_backfill.rs
deleted file mode 100644
index 6ae48de5..00000000
--- a/lnvps_api/src/data_migration/vm_subscription_backfill.rs
+++ /dev/null
@@ -1,628 +0,0 @@
-//! Startup backfill: migrate VMs and vm_payment records into the subscription system.
-//!
-//! This runs unconditionally at app startup, immediately after schema migrations and
-//! BEFORE `run_data_migrations` (which calls `list_vms()` and would fail to decode the
-//! non-nullable `vm.subscription_line_item_id` if any VM were still unlinked).
-//!
-//! Phase 1 — Subscription backfill: for every VM (including deleted) without a
-//! `subscription_line_item_id`, create a subscription + line item and link the VM.
-//! The VM's existing `expires` and `auto_renewal_enabled` are copied onto the
-//! subscription so billing/renewal enforcement continues seamlessly.
-//!
-//! Phase 2 — Payment backfill: copy every `vm_payment` row that has not yet been
-//! copied into `subscription_payment`, preserving all fields.
-//!
-//! Both phases are idempotent: VMs already linked and payments already copied are skipped.
-use anyhow::{Context, Result, bail};
-use lnvps_api_common::PricingEngine;
-use lnvps_db::{
- IntervalType, LNVpsDb, LNVpsDbMysql, Subscription, SubscriptionLineItem,
- SubscriptionPaymentType, SubscriptionType, VmForMigration, VmPaymentRaw,
-};
-use log::{info, warn};
-use std::sync::Arc;
-
-/// Compute interval-to-seconds matching PricingEngine::cost_plan_interval_to_seconds.
-fn interval_to_seconds(interval_type: IntervalType, interval_amount: u64) -> i64 {
- let base = match interval_type {
- IntervalType::Day => 86_400i64,
- IntervalType::Month => 2_592_000i64, // 30 days
- IntervalType::Year => 31_536_000i64, // 365 days
- };
- base * interval_amount as i64
-}
-
-/// Billing details resolved for a VM from its template or custom pricing.
-struct VmBilling {
- currency: String,
- interval_amount: u64,
- interval_type: IntervalType,
- line_item_amount: u64,
- description: String,
-}
-
-/// Resolve a VM's billing details (currency, interval, recurring amount, description) so they
-/// match what the live provisioning paths set, ensuring the admin UI shows the correct cost:
-/// - standard template: subscription.currency = cost_plan.currency, amount = cost_plan.amount
-/// - custom template: subscription.currency = pricing.currency, amount = computed cost
-///
-/// Shared by the initial backfill and the one-time repair pass so both produce identical values.
-async fn resolve_vm_billing(db: &Arc, vm: &VmForMigration) -> Result {
- if let Some(template_id) = vm.template_id {
- let template = db
- .get_vm_template(template_id)
- .await
- .context("Failed to get VM template")?;
- let cost_plan = db
- .get_cost_plan(template.cost_plan_id)
- .await
- .context("Failed to get cost plan")?;
- Ok(VmBilling {
- currency: cost_plan.currency,
- interval_amount: cost_plan.interval_amount,
- interval_type: cost_plan.interval_type,
- line_item_amount: cost_plan.amount,
- description: format!("{} (VM {})", template.name, vm.id),
- })
- } else if let Some(custom_template_id) = vm.custom_template_id {
- // Custom VMs are always billed monthly; the amount is computed from the custom
- // pricing (CPU/memory/disk/IPs) just like update_line_item_cost_for_custom_vm.
- let custom_template = db
- .get_custom_vm_template(custom_template_id)
- .await
- .context("Failed to get custom VM template")?;
- let price = PricingEngine::get_custom_vm_cost_amount(db, vm.id, &custom_template)
- .await
- .context("Failed to compute custom VM cost")?;
- Ok(VmBilling {
- currency: price.currency.to_string(),
- interval_amount: 1,
- interval_type: IntervalType::Month,
- line_item_amount: price.total(),
- description: format!("Custom VM {}", vm.id),
- })
- } else {
- bail!(
- "VM {} has neither template_id nor custom_template_id",
- vm.id
- );
- }
-}
-
-/// Run the VM → subscription backfill. Safe to call on every startup (idempotent).
-pub async fn run_vm_subscription_backfill(db_impl: Arc) -> Result<()> {
- let db: Arc = db_impl.clone();
-
- // Phase 0: repair subscriptions written by earlier (buggy) backfill revisions.
- // Idempotent — performs no writes once every already-migrated row is correct.
- let linked_vm_ids = db_impl
- .list_vm_ids_with_subscription()
- .await
- .context("Failed to list VMs with subscription for repair")?;
- let mut repaired = 0usize;
- let mut repair_errored = 0usize;
- for vm_id in &linked_vm_ids {
- match repair_migrated_vm_subscription(db_impl.clone(), db.clone(), *vm_id).await {
- Ok(true) => repaired += 1,
- Ok(false) => {}
- Err(e) => {
- warn!("Phase 0: Failed to repair VM {}: {:#}", vm_id, e);
- repair_errored += 1;
- }
- }
- }
- if repaired > 0 || repair_errored > 0 {
- info!(
- "VM subscription backfill — Phase 0 complete: {} subscriptions repaired, {} errors",
- repaired, repair_errored
- );
- }
-
- // Phase 1: create subscriptions for all VMs (including deleted)
- let vm_ids = db_impl
- .list_vm_ids_without_subscription()
- .await
- .context("Failed to list VMs needing subscription")?;
-
- if !vm_ids.is_empty() {
- info!(
- "VM subscription backfill — Phase 1: {} VMs need a subscription",
- vm_ids.len()
- );
- }
-
- let mut sub_migrated = 0usize;
- let mut sub_errored = 0usize;
- for vm_id in &vm_ids {
- match migrate_vm_subscription(db_impl.clone(), db.clone(), *vm_id).await {
- Ok(()) => sub_migrated += 1,
- Err(e) => {
- warn!("Phase 1: Failed to migrate VM {}: {:#}", vm_id, e);
- sub_errored += 1;
- }
- }
- }
- if !vm_ids.is_empty() {
- info!(
- "VM subscription backfill — Phase 1 complete: {} subscriptions created, {} errors",
- sub_migrated, sub_errored
- );
- }
-
- // Phase 2: backfill vm_payment → subscription_payment
- let payment_vm_ids = db_impl
- .list_vm_ids_with_uncopied_payments()
- .await
- .context("Failed to list VMs with uncopied payments")?;
-
- if !payment_vm_ids.is_empty() {
- info!(
- "VM subscription backfill — Phase 2: {} VMs have vm_payment records to backfill",
- payment_vm_ids.len()
- );
- }
-
- let mut pay_migrated = 0usize;
- let mut pay_errored = 0usize;
- for vm_id in &payment_vm_ids {
- match migrate_vm_payments(db_impl.clone(), db.clone(), *vm_id).await {
- Ok(n) => pay_migrated += n,
- Err(e) => {
- warn!(
- "Phase 2: Failed to migrate payments for VM {}: {:#}",
- vm_id, e
- );
- pay_errored += 1;
- }
- }
- }
- if !payment_vm_ids.is_empty() {
- info!(
- "VM subscription backfill — Phase 2 complete: {} payments backfilled, {} VM errors",
- pay_migrated, pay_errored
- );
- }
-
- if repair_errored > 0 || sub_errored > 0 || pay_errored > 0 {
- bail!(
- "VM subscription backfill incomplete: {} repair errors, {} subscription errors, {} payment VM errors (see warnings above)",
- repair_errored,
- sub_errored,
- pay_errored
- );
- }
-
- Ok(())
-}
-
-// ─── Phase 1: subscription creation ─────────────────────────────────────────
-
-async fn migrate_vm_subscription(
- db_impl: Arc,
- db: Arc,
- vm_id: u64,
-) -> Result<()> {
- let vm: VmForMigration = db_impl
- .get_vm_for_migration(vm_id)
- .await
- .context("Failed to get VM")?;
-
- let company_id = db
- .get_vm_company_id(vm_id)
- .await
- .context("Failed to get company id for VM")?;
-
- let billing = resolve_vm_billing(&db, &vm).await?;
- let VmBilling {
- currency,
- interval_amount,
- interval_type,
- line_item_amount,
- description,
- } = billing;
-
- let time_value = interval_to_seconds(interval_type, interval_amount);
- info!(
- "Phase 1: VM {} → subscription ({} {}, time_value={}s, amount={})",
- vm_id,
- interval_amount,
- match interval_type {
- IntervalType::Day => "day(s)",
- IntervalType::Month => "month(s)",
- IntervalType::Year => "year(s)",
- },
- time_value,
- line_item_amount,
- );
-
- // Deleted VMs should have inactive subscriptions — they are no longer running.
- let subscription = build_subscription_for_vm(
- &vm,
- company_id,
- currency,
- interval_amount,
- interval_type,
- &description,
- );
- let line_item = SubscriptionLineItem {
- id: 0,
- subscription_id: 0,
- subscription_type: SubscriptionType::Vps,
- name: description,
- description: None,
- amount: line_item_amount,
- setup_amount: 0,
- configuration: None,
- };
-
- let (_sub_id, line_item_ids) = db
- .insert_subscription_with_line_items(&subscription, vec![line_item])
- .await
- .context("Failed to insert subscription")?;
- let subscription_line_item_id = line_item_ids[0];
-
- db_impl
- .set_vm_subscription_line_item(vm_id, subscription_line_item_id)
- .await
- .context("Failed to link VM to subscription")?;
-
- Ok(())
-}
-
-/// Build the `Subscription` row for a VM during backfill.
-///
-/// Pure mapping (no DB access) so it can be unit-tested. The key invariants:
-/// - `created` is copied from the VM's original creation date (NOT `Utc::now()`), because the
-/// subscription is now the source of truth for the VM's "created" timestamp in the API.
-/// - `expires` and `auto_renewal_enabled` are copied from the VM so billing/renewal continue.
-/// - `is_setup` mirrors the legacy "has this VM ever been paid" signal: pre-migration, a
-/// never-paid VM had `expires == created` and `check_vms` deleted it after 1h. The new
-/// `check_vms` uses `subscription.is_setup` for that decision, so we must NOT blindly set
-/// `is_setup = true` — a VM that was still unpaid at migration time would then look paid and
-/// never be cleaned up. `expires > created` means at least one payment advanced the expiry.
-/// - Deleted VMs get an inactive subscription — they are no longer running.
-fn build_subscription_for_vm(
- vm: &VmForMigration,
- company_id: u64,
- currency: String,
- interval_amount: u64,
- interval_type: IntervalType,
- description: &str,
-) -> Subscription {
- // A pre-migration VM was "set up" (paid at least once) iff its expiry was advanced past
- // its creation time. Never-paid VMs had expires == created and must stay !is_setup so the
- // worker's unpaid-VM cleanup continues to apply to them after migration. A NULL expires
- // means the VM was created by the new (post-migration) path and has no legacy expiry to
- // copy — treat it as not-yet-set-up with no subscription expiry.
- let is_setup = vm.expires.map(|e| e > vm.created).unwrap_or(false);
- Subscription {
- id: 0,
- user_id: vm.user_id,
- company_id,
- name: format!("VM {} Subscription", vm.id),
- description: Some(description.to_string()),
- // Preserve the VM's original creation date so the subscription (now the source of
- // truth for the VM's "created" timestamp in the API) reflects when the VM was
- // actually ordered, not when the migration ran.
- created: vm.created,
- // Preserve the VM's existing billing expiry so renewal/suspension/auto-renewal
- // enforcement continues seamlessly. The legacy vm.expires column is the source of
- // truth pre-migration and is dropped only at finalization.
- expires: vm.expires,
- // An unpaid VM (never set up) must not be marked active.
- is_active: !vm.deleted && is_setup,
- is_setup,
- currency,
- interval_amount,
- interval_type,
- setup_fee: 0,
- // Preserve the VM's auto-renewal preference so NWC auto-renewal keeps working.
- auto_renewal_enabled: vm.auto_renewal_enabled,
- external_id: None,
- }
-}
-
-// ─── Phase 0: repair already-migrated subscriptions ──────────────────────────
-
-/// Repair subscriptions created by earlier (buggy) backfill revisions.
-///
-/// Earlier revisions wrote several fields incorrectly for already-linked VMs:
-/// - `subscription.created` was stamped with the migration time instead of `vm.created`
-/// - custom-VM line items got `amount = 0` (showing $0 in the admin UI)
-/// - `subscription.currency` used the company base currency instead of the cost-plan /
-/// pricing currency
-/// - `is_setup`/`is_active` were forced true even for never-paid VMs
-///
-/// This pass re-derives the correct values and updates the subscription + line item only when
-/// they actually differ. It is idempotent: once every row is correct it performs no writes.
-async fn repair_migrated_vm_subscription(
- db_impl: Arc,
- db: Arc,
- vm_id: u64,
-) -> Result {
- let vm: VmForMigration = db_impl
- .get_vm_for_migration(vm_id)
- .await
- .context("Failed to get VM")?;
-
- let line_item_id = match vm.subscription_line_item_id.filter(|&id| id != 0) {
- Some(id) => id,
- None => return Ok(false), // not migrated yet; Phase 1 will handle it
- };
-
- // A NULL legacy expires means this VM was provisioned by the new (post-migration) path,
- // so its subscription was created correctly and there is no legacy data to back-derive
- // created/is_setup from. The buggy revisions this pass repairs only ever ran against
- // pre-migration VMs (which always have a non-NULL legacy expires), so skip these.
- let Some(legacy_expires) = vm.expires else {
- return Ok(false);
- };
-
- let mut subscription = db
- .get_subscription_by_line_item_id(line_item_id)
- .await
- .context("Failed to get subscription for VM")?;
- let mut line_item = db
- .get_subscription_line_item(line_item_id)
- .await
- .context("Failed to get subscription line item")?;
-
- let billing = resolve_vm_billing(&db, &vm).await?;
- let is_setup = legacy_expires > vm.created;
- let want_created = vm.created;
- let want_active = !vm.deleted && is_setup;
-
- let mut changed = false;
- if subscription.created != want_created {
- subscription.created = want_created;
- changed = true;
- }
- if subscription.currency != billing.currency {
- subscription.currency = billing.currency.clone();
- changed = true;
- }
- if subscription.interval_amount != billing.interval_amount {
- subscription.interval_amount = billing.interval_amount;
- changed = true;
- }
- if subscription.interval_type != billing.interval_type {
- subscription.interval_type = billing.interval_type;
- changed = true;
- }
- if subscription.is_setup != is_setup {
- subscription.is_setup = is_setup;
- changed = true;
- }
- if subscription.is_active != want_active {
- subscription.is_active = want_active;
- changed = true;
- }
-
- if changed {
- db.update_subscription(&subscription)
- .await
- .context("Failed to update subscription during repair")?;
- }
-
- if line_item.amount != billing.line_item_amount {
- line_item.amount = billing.line_item_amount;
- db.update_subscription_line_item(&line_item)
- .await
- .context("Failed to update line item during repair")?;
- changed = true;
- }
-
- if changed {
- info!(
- "Phase 0: repaired VM {} subscription (created={}, currency={}, amount={}, is_setup={})",
- vm_id, want_created, billing.currency, billing.line_item_amount, is_setup
- );
- }
-
- Ok(changed)
-}
-
-// ─── Phase 2: payment backfill ───────────────────────────────────────────────
-
-async fn migrate_vm_payments(
- db_impl: Arc,
- db: Arc,
- vm_id: u64,
-) -> Result {
- // Get the subscription_line_item_id (must exist after Phase 1)
- let vm: VmForMigration = db_impl
- .get_vm_for_migration(vm_id)
- .await
- .context("Failed to get VM")?;
-
- let subscription_line_item_id = vm
- .subscription_line_item_id
- .filter(|&id| id != 0)
- .with_context(|| format!("VM {} has no subscription_line_item_id", vm_id))?;
-
- let subscription_id = db
- .get_subscription_by_line_item_id(subscription_line_item_id)
- .await?
- .id;
-
- // Load all vm_payment rows for this VM (raw — external_data not decrypted)
- let vm_payments: Vec = db_impl
- .list_vm_payments_for_migration(vm_id)
- .await
- .context("Failed to list vm_payments")?;
-
- // Idempotency check: find already-copied ids via raw query to avoid decryption.
- let existing_ids: std::collections::HashSet> = db_impl
- .list_subscription_payment_ids_for_subscription(subscription_id)
- .await
- .context("Failed to list existing subscription payment ids")?
- .into_iter()
- .collect();
-
- let mut copied = 0usize;
-
- for vp in &vm_payments {
- // Idempotency: skip if a subscription_payment with the same id already exists
- if existing_ids.contains(&vp.id) {
- continue;
- }
-
- let payment_type = match vp.payment_type {
- lnvps_db::PaymentType::Renewal => SubscriptionPaymentType::Renewal,
- lnvps_db::PaymentType::Upgrade => SubscriptionPaymentType::Upgrade,
- };
-
- // Parse upgrade_params string → serde_json::Value for metadata
- let metadata: Option = vp
- .upgrade_params
- .as_deref()
- .and_then(|s| serde_json::from_str(s).ok());
-
- // time_value: VmPaymentRaw has u64 (0 = none), SubscriptionPayment has Option
- let time_value = if vp.time_value > 0 {
- Some(vp.time_value)
- } else {
- None
- };
-
- let payment_type_u16 = payment_type as u16;
- let metadata_str: Option = metadata.as_ref().map(|v| v.to_string());
-
- db_impl
- .insert_subscription_payment_raw(
- vp,
- subscription_id,
- vm.user_id,
- payment_type_u16,
- time_value,
- metadata_str.as_deref(),
- )
- .await
- .with_context(|| {
- format!(
- "Failed to insert subscription_payment for vm_payment {}",
- hex::encode(&vp.id)
- )
- })?;
- copied += 1;
- }
-
- Ok(copied)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use chrono::{TimeZone, Utc};
-
- fn vm_for_migration(created: chrono::DateTime, deleted: bool) -> VmForMigration {
- // Paid VM: expiry advanced well past creation.
- vm_with_expires(
- created,
- Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
- deleted,
- )
- }
-
- fn vm_with_expires(
- created: chrono::DateTime,
- expires: chrono::DateTime,
- deleted: bool,
- ) -> VmForMigration {
- VmForMigration {
- id: 42,
- user_id: 7,
- template_id: Some(1),
- custom_template_id: None,
- created,
- expires: Some(expires),
- auto_renewal_enabled: true,
- subscription_line_item_id: None,
- deleted,
- }
- }
-
- /// Regression: the backfill must preserve the VM's original creation date on the
- /// subscription, not stamp it with the migration time (was `Utc::now()`).
- #[test]
- fn build_subscription_preserves_vm_created() {
- let vm_created = Utc.with_ymd_and_hms(2025, 1, 15, 12, 30, 0).unwrap();
- let vm = vm_for_migration(vm_created, false);
-
- let sub = build_subscription_for_vm(
- &vm,
- 3,
- "EUR".to_string(),
- 1,
- IntervalType::Month,
- "Test (VM 42)",
- );
-
- assert_eq!(
- sub.created, vm_created,
- "subscription.created must equal vm.created"
- );
- assert_ne!(sub.created, Utc::now());
- // Other preserved fields
- assert_eq!(sub.expires, vm.expires);
- assert_eq!(sub.auto_renewal_enabled, vm.auto_renewal_enabled);
- assert_eq!(sub.user_id, vm.user_id);
- assert_eq!(sub.company_id, 3);
- assert_eq!(sub.currency, "EUR");
- assert!(
- sub.is_active,
- "non-deleted VM must have an active subscription"
- );
- assert!(sub.is_setup);
- }
-
- /// Deleted VMs must map to inactive subscriptions.
- #[test]
- fn build_subscription_deleted_vm_is_inactive() {
- let vm = vm_for_migration(Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(), true);
- let sub = build_subscription_for_vm(&vm, 1, "USD".to_string(), 1, IntervalType::Month, "x");
- assert!(
- !sub.is_active,
- "deleted VM must have an inactive subscription"
- );
- }
-
- /// Regression: a VM that was still unpaid at migration time (legacy `expires == created`)
- /// must NOT be marked `is_setup`/`is_active`, otherwise the worker's unpaid-VM cleanup
- /// (which now keys off `subscription.is_setup`) would never delete it.
- #[test]
- fn build_subscription_unpaid_vm_is_not_setup() {
- let t = Utc.with_ymd_and_hms(2026, 6, 17, 10, 0, 0).unwrap();
- let vm = vm_with_expires(t, t, false); // expires == created => never paid
- let sub = build_subscription_for_vm(&vm, 1, "EUR".to_string(), 1, IntervalType::Month, "x");
- assert!(!sub.is_setup, "never-paid VM must not be is_setup");
- assert!(!sub.is_active, "never-paid VM must not be active");
- }
-
- /// A paid VM (expiry advanced past creation) must be `is_setup` and active.
- #[test]
- fn build_subscription_paid_vm_is_setup() {
- let created = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
- let expires = Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap();
- let vm = vm_with_expires(created, expires, false);
- let sub = build_subscription_for_vm(&vm, 1, "EUR".to_string(), 1, IntervalType::Month, "x");
- assert!(sub.is_setup, "paid VM must be is_setup");
- assert!(sub.is_active, "paid non-deleted VM must be active");
- }
-
- /// Regression: a VM with a NULL legacy expires (provisioned by the new path) must not
- /// panic/error and must produce a not-setup subscription with no expiry.
- #[test]
- fn build_subscription_null_expires_is_not_setup() {
- let created = Utc.with_ymd_and_hms(2026, 6, 17, 10, 0, 0).unwrap();
- let mut vm = vm_with_expires(created, created, false);
- vm.expires = None;
- let sub = build_subscription_for_vm(&vm, 1, "EUR".to_string(), 1, IntervalType::Month, "x");
- assert_eq!(
- sub.expires, None,
- "NULL legacy expires => no subscription expiry"
- );
- assert!(!sub.is_setup, "NULL-expires VM must not be is_setup");
- assert!(!sub.is_active);
- }
-}
diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs
index eaf33a10..fb91cb14 100644
--- a/lnvps_api/src/dvm/lnvps.rs
+++ b/lnvps_api/src/dvm/lnvps.rs
@@ -228,6 +228,7 @@ mod tests {
db.clone(),
node.clone(),
exch.clone(),
+ lnvps_api_common::VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?;
diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs
index 780c808e..de4431ae 100644
--- a/lnvps_api/src/mocks.rs
+++ b/lnvps_api/src/mocks.rs
@@ -1,5 +1,4 @@
#![allow(unused)]
-pub use lnvps_api_common::MockDnsServer;
use crate::host::dummy_host::DummyVmHost;
use crate::host::{
FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo,
@@ -7,6 +6,7 @@ use crate::host::{
use crate::router::{
ArpEntry, BgpPeer, BgpRoute, BgpRouter, BgpSession, Router, Tunnel, TunnelRouter, TunnelTraffic,
};
+pub use lnvps_api_common::MockDnsServer;
/// Type alias so tests can refer to the in-memory VM host as `MockVmHost`.
pub type MockVmHost = DummyVmHost;
@@ -28,7 +28,7 @@ use lnvps_db::{
AccessPolicy, Company, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, LNVpsDb,
NostrDomain, NostrDomainHandle, OsDistribution, User, UserSshKey, Vm, VmCostPlan,
VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk,
- VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate,
+ VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmTemplate,
};
use nostr_sdk::Timestamp;
use payments_rs::lightning::{
@@ -409,4 +409,3 @@ impl LightningNode for MockNode {
todo!()
}
}
-
diff --git a/lnvps_api/src/payments/invoice.rs b/lnvps_api/src/payments/invoice.rs
index 6ba03250..625c2ae9 100644
--- a/lnvps_api/src/payments/invoice.rs
+++ b/lnvps_api/src/payments/invoice.rs
@@ -206,6 +206,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
db.insert_subscription_payment(&payment).await?;
@@ -214,6 +219,7 @@ mod tests {
db.clone(),
node.clone(),
Arc::new(MockExchangeRate::default()),
+ lnvps_api_common::VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?;
@@ -354,6 +360,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
db.insert_subscription_payment(&payment).await?;
let sub = SubscriptionHandler::new(
@@ -361,6 +372,7 @@ mod tests {
db.clone(),
node.clone(),
Arc::new(MockExchangeRate::default()),
+ lnvps_api_common::VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?;
@@ -407,9 +419,7 @@ mod tests {
let db = Arc::new(MockDb::default());
let node = Arc::new(MockNode::default());
let rates = Arc::new(MockExchangeRate::default());
- rates
- .set_rate(Ticker::btc_rate("EUR")?, 100_000.0)
- .await;
+ rates.set_rate(Ticker::btc_rate("EUR")?, 100_000.0).await;
// Create a user
let pubkey: [u8; 32] = [3u8; 32];
@@ -537,6 +547,7 @@ mod tests {
db.clone(),
node.clone(),
rates,
+ lnvps_api_common::VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?;
diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs
index 4b1c6f66..8c42a1d6 100644
--- a/lnvps_api/src/provisioner/vm.rs
+++ b/lnvps_api/src/provisioner/vm.rs
@@ -1,4 +1,3 @@
-use lnvps_api_common::DnsServer;
use crate::host::{FullVmInfo, VmHostClient, get_host_client};
use crate::provisioner::VmNetworkProvisioner;
use crate::router::{ArpEntry, Router, get_router};
@@ -7,6 +6,7 @@ use anyhow::{Context, Result, bail, ensure};
use chrono::Utc;
use ipnetwork::IpNetwork;
use isocountry::CountryCode;
+use lnvps_api_common::DnsServer;
use lnvps_api_common::retry::{OpResult, Pipeline, RetryPolicy};
use lnvps_api_common::{
AvailableIp, CostResult, HostCapacityService, NetworkProvisioner, NewPaymentInfo,
@@ -16,7 +16,7 @@ use lnvps_api_common::{ExchangeRateService, op_fatal};
use lnvps_db::{
IntervalType, IpRange, IpRangeAllocationMode, LNVpsDb, PaymentMethod, PaymentType,
Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentType,
- SubscriptionType, Vm, VmCustomTemplate, VmIpAssignment, VmPayment, VmTemplate,
+ SubscriptionType, Vm, VmCustomTemplate, VmIpAssignment, VmTemplate,
};
use log::{debug, info};
use payments_rs::currency::{Currency, CurrencyAmount};
@@ -908,6 +908,7 @@ mod tests {
db.clone(),
node.clone(),
rates.clone(),
+ lnvps_api_common::VatClient::new(),
wrk.clone(),
VmStateCache::new(),
)?;
@@ -931,7 +932,11 @@ mod tests {
vm.subscription_line_item_id > 0,
"VM must have a subscription line item"
);
- assert_eq!(payment.tax, (payment.amount as f64 * 0.01).floor() as u64);
+ // No VAT rates are loaded into the pricing engine's VatClient in this
+ // test and the mock company/user have no EU country, so the place-of-
+ // supply determination yields no tax. (VAT logic is covered by the
+ // pricing engine unit tests.)
+ assert_eq!(payment.tax, 0);
// check invoice amount matches rounded amount+tax
let inv = node.invoices.lock().await;
@@ -1070,7 +1075,11 @@ mod tests {
assert!(prov.is_err());
if let Err(e) = prov {
println!("{}", e);
- assert!(e.to_string().to_lowercase().contains("no hosts with enough capacity"))
+ assert!(
+ e.to_string()
+ .to_lowercase()
+ .contains("no hosts with enough capacity")
+ )
}
Ok(())
}
@@ -1124,6 +1133,7 @@ mod tests {
db.clone(),
node,
rates,
+ lnvps_api_common::VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?)
@@ -2408,6 +2418,7 @@ mod tests {
db.clone(),
node,
rates,
+ lnvps_api_common::VatClient::new(),
wrk,
VmStateCache::new(),
)?)
diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs
index 03e54dd6..e594599f 100644
--- a/lnvps_api/src/settings.rs
+++ b/lnvps_api/src/settings.rs
@@ -1,10 +1,8 @@
use anyhow::Result;
-use isocountry::CountryCode;
use lnvps_api_common::RedisConfig;
use payments_rs::fiat::FiatPaymentService;
use payments_rs::lightning::LightningNode;
use serde::{Deserialize, Serialize};
-use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -52,10 +50,6 @@ pub struct Settings {
/// Config for accepting revolut payments
pub revolut: Option,
- #[serde(default)]
- /// Tax rates to change per country as a percent of the amount
- pub tax_rate: HashMap,
-
/// public host of lnvps_nostr service
pub nostr_address_host: Option,
@@ -67,6 +61,11 @@ pub struct Settings {
/// Captcha config
pub captcha: Option,
+
+ /// Path to a MaxMind GeoLite2/GeoIP2 Country database (`.mmdb`) used to
+ /// resolve client IPs to a country as VAT place-of-supply evidence. When
+ /// unset, IP geolocation is disabled.
+ pub geoip_database: Option,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -340,11 +339,11 @@ pub fn mock_settings() -> Settings {
telegram: None,
whatsapp: None,
revolut: None,
- tax_rate: HashMap::from([(CountryCode::IRL, 23.0), (CountryCode::USA, 1.0)]),
nostr_address_host: None,
redis: None,
encryption: None,
captcha: None,
+ geoip_database: None,
}
}
diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs
index 79e175d4..2f04185a 100644
--- a/lnvps_api/src/subscription/mod.rs
+++ b/lnvps_api/src/subscription/mod.rs
@@ -17,8 +17,8 @@ use anyhow::{Context, Result, bail, ensure};
use async_trait::async_trait;
use chrono::{Datelike, Utc};
use lnvps_api_common::{
- CostResult, ExchangeRateService, NewPaymentInfo, PricingEngine, UpgradeConfig, WorkCommander,
- round_msat_to_sat,
+ CostResult, ExchangeRateService, NewPaymentInfo, PricingEngine, UpgradeConfig, VatClient,
+ WorkCommander, round_msat_to_sat,
};
use lnvps_db::{
LNVpsDb, PaymentMethod, Subscription, SubscriptionLineItem, SubscriptionPayment,
@@ -112,12 +112,13 @@ impl SubscriptionHandler {
db: Arc,
node: Arc,
rates: Arc,
+ vat: VatClient,
tx: Arc,
vm_state_cache: VmStateCache,
) -> Result {
Ok(Self {
revolut: settings.get_revolut()?,
- pe: PricingEngine::new(db.clone(), rates, settings.tax_rate.clone()),
+ pe: PricingEngine::new(db.clone(), rates, vat),
vm_provisioner: VmProvisioner::new(settings, db.clone()),
db,
tx,
@@ -447,18 +448,20 @@ impl SubscriptionHandler {
let non_vm_base = non_vm_interval_cost + setup_fee_due;
// Convert non-VM amounts (+ setup fee) to the payment method currency
- let (non_vm_converted_amount, non_vm_rate, non_vm_tax, non_vm_processing_fee): (
- u64,
- f32,
- u64,
- u64,
- ) = if non_vm_base > 0 {
+ #[allow(clippy::type_complexity)]
+ let (
+ non_vm_converted_amount,
+ non_vm_rate,
+ non_vm_tax,
+ non_vm_processing_fee,
+ non_vm_tax_line,
+ ): (u64, f32, u64, u64, Option) = if non_vm_base > 0 {
let base = non_vm_base;
let list_price = CurrencyAmount::from_u64(subscription_currency, base);
let converted = self.pe.get_amount_and_rate(list_price, method).await?;
- let tax = self
+ let det = self
.pe
- .get_tax_for_user(user.id, converted.amount.value())
+ .determine_tax(user.id, converted.amount.value(), subscription.company_id)
.await?;
let processing_fee = self
.pe
@@ -469,14 +472,16 @@ impl SubscriptionHandler {
converted.amount.value(),
)
.await;
+ let line = det.to_line(converted.amount.value());
(
converted.amount.value(),
converted.rate.rate,
- tax,
+ det.amount,
processing_fee,
+ Some(line),
)
} else {
- (0u64, 0f32, 0u64, 0u64)
+ (0u64, 0f32, 0u64, 0u64, None)
};
// Aggregate all line item amounts. All VM infos are already in the
@@ -500,6 +505,27 @@ impl SubscriptionHandler {
let total_amount = vm_amount + non_vm_converted_amount;
+ // Per-line tax breakdown for the payment. Each VM line carries its own
+ // computed line (against its own company), plus the non-VM line if
+ // present. Stored as an array so per-line values are preserved when they
+ // differ across lines.
+ let mut tax_lines: Vec = vm_payment_infos
+ .iter()
+ .map(|p| p.tax_details.to_line(p.amount))
+ .collect();
+ if let Some(line) = non_vm_tax_line {
+ tax_lines.push(line);
+ }
+ let tax_summary = lnvps_api_common::summarize_tax_lines(&tax_lines);
+ let tax_breakdown = serde_json::to_value(&tax_lines).ok();
+ // The country signals are the same for every line (one customer).
+ let tax_evidence = Some(
+ self.pe
+ .determine_tax(subscription.user_id, 0, subscription.company_id)
+ .await?
+ .evidence_json(),
+ );
+
// Payment method currency: BTC for Lightning, otherwise subscription currency
let payment_currency = vm_payment_infos
.first()
@@ -570,6 +596,11 @@ impl SubscriptionHandler {
tax,
processing_fee,
paid_at: None,
+ tax_rate: tax_summary.rate,
+ tax_country_code: tax_summary.country_code.clone(),
+ tax_treatment: tax_summary.treatment.clone(),
+ tax_evidence: tax_evidence.clone(),
+ tax_breakdown: tax_breakdown.clone(),
}
}
PaymentMethod::Revolut => {
@@ -604,14 +635,11 @@ impl SubscriptionHandler {
// Merchant-initiated charge against a saved Revolut
// payment method (specific one if requested, else the
// default) — no customer interaction.
- let method =
- self.revolut_payment_method(user.id, *method_id).await?;
+ let method = self.revolut_payment_method(user.id, *method_id).await?;
let customer_id: String = method
.external_customer_id
.clone()
- .ok_or_else(|| {
- anyhow::anyhow!("Revolut method missing customer id")
- })?
+ .ok_or_else(|| anyhow::anyhow!("Revolut method missing customer id"))?
.into();
let payment_method_id: String = method.external_id.clone().into();
let info = rev
@@ -662,6 +690,11 @@ impl SubscriptionHandler {
tax,
processing_fee,
paid_at: None,
+ tax_rate: tax_summary.rate,
+ tax_country_code: tax_summary.country_code.clone(),
+ tax_treatment: tax_summary.treatment.clone(),
+ tax_evidence: tax_evidence.clone(),
+ tax_breakdown: tax_breakdown.clone(),
}
}
PaymentMethod::Paypal => bail!("PayPal not implemented"),
@@ -716,6 +749,12 @@ impl SubscriptionHandler {
SubscriptionPaymentType::Upgrade => format!("VM upgrade {vm_id}"),
SubscriptionPaymentType::Purchase => format!("VM purchase {vm_id}"),
};
+ // Record the tax values on the payment. Single line item, so the
+ // breakdown has exactly one entry.
+ let tax_lines = vec![p.tax_details.to_line(p.amount)];
+ let tax_summary = lnvps_api_common::summarize_tax_lines(&tax_lines);
+ let tax_breakdown = serde_json::to_value(&tax_lines).ok();
+ let tax_evidence = Some(p.tax_details.evidence_json());
let payment = match method {
PaymentMethod::Lightning => {
ensure!(
@@ -755,6 +794,11 @@ impl SubscriptionHandler {
tax: p.tax,
processing_fee: p.processing_fee,
paid_at: None,
+ tax_rate: tax_summary.rate,
+ tax_country_code: tax_summary.country_code.clone(),
+ tax_treatment: tax_summary.treatment.clone(),
+ tax_evidence: tax_evidence.clone(),
+ tax_breakdown: tax_breakdown.clone(),
}
}
PaymentMethod::Revolut => {
@@ -803,6 +847,11 @@ impl SubscriptionHandler {
tax: p.tax,
processing_fee: p.processing_fee,
paid_at: None,
+ tax_rate: tax_summary.rate,
+ tax_country_code: tax_summary.country_code.clone(),
+ tax_treatment: tax_summary.treatment.clone(),
+ tax_evidence: tax_evidence.clone(),
+ tax_breakdown: tax_breakdown.clone(),
}
}
PaymentMethod::Paypal => bail!("PayPal not implemented"),
@@ -952,7 +1001,8 @@ impl SubscriptionHandler {
rate: cost_difference.upgrade.rate,
time_value: 0, //upgrades dont add time
new_expiry: Default::default(),
- tax: 0, // No tax on upgrades for now
+ tax: 0, // No tax on upgrades for now
+ tax_details: lnvps_api_common::TaxDetermination::untaxed(),
processing_fee: 0, // No processing fee on upgrades for now
};
let metadata = serde_json::to_value(cfg)?;
@@ -1086,6 +1136,7 @@ mod revolut_autorenew_tests {
db.clone(),
node.clone(),
Arc::new(MockExchangeRate::default()),
+ VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)?;
@@ -1185,7 +1236,14 @@ mod revolut_offline_tests {
}
}
- fn mk_method(user_id: u64, cust: &str, pm: &str, default: bool, enabled: bool, exp: (u16, u16)) -> UserPaymentMethod {
+ fn mk_method(
+ user_id: u64,
+ cust: &str,
+ pm: &str,
+ default: bool,
+ enabled: bool,
+ exp: (u16, u16),
+ ) -> UserPaymentMethod {
UserPaymentMethod {
id: 0,
user_id,
@@ -1239,11 +1297,19 @@ mod revolut_offline_tests {
)
.await
.unwrap();
+ let rates = Arc::new(MockExchangeRate::default());
+ rates
+ .set_rate(
+ lnvps_api_common::Ticker::btc_rate("EUR").unwrap(),
+ 100_000.0,
+ )
+ .await;
let sub = SubscriptionHandler::new(
mock_settings(),
db.clone(),
node,
- Arc::new(MockExchangeRate::default()),
+ rates,
+ VatClient::new(),
Arc::new(ChannelWorkCommander::new()),
VmStateCache::new(),
)
@@ -1251,6 +1317,90 @@ mod revolut_offline_tests {
(db, sub, user_id, sub_id)
}
+ /// A renewal freezes the VAT determination (rate/country/treatment/evidence
+ /// + per-line breakdown) onto the persisted subscription_payment.
+ #[tokio::test]
+ async fn renew_persists_vat_snapshot() {
+ use std::collections::HashMap;
+ let db = Arc::new(MockDb::default());
+ let node = Arc::new(MockNode::default());
+ let user_id = db.upsert_user(&[21u8; 32]).await.unwrap();
+
+ // Seller (company 1) in IRL, customer in DEU -> EU B2C, OSS at 19%.
+ {
+ let mut c = db.companies.lock().await;
+ c.get_mut(&1).unwrap().country_code = Some("IRL".to_string());
+ }
+ {
+ let mut u = db.users.lock().await;
+ u.get_mut(&user_id).unwrap().country_code = Some("DEU".to_string());
+ }
+
+ // BTC-denominated subscription so a Lightning renewal needs no FX.
+ let (sub_id, _items) = db
+ .insert_subscription_with_line_items(
+ &Subscription {
+ id: 0,
+ user_id,
+ company_id: 1,
+ name: "s".to_string(),
+ description: None,
+ created: Utc::now(),
+ expires: None,
+ is_active: true,
+ is_setup: true,
+ currency: "BTC".to_string(),
+ interval_amount: 1,
+ interval_type: IntervalType::Month,
+ setup_fee: 0,
+ auto_renewal_enabled: false,
+ external_id: None,
+ },
+ vec![SubscriptionLineItem {
+ id: 0,
+ subscription_id: 0,
+ subscription_type: SubscriptionType::IpRange,
+ name: "hosting".to_string(),
+ description: None,
+ amount: 100_000,
+ setup_amount: 0,
+ configuration: None,
+ }],
+ )
+ .await
+ .unwrap();
+
+ let sub = SubscriptionHandler::new(
+ mock_settings(),
+ db.clone(),
+ node,
+ Arc::new(MockExchangeRate::default()),
+ VatClient::new(),
+ Arc::new(ChannelWorkCommander::new()),
+ VmStateCache::new(),
+ )
+ .unwrap();
+ // Seed the handler's shared VAT client with the German standard rate.
+ sub.pricing_engine()
+ .vat_client()
+ .set_rates(HashMap::from([(isocountry::CountryCode::DEU, 19.0)]));
+
+ let payment = sub
+ .renew_subscription(sub_id, PaymentMethod::Lightning, 1)
+ .await
+ .unwrap();
+
+ assert!(payment.tax > 0, "VAT should be charged");
+ assert_eq!(payment.tax_rate, Some(19.0));
+ assert_eq!(payment.tax_country_code.as_deref(), Some("DEU"));
+ assert_eq!(payment.tax_treatment.as_deref(), Some("oss_b2c"));
+ // Breakdown + evidence are frozen on the payment.
+ let breakdown = payment.tax_breakdown.expect("breakdown present");
+ assert!(breakdown.is_array());
+ let ev = payment.tax_evidence.expect("evidence present");
+ assert_eq!(ev["declared_country"], "DEU");
+ }
+
#[tokio::test]
async fn test_default_revolut_payment_method_selection() {
let (db, sub, user_id, _sub_id) = setup(true).await;
@@ -1274,7 +1424,10 @@ mod revolut_offline_tests {
.insert_user_payment_method(&mk_method(user_id, "cB", "pB", false, true, (2999, 12)))
.await
.unwrap();
- assert_eq!(sub.revolut_payment_method(user_id, None).await.unwrap().id, good);
+ assert_eq!(
+ sub.revolut_payment_method(user_id, None).await.unwrap().id,
+ good
+ );
// Disable all remaining -> error
let mut g = db.get_user_payment_method(good).await.unwrap();
@@ -1303,12 +1456,13 @@ mod revolut_offline_tests {
assert_eq!(charged[0].1, "pm1");
// Payment row persisted
- assert!(db
- .list_subscription_payments(sub_id)
- .await
- .unwrap()
- .iter()
- .any(|p| p.id == payment.id));
+ assert!(
+ db.list_subscription_payments(sub_id)
+ .await
+ .unwrap()
+ .iter()
+ .any(|p| p.id == payment.id)
+ );
}
#[tokio::test]
diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs
index 50edb9f4..e2a50265 100644
--- a/lnvps_api/src/worker.rs
+++ b/lnvps_api/src/worker.rs
@@ -2997,6 +2997,7 @@ mod tests {
db.clone(),
node,
rates,
+ lnvps_api_common::VatClient::new(),
work_commander.clone(),
cache.clone(),
)?;
@@ -3102,6 +3103,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
}
}
diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs
index edd2b4b2..393830d7 100644
--- a/lnvps_api_admin/src/admin/model.rs
+++ b/lnvps_api_admin/src/admin/model.rs
@@ -12,7 +12,7 @@ use lnvps_api_common::{
use lnvps_db::{
AdminAction, AdminResource, AdminRole, IpRangeAllocationMode, NetworkAccessPolicy,
OsDistribution, PaymentMethod, RouterKind, SubscriptionPayment, SubscriptionType, VmHistory,
- VmHistoryActionType, VmHostKind, VmPayment,
+ VmHistoryActionType, VmHostKind,
};
// Admin API Enums - Using enums from common crate where available, creating new ones only where needed
@@ -2546,25 +2546,6 @@ pub struct AdminVmPaymentInfo {
}
impl AdminVmPaymentInfo {
- pub fn from_vm_payment(payment: &VmPayment, company_base_currency: String) -> Self {
- Self {
- id: hex::encode(&payment.id),
- vm_id: payment.vm_id,
- created: payment.created,
- expires: payment.expires,
- amount: payment.amount,
- tax: payment.tax,
- processing_fee: payment.processing_fee,
- currency: payment.currency.clone(),
- company_base_currency,
- payment_method: AdminPaymentMethod::from(payment.payment_method),
- external_id: payment.external_id.clone(),
- is_paid: payment.is_paid,
- paid_at: payment.paid_at,
- rate: payment.rate,
- }
- }
-
pub fn from_subscription_payment(
payment: &SubscriptionPayment,
vm_id: u64,
diff --git a/lnvps_api_admin/src/admin/reports.rs b/lnvps_api_admin/src/admin/reports.rs
index aceca56c..325b2c72 100644
--- a/lnvps_api_admin/src/admin/reports.rs
+++ b/lnvps_api_admin/src/admin/reports.rs
@@ -79,6 +79,12 @@ struct TimeSeriesPayment {
rate: f32, // Exchange rate to company's base currency
time_value: u64, // Seconds this payment adds to VM expiry
tax: u64, // Tax amount in smallest currency unit
+ // Tax fields recorded on the payment. Summary fields are null when the
+ // payment's lines differ; `tax_breakdown` holds the per-line values.
+ tax_rate: Option, // Rate (%) when uniform
+ tax_country_code: Option, // Country (ISO alpha-3) when uniform
+ tax_treatment: Option, // Treatment label when uniform
+ tax_breakdown: Option, // Per-line-item VAT breakdown
// Company information
company_id: u64,
company_name: String,
@@ -168,6 +174,10 @@ async fn admin_time_series_report(
rate: payment.rate,
time_value: payment.time_value.unwrap_or(0),
tax: payment.tax,
+ tax_rate: payment.tax_rate,
+ tax_country_code: payment.tax_country_code.clone(),
+ tax_treatment: payment.tax_treatment.clone(),
+ tax_breakdown: payment.tax_breakdown.clone(),
company_id: payment.company_id,
company_name: payment.company_name.clone(),
company_base_currency: payment.company_base_currency.clone(),
diff --git a/lnvps_api_admin/src/admin/vms.rs b/lnvps_api_admin/src/admin/vms.rs
index 2110d8aa..0f9a7a66 100644
--- a/lnvps_api_admin/src/admin/vms.rs
+++ b/lnvps_api_admin/src/admin/vms.rs
@@ -10,7 +10,7 @@ use axum::{Json, Router};
use chrono::{DateTime, Days, Utc};
use lnvps_api_common::{
ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, PricingEngine,
- UpgradeConfig, VmHistoryLogger, VmRunningState, VmStateCache, WorkJob,
+ UpgradeConfig, VatClient, VmHistoryLogger, VmRunningState, VmStateCache, WorkJob,
};
use lnvps_db::{AdminAction, AdminResource, SubscriptionPaymentType};
use log::{error, info};
@@ -575,7 +575,9 @@ async fn admin_get_vm_history(
// Verify history entry belongs to this VM
if history.vm_id != vm_id {
- return Err(ApiError::not_found("History entry does not belong to this VM"));
+ return Err(ApiError::not_found(
+ "History entry does not belong to this VM",
+ ));
}
let admin_history_info =
@@ -691,10 +693,10 @@ async fn admin_calculate_vm_refund(
Utc::now()
};
- // Create pricing engine instance with real exchange rates
- let tax_rates = std::collections::HashMap::new();
-
- let pricing_engine = PricingEngine::new(this.db.clone(), this.exchange.clone(), tax_rates);
+ // Create pricing engine instance with real exchange rates. Refunds are
+ // computed from stored payment amounts, so an empty VAT client is fine here.
+ let pricing_engine =
+ PricingEngine::new(this.db.clone(), this.exchange.clone(), VatClient::new());
// Calculate the refund amount from the specified date
let refund_result = pricing_engine
diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs
index 56a95980..2de6a5bf 100644
--- a/lnvps_api_admin/src/bin/generate_demo_data.rs
+++ b/lnvps_api_admin/src/bin/generate_demo_data.rs
@@ -7,8 +7,9 @@ use lnvps_api_admin::settings::Settings;
use lnvps_db::{
AdminDb, Company, DiskInterface, DiskType, EncryptedString, EncryptionContext, IntervalType,
IpRange, IpRangeAllocationMode, LNVpsDbBase, LNVpsDbMysql, OsDistribution, PaymentMethod,
- PaymentType, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomTemplate, VmHost,
- VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate,
+ SubscriptionPayment, SubscriptionPaymentType, User, UserSshKey, Vm, VmCostPlan,
+ VmCustomPricing, VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion,
+ VmIpAssignment, VmOsImage, VmTemplate,
};
use log::info;
use std::path::PathBuf;
@@ -1286,7 +1287,7 @@ async fn create_payments(db: &LNVpsDbMysql, vms: &[Vm]) -> Result<()> {
1 => PaymentMethod::Revolut,
_ => PaymentMethod::Paypal,
};
- let payment_type = PaymentType::Renewal;
+ let payment_type = SubscriptionPaymentType::Renewal;
let amount = match payment_method {
PaymentMethod::Lightning => 500000 + (i as u64 * 100000), // Lightning in sats (0.005-0.025 BTC range)
_ => 500 + (i as u64 * 50), // Fiat in cents ($5.00-$15.00 range)
@@ -1310,21 +1311,27 @@ async fn create_payments(db: &LNVpsDbMysql, vms: &[Vm]) -> Result<()> {
_ => 1.0, // USD base rate
};
- let sub_created = db
+ let subscription = db
.get_subscription_by_line_item_id(vm.subscription_line_item_id)
.await
+ .ok();
+ let sub_created = subscription
+ .as_ref()
.map(|s| s.created)
- .unwrap_or_else(|_| Utc::now());
+ .unwrap_or_else(Utc::now);
+ let subscription_id = subscription.as_ref().map(|s| s.id).unwrap_or(0);
let payment_id_bytes = hex::decode(&payment_id)?;
- let payment = VmPayment {
+ let payment = SubscriptionPayment {
id: payment_id_bytes,
- vm_id: vm.id,
+ subscription_id,
+ user_id: vm.user_id,
created: sub_created,
expires: sub_created + Duration::hours(1),
amount,
external_data: EncryptedString::new(external_data),
- time_value: 7776000,
+ time_value: Some(7776000),
+ metadata: None,
is_paid: true,
rate,
currency: currency.to_string(),
@@ -1333,11 +1340,15 @@ async fn create_payments(db: &LNVpsDbMysql, vms: &[Vm]) -> Result<()> {
payment_type,
tax: 0,
processing_fee: 0,
- upgrade_params: None,
paid_at: Some(sub_created), // Demo data: assume paid immediately
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
- db.insert_vm_payment(&payment).await?;
+ db.insert_subscription_payment(&payment).await?;
}
Ok(())
diff --git a/lnvps_api_common/Cargo.toml b/lnvps_api_common/Cargo.toml
index 5373f3d3..89c34ba6 100644
--- a/lnvps_api_common/Cargo.toml
+++ b/lnvps_api_common/Cargo.toml
@@ -31,6 +31,7 @@ rand = "0.9"
sha1 = "0.10"
isocountry = "0.3"
redis = { version = "1", features = ["tokio-comp", "cluster"] }
+maxminddb = "0.29"
[dev-dependencies]
env_logger = "0.11"
diff --git a/lnvps_api_common/src/client_ip.rs b/lnvps_api_common/src/client_ip.rs
new file mode 100644
index 00000000..bb5e05ae
--- /dev/null
+++ b/lnvps_api_common/src/client_ip.rs
@@ -0,0 +1,153 @@
+use axum::extract::FromRequestParts;
+use axum::http::request::Parts;
+use std::convert::Infallible;
+use std::net::IpAddr;
+
+/// Extractor for the originating client IP address.
+///
+/// The API always runs behind a reverse proxy (and the GSL -> AVS scrubbing
+/// path), so the peer socket address is never the real client. The client IP is
+/// therefore read from forwarding headers set by the trusted front proxy:
+///
+/// 1. `X-Forwarded-For` — the left-most (original client) entry, or
+/// 2. `X-Real-IP` — a single address.
+///
+/// This is best-effort: the value is only used as *one* non-contradictory piece
+/// of place-of-supply evidence for EU VAT and is never trusted for
+/// authentication. Extraction never fails; the address is `None` when no usable
+/// header is present.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ClientIp(pub Option);
+
+impl ClientIp {
+ /// Parse the client IP out of a set of request headers.
+ pub fn from_headers(headers: &axum::http::HeaderMap) -> Self {
+ // X-Forwarded-For: client, proxy1, proxy2 -> take the left-most entry.
+ if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
+ for part in xff.split(',') {
+ if let Some(ip) = parse_ip(part) {
+ return ClientIp(Some(ip));
+ }
+ }
+ }
+ if let Some(xri) = headers.get("x-real-ip").and_then(|v| v.to_str().ok())
+ && let Some(ip) = parse_ip(xri)
+ {
+ return ClientIp(Some(ip));
+ }
+ ClientIp(None)
+ }
+}
+
+/// Parse a single IP token, tolerating surrounding whitespace and an optional
+/// `:port` suffix on IPv4 or `[addr]:port` bracketing on IPv6.
+fn parse_ip(s: &str) -> Option {
+ let s = s.trim();
+ if s.is_empty() {
+ return None;
+ }
+ if let Ok(ip) = s.parse::() {
+ return Some(ip);
+ }
+ // [2001:db8::1]:443 or [2001:db8::1]
+ if let Some(rest) = s.strip_prefix('[')
+ && let Some(end) = rest.find(']')
+ {
+ return rest[..end].parse::().ok();
+ }
+ // 1.2.3.4:443 (only strip a port when exactly one ':' is present so we
+ // don't mangle a bare IPv6 address).
+ if s.matches(':').count() == 1
+ && let Some((host, _port)) = s.rsplit_once(':')
+ {
+ return host.parse::().ok();
+ }
+ None
+}
+
+impl FromRequestParts for ClientIp
+where
+ S: Send + Sync,
+{
+ type Rejection = Infallible;
+
+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result {
+ Ok(ClientIp::from_headers(&parts.headers))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use axum::http::{HeaderMap, HeaderName, HeaderValue};
+ use std::str::FromStr;
+
+ fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
+ let mut h = HeaderMap::new();
+ for (k, v) in pairs {
+ h.insert(
+ HeaderName::from_str(k).unwrap(),
+ HeaderValue::from_str(v).unwrap(),
+ );
+ }
+ h
+ }
+
+ #[test]
+ fn parses_plain_ipv4() {
+ let ip = ClientIp::from_headers(&headers(&[("x-forwarded-for", "203.0.113.7")]));
+ assert_eq!(ip.0, Some("203.0.113.7".parse().unwrap()));
+ }
+
+ #[test]
+ fn takes_leftmost_of_chain() {
+ let ip = ClientIp::from_headers(&headers(&[(
+ "x-forwarded-for",
+ "203.0.113.7, 70.41.3.18, 150.172.238.178",
+ )]));
+ assert_eq!(ip.0, Some("203.0.113.7".parse().unwrap()));
+ }
+
+ #[test]
+ fn strips_ipv4_port() {
+ let ip = ClientIp::from_headers(&headers(&[("x-forwarded-for", "203.0.113.7:51234")]));
+ assert_eq!(ip.0, Some("203.0.113.7".parse().unwrap()));
+ }
+
+ #[test]
+ fn parses_bare_ipv6() {
+ let ip = ClientIp::from_headers(&headers(&[("x-forwarded-for", "2001:db8::1")]));
+ assert_eq!(ip.0, Some("2001:db8::1".parse().unwrap()));
+ }
+
+ #[test]
+ fn parses_bracketed_ipv6_with_port() {
+ let ip = ClientIp::from_headers(&headers(&[("x-forwarded-for", "[2001:db8::1]:443")]));
+ assert_eq!(ip.0, Some("2001:db8::1".parse().unwrap()));
+ }
+
+ #[test]
+ fn falls_back_to_x_real_ip() {
+ let ip = ClientIp::from_headers(&headers(&[("x-real-ip", "198.51.100.9")]));
+ assert_eq!(ip.0, Some("198.51.100.9".parse().unwrap()));
+ }
+
+ #[test]
+ fn none_when_absent_or_garbage() {
+ assert_eq!(ClientIp::from_headers(&HeaderMap::new()).0, None);
+ let ip = ClientIp::from_headers(&headers(&[("x-forwarded-for", "not-an-ip")]));
+ assert_eq!(ip.0, None);
+ }
+
+ #[tokio::test]
+ async fn from_request_parts_extractor() {
+ use axum::extract::FromRequestParts;
+ let req = axum::http::Request::builder()
+ .header("x-forwarded-for", "198.51.100.9, 10.0.0.1")
+ .body(())
+ .unwrap();
+ let (mut parts, _) = req.into_parts();
+ let ip = ClientIp::from_request_parts(&mut parts, &()).await.unwrap();
+ assert_eq!(ip.0, Some("198.51.100.9".parse().unwrap()));
+ }
+}
diff --git a/lnvps_api_common/src/geoip.rs b/lnvps_api_common/src/geoip.rs
new file mode 100644
index 00000000..c1f7b503
--- /dev/null
+++ b/lnvps_api_common/src/geoip.rs
@@ -0,0 +1,137 @@
+use anyhow::Result;
+use isocountry::CountryCode;
+use log::trace;
+use maxminddb::{Reader, geoip2};
+use std::net::IpAddr;
+use std::path::Path;
+use std::sync::Arc;
+
+/// Resolve an IP address to a country for VAT place-of-supply evidence.
+///
+/// Implementations return an ISO 3166-1 **alpha-3** country code (to match the
+/// `users.country_code` storage convention) or `None` when the address cannot
+/// be attributed to a country (private/reserved ranges, missing DB entry, ...).
+///
+/// Lookups are expected to be cheap and local (a MaxMind DB lookup is on the
+/// order of microseconds), so this is a synchronous trait and callers may run
+/// it inline on the request path.
+pub trait CountryResolver: Send + Sync {
+ fn resolve(&self, ip: IpAddr) -> Option;
+}
+
+impl CountryResolver for Arc {
+ fn resolve(&self, ip: IpAddr) -> Option {
+ (**self).resolve(ip)
+ }
+}
+
+/// Returns `true` for addresses that can never yield useful geolocation
+/// (loopback, private, link-local, unspecified, unique-local IPv6, ...).
+fn is_non_routable(ip: &IpAddr) -> bool {
+ match ip {
+ IpAddr::V4(v4) => {
+ v4.is_private()
+ || v4.is_loopback()
+ || v4.is_link_local()
+ || v4.is_unspecified()
+ || v4.is_broadcast()
+ || v4.is_documentation()
+ }
+ IpAddr::V6(v6) => {
+ v6.is_loopback()
+ || v6.is_unspecified()
+ // unique local (fc00::/7)
+ || (v6.segments()[0] & 0xfe00) == 0xfc00
+ // link local (fe80::/10)
+ || (v6.segments()[0] & 0xffc0) == 0xfe80
+ }
+ }
+}
+
+/// Convert an ISO 3166-1 alpha-2 code to alpha-3, returning `None` for unknowns.
+pub fn alpha2_to_alpha3(alpha2: &str) -> Option {
+ CountryCode::for_alpha2(&alpha2.to_uppercase())
+ .ok()
+ .map(|c| c.alpha3().to_string())
+}
+
+/// [`CountryResolver`] backed by a local MaxMind GeoLite2/GeoIP2 Country
+/// database (`.mmdb`). The whole file is read into memory once at construction;
+/// lookups are then pure in-memory and never touch the network.
+pub struct MaxmindCountryResolver {
+ reader: Reader>,
+}
+
+impl MaxmindCountryResolver {
+ /// Open a MaxMind Country (or City) database from disk.
+ pub fn open(path: impl AsRef) -> Result {
+ let reader = Reader::open_readfile(path)?;
+ Ok(Self { reader })
+ }
+}
+
+impl CountryResolver for MaxmindCountryResolver {
+ fn resolve(&self, ip: IpAddr) -> Option {
+ if is_non_routable(&ip) {
+ trace!("Skipping geolocation for non-routable address {}", ip);
+ return None;
+ }
+ let looked = match self.reader.lookup(ip) {
+ Ok(r) => r,
+ Err(e) => {
+ trace!("Geolocation lookup failed for {}: {}", ip, e);
+ return None;
+ }
+ };
+ let country: Option = looked.decode().ok()?;
+ let iso = country?.country.iso_code?;
+ alpha2_to_alpha3(iso)
+ }
+}
+
+/// No-op resolver that always returns `None` (used when geolocation is disabled
+/// or the database path is not configured).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct NoopCountryResolver;
+
+impl CountryResolver for NoopCountryResolver {
+ fn resolve(&self, _ip: IpAddr) -> Option {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn alpha2_conversion() {
+ assert_eq!(alpha2_to_alpha3("DE").as_deref(), Some("DEU"));
+ assert_eq!(alpha2_to_alpha3("ie").as_deref(), Some("IRL"));
+ assert_eq!(alpha2_to_alpha3("ZZ"), None);
+ }
+
+ #[test]
+ fn non_routable_detection() {
+ assert!(is_non_routable(&"127.0.0.1".parse().unwrap()));
+ assert!(is_non_routable(&"10.0.0.1".parse().unwrap()));
+ assert!(is_non_routable(&"192.168.1.1".parse().unwrap()));
+ assert!(is_non_routable(&"::1".parse().unwrap()));
+ assert!(is_non_routable(&"fd00::1".parse().unwrap()));
+ assert!(is_non_routable(&"fe80::1".parse().unwrap()));
+ assert!(!is_non_routable(&"8.8.8.8".parse().unwrap()));
+ assert!(!is_non_routable(&"1.1.1.1".parse().unwrap()));
+ }
+
+ #[test]
+ fn noop_resolver_returns_none() {
+ let r = NoopCountryResolver;
+ assert_eq!(r.resolve("203.0.113.7".parse().unwrap()), None);
+ }
+
+ #[test]
+ fn arc_dyn_resolver_delegates() {
+ let r: Arc = Arc::new(NoopCountryResolver);
+ assert_eq!(r.resolve("203.0.113.7".parse().unwrap()), None);
+ }
+}
diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs
index 7d7bd913..44d1dfcb 100644
--- a/lnvps_api_common/src/lib.rs
+++ b/lnvps_api_common/src/lib.rs
@@ -1,13 +1,15 @@
mod capacity;
+mod client_ip;
mod dns;
mod exchange;
+mod geoip;
mod json_api;
mod kv;
mod mock;
mod model;
mod network;
-mod ovh;
mod nip98;
+mod ovh;
mod pricing;
pub mod retry;
mod routes;
@@ -18,15 +20,17 @@ mod vm_history;
mod work;
pub use capacity::*;
+pub use client_ip::*;
pub use dns::*;
pub use exchange::*;
+pub use geoip::*;
pub use json_api::*;
pub use kv::*;
pub use mock::*;
pub use model::*;
pub use network::*;
-pub use ovh::*;
pub use nip98::*;
+pub use ovh::*;
pub use pricing::*;
pub use routes::*;
use serde::{Deserialize, Deserializer};
diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs
index fa8f6905..17da45c0 100644
--- a/lnvps_api_common/src/mock.rs
+++ b/lnvps_api_common/src/mock.rs
@@ -11,7 +11,7 @@ use lnvps_db::{
Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User,
UserPaymentMethod, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk,
VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind,
- VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate,
+ VmHostRegion, VmIpAssignment, VmOsImage, VmTemplate,
};
use async_trait::async_trait;
@@ -39,7 +39,6 @@ pub struct MockDb {
pub custom_pricing: Arc>>,
pub custom_pricing_disk: Arc>>,
pub custom_template: Arc>>,
- pub payments: Arc>>,
pub router: Arc>>,
pub dns_servers: Arc>>,
pub access_policy: Arc>>,
@@ -255,7 +254,6 @@ impl Default for MockDb {
user_ssh_keys: Arc::new(Mutex::new(Default::default())),
user_payment_methods: Arc::new(Default::default()),
custom_template: Arc::new(Default::default()),
- payments: Arc::new(Default::default()),
router: Arc::new(Default::default()),
dns_servers: Arc::new(Mutex::new(dns_servers)),
access_policy: Arc::new(Default::default()),
@@ -1132,105 +1130,6 @@ impl LNVpsDbBase for MockDb {
Ok(())
}
- async fn list_vm_payment(&self, vm_id: u64) -> DbResult> {
- let p = self.payments.lock().await;
- Ok(p.iter().filter(|p| p.vm_id == vm_id).cloned().collect())
- }
-
- async fn list_vm_payment_paginated(
- &self,
- vm_id: u64,
- limit: u64,
- offset: u64,
- ) -> DbResult> {
- let p = self.payments.lock().await;
- let mut filtered: Vec<_> = p.iter().filter(|p| p.vm_id == vm_id).cloned().collect();
- filtered.sort_by(|a, b| b.created.cmp(&a.created));
- Ok(filtered
- .into_iter()
- .skip(offset as usize)
- .take(limit as usize)
- .collect())
- }
-
- async fn list_vm_payment_by_method_and_type(
- &self,
- vm_id: u64,
- method: lnvps_db::PaymentMethod,
- payment_type: lnvps_db::PaymentType,
- ) -> DbResult> {
- let p = self.payments.lock().await;
- let mut filtered: Vec<_> = p
- .iter()
- .filter(|p| {
- p.vm_id == vm_id
- && p.payment_method == method
- && p.payment_type == payment_type
- && p.expires > Utc::now()
- && !p.is_paid
- })
- .cloned()
- .collect();
- filtered.sort_by(|a, b| b.created.cmp(&a.created));
- Ok(filtered)
- }
-
- async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()> {
- let mut p = self.payments.lock().await;
- p.push(vm_payment.clone());
- Ok(())
- }
-
- async fn get_vm_payment(&self, id: &Vec) -> DbResult {
- let p = self.payments.lock().await;
- Ok(p.iter()
- .find(|p| p.id == *id)
- .context("no vm_payment")?
- .clone())
- }
-
- async fn get_vm_payment_by_ext_id(&self, id: &str) -> DbResult {
- let p = self.payments.lock().await;
- Ok(p.iter()
- .find(|p| p.external_id == Some(id.to_string()))
- .context("no vm_payment")?
- .clone())
- }
-
- async fn update_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()> {
- let mut p = self.payments.lock().await;
- if let Some(p) = p.iter_mut().find(|p| p.id == *vm_payment.id) {
- p.is_paid = vm_payment.is_paid;
- p.paid_at = vm_payment.paid_at;
- }
- Ok(())
- }
-
- async fn vm_payment_paid(&self, payment: &VmPayment) -> DbResult<()> {
- let mut p = self.payments.lock().await;
- if let Some(p) = p.iter_mut().find(|p| p.id == *payment.id) {
- p.is_paid = true;
- p.paid_at = Some(Utc::now());
- }
- // vm.expires removed — expiry is managed exclusively via subscription.expires
- Ok(())
- }
-
- async fn last_paid_invoice(&self) -> DbResult> {
- let p = self.payments.lock().await;
- Ok(p.iter()
- .filter(|p| p.is_paid)
- .max_by(|a, b| a.created.cmp(&b.created))
- .cloned())
- }
-
- async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult {
- let p = self.payments.lock().await;
- Ok(p.iter()
- .filter(|p| p.vm_id == vm_id && !p.is_paid && p.expires > Utc::now())
- .count() as u64)
- }
-
async fn list_custom_pricing(&self, _tb: u64) -> DbResult> {
let p = self.custom_pricing.lock().await;
Ok(p.values().cloned().collect())
@@ -2096,6 +1995,11 @@ impl LNVpsDbBase for MockDb {
tax: payment.tax,
processing_fee: payment.processing_fee,
paid_at: payment.paid_at,
+ tax_rate: payment.tax_rate,
+ tax_country_code: payment.tax_country_code.clone(),
+ tax_treatment: payment.tax_treatment.clone(),
+ tax_evidence: payment.tax_evidence.clone(),
+ tax_breakdown: payment.tax_breakdown.clone(),
company_id: 0,
company_name: String::new(),
company_base_currency: "EUR".to_string(),
@@ -3151,6 +3055,11 @@ impl lnvps_db::AdminDb for MockDb {
tax: payment.tax,
processing_fee: payment.processing_fee,
paid_at: payment.paid_at,
+ tax_rate: payment.tax_rate,
+ tax_country_code: payment.tax_country_code.clone(),
+ tax_treatment: payment.tax_treatment.clone(),
+ tax_evidence: payment.tax_evidence.clone(),
+ tax_breakdown: payment.tax_breakdown.clone(),
company_id: cid,
company_name: company.name.clone(),
company_base_currency: company.base_currency.clone(),
@@ -3649,6 +3558,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
}
}
@@ -3744,6 +3658,27 @@ mod tests {
}
/// subscription_payment_paid marks the payment as paid and sets paid_at.
+ #[tokio::test]
+ async fn test_set_user_geo_persists_evidence() {
+ let db = MockDb::default();
+ let uid = db.upsert_user(&[7u8; 32]).await.unwrap();
+
+ // Resolved country is stored independently of country_code.
+ db.set_user_geo(uid, Some("DEU"), "198.51.100.9")
+ .await
+ .unwrap();
+ let user = db.get_user(uid).await.unwrap();
+ assert_eq!(user.geo_country_code.as_deref(), Some("DEU"));
+ assert_eq!(user.geo_ip.as_deref(), Some("198.51.100.9"));
+ assert!(user.geo_updated.is_some());
+
+ // An unresolved IP records the IP but no country.
+ db.set_user_geo(uid, None, "10.0.0.1").await.unwrap();
+ let user = db.get_user(uid).await.unwrap();
+ assert_eq!(user.geo_country_code, None);
+ assert_eq!(user.geo_ip.as_deref(), Some("10.0.0.1"));
+ }
+
#[tokio::test]
async fn test_subscription_payment_paid_marks_payment() {
let db = MockDb::default();
diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs
index febed6a0..9b14191e 100644
--- a/lnvps_api_common/src/pricing.rs
+++ b/lnvps_api_common/src/pricing.rs
@@ -1,14 +1,17 @@
-use crate::{ConvertedCurrencyAmount, ExchangeRateService, Ticker, TickerRate, UpgradeConfig};
+use crate::{
+ ConvertedCurrencyAmount, ExchangeRateService, Ticker, TickerRate, UpgradeConfig, VatClient,
+};
use anyhow::{Result, anyhow, bail, ensure};
use chrono::{DateTime, Days, Months, TimeDelta, Utc};
use ipnetwork::IpNetwork;
use isocountry::CountryCode;
use lnvps_db::{
CpuArch, CpuFeature, CpuMfg, DiskInterface, DiskType, IntervalType, LNVpsDb, PaymentMethod,
- PaymentType, SubscriptionPayment, SubscriptionPaymentType, Vm, VmCostPlan, VmCustomPricing,
- VmCustomTemplate, VmPayment,
+ SubscriptionPayment, SubscriptionPaymentType, Vm, VmCostPlan, VmCustomPricing,
+ VmCustomTemplate,
};
use payments_rs::currency::{Currency, CurrencyAmount};
+#[cfg(test)]
use std::collections::HashMap;
use std::ops::{Add, Sub};
use std::str::FromStr;
@@ -53,26 +56,230 @@ pub struct RemainingTimeInfo {
pub prorated_cost: CurrencyAmount,
}
+/// ISO 3166-1 alpha-3 codes treated as inside the EU VAT area (27 member states).
+const EU_VAT_COUNTRIES: [&str; 27] = [
+ "AUT", "BEL", "BGR", "HRV", "CYP", "CZE", "DNK", "EST", "FIN", "FRA", "DEU", "GRC", "HUN",
+ "IRL", "ITA", "LVA", "LTU", "LUX", "MLT", "NLD", "POL", "PRT", "ROU", "SVK", "SVN", "ESP",
+ "SWE",
+];
+
+/// Returns `true` if the ISO 3166-1 alpha-3 country code is inside the EU VAT area.
+pub fn is_eu_vat_country(alpha3: &str) -> bool {
+ let up = alpha3.to_uppercase();
+ EU_VAT_COUNTRIES.contains(&up.as_str())
+}
+
+/// Extract the country of a VAT number (its 2-letter prefix) as ISO alpha-3.
+///
+/// Greek VAT numbers use the `EL` prefix rather than the ISO code `GR`; that
+/// special case is mapped. Returns `None` if no valid country prefix is present.
+pub fn vat_number_country_alpha3(vat: &str) -> Option {
+ let cleaned: String = vat.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
+ let prefix: String = cleaned.chars().take(2).collect();
+ if prefix.len() != 2 || !prefix.chars().all(|c| c.is_ascii_alphabetic()) {
+ return None;
+ }
+ let alpha2 = match prefix.to_uppercase().as_str() {
+ "EL" => "GR".to_string(),
+ other => other.to_string(),
+ };
+ CountryCode::for_alpha2(&alpha2)
+ .ok()
+ .map(|c| c.alpha3().to_string())
+}
+
+/// Which rate branch was selected for a payment. Recorded on the payment.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TaxTreatment {
+ /// Customer country equals the seller country: seller-country rate applied.
+ Domestic,
+ /// EU customer in a different country, no VAT number: destination-country rate.
+ OssB2c,
+ /// EU customer in a different country with a VAT number: 0% (reverse charge).
+ ReverseCharge,
+ /// Non-EU customer: 0%.
+ OutOfScope,
+ /// Country could not be determined; the seller-country fallback rate applied.
+ UndeterminedDefault,
+}
+
+impl TaxTreatment {
+ /// Stable machine-readable identifier (for storage / reports).
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ TaxTreatment::Domestic => "domestic",
+ TaxTreatment::OssB2c => "oss_b2c",
+ TaxTreatment::ReverseCharge => "reverse_charge",
+ TaxTreatment::OutOfScope => "out_of_scope",
+ TaxTreatment::UndeterminedDefault => "undetermined_default",
+ }
+ }
+}
+
+/// The tax values for a single line of a payment.
+///
+/// A payment stores an array of these (`tax_breakdown`) so per-line values are
+/// preserved when lines differ, rather than collapsed to a single rate.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct TaxLine {
+ /// Net (pre-tax) amount for this line, in the payment's smallest unit.
+ pub net: u64,
+ /// Tax amount for this line, in the payment's smallest unit.
+ pub tax: u64,
+ /// Rate applied, as a percentage.
+ pub rate: f32,
+ /// Country (ISO alpha-3) used for this line, if known.
+ pub country_code: Option,
+ /// The rate branch selected for this line.
+ pub treatment: TaxTreatment,
+}
+
+/// The shared summary of a breakdown, when every line has the same rate,
+/// country and treatment; `None` fields indicate the lines differ.
+#[derive(Debug, Clone, Default)]
+pub struct TaxSummary {
+ pub rate: Option,
+ pub country_code: Option,
+ pub treatment: Option,
+}
+
+/// Summarise a per-line breakdown: return the shared rate/country/treatment when
+/// uniform, otherwise leave the differing field(s) `None` ("mixed").
+pub fn summarize_tax_lines(lines: &[TaxLine]) -> TaxSummary {
+ let mut it = lines.iter();
+ let Some(first) = it.next() else {
+ return TaxSummary::default();
+ };
+ let mut rate = Some(first.rate);
+ let mut country = first.country_code.clone();
+ let mut treatment = Some(first.treatment);
+ for l in it {
+ if Some(l.rate) != rate {
+ rate = None;
+ }
+ if l.country_code != country {
+ country = None;
+ }
+ if Some(l.treatment) != treatment {
+ treatment = None;
+ }
+ }
+ TaxSummary {
+ rate,
+ country_code: country,
+ treatment: treatment.map(|t| t.as_str().to_string()),
+ }
+}
+
+/// Result of a VAT place-of-supply determination.
+#[derive(Debug, Clone)]
+pub struct TaxDetermination {
+ /// Tax amount in the same unit as the input amount (smallest currency unit).
+ pub amount: u64,
+ /// VAT rate applied, as a percentage (e.g. `23.0`).
+ pub rate: f32,
+ /// Determined place-of-supply country (ISO alpha-3), if known.
+ pub country_code: Option,
+ /// How the sale was treated.
+ pub treatment: TaxTreatment,
+ /// The customer VAT number used (B2B reverse charge / domestic), if any.
+ pub vat_number: Option,
+ /// Evidence used at determination time: the customer's self-declared
+ /// country (ISO alpha-3), if any.
+ pub declared_country: Option,
+ /// Evidence used at determination time: the IP-derived country (ISO
+ /// alpha-3), if any.
+ pub geo_country: Option,
+}
+
+impl TaxDetermination {
+ fn taxed(
+ amount: u64,
+ rate: f32,
+ cc: Option,
+ t: TaxTreatment,
+ vat: Option,
+ ) -> Self {
+ let tax = ((amount as f64) * (rate as f64 / 100.0)).floor() as u64;
+ Self {
+ amount: tax,
+ rate,
+ country_code: cc,
+ treatment: t,
+ vat_number: vat,
+ declared_country: None,
+ geo_country: None,
+ }
+ }
+
+ fn zero(cc: Option, t: TaxTreatment, vat: Option) -> Self {
+ Self {
+ amount: 0,
+ rate: 0.0,
+ country_code: cc,
+ treatment: t,
+ vat_number: vat,
+ declared_country: None,
+ geo_country: None,
+ }
+ }
+
+ /// Attach the evidence signals observed at determination time.
+ fn with_evidence(mut self, declared: Option, geo: Option) -> Self {
+ self.declared_country = declared;
+ self.geo_country = geo;
+ self
+ }
+
+ /// The evidence used, as a JSON object suitable for freezing on a payment.
+ pub fn evidence_json(&self) -> serde_json::Value {
+ serde_json::json!({
+ "declared_country": self.declared_country,
+ "geo_country": self.geo_country,
+ "vat_number": self.vat_number,
+ })
+ }
+
+ /// The treatment as its stable string identifier (for storage).
+ pub fn treatment_str(&self) -> String {
+ self.treatment.as_str().to_string()
+ }
+
+ /// A determination carrying no VAT (e.g. upgrade lines that are not taxed).
+ pub fn untaxed() -> Self {
+ Self::zero(None, TaxTreatment::OutOfScope, None)
+ }
+
+ /// Turn this determination into a breakdown line for a given net amount.
+ pub fn to_line(&self, net: u64) -> TaxLine {
+ TaxLine {
+ net,
+ tax: self.amount,
+ rate: self.rate,
+ country_code: self.country_code.clone(),
+ treatment: self.treatment,
+ }
+ }
+}
+
/// Pricing engine is used to calculate billing amounts for
/// different resource allocations
#[derive(Clone)]
pub struct PricingEngine {
db: Arc,
rates: Arc,
- tax_rates: HashMap,
+ vat: VatClient,
}
impl PricingEngine {
- pub fn new(
- db: Arc,
- rates: Arc,
- tax_rates: HashMap,
- ) -> Self {
- Self {
- db,
- rates,
- tax_rates,
- }
+ pub fn new(db: Arc, rates: Arc, vat: VatClient) -> Self {
+ Self { db, rates, vat }
+ }
+
+ /// The shared VAT client backing this engine (for rate refreshes).
+ pub fn vat_client(&self) -> VatClient {
+ self.vat.clone()
}
/// Convert cost plan interval to seconds
@@ -211,13 +418,17 @@ impl PricingEngine {
.await
.unwrap_or_else(Utc::now)
.max(Utc::now());
+ let tax_details = self
+ .determine_tax(vm.user_id, input.value(), company_id)
+ .await?;
Ok(CostResult::New(NewPaymentInfo {
amount: input.value(),
currency: cost.currency,
time_value: new_time,
new_expiry: vm_expires.add(TimeDelta::seconds(new_time as i64)),
rate: cost.rate,
- tax: self.get_tax_for_user(vm.user_id, input.value()).await?,
+ tax: tax_details.amount,
+ tax_details,
processing_fee: self
.calculate_processing_fee(company_id, method, cost.currency, input.value())
.await,
@@ -274,13 +485,16 @@ impl PricingEngine {
} else {
let scaled_amount = base_cost.amount * intervals as u64;
let scaled_time = base_cost.time_value * intervals as u64;
- let scaled_tax = self.get_tax_for_user(vm.user_id, scaled_amount).await?;
+ let tax_details = self
+ .determine_tax(vm.user_id, scaled_amount, company_id)
+ .await?;
let processing_fee = self
.calculate_processing_fee(company_id, method, base_cost.currency, scaled_amount)
.await;
Ok(CostResult::New(NewPaymentInfo {
amount: scaled_amount,
- tax: scaled_tax,
+ tax: tax_details.amount,
+ tax_details,
processing_fee,
currency: base_cost.currency,
rate: base_cost.rate,
@@ -438,11 +652,13 @@ impl PricingEngine {
method,
)
.await?;
+ let tax_details = self
+ .determine_tax(vm.user_id, converted_amount.amount.value(), company_id)
+ .await?;
Ok(NewPaymentInfo {
amount: converted_amount.amount.value(),
- tax: self
- .get_tax_for_user(vm.user_id, converted_amount.amount.value())
- .await?,
+ tax: tax_details.amount,
+ tax_details,
processing_fee: self
.calculate_processing_fee(
company_id,
@@ -458,16 +674,128 @@ impl PricingEngine {
})
}
- pub async fn get_tax_for_user(&self, user_id: u64, amount: u64) -> Result {
+ /// Look up the VAT rate (%) for an ISO alpha-3 country from the VAT client's
+ /// cached table, or `0.0` when unknown.
+ fn rate_for_country(&self, alpha3: &str) -> f32 {
+ CountryCode::for_alpha3(alpha3)
+ .ok()
+ .and_then(|cc| self.vat.rate_for(cc))
+ .unwrap_or(0.0)
+ }
+
+ /// Select the rate for a sale from the seller and customer countries.
+ ///
+ /// The seller country comes from the VM's company. Selection order:
+ /// 1. Customer has a stored VAT number: same country as seller → seller
+ /// rate; different EU country → 0%; non-EU → 0%.
+ /// 2. No VAT number: customer country from the self-declared value, else the
+ /// IP-derived value. EU → that country's rate; non-EU → 0%.
+ /// 3. No country available: seller-country rate when the seller is in the
+ /// EU list, otherwise 0%.
+ pub async fn determine_tax(
+ &self,
+ user_id: u64,
+ amount: u64,
+ company_id: u64,
+ ) -> Result {
let user = self.db.get_user(user_id).await?;
- if let Some(cc) = user
- .country_code
- .and_then(|c| CountryCode::for_alpha3(&c).ok())
- && let Some(c) = self.tax_rates.get(&cc)
+ // The seller country is taken from our own VAT registration number
+ // (`company.tax_id`) when present — that number is our VIES registration
+ // and identifies the country we are registered in — and otherwise from
+ // the company's configured country.
+ let seller_cc = self.db.get_company(company_id).await.ok().and_then(|c| {
+ c.tax_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .and_then(vat_number_country_alpha3)
+ .or_else(|| c.country_code.map(|cc| cc.to_uppercase()))
+ });
+
+ // Record the raw country signals observed now, even when only one of
+ // them drives the decision.
+ let declared = user.country_code.as_ref().map(|c| c.to_uppercase());
+ let geo = user.geo_country_code.as_ref().map(|c| c.to_uppercase());
+ let determination = self.determine_tax_inner(&user, amount, seller_cc);
+ Ok(determination.with_evidence(declared, geo))
+ }
+
+ /// Core rate selection, without evidence attachment.
+ ///
+ /// This implements EU VAT only. It is scoped to sellers established in the
+ /// EU VAT area: when the seller country is not in that list (e.g. a US
+ /// company) no rate is selected here and the amount is untaxed. Other tax
+ /// systems (e.g. US sales tax) are out of scope for this function.
+ fn determine_tax_inner(
+ &self,
+ user: &lnvps_db::User,
+ amount: u64,
+ seller_cc: Option,
+ ) -> TaxDetermination {
+ // Only sellers in the EU VAT area select a rate here.
+ if !seller_cc.as_deref().map(is_eu_vat_country).unwrap_or(false) {
+ return TaxDetermination::zero(None, TaxTreatment::OutOfScope, None);
+ }
+
+ // 1. Customer supplied a VAT number (validated when it was saved).
+ if let Some(vat) = user
+ .billing_tax_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ && let Some(vat_cc) = vat_number_country_alpha3(vat)
{
- return Ok((amount as f64 * (*c as f64 / 100f64)).floor() as u64);
+ if seller_cc.as_deref() == Some(vat_cc.as_str()) {
+ let rate = self.rate_for_country(&vat_cc);
+ return TaxDetermination::taxed(
+ amount,
+ rate,
+ Some(vat_cc),
+ TaxTreatment::Domestic,
+ Some(vat.to_string()),
+ );
+ }
+ let treatment = if is_eu_vat_country(&vat_cc) {
+ TaxTreatment::ReverseCharge
+ } else {
+ TaxTreatment::OutOfScope
+ };
+ return TaxDetermination::zero(Some(vat_cc), treatment, Some(vat.to_string()));
+ }
+
+ // 2. No VAT number: pick the customer country from available signals.
+ let customer_cc = user
+ .country_code
+ .clone()
+ .or_else(|| user.geo_country_code.clone())
+ .map(|c| c.to_uppercase());
+ match customer_cc {
+ Some(cc) if is_eu_vat_country(&cc) => {
+ let rate = self.rate_for_country(&cc);
+ let treatment = if seller_cc.as_deref() == Some(cc.as_str()) {
+ TaxTreatment::Domestic
+ } else {
+ TaxTreatment::OssB2c
+ };
+ TaxDetermination::taxed(amount, rate, Some(cc), treatment, None)
+ }
+ Some(cc) => TaxDetermination::zero(Some(cc), TaxTreatment::OutOfScope, None),
+ // 3. No customer country available: fall back to the seller country
+ // (guaranteed in the EU list by the gate above).
+ None => match seller_cc {
+ Some(scc) => {
+ let rate = self.rate_for_country(&scc);
+ TaxDetermination::taxed(
+ amount,
+ rate,
+ Some(scc),
+ TaxTreatment::UndeterminedDefault,
+ None,
+ )
+ }
+ None => TaxDetermination::zero(None, TaxTreatment::OutOfScope, None),
+ },
}
- Ok(0)
}
async fn get_ticker(
@@ -527,11 +855,13 @@ impl PricingEngine {
.unwrap_or_else(Utc::now);
let time_value = Self::next_template_expire(vm_expires, &cost_plan);
let base = vm_expires.max(Utc::now());
+ let tax_details = self
+ .determine_tax(vm.user_id, converted_amount.amount.value(), company_id)
+ .await?;
Ok(NewPaymentInfo {
amount: converted_amount.amount.value(),
- tax: self
- .get_tax_for_user(vm.user_id, converted_amount.amount.value())
- .await?,
+ tax: tax_details.amount,
+ tax_details,
processing_fee: self
.calculate_processing_fee(
company_id,
@@ -889,6 +1219,9 @@ pub struct NewPaymentInfo {
pub new_expiry: DateTime,
/// Taxes to charge
pub tax: u64,
+ /// Full VAT determination for this (single line item) cost, so callers can
+ /// build a per-line-item breakdown on the aggregated payment.
+ pub tax_details: TaxDetermination,
/// Processing fee charged by the payment provider
pub processing_fee: u64,
}
@@ -1060,7 +1393,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
// Test a range of amounts to ensure gross-up always holds
for base in [100u64, 345, 1000, 9999, 50000] {
@@ -1129,7 +1462,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let amount = 990u64; // €9.90 in cents
let fee = pe
@@ -1286,11 +1619,14 @@ mod tests {
..Default::default()
},
);
+
+ // Seller company is established in the EU (IE) so EU VAT applies.
+ db.companies.lock().await.get_mut(&1).unwrap().country_code = Some("IRL".to_string());
}
let db: Arc = Arc::new(db);
- let taxes = HashMap::from([(CountryCode::IRL, 23.0)]);
+ let taxes = VatClient::with_rates(HashMap::from([(CountryCode::IRL, 23.0)]));
let pe = PricingEngine::new(db.clone(), rates, taxes);
let plan = MockDb::mock_cost_plan();
@@ -1434,7 +1770,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
// Test upgrade configuration - increase CPU from 1 to 2
@@ -1491,7 +1827,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
let upgrade_config = UpgradeConfig {
@@ -1541,7 +1877,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
let upgrade_config = UpgradeConfig {
@@ -1592,7 +1928,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
let upgrade_config = UpgradeConfig {
@@ -1653,7 +1989,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
// Test upgrade - increase CPU from 2 to 4 (double the CPU)
@@ -1824,7 +2160,7 @@ mod tests {
}
let db_arc: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db_arc.clone(), rates, taxes);
// Test large upgrade - significantly increase all resources
@@ -1981,7 +2317,7 @@ mod tests {
add_revolut_processing_fee_config(&db).await;
let db: Arc = Arc::new(db);
- let taxes = HashMap::new();
+ let taxes = VatClient::new();
let pe = PricingEngine::new(db.clone(), rates, taxes.clone());
// Test Lightning payment (no processing fee)
@@ -2142,7 +2478,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2165,7 +2501,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2192,7 +2528,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2220,7 +2556,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2244,7 +2580,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2283,7 +2619,7 @@ mod tests {
let db: Arc = Arc::new(db);
let rates = Arc::new(MockExchangeRate::new());
- let pe = PricingEngine::new(db, rates, HashMap::new());
+ let pe = PricingEngine::new(db, rates, VatClient::new());
let cfg = crate::UpgradeConfig {
new_cpu: Some(4),
@@ -2304,7 +2640,7 @@ mod tests {
rates
.set_rate(Ticker::btc_rate("EUR").unwrap(), MOCK_RATE)
.await;
- PricingEngine::new(db, rates as Arc, HashMap::new())
+ PricingEngine::new(db, rates as Arc, VatClient::new())
}
/// get_vm_cost_for_intervals returns CostResult::Existing when a valid (non-expired)
@@ -2356,6 +2692,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
db.insert_subscription_payment(&existing).await?;
@@ -2421,6 +2762,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
db.insert_subscription_payment(&existing).await?;
@@ -2472,6 +2818,11 @@ mod tests {
tax: 0,
processing_fee: 0,
paid_at: None,
+ tax_rate: None,
+ tax_country_code: None,
+ tax_treatment: None,
+ tax_evidence: None,
+ tax_breakdown: None,
};
db.insert_subscription_payment(&expired).await?;
@@ -2495,4 +2846,328 @@ mod tests {
}
Ok(())
}
+
+ // ---- VAT place-of-supply determination -------------------------------
+
+ fn eu_tax_rates() -> HashMap {
+ HashMap::from([
+ (CountryCode::IRL, 23.0),
+ (CountryCode::DEU, 19.0),
+ (CountryCode::FRA, 20.0),
+ ])
+ }
+
+ async fn tax_db(seller_cc: Option<&str>) -> MockDb {
+ let db = MockDb::default();
+ {
+ let mut c = db.companies.lock().await;
+ c.get_mut(&1).unwrap().country_code = seller_cc.map(|s| s.to_string());
+ }
+ db
+ }
+
+ async fn tax_db_with_vat(seller_vat: &str) -> MockDb {
+ let db = MockDb::default();
+ {
+ let mut c = db.companies.lock().await;
+ let company = c.get_mut(&1).unwrap();
+ company.country_code = None;
+ company.tax_id = Some(seller_vat.to_string());
+ }
+ db
+ }
+
+ async fn tax_user(
+ db: &MockDb,
+ id: u64,
+ country: Option<&str>,
+ geo: Option<&str>,
+ vat: Option<&str>,
+ ) {
+ let mut u = db.users.lock().await;
+ u.insert(
+ id,
+ User {
+ id,
+ pubkey: vec![],
+ country_code: country.map(|s| s.to_string()),
+ geo_country_code: geo.map(|s| s.to_string()),
+ billing_tax_id: vat.map(|s| s.to_string()),
+ ..Default::default()
+ },
+ );
+ }
+
+ async fn make_pe_tax(db: MockDb) -> PricingEngine {
+ let db: Arc = Arc::new(db);
+ PricingEngine::new(
+ db,
+ Arc::new(MockExchangeRate::new()),
+ VatClient::with_rates(eu_tax_rates()),
+ )
+ }
+
+ #[test]
+ fn eu_membership_and_vat_prefix() {
+ assert!(is_eu_vat_country("IRL"));
+ assert!(is_eu_vat_country("deu"));
+ assert!(!is_eu_vat_country("USA"));
+ assert!(!is_eu_vat_country("GBR"));
+ assert_eq!(
+ vat_number_country_alpha3("DE123456789").as_deref(),
+ Some("DEU")
+ );
+ assert_eq!(
+ vat_number_country_alpha3("IE1234567X").as_deref(),
+ Some("IRL")
+ );
+ // Greek VAT numbers use the EL prefix, not the ISO code GR.
+ assert_eq!(
+ vat_number_country_alpha3("EL123456789").as_deref(),
+ Some("GRC")
+ );
+ assert_eq!(vat_number_country_alpha3("12345"), None);
+ }
+
+ #[tokio::test]
+ async fn seller_country_from_own_vat_number() -> Result<()> {
+ // Seller country is taken from our own VAT number even when country_code
+ // is unset. IE VAT number + IE customer -> domestic 23%.
+ let db = tax_db_with_vat("IE1234567X").await;
+ tax_user(&db, 10, Some("IRL"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::Domestic);
+ assert_eq!(d.amount, 2300);
+
+ // Same IE-registered seller, German consumer -> OSS 19%.
+ let db = tax_db_with_vat("IE1234567X").await;
+ tax_user(&db, 11, Some("DEU"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(11, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OssB2c);
+ assert_eq!(d.amount, 1900);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn non_eu_seller_charges_no_tax() -> Result<()> {
+ // A US company selling to an EU consumer charges no EU VAT here.
+ let db = tax_db(Some("USA")).await;
+ tax_user(&db, 10, Some("DEU"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OutOfScope);
+ assert_eq!(d.amount, 0);
+
+ // Even a same-country US B2C sale is untaxed by this (EU-only) logic.
+ let db = tax_db(Some("USA")).await;
+ tax_user(&db, 11, Some("USA"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(11, 10000, 1).await?;
+ assert_eq!(d.amount, 0);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn no_seller_country_charges_no_tax() -> Result<()> {
+ let db = tax_db(None).await;
+ tax_user(&db, 10, Some("DEU"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OutOfScope);
+ assert_eq!(d.amount, 0);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_non_eu_consumer_out_of_scope() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("USA"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OutOfScope);
+ assert_eq!(d.amount, 0);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_eu_consumer_destination_rate() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("DEU"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OssB2c);
+ assert_eq!(d.country_code.as_deref(), Some("DEU"));
+ assert_eq!(d.amount, 1900); // 19% of 10000
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_eu_consumer_domestic_when_same_as_seller() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("IRL"), None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::Domestic);
+ assert_eq!(d.amount, 2300); // 23%
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_b2b_reverse_charge_other_eu_country() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("DEU"), None, Some("DE123456789")).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::ReverseCharge);
+ assert_eq!(d.amount, 0);
+ assert_eq!(d.vat_number.as_deref(), Some("DE123456789"));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_b2b_domestic_same_country_vat() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("IRL"), None, Some("IE1234567X")).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::Domestic);
+ assert_eq!(d.amount, 2300);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_b2b_non_eu_vat_out_of_scope() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ // A non-EU (e.g. Norwegian) VAT number.
+ tax_user(&db, 10, Some("NOR"), None, Some("NO999999999")).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OutOfScope);
+ assert_eq!(d.amount, 0);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_geo_fallback_when_no_self_declared_country() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, None, Some("FRA"), None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OssB2c);
+ assert_eq!(d.country_code.as_deref(), Some("FRA"));
+ assert_eq!(d.amount, 2000); // 20%
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_undetermined_defaults_to_eu_seller_rate() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, None, None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::UndeterminedDefault);
+ assert_eq!(d.country_code.as_deref(), Some("IRL"));
+ assert_eq!(d.amount, 2300);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_undetermined_non_eu_seller_out_of_scope() -> Result<()> {
+ let db = tax_db(Some("USA")).await;
+ tax_user(&db, 10, None, None, None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.treatment, TaxTreatment::OutOfScope);
+ assert_eq!(d.amount, 0);
+ Ok(())
+ }
+
+ #[test]
+ fn summarize_uniform_and_mixed_breakdowns() {
+ // Uniform: two lines, same rate/country/treatment -> populated summary.
+ let uniform = vec![
+ TaxLine {
+ net: 1000,
+ tax: 190,
+ rate: 19.0,
+ country_code: Some("DEU".into()),
+ treatment: TaxTreatment::OssB2c,
+ },
+ TaxLine {
+ net: 500,
+ tax: 95,
+ rate: 19.0,
+ country_code: Some("DEU".into()),
+ treatment: TaxTreatment::OssB2c,
+ },
+ ];
+ let s = summarize_tax_lines(&uniform);
+ assert_eq!(s.rate, Some(19.0));
+ assert_eq!(s.country_code.as_deref(), Some("DEU"));
+ assert_eq!(s.treatment.as_deref(), Some("oss_b2c"));
+
+ // Mixed: reverse charge (0%) on one line, domestic 23% on another ->
+ // differing fields collapse to None ("see breakdown").
+ let mixed = vec![
+ TaxLine {
+ net: 1000,
+ tax: 0,
+ rate: 0.0,
+ country_code: Some("DEU".into()),
+ treatment: TaxTreatment::ReverseCharge,
+ },
+ TaxLine {
+ net: 1000,
+ tax: 230,
+ rate: 23.0,
+ country_code: Some("IRL".into()),
+ treatment: TaxTreatment::Domestic,
+ },
+ ];
+ let s = summarize_tax_lines(&mixed);
+ assert_eq!(s.rate, None);
+ assert_eq!(s.country_code, None);
+ assert_eq!(s.treatment, None);
+
+ // Empty breakdown -> empty summary.
+ let s = summarize_tax_lines(&[]);
+ assert_eq!(s.rate, None);
+ assert_eq!(s.country_code, None);
+ assert_eq!(s.treatment, None);
+ }
+
+ #[tokio::test]
+ async fn determination_to_line_and_evidence() -> Result<()> {
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("DEU"), Some("FRA"), None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ let line = d.to_line(10000);
+ assert_eq!(line.net, 10000);
+ assert_eq!(line.tax, 1900);
+ assert_eq!(line.rate, 19.0);
+ assert_eq!(line.treatment, TaxTreatment::OssB2c);
+ // Evidence records both declared and geo signals.
+ let ev = d.evidence_json();
+ assert_eq!(ev["declared_country"], "DEU");
+ assert_eq!(ev["geo_country"], "FRA");
+ assert!(ev["vat_number"].is_null());
+ // Untaxed determination yields a zero line.
+ assert_eq!(TaxDetermination::untaxed().to_line(500).tax, 0);
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn tax_self_declared_takes_priority_over_geo() -> Result<()> {
+ // Conflicting evidence: self-declared IE (domestic 23%) beats geo DE.
+ let db = tax_db(Some("IRL")).await;
+ tax_user(&db, 10, Some("IRL"), Some("DEU"), None).await;
+ let pe = make_pe_tax(db).await;
+ let d = pe.determine_tax(10, 10000, 1).await?;
+ assert_eq!(d.country_code.as_deref(), Some("IRL"));
+ assert_eq!(d.amount, 2300);
+ Ok(())
+ }
}
diff --git a/lnvps_api_common/src/vat.rs b/lnvps_api_common/src/vat.rs
index fcf681ba..07723991 100644
--- a/lnvps_api_common/src/vat.rs
+++ b/lnvps_api_common/src/vat.rs
@@ -1,7 +1,21 @@
use anyhow::{Result, bail};
+use isocountry::CountryCode;
use log::trace;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::sync::{Arc, RwLock};
+
+/// Convert a VAT-territory 2-letter code to an [`isocountry::CountryCode`].
+///
+/// Greek VAT rates are published under the `EL` code rather than the ISO
+/// `GR`; that special case is mapped so Greece is not silently dropped.
+pub fn vat_code_to_isocountry(code: &str) -> Option {
+ let alpha2 = match code.to_uppercase().as_str() {
+ "EL" => "GR".to_string(),
+ other => other.to_string(),
+ };
+ CountryCode::for_alpha2(&alpha2).ok()
+}
/// EU VAT rates API response
#[derive(Debug, Deserialize)]
@@ -85,22 +99,37 @@ impl VatRate {
}
}
-/// Client for fetching EU VAT rates and validating VAT numbers
-#[derive(Debug, Clone, Default)]
-pub struct EuVatClient {
+/// Client for fetching VAT rates and validating VAT numbers.
+///
+/// Cloneable and cheap to share: clones point at the same internal rate cache
+/// (`Arc>`), so refreshing rates on one clone is visible to all
+/// others. The cache starts empty; call [`refresh_rates`](Self::refresh_rates)
+/// (e.g. at startup and periodically) to populate it. Rate lookups
+/// ([`rate_for`](Self::rate_for)) are synchronous and never hit the network.
+#[derive(Debug, Clone)]
+pub struct VatClient {
/// URL for fetching VAT rates
rates_url: String,
/// URL for VAT validation API
validation_url: String,
+ /// Cached standard VAT rates keyed by country, shared across clones.
+ cache: Arc>>,
}
-impl EuVatClient {
- /// Create a new client with default API URLs
+impl Default for VatClient {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl VatClient {
+ /// Create a new client with default API URLs and an empty rate cache.
pub fn new() -> Self {
Self {
rates_url: "https://euvatrates.com/rates.json".to_string(),
validation_url: "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number"
.to_string(),
+ cache: Arc::new(RwLock::new(HashMap::new())),
}
}
@@ -109,9 +138,42 @@ impl EuVatClient {
Self {
rates_url: rates_url.into(),
validation_url: validation_url.into(),
+ cache: Arc::new(RwLock::new(HashMap::new())),
}
}
+ /// Create a client with a pre-populated rate cache (useful for testing /
+ /// offline operation). No network access is performed.
+ pub fn with_rates(rates: HashMap) -> Self {
+ let s = Self::new();
+ *s.cache.write().expect("vat cache poisoned") = rates;
+ s
+ }
+
+ /// Replace the cached rate table directly (no network). Useful for tests and
+ /// for seeding rates from an alternative source.
+ pub fn set_rates(&self, rates: HashMap) {
+ *self.cache.write().expect("vat cache poisoned") = rates;
+ }
+
+ /// Fetch the latest rates and replace the cached table. Returns the number
+ /// of countries loaded.
+ pub async fn refresh_rates(&self) -> Result {
+ let map = self.fetch_rates_map().await?;
+ let n = map.len();
+ *self.cache.write().expect("vat cache poisoned") = map;
+ Ok(n)
+ }
+
+ /// Look up the cached standard VAT rate (%) for a country, if known.
+ pub fn rate_for(&self, country: CountryCode) -> Option {
+ self.cache
+ .read()
+ .expect("vat cache poisoned")
+ .get(&country)
+ .copied()
+ }
+
/// Fetch all EU VAT rates
pub async fn fetch_rates(&self) -> Result> {
trace!("Fetching VAT rates from: {}", self.rates_url);
@@ -131,6 +193,18 @@ impl EuVatClient {
Ok(rates)
}
+ /// Fetch all EU standard VAT rates as a map keyed by [`CountryCode`].
+ ///
+ /// Codes that don't resolve to a known country are skipped. Intended for
+ /// building the pricing engine's rate table at startup.
+ pub async fn fetch_rates_map(&self) -> Result> {
+ let rates = self.fetch_rates().await?;
+ Ok(rates
+ .into_iter()
+ .filter_map(|r| vat_code_to_isocountry(r.country_code_str()).map(|cc| (cc, r.rate)))
+ .collect())
+ }
+
/// Fetch VAT rate for a specific country
pub async fn fetch_rate(&self, country_code: &str) -> Result {
let rates = self.fetch_rates().await?;
@@ -149,7 +223,7 @@ impl EuVatClient {
///
/// # Examples
/// ```ignore
- /// let client = EuVatClient::new();
+ /// let client = VatClient::new();
/// // With country prefix
/// let result = client.validate_vat_number("DE123456789", None).await?;
/// // Without country prefix
@@ -220,6 +294,16 @@ impl EuVatClient {
mod tests {
use super::*;
+ #[test]
+ fn test_vat_code_to_isocountry() {
+ assert_eq!(vat_code_to_isocountry("DE"), Some(CountryCode::DEU));
+ assert_eq!(vat_code_to_isocountry("ie"), Some(CountryCode::IRL));
+ // Greek VAT code EL must map to Greece.
+ assert_eq!(vat_code_to_isocountry("EL"), Some(CountryCode::GRC));
+ assert_eq!(vat_code_to_isocountry("GR"), Some(CountryCode::GRC));
+ assert_eq!(vat_code_to_isocountry("ZZ"), None);
+ }
+
#[test]
fn test_vat_rate_new() {
let rate = VatRate::new("DE", 19.0).unwrap();
@@ -290,7 +374,7 @@ mod tests {
#[tokio::test]
async fn test_fetch_vat_rates() {
- let client = EuVatClient::new();
+ let client = VatClient::new();
let rates = client.fetch_rates().await.unwrap();
// Should have rates for EU member states
@@ -299,7 +383,7 @@ mod tests {
#[tokio::test]
async fn test_validate_vat_number() {
- let client = EuVatClient::new();
+ let client = VatClient::new();
// Test with an invalid VAT number - should return valid=false
let result = client
@@ -316,7 +400,7 @@ mod tests {
/// `&cleaned[..2]`. Parsing fails before any network call is made.
#[tokio::test]
async fn test_validate_vat_number_multibyte_does_not_panic() {
- let client = EuVatClient::new();
+ let client = VatClient::new();
let result = client.validate_vat_number("€12345", None).await;
assert!(
result.is_err(),
@@ -327,7 +411,7 @@ mod tests {
/// A single-character (too short) input must also error cleanly.
#[tokio::test]
async fn test_validate_vat_number_too_short() {
- let client = EuVatClient::new();
+ let client = VatClient::new();
assert!(client.validate_vat_number("D", None).await.is_err());
}
}
diff --git a/lnvps_db/migrations/20260716120000_subscription_payment_vat_snapshot.sql b/lnvps_db/migrations/20260716120000_subscription_payment_vat_snapshot.sql
new file mode 100644
index 00000000..818eb2b6
--- /dev/null
+++ b/lnvps_db/migrations/20260716120000_subscription_payment_vat_snapshot.sql
@@ -0,0 +1,14 @@
+-- Record the tax fields on each payment at the time it is created. The `tax`
+-- amount (cents) already exists; these store the rate and inputs used so the
+-- values can be reproduced later independently of the account or rate table.
+--
+-- `tax_breakdown` holds the per-line values as a JSON array. `tax_rate`,
+-- `tax_country_code` and `tax_treatment` are a convenience summary, set only
+-- when every line matches and left NULL when the lines differ (see
+-- `tax_breakdown`). `tax_evidence` holds the country signals for the customer.
+alter table subscription_payment
+ add column tax_rate double null,
+ add column tax_country_code varchar(3) null,
+ add column tax_treatment varchar(32) null,
+ add column tax_evidence json null,
+ add column tax_breakdown json null;
diff --git a/lnvps_db/migrations/20260716130000_drop_vm_payment.sql b/lnvps_db/migrations/20260716130000_drop_vm_payment.sql
new file mode 100644
index 00000000..8b4f4275
--- /dev/null
+++ b/lnvps_db/migrations/20260716130000_drop_vm_payment.sql
@@ -0,0 +1,4 @@
+-- Retire the defunct `vm_payment` table. All payments now live in
+-- `subscription_payment`; historical `vm_payment` rows were copied there by the
+-- startup backfill (Phase 2) in earlier releases, which has since been removed.
+drop table if exists vm_payment;
diff --git a/lnvps_db/migrations/20260716140000_user_geo_location.sql b/lnvps_db/migrations/20260716140000_user_geo_location.sql
new file mode 100644
index 00000000..beb3d242
--- /dev/null
+++ b/lnvps_db/migrations/20260716140000_user_geo_location.sql
@@ -0,0 +1,8 @@
+-- Store IP-derived geolocation as a second, independent piece of place-of-supply
+-- evidence for EU VAT on electronically supplied services. Kept separate from the
+-- self-declared `country_code` so the two signals can be compared and conflicts
+-- flagged when determining whether/what VAT to charge.
+alter table users
+ add column geo_country_code varchar(3) null,
+ add column geo_ip varchar(45) null,
+ add column geo_updated timestamp null;
diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs
index c698e61d..6f435ba6 100644
--- a/lnvps_db/src/lib.rs
+++ b/lnvps_db/src/lib.rs
@@ -378,46 +378,6 @@ pub trait LNVpsDbBase: Send + Sync {
policy_out: Option,
) -> DbResult<()>;
- /// List payments by VM id
- async fn list_vm_payment(&self, vm_id: u64) -> DbResult>;
-
- /// List payments by VM id with pagination
- async fn list_vm_payment_paginated(
- &self,
- vm_id: u64,
- limit: u64,
- offset: u64,
- ) -> DbResult>;
-
- /// List active payments by VM id, payment method, and payment type
- async fn list_vm_payment_by_method_and_type(
- &self,
- vm_id: u64,
- method: PaymentMethod,
- payment_type: PaymentType,
- ) -> DbResult>;
-
- /// Insert a new VM payment record
- async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()>;
-
- /// Get VM payment by payment id
- async fn get_vm_payment(&self, id: &Vec) -> DbResult;
-
- /// Get VM payment by payment id
- async fn get_vm_payment_by_ext_id(&self, id: &str) -> DbResult;
-
- /// Update a VM payment record
- async fn update_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()>;
-
- /// Mark a payment as paid and update the vm expiry
- async fn vm_payment_paid(&self, id: &VmPayment) -> DbResult<()>;
-
- /// Return the most recently settled invoice
- async fn last_paid_invoice(&self) -> DbResult>;
-
- /// Count active (unpaid, non-expired) payments for a VM
- async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult;
-
/// Return the list of active custom pricing models for a given region
async fn list_custom_pricing(&self, region_id: u64) -> DbResult>;
diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs
index b0ede234..02baadee 100644
--- a/lnvps_db/src/model.rs
+++ b/lnvps_db/src/model.rs
@@ -1194,46 +1194,6 @@ pub struct Vm {
pub fw_policy_out: Option,
}
-/// Raw vm_payment row with external_data as a plain String (not decrypted).
-/// Used by the data migration tool to copy rows without needing the encryption key.
-#[derive(FromRow, Clone, Debug)]
-pub struct VmPaymentRaw {
- pub id: Vec,
- pub vm_id: u64,
- pub created: DateTime,
- pub expires: DateTime,
- pub amount: u64,
- pub currency: String,
- pub payment_method: PaymentMethod,
- pub payment_type: PaymentType,
- pub external_data: String,
- pub external_id: Option,
- pub is_paid: bool,
- pub rate: f32,
- pub time_value: u64,
- pub tax: u64,
- pub upgrade_params: Option,
- pub processing_fee: u64,
- pub paid_at: Option>,
-}
-
-/// Minimal VM projection used by the data migration tool where
-/// `subscription_line_item_id` may still be NULL for pre-migration rows.
-#[derive(FromRow, Clone, Debug)]
-pub struct VmForMigration {
- pub id: u64,
- pub user_id: u64,
- pub template_id: Option,
- pub custom_template_id: Option,
- pub created: DateTime,
- /// Legacy expiry column. Nullable since the new provisioning path no longer writes it
- /// (expiry now lives on the subscription). `None` => managed by the new subscription path.
- pub expires: Option>,
- pub auto_renewal_enabled: bool,
- pub subscription_line_item_id: Option,
- pub deleted: bool,
-}
-
#[derive(FromRow, Clone, Debug, Default)]
pub struct VmIpAssignment {
/// Unique id of this assignment
@@ -1342,35 +1302,6 @@ pub struct VmFirewallRule {
pub updated: DateTime,
}
-#[derive(FromRow, Clone, Debug, Default)]
-pub struct VmPayment {
- pub id: Vec,
- pub vm_id: u64,
- pub created: DateTime,
- pub expires: DateTime,
- pub amount: u64,
- pub currency: String,
- pub payment_method: PaymentMethod,
- pub payment_type: PaymentType,
- /// External data (invoice / json) (encrypted)
- pub external_data: EncryptedString,
- /// External id on other system
- pub external_id: Option,
- pub is_paid: bool,
- /// Exchange rate back to company's base currency
- pub rate: f32,
- /// Number of seconds this payment will add to vm expiry
- pub time_value: u64,
- /// Taxes to charge on payment
- pub tax: u64,
- /// Processing fee charged by the payment provider
- pub processing_fee: u64,
- /// JSON-encoded upgrade parameters (CPU, memory, disk) for upgrade payments
- pub upgrade_params: Option,
- /// Timestamp when the payment was completed
- pub paid_at: Option>,
-}
-
#[derive(FromRow, Clone, Debug, Default)]
pub struct Referral {
/// Unique id of this referral entry
@@ -1418,46 +1349,6 @@ pub struct ReferralCostUsage {
pub base_currency: String,
}
-/// VM Payment with company information for time-series reporting
-#[derive(FromRow, Clone, Debug)]
-pub struct VmPaymentWithCompany {
- pub id: Vec,
- pub vm_id: u64,
- pub created: DateTime,
- pub expires: DateTime,
- pub amount: u64,
- pub currency: String,
- pub payment_method: PaymentMethod,
- pub payment_type: PaymentType,
- /// External data (invoice / json) (encrypted)
- pub external_data: EncryptedString,
- /// External id on other system
- pub external_id: Option,
- pub is_paid: bool,
- /// Exchange rate back to company's base currency
- pub rate: f32,
- /// Number of seconds this payment will add to vm expiry
- pub time_value: u64,
- /// Taxes to charge on payment
- pub tax: u64,
- /// Processing fee charged by the payment provider
- pub processing_fee: u64,
- /// JSON-encoded upgrade parameters (CPU, memory, disk) for upgrade payments
- pub upgrade_params: Option,
- // Company information
- pub company_id: u64,
- pub company_name: String,
- pub company_base_currency: String,
- // User information
- pub user_id: u64,
- // Host information
- pub host_id: u64,
- pub host_name: String,
- // Region information
- pub region_id: u64,
- pub region_name: String,
-}
-
#[derive(Type, Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[repr(u16)]
pub enum PaymentMethod {
@@ -2021,6 +1912,24 @@ pub struct SubscriptionPayment {
pub processing_fee: u64,
/// Timestamp when the payment was completed
pub paid_at: Option>,
+ /// Summary VAT rate (%) when the breakdown is uniform; `None` if the payment
+ /// mixes rates across line items (see `tax_breakdown`).
+ #[sqlx(default)]
+ pub tax_rate: Option,
+ /// Summary place-of-supply country (ISO alpha-3) when uniform; `None` if mixed.
+ #[sqlx(default)]
+ pub tax_country_code: Option,
+ /// Summary treatment (see `TaxTreatment`) when uniform; `None` if mixed.
+ #[sqlx(default)]
+ pub tax_treatment: Option,
+ /// Evidence used for the determination (declared/geo country, VAT number),
+ /// as JSON, frozen at sale time. Uniform per payment (one customer).
+ #[sqlx(default)]
+ pub tax_evidence: Option,
+ /// Authoritative per-line-item VAT breakdown (JSON array), frozen at sale
+ /// time. Losslessly records payments that mix rates/treatments.
+ #[sqlx(default)]
+ pub tax_breakdown: Option,
}
/// Subscription payment with company info (for admin views and time-series reporting)
@@ -2047,6 +1956,21 @@ pub struct SubscriptionPaymentWithCompany {
pub processing_fee: u64,
/// Timestamp when the payment was completed
pub paid_at: Option>,
+ /// Summary VAT rate (%) when uniform; `None` if mixed (see `tax_breakdown`).
+ #[sqlx(default)]
+ pub tax_rate: Option,
+ /// Summary place-of-supply country (ISO alpha-3) when uniform; `None` if mixed.
+ #[sqlx(default)]
+ pub tax_country_code: Option,
+ /// Summary treatment (see `TaxTreatment`) when uniform; `None` if mixed.
+ #[sqlx(default)]
+ pub tax_treatment: Option,
+ /// Evidence used for the determination, as JSON, frozen at sale time.
+ #[sqlx(default)]
+ pub tax_evidence: Option,
+ /// Authoritative per-line-item VAT breakdown (JSON array), frozen at sale time.
+ #[sqlx(default)]
+ pub tax_breakdown: Option,
// Company information
pub company_id: u64,
pub company_name: String,
diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs
index 6b709ca8..858aedad 100644
--- a/lnvps_db/src/mysql.rs
+++ b/lnvps_db/src/mysql.rs
@@ -5,8 +5,8 @@ use crate::{
RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem,
SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm,
VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy,
- VmFirewallRule, VmForMigration, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment,
- VmOsImage, VmPayment, VmPaymentRaw, VmTemplate,
+ VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage,
+ VmTemplate,
};
#[cfg(feature = "admin")]
use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost};
@@ -37,145 +37,6 @@ impl LNVpsDbMysql {
pub fn pool(&self) -> &MySqlPool {
&self.db
}
-
- /// List IDs of ALL VMs (including deleted) that have not yet been linked to a subscription
- /// line item. Used by the data migration tool to avoid decoding nullable
- /// subscription_line_item_id via the Vm struct (which requires it non-null).
- pub async fn list_vm_ids_without_subscription(&self) -> DbResult> {
- let rows = sqlx::query(
- "SELECT id FROM vm \
- WHERE subscription_line_item_id IS NULL OR subscription_line_item_id = 0",
- )
- .fetch_all(&self.db)
- .await?;
- Ok(rows.iter().map(|r| r.get::("id") as u64).collect())
- }
-
- /// List ids of all VMs that already have a subscription linked. Used by the one-time
- /// repair pass that corrects fields written incorrectly by earlier backfill revisions
- /// (subscription.created/currency/is_setup and the custom-VM line-item amount).
- pub async fn list_vm_ids_with_subscription(&self) -> DbResult> {
- let rows = sqlx::query(
- "SELECT id FROM vm \
- WHERE subscription_line_item_id IS NOT NULL AND subscription_line_item_id != 0",
- )
- .fetch_all(&self.db)
- .await?;
- Ok(rows.iter().map(|r| r.get::("id") as u64).collect())
- }
-
- /// Insert a subscription_payment row by copying a vm_payment row verbatim,
- /// writing external_data as raw bytes (no encrypt/decrypt round-trip).
- pub async fn insert_subscription_payment_raw(
- &self,
- vp: &VmPaymentRaw,
- subscription_id: u64,
- user_id: u64,
- payment_type: u16,
- time_value: Option,
- metadata: Option<&str>,
- ) -> DbResult<()> {
- sqlx::query(
- "INSERT INTO subscription_payment \
- (id, subscription_id, user_id, created, expires, amount, currency, \
- payment_method, payment_type, external_data, external_id, is_paid, rate, \
- time_value, metadata, tax, processing_fee, paid_at) \
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- )
- .bind(&vp.id)
- .bind(subscription_id)
- .bind(user_id)
- .bind(vp.created)
- .bind(vp.expires)
- .bind(vp.amount)
- .bind(&vp.currency)
- .bind(vp.payment_method as u16)
- .bind(payment_type)
- .bind(&vp.external_data) // raw string — no encryption
- .bind(&vp.external_id)
- .bind(vp.is_paid)
- .bind(vp.rate)
- .bind(time_value)
- .bind(metadata)
- .bind(vp.tax)
- .bind(vp.processing_fee)
- .bind(vp.paid_at)
- .execute(&self.db)
- .await?;
- Ok(())
- }
-
- /// List the binary ids of all subscription_payments for a subscription.
- /// Used by the migration tool for idempotency checking without decrypting external_data.
- pub async fn list_subscription_payment_ids_for_subscription(
- &self,
- subscription_id: u64,
- ) -> DbResult>> {
- let rows = sqlx::query("SELECT id FROM subscription_payment WHERE subscription_id = ?")
- .bind(subscription_id)
- .fetch_all(&self.db)
- .await?;
- Ok(rows.iter().map(|r| r.get::, _>("id")).collect())
- }
-
- /// List VM ids that have vm_payment rows not yet copied to subscription_payment.
- /// Identifies by id: a vm_payment is considered copied if a subscription_payment
- /// with the same binary id exists.
- pub async fn list_vm_ids_with_uncopied_payments(&self) -> DbResult> {
- let rows = sqlx::query(
- "SELECT DISTINCT vp.vm_id FROM vm_payment vp \
- WHERE NOT EXISTS ( \
- SELECT 1 FROM subscription_payment sp WHERE sp.id = vp.id \
- )",
- )
- .fetch_all(&self.db)
- .await?;
- Ok(rows
- .iter()
- .map(|r| r.get::("vm_id") as u64)
- .collect())
- }
-
- /// List all vm_payment rows for a VM, with external_data as raw String (no decryption).
- /// Used by the data migration tool to copy rows without needing the encryption key.
- pub async fn list_vm_payments_for_migration(&self, vm_id: u64) -> DbResult> {
- Ok(sqlx::query_as(
- "SELECT id, vm_id, created, expires, amount, currency, payment_method, payment_type, \
- external_data, external_id, is_paid, rate, time_value, tax, upgrade_params, \
- processing_fee, paid_at FROM vm_payment WHERE vm_id = ? ORDER BY created ASC",
- )
- .bind(vm_id)
- .fetch_all(&self.db)
- .await?)
- }
-
- /// Set subscription_line_item_id on a VM by id.
- /// Used by the data migration tool where full Vm round-trip is not possible.
- pub async fn set_vm_subscription_line_item(
- &self,
- vm_id: u64,
- subscription_line_item_id: u64,
- ) -> DbResult<()> {
- sqlx::query("UPDATE vm SET subscription_line_item_id = ? WHERE id = ?")
- .bind(subscription_line_item_id)
- .bind(vm_id)
- .execute(&self.db)
- .await?;
- Ok(())
- }
-
- /// Fetch a VM row with subscription_line_item_id decoded as Option.
- /// Used by the data migration tool where the column may still be NULL.
- pub async fn get_vm_for_migration(&self, vm_id: u64) -> DbResult {
- Ok(sqlx::query_as(
- "SELECT id, user_id, template_id, custom_template_id, created, expires, \
- auto_renewal_enabled, subscription_line_item_id, deleted \
- FROM vm WHERE id = ?",
- )
- .bind(vm_id)
- .fetch_one(&self.db)
- .await?)
- }
}
#[async_trait]
@@ -1205,132 +1066,6 @@ impl LNVpsDbBase for LNVpsDbMysql {
Ok(())
}
- async fn list_vm_payment(&self, vm_id: u64) -> DbResult> {
- Ok(sqlx::query_as("select * from vm_payment where vm_id = ?")
- .bind(vm_id)
- .fetch_all(&self.db)
- .await?)
- }
-
- async fn list_vm_payment_paginated(
- &self,
- vm_id: u64,
- limit: u64,
- offset: u64,
- ) -> DbResult> {
- Ok(sqlx::query_as(
- "select * from vm_payment where vm_id = ? order by created desc limit ? offset ?",
- )
- .bind(vm_id)
- .bind(limit)
- .bind(offset)
- .fetch_all(&self.db)
- .await?)
- }
-
- async fn list_vm_payment_by_method_and_type(
- &self,
- vm_id: u64,
- method: PaymentMethod,
- payment_type: PaymentType,
- ) -> DbResult> {
- Ok(sqlx::query_as(
- "select * from vm_payment where vm_id = ? and payment_method = ? and payment_type = ? and expires > NOW() and is_paid = false order by created desc",
- )
- .bind(vm_id)
- .bind(method)
- .bind(payment_type)
- .fetch_all(&self.db)
- .await?)
- }
-
- async fn insert_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()> {
- sqlx::query("insert into vm_payment(id,vm_id,created,expires,amount,tax,processing_fee,currency,payment_method,payment_type,time_value,is_paid,rate,external_id,external_data,upgrade_params,paid_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
- .bind(&vm_payment.id)
- .bind(vm_payment.vm_id)
- .bind(vm_payment.created)
- .bind(vm_payment.expires)
- .bind(vm_payment.amount)
- .bind(vm_payment.tax)
- .bind(vm_payment.processing_fee)
- .bind(&vm_payment.currency)
- .bind(vm_payment.payment_method)
- .bind(vm_payment.payment_type)
- .bind(vm_payment.time_value)
- .bind(vm_payment.is_paid)
- .bind(vm_payment.rate)
- .bind(&vm_payment.external_id)
- .bind(&vm_payment.external_data)
- .bind(&vm_payment.upgrade_params)
- .bind(vm_payment.paid_at)
- .execute(&self.db)
- .await?;
- Ok(())
- }
-
- async fn get_vm_payment(&self, id: &Vec) -> DbResult {
- Ok(sqlx::query_as("select * from vm_payment where id=?")
- .bind(id)
- .fetch_one(&self.db)
- .await?)
- }
-
- async fn get_vm_payment_by_ext_id(&self, id: &str) -> DbResult {
- Ok(
- sqlx::query_as("select * from vm_payment where external_id=?")
- .bind(id)
- .fetch_one(&self.db)
- .await?,
- )
- }
-
- async fn update_vm_payment(&self, vm_payment: &VmPayment) -> DbResult<()> {
- sqlx::query("update vm_payment set is_paid = ? where id = ?")
- .bind(vm_payment.is_paid)
- .bind(&vm_payment.id)
- .execute(&self.db)
- .await?;
- Ok(())
- }
-
- async fn vm_payment_paid(&self, vm_payment: &VmPayment) -> DbResult<()> {
- if vm_payment.is_paid {
- return Err(DbError::Source(
- anyhow!("Invoice already paid").into_boxed_dyn_error(),
- ));
- }
-
- let mut tx = self.db.begin().await?;
-
- sqlx::query(
- "update vm_payment set is_paid = true, external_data = ?, paid_at = NOW() where id = ?",
- )
- .bind(&vm_payment.external_data)
- .bind(&vm_payment.id)
- .execute(&mut *tx)
- .await?;
-
- tx.commit().await?;
- Ok(())
- }
-
- async fn last_paid_invoice(&self) -> DbResult> {
- Ok(sqlx::query_as(
- "select * from vm_payment where is_paid = true order by created desc limit 1",
- )
- .fetch_optional(&self.db)
- .await?)
- }
-
- async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult {
- let (count,): (i64,) =
- sqlx::query_as("select count(*) from vm_payment where vm_id = ? and is_paid = false and expires > NOW()")
- .bind(vm_id)
- .fetch_one(&self.db)
- .await?;
- Ok(count as u64)
- }
-
async fn list_custom_pricing(&self, region_id: u64) -> DbResult> {
Ok(
sqlx::query_as("select * from vm_custom_pricing where region_id = ?")
@@ -2506,7 +2241,7 @@ impl LNVpsDbBase for LNVpsDbMysql {
async fn insert_subscription_payment(&self, payment: &SubscriptionPayment) -> DbResult<()> {
sqlx::query(
- "INSERT INTO subscription_payment (id, subscription_id, user_id, created, expires, amount, currency, payment_method, payment_type, external_data, external_id, is_paid, rate, tax, processing_fee, time_value, metadata, paid_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ "INSERT INTO subscription_payment (id, subscription_id, user_id, created, expires, amount, currency, payment_method, payment_type, external_data, external_id, is_paid, rate, tax, processing_fee, time_value, metadata, paid_at, tax_rate, tax_country_code, tax_treatment, tax_evidence, tax_breakdown) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&payment.id)
.bind(payment.subscription_id)
@@ -2526,6 +2261,11 @@ impl LNVpsDbBase for LNVpsDbMysql {
.bind(payment.time_value)
.bind(&payment.metadata)
.bind(payment.paid_at)
+ .bind(payment.tax_rate)
+ .bind(&payment.tax_country_code)
+ .bind(&payment.tax_treatment)
+ .bind(&payment.tax_evidence)
+ .bind(&payment.tax_breakdown)
.execute(&self.db)
.await?;
diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs
index 3468f797..a33693ac 100644
--- a/lnvps_e2e/src/db.rs
+++ b/lnvps_e2e/src/db.rs
@@ -187,11 +187,6 @@ pub async fn hard_delete_vm(pool: &MySqlPool, vm_id: u64) -> anyhow::Result<()>
.fetch_optional(pool)
.await?;
- // Delete legacy vm_payment rows (pre-subscription-migration VMs only).
- sqlx::query("DELETE FROM vm_payment WHERE vm_id = ?")
- .bind(vm_id)
- .execute(pool)
- .await?;
sqlx::query("DELETE FROM vm_ip_assignment WHERE vm_id = ?")
.bind(vm_id)
.execute(pool)
diff --git a/work/vat-snapshot-and-vm-payment-retirement.md b/work/vat-snapshot-and-vm-payment-retirement.md
new file mode 100644
index 00000000..610fcf4f
--- /dev/null
+++ b/work/vat-snapshot-and-vm-payment-retirement.md
@@ -0,0 +1,93 @@
+# VAT snapshot on payments + retire defunct vm_payment
+
+**Status:** complete
+**Started:** 2026-07-16
+**Last updated:** 2026-07-16
+
+## Goal
+
+1. Freeze a full VAT determination snapshot (rate, place-of-supply country,
+ treatment, and the evidence used) on every `subscription_payment` for OSS
+ filing / audit defensibility.
+2. Finish retiring the defunct `vm_payment` table: drop the table and remove the
+ dead model/queries/backfill-phase-2/demo-data that still reference it.
+
+## Findings
+
+- **Live payment path is `subscription_payment`.** The public API
+ (`/api/v1/vm/{id}/payments`, renew, etc.) already builds `ApiVmPayment` via
+ `ApiVmPayment::from_subscription_payment(...)`. `ApiVmPayment` is only a
+ response DTO name — not backed by the `vm_payment` table.
+- **`vm_payment` is only referenced by:**
+ - `lnvps_api/src/data_migration/vm_subscription_backfill.rs` Phase 2
+ (`list_vm_payments_for_migration`) — copies old rows into
+ `subscription_payment`. Phase 1 (VM→subscription linking) does NOT touch
+ vm_payment and must be kept.
+ - `lnvps_api_admin/src/bin/generate_demo_data.rs` (`insert_vm_payment`).
+ - DB trait methods in `lnvps_db/src/lib.rs` (~12) + mysql impls (~53 refs) +
+ mock impls + models `VmPayment`/`VmPaymentRaw`/`VmPaymentWithCompany`.
+- **Payment tax is computed uniformly per payment** (one user + one
+ `subscription.company_id`), so a single `TaxDetermination` per payment is
+ correct. 4 `SubscriptionPayment {}` construction sites in
+ `subscription/mod.rs`: lines ~550, 639 (aggregated renew) and ~736, 784
+ (single-item). Upgrade path ~934 sets `tax: 0`.
+- Migration ordering: the startup backfill runs AFTER schema migrations, so the
+ `DROP TABLE vm_payment` migration and the removal of backfill Phase 2 must land
+ together (otherwise Phase 2 queries a dropped table). Operator confirms prod is
+ already migrated.
+- Snapshot column design (chosen): `tax_rate double`, `tax_country_code
+ varchar(3)`, `tax_treatment varchar(32)`, `tax_evidence json` (declared
+ country / geo country / vat number). Discrete columns for reporting GROUP BY;
+ JSON blob for rarely-queried evidence.
+
+## Tasks
+
+### Increment 1 — VAT snapshot on subscription_payment
+- [x] Extend `TaxDetermination` with evidence (`declared_country`,
+ `geo_country`); populate in `determine_tax`.
+- [x] Per-line-item design: added `TaxLine`, `summarize_tax_lines`, `TaxSummary`,
+ `NewPaymentInfo.tax_details`; determination now threaded per line item so a
+ payment mixing sellers/treatments is recorded losslessly.
+- [x] Migration: add `tax_rate`(nullable), `tax_country_code`, `tax_treatment`,
+ `tax_evidence`, `tax_breakdown`(json) to `subscription_payment`.
+- [x] Add fields to `SubscriptionPayment` + `SubscriptionPaymentWithCompany`.
+- [x] `insert_subscription_payment` (WithCompany selects use `sp.*`) + mock.
+- [x] Fill snapshot at the 4 construction sites (aggregated + single) from the
+ per-line breakdown; upgrade path → `TaxDetermination::untaxed()`.
+- [x] Removed now-unused `get_tax_for_user`.
+- [ ] Surface in admin reports where subscription payments are exposed.
+- [x] Tests (summarize uniform/mixed, to_line/evidence, determination branches);
+ fixed provisioner test that asserted the old hardcoded 1%.
+
+Note: a payment maps to one subscription (per-VM, single company+user) today, so
+breakdowns are single-line in practice; the array design future-proofs multi-
+seller subscriptions without a schema change.
+
+### Increment 2 — Retire vm_payment
+- [x] Migration `20260716130000_drop_vm_payment.sql`: `DROP TABLE IF EXISTS vm_payment`.
+- [x] Removed backfill Phase 2 (kept Phase 1) + `migrate_vm_payments` +
+ `list_vm_payments_for_migration` / `list_vm_ids_with_uncopied_payments` /
+ `insert_subscription_payment_raw` / `list_subscription_payment_ids_for_subscription`.
+- [x] Converted generate_demo_data to insert `SubscriptionPayment`.
+- [x] Removed 12 trait methods (lib.rs), mysql impls, mock impls + mock
+ `payments` field.
+- [x] Removed models `VmPayment` / `VmPaymentRaw` / `VmPaymentWithCompany`,
+ `ApiInvoiceItem::from_vm_payment`, `From for ApiVmPayment`,
+ `AdminVmPaymentInfo::from_vm_payment` (kept `ApiVmPayment` DTO name and the
+ `AdminResource::VmPayment` RBAC resource).
+- [x] Build/test/clippy clean across the workspace (e2e excluded — needs live stack).
+
+## Notes
+
+- No commits yet this session; user reviews before commit.
+- `AdminResource::VmPayment` (RBAC resource id 15) intentionally kept — it gates
+ the payment endpoints, which now read `subscription_payment`.
+- Remaining `vm_payment`-ish identifiers are safe: `ApiVmPayment` DTO,
+ `vm_payment_infos` locals, `log_vm_payment_received`, admin route names.
+
+## Summary
+
+Both increments complete. VAT determinations are now frozen per-payment as a
+per-line-item breakdown (future-proof for multi-seller payments) plus a uniform
+summary + customer evidence, surfaced in admin time-series reports. The defunct
+`vm_payment` table and all its code are removed and the table is dropped.