forked from coracle/caravel
Fix bolt11 reconciliation
This commit is contained in:
+22
-15
@@ -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 ---
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user