Fix bolt11 reconciliation

This commit is contained in:
Jon Staab
2026-06-01 15:09:03 -07:00
parent 76fbee6be1
commit fd38f9cbc0
4 changed files with 61 additions and 37 deletions
+22 -15
View File
@@ -372,7 +372,7 @@ impl Billing {
let result: Result<()> = async { let result: Result<()> = async {
let nwc_url = env::get().decrypt(&tenant.nwc_url)?; let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?; let tenant_wallet = Wallet::from_url(&nwc_url)?;
let bolt11 = self.ensure_bolt11(invoice).await?; let bolt11 = self.ensure_bolt11_for_invoice(invoice).await?;
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?; tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
@@ -433,7 +433,10 @@ impl Billing {
} }
// The dunning poll runs hourly; avoid excessive reminder DMs. // The dunning poll runs hourly; avoid excessive reminder DMs.
if invoice.notified_at.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS) { if invoice
.notified_at
.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS)
{
return Ok(()); return Ok(());
} }
@@ -445,7 +448,10 @@ impl Billing {
let dm_message = match error { let dm_message = match error {
Some(error) if !error.is_empty() => { Some(error) if !error.is_empty() => {
let limit: usize = 240; let limit: usize = 240;
let summary = error.chars().take(limit.saturating_sub(3)).collect::<String>(); let summary = error
.chars()
.take(limit.saturating_sub(3))
.collect::<String>();
format!("{base}\n\nAuto-payment failed: {summary}") format!("{base}\n\nAuto-payment failed: {summary}")
} }
_ => base, _ => base,
@@ -460,7 +466,7 @@ impl Billing {
// --- Bolt11 utils --- // --- Bolt11 utils ---
pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> { pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Bolt11> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await? if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
@@ -479,22 +485,23 @@ impl Billing {
.ok_or_else(|| anyhow!("failed to insert bolt11")) .ok_or_else(|| anyhow!("failed to insert bolt11"))
} }
/// Catch an out-of-band payment we never recorded — e.g. the user paid the pub async fn reconcile_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Option<Bolt11>> {
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? {
/// settled on the robot wallet, mark it paid and return the refreshed record; return self.reconcile_bolt11(&bolt11).await;
/// otherwise return it unchanged. Meant to run before presenting a payable };
/// invoice so we never hand back one that's already been paid.
pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
let bolt11 = self.ensure_bolt11(invoice).await?;
Ok(None)
}
async fn reconcile_bolt11(&self, bolt11: &Bolt11) -> Result<Option<Bolt11>> {
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? { if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?; command::settle_invoice_out_of_band(&bolt11.id, &bolt11.invoice_id).await?;
// Re-fetch so the caller sees that it's been settled. // Re-fetch so the caller sees that it's been settled.
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11)) return query::get_bolt11(&bolt11.id).await;
} else {
Ok(bolt11)
} }
Ok(Some(bolt11.clone()))
} }
// --- Stripe utils --- // --- Stripe utils ---
+14 -1
View File
@@ -18,6 +18,12 @@ pub async fn get_invoice(
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
// Implicitly reconcile an outstanding lightning invoice if we have one
api.billing
.reconcile_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
ok(invoice) ok(invoice)
} }
@@ -35,9 +41,16 @@ pub async fn get_invoice_bolt11(
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
// Make sure we have a bolt11 for this invoice
api.billing
.ensure_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
// Check to see whether it was reconciled out of band
let bolt11 = api let bolt11 = api
.billing .billing
.ensure_and_reconcile_bolt11(&invoice) .reconcile_bolt11_for_invoice(&invoice)
.await .await
.map_err(internal)?; .map_err(internal)?;
+4 -1
View File
@@ -66,7 +66,10 @@ pub async fn get_tenant(
let mut tenant = api.get_tenant_or_404(&pubkey).await?; let mut tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing.sync_stripe_customer(&mut tenant).await.map_err(internal)?; api.billing
.sync_stripe_customer(&mut tenant)
.await
.map_err(internal)?;
ok(TenantResponse::from(tenant)) ok(TenantResponse::from(tenant))
} }
+21 -20
View File
@@ -200,36 +200,37 @@ export default function Account() {
} }
return ( return (
<li <li class="rounded-lg border border-gray-200 p-4 text-sm">
class={`rounded-lg border border-gray-200 p-4 text-sm ${isOpen() ? "cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors" : ""}`}
onClick={() => isOpen() && setSelectedInvoice(invoice)}
title={isOpen() ? "Click to pay this invoice" : undefined}
>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div class="min-w-0">
<span class="font-medium text-gray-900"> <div class="flex items-center gap-2">
${(invoice.amount / 100).toFixed(2)} <span class="font-medium text-gray-900">
</span> ${(invoice.amount / 100).toFixed(2)}
<Show when={invoice.method}> </span>
<span class="text-xs text-gray-500"> · paid via {methodLabels[invoice.method!] ?? invoice.method}</span> <span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
</Show> {status()}
</span>
<Show when={invoice.method}>
<span class="text-xs text-gray-500">· paid via {methodLabels[invoice.method!] ?? invoice.method}</span>
</Show>
</div>
<Show when={invoice.period_start && invoice.period_end}> <Show when={invoice.period_start && invoice.period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p> <p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show> </Show>
</div> </div>
<div class="flex items-center gap-2 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
<Show when={isOpen()}> <Show when={isOpen()}>
<span class="text-xs text-blue-600 font-medium">Pay now</span> <button
type="button"
onClick={() => setSelectedInvoice(invoice)}
class="py-1 px-3 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Pay now
</button>
</Show> </Show>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{status()}
</span>
<button <button
type="button" type="button"
onClick={(e) => { onClick={() => void printInvoice(invoice)}
e.stopPropagation()
void printInvoice(invoice)
}}
disabled={printing()} disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50" class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
> >