forked from coracle/caravel
Collapse multiple invoice tables into one
This commit is contained in:
@@ -39,19 +39,13 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS invoice_nwc_payment (
|
CREATE TABLE IF NOT EXISTS lightning_invoice (
|
||||||
invoice_id TEXT PRIMARY KEY,
|
stripe_invoice_id TEXT PRIMARY KEY,
|
||||||
tenant_pubkey TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL CHECK (state IN ('pending', 'paid')),
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS invoice_manual_lightning_payment (
|
|
||||||
invoice_id TEXT PRIMARY KEY,
|
|
||||||
tenant_pubkey TEXT NOT NULL,
|
tenant_pubkey TEXT NOT NULL,
|
||||||
bolt11 TEXT NOT NULL,
|
bolt11 TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
|
||||||
|
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||||
@@ -69,8 +63,5 @@ CREATE INDEX IF NOT EXISTS idx_relay_tenant_status_plan
|
|||||||
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
CREATE INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
|
||||||
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
ON activity (resource_type, resource_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invoice_nwc_payment_tenant_pubkey
|
CREATE INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
|
||||||
ON invoice_nwc_payment (tenant_pubkey);
|
ON lightning_invoice (tenant_pubkey);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invoice_manual_lightning_payment_tenant_pubkey
|
|
||||||
ON invoice_manual_lightning_payment (tenant_pubkey);
|
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ Notes:
|
|||||||
- Return `data` is a single Stripe invoice object
|
- Return `data` is a single Stripe invoice object
|
||||||
- If invoice does not exist, return `404` with `code=not-found`
|
- If invoice does not exist, return `404` with `code=not-found`
|
||||||
|
|
||||||
## `async fn get_invoice_bolt11(...) -> Response`
|
## `async fn get_lightning_invoice(...) -> Response`
|
||||||
|
|
||||||
- Serves `GET /invoices/:id/bolt11`
|
- Serves `GET /invoices/:id/bolt11`
|
||||||
- Fetches invoice from Stripe API by ID
|
- Fetches invoice from Stripe API by ID
|
||||||
|
|||||||
+2
-2
@@ -36,7 +36,7 @@ use crate::query::Query;
|
|||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::stripe::Stripe;
|
use crate::stripe::Stripe;
|
||||||
use crate::routes::identity::get_identity;
|
use crate::routes::identity::get_identity;
|
||||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_tenant_invoices};
|
use crate::routes::invoices::{get_invoice, get_lightning_invoice, list_tenant_invoices};
|
||||||
use crate::routes::plans::{get_plan, list_plans};
|
use crate::routes::plans::{get_plan, list_plans};
|
||||||
use crate::routes::relays::{
|
use crate::routes::relays::{
|
||||||
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
||||||
@@ -98,7 +98,7 @@ impl Api {
|
|||||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||||
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
|
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
|
||||||
.route("/invoices/:id", get(get_invoice))
|
.route("/invoices/:id", get(get_invoice))
|
||||||
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
|
.route("/invoices/:id/bolt11", get(get_lightning_invoice))
|
||||||
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
|
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
|
||||||
.route("/stripe/webhook", post(stripe_webhook))
|
.route("/stripe/webhook", post(stripe_webhook))
|
||||||
.with_state(api)
|
.with_state(api)
|
||||||
|
|||||||
+113
-179
@@ -11,11 +11,10 @@ use crate::wallet::Wallet;
|
|||||||
|
|
||||||
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
|
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
|
||||||
|
|
||||||
pub enum NwcInvoicePaymentOutcome {
|
/// How long a freshly minted bolt11 stays valid. Once it lapses, an unpaid
|
||||||
Paid,
|
/// invoice's bolt11 is regenerated on next access, so the tenant is never shown
|
||||||
Fallback(anyhow::Error),
|
/// a dead invoice and the sat amount stays pegged to the current BTC price.
|
||||||
Pending(anyhow::Error),
|
const BOLT11_EXPIRY_SECS: i64 = 3600;
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Billing {
|
pub struct Billing {
|
||||||
@@ -234,78 +233,65 @@ impl Billing {
|
|||||||
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
||||||
let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
|
let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
|
||||||
self.wallet
|
self.wallet
|
||||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
.make_invoice(
|
||||||
|
amount_msats,
|
||||||
|
LIGHTNING_INVOICE_DESCRIPTION,
|
||||||
|
BOLT11_EXPIRY_SECS as u64,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
/// Return the current valid bolt11 for an open invoice, minting one if none
|
||||||
if tenant.nwc_url.is_empty() {
|
/// exists and regenerating it if the stored one has expired. There is
|
||||||
return Ok(());
|
/// exactly one bolt11 per invoice: whoever pays it — the tenant's NWC wallet
|
||||||
|
/// or a human — settles the same Lightning invoice, so the bolt11 itself is
|
||||||
|
/// the double-charge guard.
|
||||||
|
pub async fn ensure_lightning_invoice(
|
||||||
|
&self,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
tenant_pubkey: &str,
|
||||||
|
amount_due_minor: i64,
|
||||||
|
currency: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await? {
|
||||||
|
// Keep a still-valid invoice, or any bolt11 we've already settled.
|
||||||
|
if existing.status != "pending" || now < existing.expires_at {
|
||||||
|
return Ok(existing.bolt11);
|
||||||
|
}
|
||||||
|
// The stored invoice expired unpaid, so mint a fresh one. The old
|
||||||
|
// invoice can no longer be paid, so no settlement can be missed.
|
||||||
|
let bolt11 = self.create_bolt11(amount_due_minor, currency).await?;
|
||||||
|
self.command
|
||||||
|
.regenerate_lightning_invoice(stripe_invoice_id, &bolt11, now + BOLT11_EXPIRY_SECS)
|
||||||
|
.await?;
|
||||||
|
return Ok(bolt11);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plain_nwc_url = self.env.decrypt(&tenant.nwc_url)?;
|
let bolt11 = self.create_bolt11(amount_due_minor, currency).await?;
|
||||||
|
if self
|
||||||
let invoices = self
|
.command
|
||||||
.stripe
|
.insert_pending_lightning_invoice(
|
||||||
.list_invoices(&tenant.stripe_customer_id)
|
stripe_invoice_id,
|
||||||
.await?;
|
tenant_pubkey,
|
||||||
|
&bolt11,
|
||||||
for invoice in &invoices {
|
now + BOLT11_EXPIRY_SECS,
|
||||||
if invoice.status != "open" || invoice.amount_due == 0 {
|
)
|
||||||
continue;
|
.await?
|
||||||
}
|
{
|
||||||
let invoice_id = invoice.id.as_str();
|
Ok(bolt11)
|
||||||
|
} else {
|
||||||
match self
|
// Lost the insert race; use whatever the winner stored.
|
||||||
.nwc_pay_invoice(
|
Ok(self
|
||||||
invoice_id,
|
.query
|
||||||
&tenant.pubkey,
|
.get_lightning_invoice(stripe_invoice_id)
|
||||||
invoice.amount_due,
|
|
||||||
&invoice.currency,
|
|
||||||
&plain_nwc_url,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
{
|
.ok_or_else(|| {
|
||||||
NwcInvoicePaymentOutcome::Paid => {
|
anyhow!("lightning_invoice row missing after insert race for invoice {stripe_invoice_id}")
|
||||||
if let Err(e) = self
|
})?
|
||||||
.mark_invoice_paid_out_of_band_after_nwc(invoice_id, &tenant.pubkey)
|
.bolt11)
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
error = %e,
|
|
||||||
invoice_id,
|
|
||||||
"failed to mark invoice paid out of band"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NwcInvoicePaymentOutcome::Fallback(e) => {
|
|
||||||
let error_msg = format!("{e}");
|
|
||||||
tracing::error!(
|
|
||||||
error = %e,
|
|
||||||
invoice_id,
|
|
||||||
"nwc payment failed for outstanding invoice"
|
|
||||||
);
|
|
||||||
let _ = self
|
|
||||||
.command
|
|
||||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
NwcInvoicePaymentOutcome::Pending(e) => {
|
|
||||||
let error_msg = format!("{e}");
|
|
||||||
tracing::error!(
|
|
||||||
error = %e,
|
|
||||||
invoice_id,
|
|
||||||
"outstanding invoice requires NWC reconciliation before retry"
|
|
||||||
);
|
|
||||||
let _ = self
|
|
||||||
.command
|
|
||||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pay_outstanding_card_invoices(
|
pub async fn pay_outstanding_card_invoices(
|
||||||
@@ -332,7 +318,7 @@ impl Billing {
|
|||||||
if let Err(error) = self.stripe.pay_invoice(&invoice.id).await {
|
if let Err(error) = self.stripe.pay_invoice(&invoice.id).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id = %invoice.id,
|
stripe_invoice_id = %invoice.id,
|
||||||
"failed to retry card payment for outstanding invoice"
|
"failed to retry card payment for outstanding invoice"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,40 +329,58 @@ impl Billing {
|
|||||||
|
|
||||||
// --- Lightning / NWC orchestration ---
|
// --- Lightning / NWC orchestration ---
|
||||||
|
|
||||||
pub async fn mark_invoice_paid_out_of_band_after_nwc(
|
/// Push a payment for an invoice's persisted bolt11 from the tenant's NWC
|
||||||
|
/// wallet, then confirm settlement against the system wallet. On success the
|
||||||
|
/// Stripe invoice is marked paid out of band and `Ok(())` is returned; on
|
||||||
|
/// failure the returned error is the reason to surface to the tenant (the
|
||||||
|
/// caller falls through to other payment methods rather than propagating
|
||||||
|
/// it). Reusing the same bolt11 means a retry — or a concurrent manual
|
||||||
|
/// payment — can never double-charge.
|
||||||
|
pub async fn nwc_pay_invoice(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
|
bolt11: &str,
|
||||||
|
tenant_nwc_url: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
self.stripe.pay_invoice_out_of_band(invoice_id).await?;
|
let tenant_wallet = Wallet::from_url(tenant_nwc_url)?;
|
||||||
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
|
|
||||||
Ok(())
|
match tenant_wallet.pay_invoice(bolt11.to_string()).await {
|
||||||
|
Ok(()) => self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await,
|
||||||
|
Err(pay_error) => {
|
||||||
|
// The pay request errored, but the payment may have landed
|
||||||
|
// before the response was lost. Confirm against the system
|
||||||
|
// wallet before reporting failure.
|
||||||
|
if self.wallet.is_settled(bolt11).await.unwrap_or(false) {
|
||||||
|
self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await
|
||||||
|
} else {
|
||||||
|
Err(pay_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reconcile_manual_lightning_invoice(
|
/// If an open invoice's bolt11 has settled on Lightning, record it as paid.
|
||||||
&self,
|
/// This is the shared settlement path for both NWC payments that landed late
|
||||||
invoice_id: &str,
|
/// and manual payments; it is driven by the actual Lightning settlement
|
||||||
invoice: &StripeInvoice,
|
/// rather than our local state, so it self-corrects if a previous attempt
|
||||||
) -> Result<StripeInvoice> {
|
/// updated Stripe but not our row (or vice versa).
|
||||||
|
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
|
||||||
if invoice.status != "open" {
|
if invoice.status != "open" {
|
||||||
return Ok(invoice.clone());
|
return Ok(invoice.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(bolt11) = self
|
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
|
||||||
.query
|
|
||||||
.get_invoice_manual_lightning_bolt11(invoice_id)
|
|
||||||
.await?
|
|
||||||
else {
|
|
||||||
return Ok(invoice.clone());
|
return Ok(invoice.clone());
|
||||||
};
|
};
|
||||||
|
|
||||||
let settled = match self.is_manual_lightning_invoice_settled(&bolt11).await {
|
let settled = match self.wallet.is_settled(&row.bolt11).await {
|
||||||
Ok(settled) => settled,
|
Ok(settled) => settled,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id,
|
stripe_invoice_id = %invoice.id,
|
||||||
"failed to lookup manual lightning invoice settlement"
|
"failed to look up bolt11 invoice settlement"
|
||||||
);
|
);
|
||||||
return Ok(invoice.clone());
|
return Ok(invoice.clone());
|
||||||
}
|
}
|
||||||
@@ -386,108 +390,38 @@ impl Billing {
|
|||||||
return Ok(invoice.clone());
|
return Ok(invoice.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = self.stripe.pay_invoice_out_of_band(invoice_id).await {
|
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %error,
|
error = %error,
|
||||||
invoice_id,
|
stripe_invoice_id = %invoice.id,
|
||||||
"failed to mark settled manual lightning invoice as paid_out_of_band"
|
"failed to mark settled bolt11 invoice as paid"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The invoice existed when we called pay_invoice_out_of_band a moment ago;
|
// The invoice existed a moment ago; if Stripe suddenly returns 404, fall
|
||||||
// if Stripe suddenly returns 404, fall back to the pre-reconcile snapshot
|
// back to the pre-reconcile snapshot rather than failing the request.
|
||||||
// rather than failing the request.
|
|
||||||
Ok(self
|
Ok(self
|
||||||
.stripe
|
.stripe
|
||||||
.get_invoice(invoice_id)
|
.get_invoice(&invoice.id)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or_else(|| invoice.clone()))
|
.unwrap_or_else(|| invoice.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_manual_lightning_invoice_settled(&self, bolt11: &str) -> Result<bool> {
|
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
|
||||||
self.wallet.is_settled(bolt11).await
|
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
|
||||||
}
|
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
|
||||||
|
/// first-writer-wins, so the recorded method reflects whoever settled first.
|
||||||
/// Charges a Stripe invoice over Lightning: the system wallet issues a bolt11
|
async fn settle_invoice(
|
||||||
/// invoice for the fiat amount, the tenant's wallet pays it. A `pending` row in
|
|
||||||
/// `invoice_nwc_payment` guards against double-charging across retries.
|
|
||||||
pub async fn nwc_pay_invoice(
|
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
amount_due_minor: i64,
|
method: &str,
|
||||||
currency: &str,
|
) -> Result<()> {
|
||||||
tenant_nwc_url: &str,
|
self.command
|
||||||
) -> Result<NwcInvoicePaymentOutcome> {
|
.mark_lightning_invoice_paid(stripe_invoice_id, method)
|
||||||
if let Some(existing_outcome) = self
|
.await?;
|
||||||
.existing_invoice_nwc_payment_outcome(invoice_id)
|
self.stripe.pay_invoice_out_of_band(stripe_invoice_id).await?;
|
||||||
.await?
|
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
|
||||||
{
|
Ok(())
|
||||||
return Ok(existing_outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
let amount_msats = match bitcoin::fiat_to_msats(amount_due_minor, currency).await {
|
|
||||||
Ok(msats) => msats,
|
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let bolt11 = match self
|
|
||||||
.wallet
|
|
||||||
.make_invoice(amount_msats, LIGHTNING_INVOICE_DESCRIPTION)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(bolt11) => bolt11,
|
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tenant_wallet = match Wallet::from_url(tenant_nwc_url) {
|
|
||||||
Ok(wallet) => wallet,
|
|
||||||
Err(error) => return Ok(NwcInvoicePaymentOutcome::Fallback(error)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !self
|
|
||||||
.command
|
|
||||||
.insert_pending_invoice_nwc_payment(invoice_id, tenant_pubkey)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
if let Some(existing_outcome) = self
|
|
||||||
.existing_invoice_nwc_payment_outcome(invoice_id)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Ok(existing_outcome);
|
|
||||||
}
|
|
||||||
return Err(anyhow!(
|
|
||||||
"invoice_nwc_payment row missing after insert race for invoice {invoice_id}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match tenant_wallet.pay_invoice(bolt11).await {
|
|
||||||
Ok(()) => match self.command.mark_invoice_nwc_payment_paid(invoice_id).await {
|
|
||||||
Ok(()) => Ok(NwcInvoicePaymentOutcome::Paid),
|
|
||||||
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
|
||||||
"invoice {invoice_id} was charged over NWC but failed to persist paid state: {error}"
|
|
||||||
))),
|
|
||||||
},
|
|
||||||
Err(error) => Ok(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
|
||||||
"invoice {invoice_id} NWC payment attempt requires reconciliation: {error}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn existing_invoice_nwc_payment_outcome(
|
|
||||||
&self,
|
|
||||||
invoice_id: &str,
|
|
||||||
) -> Result<Option<NwcInvoicePaymentOutcome>> {
|
|
||||||
let state = self.query.get_invoice_nwc_payment_state(invoice_id).await?;
|
|
||||||
match state.as_deref() {
|
|
||||||
Some("paid") => Ok(Some(NwcInvoicePaymentOutcome::Paid)),
|
|
||||||
Some("pending") => Ok(Some(NwcInvoicePaymentOutcome::Pending(anyhow!(
|
|
||||||
"invoice {invoice_id} has a pending NWC reconciliation; refusing to create a new Lightning charge"
|
|
||||||
)))),
|
|
||||||
Some(other) => Err(anyhow!(
|
|
||||||
"unknown invoice_nwc_payment state '{other}' for invoice {invoice_id}"
|
|
||||||
)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-32
@@ -298,19 +298,27 @@ impl Command {
|
|||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
|
|
||||||
pub async fn insert_pending_invoice_nwc_payment(
|
/// Insert a freshly minted pending bolt11 for an invoice. Returns `false` if
|
||||||
|
/// a row already exists (lost an insert race), in which case the caller
|
||||||
|
/// should read and use the existing row's bolt11.
|
||||||
|
pub async fn insert_pending_lightning_invoice(
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
|
bolt11: &str,
|
||||||
|
expires_at: i64,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO invoice_nwc_payment (invoice_id, tenant_pubkey, state, created_at, updated_at)
|
"INSERT INTO lightning_invoice
|
||||||
VALUES (?, ?, 'pending', ?, ?)
|
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
|
||||||
ON CONFLICT(invoice_id) DO NOTHING",
|
VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
||||||
|
ON CONFLICT(stripe_invoice_id) DO NOTHING",
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(stripe_invoice_id)
|
||||||
.bind(tenant_pubkey)
|
.bind(tenant_pubkey)
|
||||||
|
.bind(bolt11)
|
||||||
|
.bind(expires_at)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -319,46 +327,48 @@ impl Command {
|
|||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_invoice_nwc_payment_paid(&self, invoice_id: &str) -> Result<()> {
|
/// Replace the stored bolt11 for a still-pending invoice whose previous
|
||||||
|
/// invoice expired. No-op once the invoice is paid, so this can never
|
||||||
|
/// overwrite a settled invoice.
|
||||||
|
pub async fn regenerate_lightning_invoice(
|
||||||
|
&self,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
bolt11: &str,
|
||||||
|
expires_at: i64,
|
||||||
|
) -> Result<()> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let result = sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE invoice_nwc_payment
|
"UPDATE lightning_invoice
|
||||||
SET state = 'paid', updated_at = ?
|
SET bolt11 = ?, expires_at = ?, updated_at = ?
|
||||||
WHERE invoice_id = ?",
|
WHERE stripe_invoice_id = ? AND status = 'pending'",
|
||||||
)
|
)
|
||||||
|
.bind(bolt11)
|
||||||
|
.bind(expires_at)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(invoice_id)
|
.bind(stripe_invoice_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
anyhow::bail!("invoice_nwc_payment row missing for invoice_id: {invoice_id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_manual_lightning_invoice_payment(
|
/// Mark a pending invoice paid, recording which method settled it. The
|
||||||
&self,
|
/// `status = 'pending'` guard makes this idempotent and first-writer-wins:
|
||||||
invoice_id: &str,
|
/// a later reconcile won't clobber the method recorded by whoever settled
|
||||||
tenant_pubkey: &str,
|
/// it first.
|
||||||
bolt11: &str,
|
pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()> {
|
||||||
) -> Result<bool> {
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let result = sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO invoice_manual_lightning_payment
|
"UPDATE lightning_invoice
|
||||||
(invoice_id, tenant_pubkey, bolt11, created_at, updated_at)
|
SET status = 'paid', paid_method = ?, updated_at = ?
|
||||||
VALUES (?, ?, ?, ?, ?)
|
WHERE stripe_invoice_id = ? AND status = 'pending'",
|
||||||
ON CONFLICT(invoice_id) DO NOTHING",
|
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(method)
|
||||||
.bind(tenant_pubkey)
|
|
||||||
.bind(bolt11)
|
|
||||||
.bind(now)
|
|
||||||
.bind(now)
|
.bind(now)
|
||||||
|
.bind(stripe_invoice_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ pub struct Tenant {
|
|||||||
pub past_due_at: Option<i64>,
|
pub past_due_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct LightningInvoice {
|
||||||
|
pub stripe_invoice_id: String,
|
||||||
|
pub tenant_pubkey: String,
|
||||||
|
pub bolt11: String,
|
||||||
|
pub status: String,
|
||||||
|
pub paid_method: Option<String>,
|
||||||
|
pub expires_at: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Relay {
|
pub struct Relay {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
+9
-19
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::env::Env;
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, Plan, Relay, Tenant};
|
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
|
||||||
|
|
||||||
fn select_tenant(tail: &str) -> String {
|
fn select_tenant(tail: &str) -> String {
|
||||||
format!("SELECT * FROM tenant {tail}")
|
format!("SELECT * FROM tenant {tail}")
|
||||||
@@ -134,29 +134,19 @@ impl Query {
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice state
|
// Invoices
|
||||||
|
|
||||||
pub async fn get_invoice_nwc_payment_state(&self, invoice_id: &str) -> Result<Option<String>> {
|
pub async fn get_lightning_invoice(
|
||||||
let state = sqlx::query_scalar::<_, String>(
|
|
||||||
"SELECT state FROM invoice_nwc_payment WHERE invoice_id = ?",
|
|
||||||
)
|
|
||||||
.bind(invoice_id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_invoice_manual_lightning_bolt11(
|
|
||||||
&self,
|
&self,
|
||||||
invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<LightningInvoice>> {
|
||||||
let bolt11 = sqlx::query_scalar::<_, String>(
|
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||||
"SELECT bolt11 FROM invoice_manual_lightning_payment WHERE invoice_id = ?",
|
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(stripe_invoice_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(bolt11)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
|
|||||||
@@ -26,47 +26,30 @@ pub async fn get_invoice(
|
|||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
let Some(invoice) = self.stripe.get_invoice(id).await? else {
|
let (invoice, _tenant) = load_authorized_invoice(&api, &auth, &id).await?;
|
||||||
return not_found("invoice not found")
|
|
||||||
};
|
|
||||||
|
|
||||||
let tenant = api
|
|
||||||
.query
|
|
||||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| anyhow!("invoice not found"))?;
|
|
||||||
|
|
||||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
|
||||||
|
|
||||||
let invoice = api
|
let invoice = api
|
||||||
.billing
|
.billing
|
||||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
.reconcile_invoice(&invoice)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
ok(invoice)
|
ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_invoice_bolt11(
|
pub async fn get_lightning_invoice(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
let Some(invoice) = self.stripe.get_invoice(id).await? else {
|
let (invoice, tenant) = load_authorized_invoice(&api, &auth, &id).await?;
|
||||||
return not_found("invoice not found")
|
|
||||||
};
|
|
||||||
|
|
||||||
let tenant = api
|
|
||||||
.query
|
|
||||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| anyhow!("invoice not found"))?;
|
|
||||||
|
|
||||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
|
||||||
|
|
||||||
|
// Settle first: this checks the currently-stored bolt11 against the wallet,
|
||||||
|
// so a payment that landed before expiry is always caught before we'd
|
||||||
|
// consider regenerating below.
|
||||||
let invoice = api
|
let invoice = api
|
||||||
.billing
|
.billing
|
||||||
.reconcile_manual_lightning_invoice(&id, &invoice)
|
.reconcile_invoice(&invoice)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
@@ -74,39 +57,37 @@ pub async fn get_invoice_bolt11(
|
|||||||
return Err(bad_request("invoice-not-open", "invoice is not open"));
|
return Err(bad_request("invoice-not-open", "invoice is not open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bolt11 = if let Some(existing_bolt11) = api
|
let bolt11 = api
|
||||||
.query
|
.billing
|
||||||
.get_invoice_manual_lightning_bolt11(&id)
|
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?
|
.map_err(internal)?;
|
||||||
{
|
|
||||||
existing_bolt11
|
|
||||||
} else {
|
|
||||||
let bolt11 = api
|
|
||||||
.billing
|
|
||||||
.create_bolt11(invoice.amount_due, &invoice.currency)
|
|
||||||
.await
|
|
||||||
.map_err(internal)?;
|
|
||||||
|
|
||||||
if api
|
|
||||||
.command
|
|
||||||
.insert_manual_lightning_invoice_payment(&id, &tenant.pubkey, &bolt11)
|
|
||||||
.await
|
|
||||||
.map_err(internal)?
|
|
||||||
{
|
|
||||||
bolt11
|
|
||||||
} else {
|
|
||||||
api.query
|
|
||||||
.get_invoice_manual_lightning_bolt11(&id)
|
|
||||||
.await
|
|
||||||
.map_err(internal)?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
internal(format!(
|
|
||||||
"manual lightning payment row missing after insert race for invoice {id}"
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ok(serde_json::json!({ "bolt11": bolt11 }))
|
ok(serde_json::json!({ "bolt11": bolt11 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a Stripe invoice and the tenant that owns it, enforcing that the
|
||||||
|
/// caller is that tenant (or an admin). Returns 404 if the invoice or its
|
||||||
|
/// tenant can't be found.
|
||||||
|
async fn load_authorized_invoice(
|
||||||
|
api: &Api,
|
||||||
|
auth: &str,
|
||||||
|
stripe_invoice_id: &str,
|
||||||
|
) -> Result<(crate::stripe::StripeInvoice, crate::models::Tenant), crate::web::ApiError> {
|
||||||
|
let Some(invoice) = api.stripe.get_invoice(stripe_invoice_id).await.map_err(internal)? else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(tenant) = api
|
||||||
|
.query
|
||||||
|
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
else {
|
||||||
|
return Err(not_found("invoice not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
api.require_admin_or_tenant(auth, &tenant.pubkey)?;
|
||||||
|
|
||||||
|
Ok((invoice, tenant))
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
use crate::api::{Api, AuthedPubkey};
|
||||||
use crate::billing::NwcInvoicePaymentOutcome;
|
|
||||||
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||||
use crate::web::{ApiResult, bad_request, internal, ok};
|
use crate::web::{ApiResult, bad_request, internal, ok};
|
||||||
|
|
||||||
@@ -75,8 +74,8 @@ async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()>
|
|||||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
|
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
|
||||||
let currency = obj["currency"].as_str().unwrap_or("usd");
|
let currency = obj["currency"].as_str().unwrap_or("usd");
|
||||||
let invoice_id = obj["id"].as_str().unwrap_or_default();
|
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
|
||||||
handle_invoice_created(api, customer, amount_due, currency, invoice_id).await?;
|
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
|
||||||
}
|
}
|
||||||
"invoice.paid" => {
|
"invoice.paid" => {
|
||||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||||
@@ -114,7 +113,7 @@ async fn handle_invoice_created(
|
|||||||
stripe_customer_id: &str,
|
stripe_customer_id: &str,
|
||||||
amount_due: i64,
|
amount_due: i64,
|
||||||
currency: &str,
|
currency: &str,
|
||||||
invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if amount_due == 0 {
|
if amount_due == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -128,6 +127,12 @@ async fn handle_invoice_created(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mint (or reuse) the single bolt11 that both NWC and manual payment settle.
|
||||||
|
let bolt11 = api
|
||||||
|
.billing
|
||||||
|
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut nwc_error_for_dm: Option<String> = None;
|
let mut nwc_error_for_dm: Option<String> = None;
|
||||||
|
|
||||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
@@ -135,22 +140,11 @@ async fn handle_invoice_created(
|
|||||||
let plain_nwc_url = api.env.decrypt(&tenant.nwc_url)?;
|
let plain_nwc_url = api.env.decrypt(&tenant.nwc_url)?;
|
||||||
match api
|
match api
|
||||||
.billing
|
.billing
|
||||||
.nwc_pay_invoice(
|
.nwc_pay_invoice(stripe_invoice_id, &tenant.pubkey, &bolt11, &plain_nwc_url)
|
||||||
invoice_id,
|
.await
|
||||||
&tenant.pubkey,
|
|
||||||
amount_due,
|
|
||||||
currency,
|
|
||||||
&plain_nwc_url,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
{
|
{
|
||||||
NwcInvoicePaymentOutcome::Paid => {
|
Ok(()) => return Ok(()),
|
||||||
api.billing
|
Err(e) => {
|
||||||
.mark_invoice_paid_out_of_band_after_nwc(invoice_id, &tenant.pubkey)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
NwcInvoicePaymentOutcome::Fallback(e) => {
|
|
||||||
let error_msg = format!("{e}");
|
let error_msg = format!("{e}");
|
||||||
api.command
|
api.command
|
||||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||||
@@ -159,25 +153,11 @@ async fn handle_invoice_created(
|
|||||||
error = %e,
|
error = %e,
|
||||||
tenant_pubkey = %tenant.pubkey,
|
tenant_pubkey = %tenant.pubkey,
|
||||||
stripe_customer_id,
|
stripe_customer_id,
|
||||||
invoice_id,
|
stripe_invoice_id,
|
||||||
"nwc auto-payment failed for invoice.created"
|
"nwc auto-payment failed for invoice.created"
|
||||||
);
|
);
|
||||||
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||||
// Fall through to next option
|
// Fall through to card / manual payment
|
||||||
}
|
|
||||||
NwcInvoicePaymentOutcome::Pending(e) => {
|
|
||||||
let error_msg = format!("{e}");
|
|
||||||
api.command
|
|
||||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
|
||||||
.await?;
|
|
||||||
tracing::error!(
|
|
||||||
error = %e,
|
|
||||||
tenant_pubkey = %tenant.pubkey,
|
|
||||||
stripe_customer_id,
|
|
||||||
invoice_id,
|
|
||||||
"nwc auto-payment requires reconciliation before retry"
|
|
||||||
);
|
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,19 @@ impl Wallet {
|
|||||||
Ok(Self { url })
|
Ok(Self { url })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn make_invoice(&self, amount_msats: u64, description: &str) -> Result<String> {
|
pub async fn make_invoice(
|
||||||
|
&self,
|
||||||
|
amount_msats: u64,
|
||||||
|
description: &str,
|
||||||
|
expiry_secs: u64,
|
||||||
|
) -> Result<String> {
|
||||||
let nwc = NWC::new(self.url.clone());
|
let nwc = NWC::new(self.url.clone());
|
||||||
let result = nwc
|
let result = nwc
|
||||||
.make_invoice(MakeInvoiceRequest {
|
.make_invoice(MakeInvoiceRequest {
|
||||||
amount: amount_msats,
|
amount: amount_msats,
|
||||||
description: Some(description.to_string()),
|
description: Some(description.to_string()),
|
||||||
description_hash: None,
|
description_hash: None,
|
||||||
expiry: None,
|
expiry: Some(expiry_secs),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
nwc.shutdown().await;
|
nwc.shutdown().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user