Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`).
Expand Down
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 36 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 7 additions & 2 deletions lnvps_api/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ <h2>Details:</h2>
<tr>
<th>Description</th>
<th>Currency</th>
<th>Gross</th>
<th>Taxes</th>
<th>Net</th>
<th>VAT{{#vat.rate_label}} {{vat.rate_label}}{{/vat.rate_label}}</th>
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -158,6 +158,11 @@ <h2>Details:</h2>
</tr>
</tbody>
<tbody>
{{#vat.note}}
<tr>
<td colspan="4"><small><em>{{vat.note}}</em></small></td>
</tr>
{{/vat.note}}
<tr>
<td colspan="4" class="total">
Total: {{total_formatted}}
Expand Down
7 changes: 6 additions & 1 deletion lnvps_api/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,4 +62,7 @@ pub struct RouterState {
pub settings: Settings,
pub rates: Arc<dyn ExchangeRateService>,
pub work_sender: Arc<dyn WorkCommander>,
/// Resolves client IPs to a country for VAT place-of-supply evidence.
/// `None` when no geolocation database is configured.
pub geoip: Option<Arc<dyn CountryResolver>>,
}
111 changes: 4 additions & 107 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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, anyhow::Error> {
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,
Expand Down Expand Up @@ -389,52 +378,6 @@ impl ApiVmPayment {
}
}

impl From<lnvps_db::VmPayment> 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,
Expand Down Expand Up @@ -1083,10 +1026,9 @@ impl From<lnvps_db::SubscriptionPayment> for ApiSubscriptionPayment {
struct StripeData {
pub session_id: String,
}
let session_id =
serde_json::from_str::<StripeData>(payment.external_data.as_str())
.map(|d| d.session_id)
.unwrap_or_default();
let session_id = serde_json::from_str::<StripeData>(payment.external_data.as_str())
.map(|d| d.session_id)
.unwrap_or_default();
ApiPaymentData::Stripe { session_id }
}
PaymentMethod::Paypal => ApiPaymentData::Lightning(String::new()),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading