From 1dda3a561d58b8f8903544f8cbc6b5dbd7e1fa10 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 1 May 2025 17:46:37 +0100 Subject: [PATCH] feat: generate invoices closes #29 --- Cargo.lock | 72 +++++--- lnvps_api/Cargo.toml | 1 + lnvps_api/invoice.html | 159 ++++++++++++++++++ lnvps_api/src/api/model.rs | 60 +++++++ lnvps_api/src/api/routes.rs | 117 +++++++++++-- lnvps_api/src/mocks.rs | 12 +- .../20250501162308_company_info.sql | 19 +++ lnvps_db/src/lib.rs | 3 + lnvps_db/src/model.rs | 17 ++ lnvps_db/src/mysql.rs | 14 +- 10 files changed, 421 insertions(+), 53 deletions(-) create mode 100644 lnvps_api/invoice.html create mode 100644 lnvps_db/migrations/20250501162308_company_info.sql diff --git a/Cargo.lock b/Cargo.lock index 68ade2d..7316a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,7 +1000,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ - "log", + "log 0.4.27", "regex", ] @@ -1014,7 +1014,7 @@ dependencies = [ "anstyle", "env_filter", "jiff", - "log", + "log 0.4.27", ] [[package]] @@ -1266,7 +1266,7 @@ checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" dependencies = [ "cc", "libc", - "log", + "log 0.4.27", "rustversion", "windows 0.48.0", ] @@ -1700,7 +1700,7 @@ dependencies = [ "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log", + "log 0.4.27", "wasm-bindgen", "windows-core 0.61.0", ] @@ -1969,7 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260" dependencies = [ "jiff-static", - "log", + "log 0.4.27", "portable-atomic", "portable-atomic-util", "serde", @@ -2142,7 +2142,8 @@ dependencies = [ "lnurl-rs", "lnvps_common", "lnvps_db", - "log", + "log 0.4.27", + "mustache", "native-tls", "nostr", "nostr-sdk", @@ -2194,7 +2195,7 @@ dependencies = [ "hex", "lnvps_common", "lnvps_db", - "log", + "log 0.4.27", "rocket", "serde", "serde_json", @@ -2211,6 +2212,15 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" +dependencies = [ + "log 0.4.27", +] + [[package]] name = "log" version = "0.4.27" @@ -2320,6 +2330,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +[[package]] +name = "mustache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51956ef1c5d20a1384524d91e616fb44dfc7d8f249bf696d49c97dd3289ecab5" +dependencies = [ + "log 0.3.9", + "serde", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2327,7 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", - "log", + "log 0.4.27", "openssl", "openssl-probe", "openssl-sys", @@ -2513,7 +2533,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a64853d7ab065474e87696f7601cee817d200e86c42e04004e005cb3e20c3c5" dependencies = [ - "log", + "log 0.4.27", "schemars", "serde", "serde_json", @@ -2938,7 +2958,7 @@ dependencies = [ "bytes", "heck", "itertools", - "log", + "log 0.4.27", "multimap", "once_cell", "petgraph", @@ -3166,7 +3186,7 @@ dependencies = [ "hyper-util", "ipnet", "js-sys", - "log", + "log 0.4.27", "mime", "native-tls", "once_cell", @@ -3228,7 +3248,7 @@ dependencies = [ "figment", "futures", "indexmap 2.8.0", - "log", + "log 0.4.27", "memchr", "multer", "num_cpus", @@ -3280,7 +3300,7 @@ dependencies = [ "http 0.2.12", "hyper 0.14.32", "indexmap 2.8.0", - "log", + "log 0.4.27", "memchr", "pear", "percent-encoding", @@ -3301,7 +3321,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "074297bec35db2fc7ebb6ade6a955b5566de66f83d9af5b5602a350a71bdef43" dependencies = [ - "log", + "log 0.4.27", "okapi", "rocket", "rocket_okapi_codegen", @@ -3402,7 +3422,7 @@ version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ - "log", + "log 0.4.27", "ring", "rustls-webpki 0.101.7", "sct", @@ -3814,7 +3834,7 @@ dependencies = [ "hashbrown 0.15.2", "hashlink", "indexmap 2.8.0", - "log", + "log 0.4.27", "memchr", "once_cell", "percent-encoding", @@ -3893,7 +3913,7 @@ dependencies = [ "hkdf", "hmac", "itoa", - "log", + "log 0.4.27", "md-5", "memchr", "once_cell", @@ -3933,7 +3953,7 @@ dependencies = [ "hmac", "home", "itoa", - "log", + "log 0.4.27", "md-5", "memchr", "once_cell", @@ -3964,7 +3984,7 @@ dependencies = [ "futures-intrusive", "futures-util", "libsqlite3-sys", - "log", + "log 0.4.27", "percent-encoding", "serde", "serde_urlencoded", @@ -4388,7 +4408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" dependencies = [ "futures-util", - "log", + "log 0.4.27", "native-tls", "tokio", "tokio-native-tls", @@ -4402,7 +4422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", - "log", + "log 0.4.27", "rustls 0.23.25", "rustls-pki-types", "tokio", @@ -4554,7 +4574,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log", + "log 0.4.27", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4587,7 +4607,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "log", + "log 0.4.27", "once_cell", "tracing-core", ] @@ -4633,7 +4653,7 @@ dependencies = [ "data-encoding", "http 1.3.1", "httparse", - "log", + "log 0.4.27", "native-tls", "rand 0.8.5", "sha1", @@ -4652,7 +4672,7 @@ dependencies = [ "data-encoding", "http 1.3.1", "httparse", - "log", + "log 0.4.27", "rand 0.9.0", "rustls 0.23.25", "rustls-pki-types", @@ -4885,7 +4905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", - "log", + "log 0.4.27", "proc-macro2", "quote", "syn 2.0.100", diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index 00b0210..831a9c6 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -57,6 +57,7 @@ lettre = { version = "0.11.10", features = ["tokio1-native-tls"] } ws = { package = "rocket_ws", version = "0.1.1" } native-tls = "0.2.12" lnurl-rs = { version = "0.9.0", default-features = false } +mustache = "0.9.0" futures = "0.3.31" isocountry = "0.3.2" diff --git a/lnvps_api/invoice.html b/lnvps_api/invoice.html new file mode 100644 index 0000000..8a9a3ac --- /dev/null +++ b/lnvps_api/invoice.html @@ -0,0 +1,159 @@ + + + + {{payment.id}} + + + + + + + +
+
+ LNVPS + logo +
+
+

Invoice

+
+
+ ID: + {{payment.id}} +
+
+ Date: + {{payment.created}} +
+
+ Status: + {{#payment.is_paid}}Paid{{/payment.is_paid}} + {{^payment.is_paid}}Unpaid{{/payment.is_paid}} +
+
+ Nostr Pubkey: + {{npub}} +
+
+
+
+

Bill To:

+
{{user.name}}
+
{{user.address_1}}
+
{{user.address_2}}
+
{{user.city}}
+
{{user.state}}
+
{{user.postcode}}
+
{{user.country}}
+
{{user.tax_id}}
+
+ {{#company}} +
+

 

+
{{company.name}}
+
{{company.address_1}}
+
{{company.address_2}}
+
{{company.city}}
+
{{company.state}}
+
{{company.postcode}}
+
{{company.country}}
+
{{company.tax_id}}
+
+ {{/company}} +
+
+

Details:

+ + + + + + + + + + + + + + + + + + + + + + +
DescriptionCurrencyGrossTaxes
+ VM Renewal #{{vm.id}} + - {{vm.template.name}} + - {{vm.image.distribution}} {{vm.image.version}} + - {{payment.time}} seconds + {{payment.currency}}{{payment.amount}}{{payment.tax}}
+ Total: {{total}} +
+
+ + All BTC amounts are in milli-satoshis and all fiat amounts are in cents. + +
+ + (c) {{year}} LNVPS.net - Generated at {{current_date}} + +
+ + \ No newline at end of file diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 0da06bb..30cf0cf 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -425,6 +425,24 @@ pub struct AccountPatchRequest { pub tax_id: Option, } +impl From for AccountPatchRequest { + fn from(user: lnvps_db::User) -> Self { + AccountPatchRequest { + email: user.email, + contact_nip17: user.contact_nip17, + contact_email: user.contact_email, + country_code: user.country_code, + name: user.billing_name, + address_1: user.billing_address_1, + address_2: user.billing_address_2, + state: user.billing_state, + city: user.billing_city, + postcode: user.billing_postcode, + tax_id: user.billing_tax_id, + } + } +} + #[derive(Serialize, Deserialize, JsonSchema)] pub struct CreateVmRequest { pub template_id: u64, @@ -574,3 +592,45 @@ impl From for ApiPaymentMethod { } } } + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct ApiCompany { + pub id: u64, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub country_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address_1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address_2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub postcode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, +} + +impl From for ApiCompany { + fn from(value: lnvps_db::Company) -> Self { + Self { + email: value.email, + country_code: value.country_code, + name: value.name, + id: value.id, + address_1: value.address_1, + address_2: value.address_2, + state: value.state, + city: value.city, + postcode: value.postcode, + tax_id: value.tax_id, + phone: value.phone, + } + } +} diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 6e13fc4..e8d4f51 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1,5 +1,5 @@ use crate::api::model::{ - AccountPatchRequest, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest, + AccountPatchRequest, ApiCompany, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest, ApiPaymentInfo, ApiPaymentMethod, ApiPrice, ApiTemplatesResponse, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest, @@ -12,6 +12,7 @@ use crate::settings::Settings; use crate::status::{VmState, VmStateCache}; use crate::worker::WorkJob; use anyhow::{bail, Result}; +use chrono::{DateTime, Datelike, Utc}; use futures::future::join_all; use futures::{SinkExt, StreamExt}; use isocountry::CountryCode; @@ -22,7 +23,8 @@ use lnvps_db::{ }; use log::{error, info}; use nostr::util::hex; -use nostr::Url; +use nostr::{ToBech32, Url}; +use rocket::http::ContentType; use rocket::serde::json::Json; use rocket::{get, patch, post, routes, Responder, Route, State}; use rocket_okapi::gen::OpenApiGenerator; @@ -34,6 +36,7 @@ use serde::{Deserialize, Serialize}; use ssh_key::PublicKey; use std::collections::{HashMap, HashSet}; use std::fmt::Display; +use std::io::{BufWriter, Cursor}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::mpsc::{Sender, UnboundedSender}; @@ -71,7 +74,8 @@ pub fn routes() -> Vec { routes.append(&mut routes![ v1_terminal_proxy, v1_lnurlp, - v1_renew_vm_lnurlp + v1_renew_vm_lnurlp, + v1_get_payment_invoice ]); routes @@ -156,19 +160,7 @@ async fn v1_get_account( let uid = db.upsert_user(&pubkey).await?; let user = db.get_user(uid).await?; - ApiData::ok(AccountPatchRequest { - email: user.email, - contact_nip17: user.contact_nip17, - contact_email: user.contact_email, - country_code: user.country_code, - name: user.billing_name, - address_1: user.billing_address_1, - address_2: user.billing_address_2, - state: user.billing_state, - city: user.billing_city, - postcode: user.billing_postcode, - tax_id: user.billing_tax_id, - }) + ApiData::ok(user.into()) } async fn vm_to_status( @@ -894,6 +886,99 @@ async fn v1_get_payment( ApiData::ok(payment.into()) } +/// Print payment invoice +#[get("/api/v1/payment//invoice?")] +async fn v1_get_payment_invoice( + db: &State>, + id: &str, + auth: &str, +) -> Result<(ContentType, Vec), &'static str> { + let auth = Nip98Auth::from_base64(auth).map_err(|e| "Missing or invalid auth param")?; + if auth + .check(&format!("/api/v1/payment/{id}/invoice"), "GET") + .is_err() + { + return Err("Invalid auth event"); + } + let pubkey = auth.event.pubkey.to_bytes(); + let uid = db.upsert_user(&pubkey).await.map_err(|_| "Insert failed")?; + let id = if let Ok(i) = hex::decode(id) { + i + } else { + return Err("Invalid payment id"); + }; + + let payment = db + .get_vm_payment(&id) + .await + .map_err(|_| "Payment not found")?; + let vm = db.get_vm(payment.vm_id).await.map_err(|_| "VM not found")?; + if vm.user_id != uid { + return Err("VM does not belong to you"); + } + + if !payment.is_paid { + return Err("Payment is not paid, can't generate invoice"); + } + + #[derive(Serialize)] + struct PaymentInfo { + year: i32, + current_date: DateTime, + vm: ApiVmStatus, + payment: ApiVmPayment, + user: AccountPatchRequest, + npub: String, + total: u64, + company: Option, + } + + let host = db + .get_host(vm.host_id) + .await + .map_err(|_| "Host not found")?; + let region = db + .get_host_region(host.region_id) + .await + .map_err(|_| "Region not found")?; + let company = if let Some(c) = region.company_id { + Some(db.get_company(c).await.map_err(|_| "Company not found")?) + } else { + None + }; + let user = db.get_user(uid).await.map_err(|_| "User not found")?; + #[cfg(debug_assertions)] + let template = + mustache::compile_path("lnvps_api/invoice.html").map_err(|_| "Invalid template")?; + #[cfg(not(debug_assertions))] + let template = mustache::compile_str(include_str!("../../invoice.html")) + .map_err(|_| "Invalid template")?; + + let now = Utc::now(); + let mut html = Cursor::new(Vec::new()); + template + .render( + &mut html, + &PaymentInfo { + year: now.year(), + current_date: now, + vm: vm_to_status(db, vm, None) + .await + .map_err(|_| "Failed to get VM state")?, + total: payment.amount + payment.tax, + payment: payment.into(), + npub: nostr::PublicKey::from_slice(&user.pubkey) + .map_err(|_| "Invalid pubkey")? + .to_bech32() + .unwrap(), + user: user.into(), + company: company.map(|c| c.into()), + }, + ) + .map_err(|_| "Failed to generate invoice")?; + Ok((ContentType::HTML, html.into_inner())) +} + /// List payment history of a VM #[openapi(tag = "VM")] #[get("/api/v1/vm//payments")] diff --git a/lnvps_api/src/mocks.rs b/lnvps_api/src/mocks.rs index 7c27406..5f0d670 100644 --- a/lnvps_api/src/mocks.rs +++ b/lnvps_api/src/mocks.rs @@ -10,12 +10,7 @@ use crate::status::{VmRunningState, VmState}; use anyhow::{anyhow, bail, ensure, Context}; use chrono::{DateTime, TimeDelta, Utc}; use fedimint_tonic_lnd::tonic::codegen::tokio_stream::Stream; -use lnvps_db::{ - async_trait, AccessPolicy, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, - LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, OsDistribution, User, UserSshKey, Vm, - VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, - VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, -}; +use lnvps_db::{async_trait, AccessPolicy, Company, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, OsDistribution, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate}; use std::collections::HashMap; use std::ops::Add; use std::pin::Pin; @@ -108,6 +103,7 @@ impl Default for MockDb { id: 1, name: "Mock".to_string(), enabled: true, + company_id: None, }, ); let mut ip_ranges = HashMap::new(); @@ -695,6 +691,10 @@ impl LNVpsDb for MockDb { .cloned() .context("no access policy")?) } + + async fn get_company(&self, company_id: u64) -> anyhow::Result { + todo!() + } } #[derive(Debug, Clone)] diff --git a/lnvps_db/migrations/20250501162308_company_info.sql b/lnvps_db/migrations/20250501162308_company_info.sql new file mode 100644 index 0000000..e031038 --- /dev/null +++ b/lnvps_db/migrations/20250501162308_company_info.sql @@ -0,0 +1,19 @@ +-- Add migration script here +create table company +( + id integer unsigned not null auto_increment primary key, + created timestamp not null default current_timestamp, + name varchar(100) not null, + email varchar(100) not null, + phone varchar(100), + address_1 varchar(200), + address_2 varchar(200), + city varchar(100), + state varchar(100), + postcode varchar(50), + country_code varchar(3), + tax_id varchar(50) +); +alter table vm_host_region + add column company_id integer unsigned, + add constraint fk_host_region_company foreign key (company_id) references company (id); \ No newline at end of file diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 51dbf33..ea94c8a 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -172,6 +172,9 @@ pub trait LNVpsDb: LNVPSNostrDb + Send + Sync { /// Get access policy async fn get_access_policy(&self, access_policy_id: u64) -> Result; + + /// Get company + async fn get_company(&self, company_id: u64) -> Result; } #[cfg(feature = "nostr-domain")] diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 8187362..e9bc7c2 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -71,6 +71,7 @@ pub struct VmHostRegion { pub id: u64, pub name: String, pub enabled: bool, + pub company_id: Option, } #[derive(FromRow, Clone, Debug, Default)] @@ -524,3 +525,19 @@ pub struct NostrDomainHandle { pub pubkey: Vec, pub relays: Option, } + +#[derive(FromRow, Clone, Debug, Default)] +pub struct Company { + pub id: u64, + pub created: DateTime, + pub name: String, + pub address_1: Option, + pub address_2: Option, + pub city: Option, + pub state: Option, + pub country_code: Option, + pub tax_id: Option, + pub postcode: Option, + pub phone: Option, + pub email: Option, +} diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index e443e13..d1cde7c 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,8 +1,4 @@ -use crate::{ - AccessPolicy, IpRange, LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, Router, User, - UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, - VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, -}; +use crate::{AccessPolicy, Company, IpRange, LNVPSNostrDb, LNVpsDb, NostrDomain, NostrDomainHandle, Router, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate}; use anyhow::{bail, Error, Result}; use async_trait::async_trait; use sqlx::{Executor, MySqlPool, Row}; @@ -562,6 +558,14 @@ impl LNVpsDb for LNVpsDbMysql { .await .map_err(Error::new) } + + async fn get_company(&self, company_id: u64) -> Result { + sqlx::query_as("select * from company where id=?") + .bind(company_id) + .fetch_one(&self.db) + .await + .map_err(Error::new) + } } #[cfg(feature = "nostr-domain")]