forked from coracle/caravel
refactor billing endpoints to separate reads from reconciliation requests
This commit is contained in:
+7
-3
@@ -32,7 +32,9 @@ use crate::models::{Relay, Tenant};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items, list_invoices};
|
||||
use crate::routes::invoices::{
|
||||
ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, reconcile_invoice,
|
||||
};
|
||||
use crate::routes::plans::{get_plan, list_plans};
|
||||
use crate::routes::relays::{
|
||||
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
||||
@@ -40,7 +42,7 @@ use crate::routes::relays::{
|
||||
};
|
||||
use crate::routes::tenants::{
|
||||
create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items,
|
||||
list_tenant_invoices, list_tenant_relays, list_tenants, update_tenant,
|
||||
list_tenant_invoices, list_tenant_relays, list_tenants, reconcile_tenant, update_tenant,
|
||||
};
|
||||
use crate::stripe::Stripe;
|
||||
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
|
||||
@@ -77,6 +79,7 @@ impl Api {
|
||||
"/tenants/:pubkey/invoices/draft/items",
|
||||
get(list_draft_invoice_items),
|
||||
)
|
||||
.route("/tenants/:pubkey/reconcile", post(reconcile_tenant))
|
||||
.route(
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
@@ -89,7 +92,8 @@ impl Api {
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
.route("/invoices", get(list_invoices))
|
||||
.route("/invoices/:id", get(get_invoice))
|
||||
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
|
||||
.route("/invoices/:id/reconcile", post(reconcile_invoice))
|
||||
.route("/invoices/:id/bolt11", post(ensure_invoice_bolt11))
|
||||
.route("/invoices/:id/items", get(list_invoice_items))
|
||||
.with_state(api)
|
||||
}
|
||||
|
||||
+24
-42
@@ -122,7 +122,7 @@ impl Billing {
|
||||
|
||||
// Attempt payment on every open invoice after syncing with stripe.
|
||||
if attempt_payment {
|
||||
self.sync_stripe_customer(&mut tenant).await?;
|
||||
tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?;
|
||||
self.collect_open_invoices(&tenant).await?;
|
||||
}
|
||||
|
||||
@@ -311,17 +311,24 @@ impl Billing {
|
||||
}
|
||||
|
||||
for invoice in &open {
|
||||
self.attempt_payment(tenant, invoice).await?;
|
||||
self.attempt_payment(tenant, invoice, true).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect an invoice via NWC, then a saved card, then a manual DM. A failing
|
||||
/// method's error is stored on the tenant (to warn them in the UI) but never
|
||||
/// aborts the cascade or future retries; a method's error is cleared when it
|
||||
/// next succeeds.
|
||||
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||
/// Collect an invoice via NWC, then a saved card, then (when `notify`) a
|
||||
/// manual DM. A failing method's error is stored on the tenant (to warn them
|
||||
/// in the UI) but never aborts the cascade or future retries; a method's
|
||||
/// error is cleared when it next succeeds. Caller-initiated payments pass
|
||||
/// `notify = false` to skip the dunning DM, since the failure is already
|
||||
/// surfaced on screen.
|
||||
pub async fn attempt_payment(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
invoice: &Invoice,
|
||||
notify: bool,
|
||||
) -> Result<()> {
|
||||
let mut error_message: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
|
||||
@@ -354,9 +361,10 @@ impl Billing {
|
||||
}
|
||||
|
||||
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||
if let Err(e) = self
|
||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||
.await
|
||||
if notify
|
||||
&& let Err(e) = self
|
||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
@@ -493,44 +501,18 @@ impl Billing {
|
||||
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
||||
}
|
||||
|
||||
pub async fn reconcile_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Option<Bolt11>> {
|
||||
if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? {
|
||||
// Don't settle an invoice that is already resolved
|
||||
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
|
||||
return Ok(Some(bolt11));
|
||||
}
|
||||
|
||||
return self.reconcile_bolt11(&bolt11).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? {
|
||||
command::settle_invoice_out_of_band(&bolt11.id, &bolt11.invoice_id).await?;
|
||||
|
||||
// Re-fetch so the caller sees that it's been settled.
|
||||
return query::get_bolt11(&bolt11.id).await;
|
||||
}
|
||||
|
||||
Ok(Some(bolt11.clone()))
|
||||
}
|
||||
|
||||
// --- Stripe utils ---
|
||||
|
||||
/// Copy down any stripe-related stuff to our local tenant model. Fail gracefully.
|
||||
pub async fn sync_stripe_customer(&self, tenant: &mut Tenant) -> Result<()> {
|
||||
/// Refresh stripe-related state for a tenant, returning the synced payment
|
||||
/// method id (the tenant's existing one on failure). Fails gracefully.
|
||||
pub async fn sync_stripe_customer(&self, tenant: &Tenant) -> Result<Option<String>> {
|
||||
match self.sync_stripe_payment_method(tenant).await {
|
||||
Ok(payment_method_id) => {
|
||||
tenant.stripe_payment_method_id = payment_method_id;
|
||||
}
|
||||
Ok(payment_method_id) => Ok(payment_method_id),
|
||||
Err(error) => {
|
||||
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
|
||||
Ok(tenant.stripe_payment_method_id.clone())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the cached Stripe payment method from Stripe so collection can charge
|
||||
|
||||
@@ -188,15 +188,6 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
|
||||
// --- Bolt11 ---
|
||||
|
||||
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
|
||||
.bind(bolt11_id)
|
||||
.fetch_optional(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>(
|
||||
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
|
||||
@@ -15,6 +15,7 @@ pub async fn list_invoices(
|
||||
ok(query::list_invoices().await.map_err(internal)?)
|
||||
}
|
||||
|
||||
/// Read a single invoice
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -27,18 +28,50 @@ pub async fn get_invoice(
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
// Implicitly reconcile an outstanding lightning invoice if we have one
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
/// Reconcile and collect an open invoice
|
||||
pub async fn reconcile_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let invoice = query::get_invoice(&id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
// Nothing to collect on an already-resolved invoice.
|
||||
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
|
||||
return ok(invoice);
|
||||
}
|
||||
|
||||
let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?;
|
||||
|
||||
api.billing
|
||||
.reconcile_bolt11_for_invoice(&invoice)
|
||||
.ensure_bolt11_for_invoice(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
api.billing
|
||||
.attempt_payment(&tenant, &invoice, false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
// Re-read so the caller sees the possibly now-paid invoice.
|
||||
let invoice = query::get_invoice(&id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
|
||||
/// needed and first settling it if it was already paid out of band.
|
||||
pub async fn get_invoice_bolt11(
|
||||
/// Idempotently create a payable Lightning invoice (bolt11)
|
||||
pub async fn ensure_invoice_bolt11(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(invoice_id): Path<String>,
|
||||
@@ -50,24 +83,16 @@ pub async fn get_invoice_bolt11(
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
// Make sure we have a bolt11 for this invoice
|
||||
api.billing
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.ensure_bolt11_for_invoice(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
// Check to see whether it was reconciled out of band
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.reconcile_bolt11_for_invoice(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!(bolt11))
|
||||
ok(bolt11)
|
||||
}
|
||||
|
||||
/// The line items billed on an invoice, for rendering its contents and a
|
||||
/// downloadable copy.
|
||||
/// The line items billed on an invoice
|
||||
pub async fn list_invoice_items(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
|
||||
@@ -57,7 +57,7 @@ pub async fn list_tenants(
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Fetch a tenant by pubkey. Automatically refreshes the tenant's stripe payment data.
|
||||
/// Fetch a tenant by pubkey.
|
||||
pub async fn get_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -65,12 +65,7 @@ pub async fn get_tenant(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing
|
||||
.sync_stripe_customer(&mut tenant)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
@@ -159,7 +154,7 @@ pub async fn list_tenant_relays(
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
/// List a tenant's invoices after reconciling the tenant's billing state.
|
||||
/// List a tenant's invoices.
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -167,13 +162,6 @@ pub async fn list_tenant_invoices(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing
|
||||
.reconcile_subscription(&tenant, false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let invoices = query::list_invoices_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
@@ -181,14 +169,8 @@ pub async fn list_tenant_invoices(
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
|
||||
/// outstanding line items for the current period (reconciled first so it reflects
|
||||
/// the latest activity). It mirrors what `create_invoice` would bill once the
|
||||
/// balance turns positive. `null` when the tenant has no billing anchor yet or
|
||||
/// nothing is outstanding. The id is a fixed `draft` sentinel and the lifecycle
|
||||
/// fields are empty — it isn't persisted or payable, so the UI renders it
|
||||
/// read-only with a `draft` status.
|
||||
pub async fn get_draft_invoice(
|
||||
/// Reconcile a tenant's subscription
|
||||
pub async fn reconcile_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
@@ -197,13 +179,32 @@ pub async fn get_draft_invoice(
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing
|
||||
.sync_stripe_customer(&tenant)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
api.billing
|
||||
.reconcile_subscription(&tenant, false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
// Re-read so the draft sees a billing anchor that reconcile may have just set
|
||||
// (it persists the anchor but mutates only its own clone).
|
||||
// Re-read so the response reflects the synced method and any billing anchor.
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
|
||||
/// outstanding line items for the current period. It mirrors what `create_invoice`
|
||||
/// would bill once the balance turns positive.
|
||||
pub async fn get_draft_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let draft = match BillingPeriod::current(&tenant) {
|
||||
|
||||
@@ -104,8 +104,10 @@ impl Stripe {
|
||||
/// A decline or an issuer authentication demand (`authentication_required`,
|
||||
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
|
||||
/// error, so the caller naturally falls through to another payment method.
|
||||
/// The charge is made idempotent on `invoice_id`, so a retried collection
|
||||
/// reuses the same charge instead of billing the payment method twice.
|
||||
/// The charge is made idempotent on `invoice_id` and `payment_method_id`,
|
||||
/// so a retried collection against the same method reuses the same charge
|
||||
/// instead of billing twice, while a fall-back to a different method issues
|
||||
/// a distinct charge instead of colliding on the original key.
|
||||
pub async fn create_payment_intent(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
@@ -119,7 +121,7 @@ impl Stripe {
|
||||
.post("/payment_intents")
|
||||
.header(
|
||||
"Idempotency-Key",
|
||||
self.idempotency_key(&["payment_intent", invoice_id]),
|
||||
self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]),
|
||||
)
|
||||
.form(&[
|
||||
("amount", amount.as_str()),
|
||||
|
||||
Reference in New Issue
Block a user