fix: manual Lightning payment reconciliation with Stripe invoice state #54
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment (
|
||||
invoice_id TEXT PRIMARY KEY,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
bolt11 TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_manual_lightning_payment_tenant_pubkey
|
||||
ON invoice_manual_lightning_payment (tenant_pubkey);
|
||||
+20
-1
@@ -1009,6 +1009,13 @@ async fn get_invoice(
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = state
|
||||
.api
|
||||
.billing
|
||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
|
||||
Ok(ok(StatusCode::OK, invoice))
|
||||
}
|
||||
|
||||
@@ -1026,6 +1033,13 @@ async fn get_invoice_bolt11(
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
state.api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = state
|
||||
.api
|
||||
.billing
|
||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
||||
.await
|
||||
.map_err(map_invoice_lookup_error)?;
|
||||
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
if status != "open" {
|
||||
return Ok(err(
|
||||
@@ -1038,7 +1052,12 @@ async fn get_invoice_bolt11(
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let currency = invoice["currency"].as_str().unwrap_or("usd");
|
||||
|
||||
match state.api.billing.create_bolt11(amount_due, currency).await {
|
||||
match state
|
||||
.api
|
||||
.billing
|
||||
.get_or_create_manual_lightning_bolt11(&id, &tenant.pubkey, amount_due, currency)
|
||||
.await
|
||||
{
|
||||
Ok(bolt11) => Ok(ok(StatusCode::OK, serde_json::json!({ "bolt11": bolt11 }))),
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
+112
-1
@@ -1,7 +1,8 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use hmac::{Hmac, Mac};
|
||||
use nwc::prelude::{
|
||||
MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest as NwcPayInvoiceRequest,
|
||||
LookupInvoiceRequest, LookupInvoiceResponse, MakeInvoiceRequest, NWC, NostrWalletConnectURI,
|
||||
PayInvoiceRequest as NwcPayInvoiceRequest, TransactionState,
|
||||
};
|
||||
use sha2::Sha256;
|
||||
|
||||
@@ -718,6 +719,50 @@ impl Billing {
|
||||
Ok((invoice, tenant))
|
||||
}
|
||||
|
||||
pub async fn reconcile_manual_lightning_invoice(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
invoice: &serde_json::Value,
|
||||
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
|
||||
self.reconcile_manual_lightning_invoice_if_settled(invoice_id, invoice)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_or_create_manual_lightning_bolt11(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
amount_due_minor: i64,
|
||||
currency: &str,
|
||||
) -> Result<String> {
|
||||
if let Some(existing_bolt11) = self
|
||||
.query
|
||||
.get_invoice_manual_lightning_bolt11(invoice_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(existing_bolt11);
|
||||
}
|
||||
|
||||
let bolt11 = self.create_bolt11(amount_due_minor, currency).await?;
|
||||
|
||||
if self
|
||||
.command
|
||||
.insert_manual_lightning_invoice_payment(invoice_id, tenant_pubkey, &bolt11)
|
||||
.await?
|
||||
{
|
||||
return Ok(bolt11);
|
||||
}
|
||||
|
||||
self.query
|
||||
.get_invoice_manual_lightning_bolt11(invoice_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"manual lightning payment row missing after insert race for invoice {invoice_id}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stripe_create_customer(&self, tenant_pubkey: &str) -> Result<String> {
|
||||
let short_pubkey: String = tenant_pubkey.chars().take(12).collect();
|
||||
let display_name = format!("Caravel tenant {short_pubkey}");
|
||||
@@ -1138,6 +1183,72 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_manual_lightning_invoice_if_settled(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
invoice: &serde_json::Value,
|
||||
) -> std::result::Result<serde_json::Value, InvoiceLookupError> {
|
||||
if invoice["status"].as_str().unwrap_or_default() != "open" {
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
|
||||
let Some(bolt11) = self
|
||||
.query
|
||||
.get_invoice_manual_lightning_bolt11(invoice_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(invoice.clone());
|
||||
};
|
||||
|
||||
let settled = match self.is_manual_lightning_invoice_settled(&bolt11).await {
|
||||
Ok(settled) => settled,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
invoice_id,
|
||||
"failed to lookup manual lightning invoice settlement"
|
||||
);
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
};
|
||||
|
||||
if !settled {
|
||||
return Ok(invoice.clone());
|
||||
}
|
||||
|
||||
if let Err(error) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
invoice_id,
|
||||
"failed to mark settled manual lightning invoice as paid_out_of_band"
|
||||
);
|
||||
}
|
||||
|
||||
self.stripe_get_invoice(invoice_id).await
|
||||
}
|
||||
|
||||
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
||||
let system_uri = Self::parse_nwc_uri(&self.nwc_url, "system")?;
|
||||
let system_nwc = NWC::new(system_uri);
|
||||
|
||||
let lookup_req = LookupInvoiceRequest {
|
||||
payment_hash: None,
|
||||
invoice: Some(bolt11.to_string()),
|
||||
};
|
||||
|
||||
let lookup_result = system_nwc.lookup_invoice(lookup_req).await;
|
||||
system_nwc.shutdown().await;
|
||||
|
||||
let lookup_response =
|
||||
lookup_result.map_err(|error| anyhow!("failed to lookup invoice: {error}"))?;
|
||||
|
||||
Ok(Self::lookup_invoice_response_is_settled(&lookup_response))
|
||||
}
|
||||
|
||||
fn lookup_invoice_response_is_settled(response: &LookupInvoiceResponse) -> bool {
|
||||
response.state == Some(TransactionState::Settled) || response.settled_at.is_some()
|
||||
}
|
||||
|
||||
fn parse_nwc_uri(nwc_url: &str, role: &str) -> Result<NostrWalletConnectURI> {
|
||||
nwc_url
|
||||
.parse::<NostrWalletConnectURI>()
|
||||
|
||||
@@ -353,6 +353,30 @@ impl Command {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_manual_lightning_invoice_payment(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
tenant_pubkey: &str,
|
||||
bolt11: &str,
|
||||
) -> Result<bool> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO invoice_manual_lightning_payment
|
||||
(invoice_id, tenant_pubkey, bolt11, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(invoice_id) DO NOTHING",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(tenant_pubkey)
|
||||
.bind(bolt11)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
sqlx::query("UPDATE tenant SET past_due_at = ? WHERE pubkey = ?")
|
||||
|
||||
@@ -171,6 +171,19 @@ impl Query {
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_manual_lightning_bolt11(
|
||||
&self,
|
||||
invoice_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let bolt11 = sqlx::query_scalar::<_, String>(
|
||||
"SELECT bolt11 FROM invoice_manual_lightning_payment WHERE invoice_id = ?",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(bolt11)
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
|
||||
let plans = sqlx::query_scalar::<_, String>(
|
||||
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
|
||||
|
||||
Reference in New Issue
Block a user