feat: generate invoices
All checks were successful
continuous-integration/drone/push Build is passing

closes #29
This commit is contained in:
2025-05-01 17:46:37 +01:00
parent 179d70edb0
commit 1dda3a561d
10 changed files with 421 additions and 53 deletions

72
Cargo.lock generated
View File

@ -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",

View File

@ -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"

159
lnvps_api/invoice.html Normal file
View File

@ -0,0 +1,159 @@
<!doctype html>
<html lang="en">
<head>
<title>{{payment.id}}</title>
<meta charset="UTF-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link
href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
<style>
html, body {
margin: 0;
font-size: 12px;
font-family: "Source Code Pro", monospace;
}
@media screen {
.page {
margin-left: 4rem;
margin-right: 4rem;
}
}
.header {
display: flex;
gap: 2rem;
align-items: center;
justify-content: space-between;
font-size: 3rem;
margin: 2rem 0;
}
.billing {
display: grid;
grid-template-columns: 1fr 1fr;
}
.flex-col {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
table {
width: 100%;
border-collapse: collapse;
}
td, th {
border: 1px solid #ccc;
padding: 0.4em 0.1em;
}
.total {
text-align: end;
font-size: 16px;
font-weight: bold;
padding: 0.5em 0.2em;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
LNVPS
<img height="48" width="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
</div>
<hr/>
<h2>Invoice</h2>
<div class="flex-col">
<div>
<b>ID:</b>
{{payment.id}}
</div>
<div>
<b>Date:</b>
{{payment.created}}
</div>
<div>
<b>Status:</b>
{{#payment.is_paid}}Paid{{/payment.is_paid}}
{{^payment.is_paid}}Unpaid{{/payment.is_paid}}
</div>
<div>
<b>Nostr Pubkey:</b>
{{npub}}
</div>
</div>
<div class="billing">
<div class="flex-col">
<h2>Bill To:</h2>
<div>{{user.name}}</div>
<div>{{user.address_1}}</div>
<div>{{user.address_2}}</div>
<div>{{user.city}}</div>
<div>{{user.state}}</div>
<div>{{user.postcode}}</div>
<div>{{user.country}}</div>
<div>{{user.tax_id}}</div>
</div>
{{#company}}
<div class="flex-col">
<h2>&nbsp;</h2>
<div>{{company.name}}</div>
<div>{{company.address_1}}</div>
<div>{{company.address_2}}</div>
<div>{{company.city}}</div>
<div>{{company.state}}</div>
<div>{{company.postcode}}</div>
<div>{{company.country}}</div>
<div>{{company.tax_id}}</div>
</div>
{{/company}}
</div>
<hr/>
<h2>Details:</h2>
<table>
<thead>
<tr>
<th>Description</th>
<th>Currency</th>
<th>Gross</th>
<th>Taxes</th>
</tr>
</thead>
<tbody>
<tr>
<td>
VM Renewal #{{vm.id}}
- {{vm.template.name}}
- {{vm.image.distribution}} {{vm.image.version}}
- {{payment.time}} seconds
</td>
<td>{{payment.currency}}</td>
<td>{{payment.amount}}</td>
<td>{{payment.tax}}</td>
</tr>
</tbody>
<tbody>
<tr>
<td colspan="4" class="total">
Total: {{total}}
</td>
</tr>
</tbody>
</table>
<br/>
<b>
All BTC amounts are in milli-satoshis and all fiat amounts are in cents.
</b>
<hr/>
<small>
(c) {{year}} LNVPS.net - Generated at {{current_date}}
</small>
</div>
</body>
</html>

View File

@ -425,6 +425,24 @@ pub struct AccountPatchRequest {
pub tax_id: Option<String>,
}
impl From<lnvps_db::User> 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<PaymentMethod> 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_1: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_2: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub postcode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
}
impl From<lnvps_db::Company> 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,
}
}
}

View File

@ -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<Route> {
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/<id>/invoice?<auth>")]
async fn v1_get_payment_invoice(
db: &State<Arc<dyn LNVpsDb>>,
id: &str,
auth: &str,
) -> Result<(ContentType, Vec<u8>), &'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<Utc>,
vm: ApiVmStatus,
payment: ApiVmPayment,
user: AccountPatchRequest,
npub: String,
total: u64,
company: Option<ApiCompany>,
}
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/<id>/payments")]

View File

@ -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<Company> {
todo!()
}
}
#[derive(Debug, Clone)]

View File

@ -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);

View File

@ -172,6 +172,9 @@ pub trait LNVpsDb: LNVPSNostrDb + Send + Sync {
/// Get access policy
async fn get_access_policy(&self, access_policy_id: u64) -> Result<AccessPolicy>;
/// Get company
async fn get_company(&self, company_id: u64) -> Result<Company>;
}
#[cfg(feature = "nostr-domain")]

View File

@ -71,6 +71,7 @@ pub struct VmHostRegion {
pub id: u64,
pub name: String,
pub enabled: bool,
pub company_id: Option<u64>,
}
#[derive(FromRow, Clone, Debug, Default)]
@ -524,3 +525,19 @@ pub struct NostrDomainHandle {
pub pubkey: Vec<u8>,
pub relays: Option<String>,
}
#[derive(FromRow, Clone, Debug, Default)]
pub struct Company {
pub id: u64,
pub created: DateTime<Utc>,
pub name: String,
pub address_1: Option<String>,
pub address_2: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub country_code: Option<String>,
pub tax_id: Option<String>,
pub postcode: Option<String>,
pub phone: Option<String>,
pub email: Option<String>,
}

View File

@ -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<Company> {
sqlx::query_as("select * from company where id=?")
.bind(company_id)
.fetch_one(&self.db)
.await
.map_err(Error::new)
}
}
#[cfg(feature = "nostr-domain")]