From 434050d86a87b7c68ae151296ff8342f8d6123c5 Mon Sep 17 00:00:00 2001 From: v0l Date: Wed, 15 Jul 2026 15:05:02 +0100 Subject: [PATCH] feat(vat): EU place-of-supply VAT with per-payment tax record; retire vm_payment Implements EU VAT and records the tax basis on each payment. The seller country is taken from the company's own VAT number (tax_id, i.e. its VIES registration) when set, otherwise from the company country_code. EU VAT applies only when that country is in the EU VAT area; a non-EU seller (e.g. a US company) charges no tax here (other systems such as US sales tax are not handled). - Rate selection (determine_tax): customer with a VAT number -> same country as seller = seller rate, other EU country = 0%, non-EU = 0%; otherwise customer country (self-declared, else IP-derived) -> EU country rate, non-EU = 0%; no customer country -> seller-country fallback rate. - Location evidence: ClientIp extractor (X-Forwarded-For/X-Real-IP) + local MaxMind CountryResolver, captured on account updates and both VM-order endpoints, stored on the user separately from the self-declared country. - Rates: a single shared VatClient (Arc-backed in-memory cache) is created once per process, refreshed at startup and daily, and injected into SubscriptionHandler so every PricingEngine shares the same cache. The static tax_rate config is removed. Rates are 0 until the first refresh. (VIES number validation and refund math use a standalone client, no cache needed.) - Per-payment record: each subscription_payment stores a per-line tax_breakdown (JSON), a uniform summary (tax_rate/tax_country_code/tax_treatment, NULL when lines differ) and the customer country signals (tax_evidence). Surfaced in admin time-series reports. - Invoices show the applied rate; EU-specific reverse-charge (Article 196) and out-of-scope notes render only for EU sellers. - Retire the defunct vm_payment table: drop it, remove models, queries and DTO conversions. ApiVmPayment response shape and the vm_payment RBAC resource are unchanged. Demo data writes subscription_payment. - Remove the completed VM->subscription startup backfill and its migration-only DB helpers (VmForMigration etc.). - README notes the EU-only scope, the seller-from-VAT-number rule, and that this is not tax advice. Behaviour change: non-EU customers/sellers are 0% (the placeholder US 1% map entry is gone). --- API_CHANGELOG.md | 27 + Cargo.lock | 14 + README.md | 46 +- lnvps_api/invoice.html | 9 +- lnvps_api/src/api/mod.rs | 7 +- lnvps_api/src/api/model.rs | 111 +-- lnvps_api/src/api/routes.rs | 203 ++++- lnvps_api/src/bin/api.rs | 61 +- lnvps_api/src/data_migration/mod.rs | 1 - .../vm_subscription_backfill.rs | 628 -------------- lnvps_api/src/dvm/lnvps.rs | 1 + lnvps_api/src/mocks.rs | 5 +- lnvps_api/src/payments/invoice.rs | 17 +- lnvps_api/src/provisioner/vm.rs | 19 +- lnvps_api/src/settings.rs | 13 +- lnvps_api/src/subscription/mod.rs | 210 ++++- lnvps_api/src/worker.rs | 6 + lnvps_api_admin/src/admin/model.rs | 21 +- lnvps_api_admin/src/admin/reports.rs | 10 + lnvps_api_admin/src/admin/vms.rs | 14 +- lnvps_api_admin/src/bin/generate_demo_data.rs | 31 +- lnvps_api_common/Cargo.toml | 1 + lnvps_api_common/src/client_ip.rs | 153 ++++ lnvps_api_common/src/geoip.rs | 137 ++++ lnvps_api_common/src/lib.rs | 8 +- lnvps_api_common/src/mock.rs | 139 +--- lnvps_api_common/src/pricing.rs | 769 ++++++++++++++++-- lnvps_api_common/src/vat.rs | 104 ++- ...0000_subscription_payment_vat_snapshot.sql | 14 + .../20260716130000_drop_vm_payment.sql | 4 + .../20260716140000_user_geo_location.sql | 8 + lnvps_db/src/lib.rs | 40 - lnvps_db/src/model.rs | 142 +--- lnvps_db/src/mysql.rs | 276 +------ lnvps_e2e/src/db.rs | 5 - .../vat-snapshot-and-vm-payment-retirement.md | 93 +++ 36 files changed, 1913 insertions(+), 1434 deletions(-) delete mode 100644 lnvps_api/src/data_migration/vm_subscription_backfill.rs create mode 100644 lnvps_api_common/src/client_ip.rs create mode 100644 lnvps_api_common/src/geoip.rs create mode 100644 lnvps_db/migrations/20260716120000_subscription_payment_vat_snapshot.sql create mode 100644 lnvps_db/migrations/20260716130000_drop_vm_payment.sql create mode 100644 lnvps_db/migrations/20260716140000_user_geo_location.sql create mode 100644 work/vat-snapshot-and-vm-payment-retirement.md diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 4e08d181..155c9f19 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- **2026-07-16** - Invoices now show the VAT treatment + - `GET /api/v1/payment/{id}/invoice` renders the applied VAT rate on the tax line (e.g. "VAT 23% (IRL)"), and prints a legal note for **reverse charge** ("VAT reverse charged — the recipient is liable to account for VAT (Article 196, Council Directive 2006/112/EC)") and **out-of-scope** ("Outside the scope of EU VAT.") supplies. Seller and customer VAT numbers were already shown. + +- **2026-07-16** - VAT determination frozen on every payment (OSS filing / audit) + - Each `subscription_payment` now stores the VAT determination made at sale time: a per-line-item `tax_breakdown` (JSON array of `{net, tax, rate, country_code, treatment}`) as the authoritative record, a uniform summary (`tax_rate`, `tax_country_code`, `tax_treatment`, left NULL when the payment mixes rates/treatments across line items), and the customer `tax_evidence` (declared country, IP-derived country, VAT number). Admin time-series reports expose these per payment. + - The breakdown is per-line-item so a payment whose lines resolve to different sellers (e.g. reverse charge on one, domestic VAT on another) is recorded losslessly rather than collapsed to one rate. + +- **2026-07-16** - Retired the defunct `vm_payment` table + - `vm_payment` is dropped; all payments live in `subscription_payment` (the public API already read from it). The startup backfill's payment-copy phase (Phase 2) and all `vm_payment` models/queries/DTO conversions are removed. The `ApiVmPayment` response shape and the `vm_payment` RBAC resource are unchanged. + +- **2026-07-16** - VAT is now charged using EU place-of-supply rules instead of a flat per-country lookup + - **EU VAT only, gated on the seller.** The seller's country is taken from the company's own VAT number (`tax_id`, i.e. its VIES registration) when set, else `country_code`. Tax is applied only when that country is in the EU VAT area. A non-EU seller (e.g. a US company) charges no tax here; other systems such as US sales tax are not handled. + - When the seller is in the EU, the tax charged is determined from the **seller's country** and the **customer's status/location**: + - **B2B** with a stored (VIES-validated) VAT number: same country as seller → domestic VAT; another EU country → **reverse charge** (0%); outside the EU → out of scope (0%). + - **B2C**: place of supply is taken from the self-declared country, falling back to the IP-derived country. EU → that country's destination rate (OSS); non-EU → out of scope (0%). + - **Undetermined** (no customer country evidence): the seller-country rate is applied as a fallback. + - **Behaviour change:** non-EU customers are now correctly out of scope (0%). Previously any country present in the `tax_rate` config map was charged its configured rate regardless of EU membership (e.g. a placeholder `USA: 1%`). + - **Config removed:** the static `tax-rate` config map is gone. Standard EU rates for all member states are now fetched at startup and refreshed daily, cached in-memory by a shared cloneable `VatClient` (formerly `EuVatClient`). Until the first successful refresh, VAT falls back to 0%. + - New public API in `lnvps_api_common`: `PricingEngine::determine_tax` (full treatment + audit detail), `TaxTreatment`, `TaxDetermination`, `is_eu_vat_country`, `vat_number_country_alpha3`; `VatClient` gains `refresh_rates`/`rate_for`/`with_rates`. `get_tax_for_user` now also takes the seller `company_id`. + ### Added - **2026-07-16** - Cost tracking (P/L groundwork, issue #82) @@ -21,6 +43,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Adds the `AdminResource::ResourceCost` (24) RBAC resource; a migration grants the full permission set to the default `super_admin` role. - `GET /api/admin/v1/reports/profit-loss` — per-period (month/year) profit/loss report netting paid revenue against tracked resource costs. Reported in a single target currency (`currency` param, or the selected company's base currency), so each period is one row. Revenue uses each payment's stored historical exchange rate to value it in its company base currency; costs (no stored rate) use current exchange rates. Recurring costs are normalized per active calendar month, `ip_range` per-IP costs scale by the range's current assigned-IP count, and one-time costs are booked in their `billing_start` period. Optional `group_by` (`month`|`year`), `company_id` and `region_id` filters (`currency` required when `company_id` omitted). Requires `analytics::view`. +- **2026-07-16** - IP geolocation captured as VAT place-of-supply evidence + - `PATCH /api/v1/account` **and both VM order endpoints** (`POST /api/v1/vm`, `POST /api/v1/vm/custom-template`) now record the client's IP-derived country (ISO 3166-1 alpha-3) alongside the self-declared `country_code`, stored independently on the user (`geo_country_code`, `geo_ip`, `geo_updated`) so the two signals can be compared when determining EU VAT. Capturing at order time ensures customers who never touch the account API still have a resolved country at purchase. The client IP is read from the `X-Forwarded-For`/`X-Real-IP` headers set by the trusted front proxy. + - New optional config key `geoip-database`: path to a local MaxMind GeoLite2/GeoIP2 Country `.mmdb`. When unset, IP geolocation is disabled and no country is recorded. Lookups are local and never leave the host. + - Non-routable addresses (private/loopback/link-local/documentation) are skipped without a lookup. + - **2026-07-15** - Admin management of users' saved payment methods - New admin endpoints to list/inspect/edit/delete the payment methods users save for automatic renewals (NWC connections and off-session Revolut cards). Distinct from the existing `payment_methods` provider-config endpoints. - `GET /api/admin/v1/user_payment_methods` — paginated list across all users; optional `user_id` filter (`user_payment_method::view`). diff --git a/Cargo.lock b/Cargo.lock index 5b8aee03..5b5bf76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,6 +2847,7 @@ dependencies = [ "isocountry", "lnvps_db", "log 0.4.32", + "maxminddb", "nostr 0.44.3", "payments-rs", "rand 0.9.4", @@ -2996,6 +2997,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maxminddb" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e84ef32bcbf18a95548989e880db4af6fafd563463753afb4b9a149fb2782c" +dependencies = [ + "ipnetwork", + "log 0.4.32", + "memchr", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "md-5" version = "0.10.6" diff --git a/README.md b/README.md index 102ba0f1..ff933015 100644 --- a/README.md +++ b/README.md @@ -224,16 +224,42 @@ encryption: Encrypted fields: SSH key material, NWC connection strings, email addresses, host API tokens. -### Taxes - -```yaml -# ISO 3166-1 alpha-2 country codes, values are whole-number percentages -tax-rate: - IE: 23 - US: 15 -``` - -Taxes are applied based on the user's specified country. EU VAT numbers are validated against the VIES service before being stored. +### Taxes (VAT) + +This implements **EU VAT only**. The seller's country is taken from the +company's own VAT number (`tax_id`) when set — that number is the company's VIES +registration and identifies the country it is registered in — otherwise from the +company's `country_code`. EU VAT applies only when that country is in the EU VAT +area; if it is outside the list (e.g. a US company), no tax is applied here — +other tax systems (such as US sales tax) are not handled. + +When the seller is in the EU, standard rates for all member states are fetched +from an external source at startup and refreshed daily (cached in-memory by the +shared `VatClient`). The rate applied to a payment is then determined from the +seller's country and the customer: + +- **B2B** with a stored (VIES-validated) VAT number: same country as seller → + domestic VAT; another EU country → reverse charge (0%); outside the EU → out + of scope (0%). +- **B2C**: place of supply is taken from the self-declared country, falling back + to the IP-derived country. EU → that country's destination rate (OSS); non-EU + → out of scope (0%). +- **Undetermined** (no country evidence): the seller's domestic rate is applied + conservatively when the seller is in the EU, otherwise out of scope. + +IP geolocation (for the fallback location signal) requires the optional +`geoip-database` setting pointing at a MaxMind GeoLite2/GeoIP2 Country `.mmdb`. +EU VAT numbers are validated against the VIES service before being stored. + +Until the first successful rate refresh (or if the rate source is unreachable), +no rates are known and VAT falls back to 0%. + +> **Disclaimer:** This VAT handling is an automated, best-effort determination +> from the available evidence and configuration — it is **not tax or legal +> advice** and makes no guarantee of compliance in any jurisdiction. Rates and +> validation come from third-party sources that may lag official changes. The +> operator is solely responsible for confirming the correct VAT/OSS treatment +> for their business and should have it reviewed by a qualified tax professional. ### Nostr address host (optional) diff --git a/lnvps_api/invoice.html b/lnvps_api/invoice.html index 258708cf..036792b6 100644 --- a/lnvps_api/invoice.html +++ b/lnvps_api/invoice.html @@ -128,8 +128,8 @@

Details:

Description Currency - Gross - Taxes + Net + VAT{{#vat.rate_label}} {{vat.rate_label}}{{/vat.rate_label}} @@ -158,6 +158,11 @@

Details:

+ {{#vat.note}} + + {{vat.note}} + + {{/vat.note}} 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.