forked from coracle/caravel
Some lightning invoice refactoring
This commit is contained in:
+35
-111
@@ -4,24 +4,18 @@ use std::collections::BTreeMap;
|
|||||||
use crate::bitcoin;
|
use crate::bitcoin;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
use crate::env::Env;
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, Tenant, RELAY_STATUS_ACTIVE};
|
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
|
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
|
||||||
use crate::wallet::Wallet;
|
use crate::wallet::Wallet;
|
||||||
|
|
||||||
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
|
|
||||||
|
|
||||||
/// How long a freshly minted bolt11 stays valid. Once it lapses, an unpaid
|
|
||||||
/// invoice's bolt11 is regenerated on next access, so the tenant is never shown
|
|
||||||
/// a dead invoice and the sat amount stays pegged to the current BTC price.
|
|
||||||
const BOLT11_EXPIRY_SECS: i64 = 3600;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Billing {
|
pub struct Billing {
|
||||||
stripe: Stripe,
|
stripe: Stripe,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
|
env: Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Billing {
|
impl Billing {
|
||||||
@@ -31,9 +25,12 @@ impl Billing {
|
|||||||
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
|
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
|
env: env.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- lifecycle methods ---
|
||||||
|
|
||||||
pub async fn start(self) {
|
pub async fn start(self) {
|
||||||
let mut rx = self.command.notify.subscribe();
|
let mut rx = self.command.notify.subscribe();
|
||||||
|
|
||||||
@@ -131,7 +128,7 @@ impl Billing {
|
|||||||
self.ensure_subscription_items(subscription, quantity_by_price_id).await
|
self.ensure_subscription_items(subscription, quantity_by_price_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// --Stripe helpers--
|
// --- Stripe helpers ---
|
||||||
|
|
||||||
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
|
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
|
||||||
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
|
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
|
||||||
@@ -228,131 +225,58 @@ impl Billing {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public API helpers ---
|
// --- lightning helpers ---
|
||||||
|
|
||||||
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> {
|
/// return or generate a lightning invoice for an open stripe invoice
|
||||||
let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
|
|
||||||
self.wallet
|
|
||||||
.make_invoice(
|
|
||||||
amount_msats,
|
|
||||||
LIGHTNING_INVOICE_DESCRIPTION,
|
|
||||||
BOLT11_EXPIRY_SECS as u64,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the current valid bolt11 for an open invoice, minting one if none
|
|
||||||
/// exists and regenerating it if the stored one has expired. There is
|
|
||||||
/// 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(
|
pub async fn ensure_lightning_invoice(
|
||||||
&self,
|
&self,
|
||||||
stripe_invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
amount_due_minor: i64,
|
amount_due: i64,
|
||||||
currency: &str,
|
currency: &str,
|
||||||
) -> Result<String> {
|
) -> Result<LightningInvoice> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await? {
|
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.
|
&& (existing.status != "pending" || now < existing.expires_at)
|
||||||
if existing.status != "pending" || now < existing.expires_at {
|
{
|
||||||
return Ok(existing.bolt11);
|
return Ok(existing);
|
||||||
}
|
|
||||||
// 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 bolt11 = self.create_bolt11(amount_due_minor, currency).await?;
|
let expiry: i64 = 3600;
|
||||||
if self
|
let info = "Relay subscription payment";
|
||||||
|
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
|
||||||
|
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
|
||||||
|
|
||||||
|
let invoice = match self
|
||||||
.command
|
.command
|
||||||
.insert_pending_lightning_invoice(
|
.insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
|
||||||
stripe_invoice_id,
|
|
||||||
tenant_pubkey,
|
|
||||||
&bolt11,
|
|
||||||
now + BOLT11_EXPIRY_SECS,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(bolt11)
|
Some(invoice) => invoice,
|
||||||
} else {
|
None => self
|
||||||
// Lost the insert race; use whatever the winner stored.
|
|
||||||
Ok(self
|
|
||||||
.query
|
.query
|
||||||
.get_lightning_invoice(stripe_invoice_id)
|
.get_lightning_invoice(stripe_invoice_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
|
||||||
anyhow!("lightning_invoice row missing after insert race for invoice {stripe_invoice_id}")
|
};
|
||||||
})?
|
|
||||||
.bolt11)
|
Ok(invoice)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pay_outstanding_card_invoices(
|
/// Attempt to pay and settle an invoice via nwc
|
||||||
&self,
|
pub async fn nwc_pay_invoice(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
|
||||||
tenant: &crate::models::Tenant,
|
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
|
||||||
) -> Result<()> {
|
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
||||||
if !self
|
|
||||||
.stripe
|
|
||||||
.has_payment_method(&tenant.stripe_customer_id)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let invoices = self
|
match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await {
|
||||||
.stripe
|
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
|
||||||
.list_invoices(&tenant.stripe_customer_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for invoice in &invoices {
|
|
||||||
if invoice.status != "open" || invoice.amount_due == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(error) = self.stripe.pay_invoice(&invoice.id).await {
|
|
||||||
tracing::error!(
|
|
||||||
error = %error,
|
|
||||||
stripe_invoice_id = %invoice.id,
|
|
||||||
"failed to retry card payment for outstanding invoice"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lightning / NWC orchestration ---
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
stripe_invoice_id: &str,
|
|
||||||
tenant_pubkey: &str,
|
|
||||||
bolt11: &str,
|
|
||||||
tenant_nwc_url: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let tenant_wallet = Wallet::from_url(tenant_nwc_url)?;
|
|
||||||
|
|
||||||
match tenant_wallet.pay_invoice(bolt11.to_string()).await {
|
|
||||||
Ok(()) => self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await,
|
|
||||||
Err(pay_error) => {
|
Err(pay_error) => {
|
||||||
// The pay request errored, but the payment may have landed
|
// The pay request errored, but the payment may have landed
|
||||||
// before the response was lost. Confirm against the system
|
// before the response was lost. Confirm against the system
|
||||||
// wallet before reporting failure.
|
// wallet before reporting failure.
|
||||||
if self.wallet.is_settled(bolt11).await.unwrap_or(false) {
|
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
|
||||||
self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await
|
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
|
||||||
} else {
|
} else {
|
||||||
Err(pay_error)
|
Err(pay_error)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-35
@@ -3,7 +3,8 @@ use sqlx::{Sqlite, SqlitePool, Transaction};
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||||
|
RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -298,22 +299,29 @@ impl Command {
|
|||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
|
|
||||||
/// Insert a freshly minted pending bolt11 for an invoice. Returns `false` if
|
/// Upsert the pending bolt11 for an invoice, returning the resulting row. On
|
||||||
/// a row already exists (lost an insert race), in which case the caller
|
/// conflict the stored bolt11/expiry are replaced — this is how an expired
|
||||||
/// should read and use the existing row's bolt11.
|
/// invoice is regenerated — except once the invoice is paid, when the
|
||||||
pub async fn insert_pending_lightning_invoice(
|
/// `status = 'pending'` guard makes the update a no-op and `None` is
|
||||||
|
/// returned so the caller can fall back to reading the settled row.
|
||||||
|
pub async fn insert_lightning_invoice(
|
||||||
&self,
|
&self,
|
||||||
stripe_invoice_id: &str,
|
stripe_invoice_id: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
bolt11: &str,
|
bolt11: &str,
|
||||||
expires_at: i64,
|
expires_at: i64,
|
||||||
) -> Result<bool> {
|
) -> Result<Option<LightningInvoice>> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let result = sqlx::query(
|
let row = sqlx::query_as::<_, LightningInvoice>(
|
||||||
"INSERT INTO lightning_invoice
|
"INSERT INTO lightning_invoice
|
||||||
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
|
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
||||||
ON CONFLICT(stripe_invoice_id) DO NOTHING",
|
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
|
||||||
|
bolt11 = excluded.bolt11,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
WHERE status = 'pending'
|
||||||
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(stripe_invoice_id)
|
.bind(stripe_invoice_id)
|
||||||
.bind(tenant_pubkey)
|
.bind(tenant_pubkey)
|
||||||
@@ -321,35 +329,10 @@ impl Command {
|
|||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(row)
|
||||||
}
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE lightning_invoice
|
|
||||||
SET bolt11 = ?, expires_at = ?, updated_at = ?
|
|
||||||
WHERE stripe_invoice_id = ? AND status = 'pending'",
|
|
||||||
)
|
|
||||||
.bind(bolt11)
|
|
||||||
.bind(expires_at)
|
|
||||||
.bind(now)
|
|
||||||
.bind(stripe_invoice_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a pending invoice paid, recording which method settled it. The
|
/// Mark a pending invoice paid, recording which method settled it. The
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ pub async fn get_lightning_invoice(
|
|||||||
return Err(bad_request("invoice-not-open", "invoice is not open"));
|
return Err(bad_request("invoice-not-open", "invoice is not open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bolt11 = api
|
let invoice = api
|
||||||
.billing
|
.billing
|
||||||
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
ok(serde_json::json!({ "bolt11": bolt11 }))
|
ok(serde_json::json!(invoice))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a Stripe invoice and the tenant that owns it, enforcing that the
|
/// Fetch a Stripe invoice and the tenant that owns it, enforcing that the
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ async fn handle_invoice_created(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mint (or reuse) the single bolt11 that both NWC and manual payment settle.
|
let invoice = api
|
||||||
let bolt11 = api
|
|
||||||
.billing
|
.billing
|
||||||
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -137,12 +136,7 @@ async fn handle_invoice_created(
|
|||||||
|
|
||||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
if !tenant.nwc_url.is_empty() {
|
if !tenant.nwc_url.is_empty() {
|
||||||
let plain_nwc_url = api.env.decrypt(&tenant.nwc_url)?;
|
match api.billing.nwc_pay_invoice(&tenant, &invoice).await {
|
||||||
match api
|
|
||||||
.billing
|
|
||||||
.nwc_pay_invoice(stripe_invoice_id, &tenant.pubkey, &bolt11, &plain_nwc_url)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!("{e}");
|
let error_msg = format!("{e}");
|
||||||
@@ -309,7 +303,24 @@ async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) ->
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
api.billing.pay_outstanding_card_invoices(&tenant).await?;
|
let invoices = api
|
||||||
|
.stripe
|
||||||
|
.list_invoices(&tenant.stripe_customer_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for invoice in &invoices {
|
||||||
|
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
|
||||||
|
tracing::error!(
|
||||||
|
error = %error,
|
||||||
|
stripe_invoice_id = %invoice.id,
|
||||||
|
"failed to retry card payment for outstanding invoice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user