Restructure reconciliation to always reconcile oob payments
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::invoices::{
|
||||
create_invoice_checkout, ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices,
|
||||
ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices,
|
||||
reconcile_invoice,
|
||||
};
|
||||
use crate::routes::plans::{get_plan, list_plans};
|
||||
|
||||
+64
-39
@@ -120,10 +120,25 @@ impl Billing {
|
||||
command::create_invoice(&tenant, &period).await?;
|
||||
}
|
||||
|
||||
// Attempt payment on every open invoice after syncing with stripe.
|
||||
// Fetch the tenant's open invoices once
|
||||
let invoices = query::list_open_invoices(&tenant.pubkey).await?;
|
||||
|
||||
// If the tenant is past due, churn them
|
||||
if self.maybe_churn_tenant(&tenant, &invoices).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we're going to try to collect, make sure we have an updated payment method
|
||||
if attempt_payment {
|
||||
tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?;
|
||||
self.collect_open_invoices(&tenant).await?;
|
||||
}
|
||||
|
||||
// Reconcile out-of-band payments (and, when collecting, charge) on every
|
||||
// open invoice. The out-of-band checks run even when attempt_payment is
|
||||
// false, so a checkout or bolt11 paid out of band settles on any reconcile.
|
||||
for invoice in &invoices {
|
||||
self.reconcile_payments(&tenant, invoice, attempt_payment, true)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -284,39 +299,38 @@ impl Billing {
|
||||
command::insert_invoice_items_for_renewal(&line_items, period).await
|
||||
}
|
||||
|
||||
// --- Payments ---
|
||||
// --- Auto-churn ---
|
||||
|
||||
/// Dunning pass over a tenant's open invoices: if the oldest has been unpaid
|
||||
/// past the grace period, churn the tenant; otherwise retry payment on each.
|
||||
async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> {
|
||||
let open = query::list_open_invoices(&tenant.pubkey).await?;
|
||||
let Some(oldest) = open.first() else {
|
||||
return Ok(());
|
||||
/// Churn a tenant whose oldest open invoice has blown past the grace period:
|
||||
/// pause their relays and DM them once, on the transition into churn. Returns
|
||||
/// whether the tenant is past due, so the caller can skip collecting this pass.
|
||||
async fn maybe_churn_tenant(&self, tenant: &Tenant, invoices: &[Invoice]) -> Result<bool> {
|
||||
let Some(oldest) = invoices.first() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if now - oldest.created_at >= GRACE_PERIOD_SECS {
|
||||
if tenant.churned_at.is_none() {
|
||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
|
||||
if now - oldest.created_at < GRACE_PERIOD_SECS {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Notify the tenant once, on the transition into churn (the guard
|
||||
// above fires this a single time). Log-and-continue on failure.
|
||||
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
|
||||
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
|
||||
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
|
||||
}
|
||||
// Past due. Churn once (the guard fires a single time on the transition)
|
||||
// and notify the tenant, logging and continuing on DM failure.
|
||||
if tenant.churned_at.is_none() {
|
||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
|
||||
|
||||
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
|
||||
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
|
||||
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for invoice in &open {
|
||||
self.attempt_payment(tenant, invoice, true).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// --- Payments ---
|
||||
|
||||
/// Collect an invoice. We check the out-of-band rails first — a Lightning
|
||||
/// invoice or Checkout session the tenant may have already paid — and only
|
||||
/// then initiate a fresh charge (NWC, then a saved card), so a payment that's
|
||||
@@ -326,10 +340,11 @@ impl Billing {
|
||||
/// retries; it's cleared when a method 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(
|
||||
pub async fn reconcile_payments(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
invoice: &Invoice,
|
||||
autopay: bool,
|
||||
notify: bool,
|
||||
) -> Result<()> {
|
||||
let mut error_message: Option<String> = None;
|
||||
@@ -360,6 +375,10 @@ impl Billing {
|
||||
return self.cleanup_pending_payments(invoice).await;
|
||||
}
|
||||
|
||||
if !autopay {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match self.attempt_payment_using_nwc(tenant, invoice).await {
|
||||
@@ -380,11 +399,14 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
if !notify {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 5. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||
if notify
|
||||
&& let Err(e) = self
|
||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||
.await
|
||||
if let Err(e) = self
|
||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
@@ -490,7 +512,6 @@ impl Billing {
|
||||
|
||||
// Record the send to avoid spammy notifications.
|
||||
command::mark_invoice_notified(invoice_id).await
|
||||
|
||||
}
|
||||
|
||||
/// Run after an invoice is settled to invalidate out-of-band payment methods
|
||||
@@ -567,16 +588,18 @@ impl Billing {
|
||||
&& now < existing.expires_at
|
||||
{
|
||||
if existing.settled_at.is_some() {
|
||||
return Err(anyhow!("a checkout has already been settled for this invoice"));
|
||||
return Err(anyhow!(
|
||||
"a checkout has already been settled for this invoice"
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
// Stripe returns the tenant to their account page; tag the invoice so the
|
||||
// landing page reconciles it promptly instead of waiting for the poll.
|
||||
let base = format!("{}/account", env::get().app_url);
|
||||
let success_url = format!("{base}?paid_invoice={}", invoice.id);
|
||||
// Stripe returns the tenant to their account page on success or cancel.
|
||||
// The landing page reconciles the tenant, which now settles a paid
|
||||
// Checkout out of band, so the URL needs no per-invoice marker.
|
||||
let return_url = format!("{}/account", env::get().app_url);
|
||||
|
||||
let (session_id, url, expires_at) = self
|
||||
.stripe
|
||||
@@ -585,12 +608,14 @@ impl Billing {
|
||||
&invoice.id,
|
||||
invoice.amount,
|
||||
"usd",
|
||||
&success_url,
|
||||
&base,
|
||||
&return_url,
|
||||
&return_url,
|
||||
)
|
||||
.await?;
|
||||
|
||||
command::insert_checkout(&invoice.id, &session_id, &url, expires_at).await
|
||||
command::insert_checkout(&invoice.id, &session_id, &url, expires_at)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to insert checkout"))
|
||||
}
|
||||
|
||||
// --- Stripe utils ---
|
||||
|
||||
@@ -684,9 +684,7 @@ async fn insert_invoice_item_tx(
|
||||
|
||||
/// Mark an invoice paid, but only while it is still open — a late Lightning
|
||||
/// payment never flips a voided/forgiven invoice to paid, and a Stripe-paid
|
||||
/// invoice never has its provenance overwritten by a later bolt11. When this
|
||||
/// call is the one that settles it, close out the invoice's other outstanding
|
||||
/// payment instruments so a late completion on another rail can't double-charge.
|
||||
/// invoice never has its provenance overwritten by a later bolt11.
|
||||
async fn mark_invoice_paid_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
invoice_id: &str,
|
||||
@@ -702,7 +700,8 @@ async fn mark_invoice_paid_tx(
|
||||
.bind(paid_at)
|
||||
.bind(invoice_id)
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Void all of a tenant's open invoices, forgiving the balance — used when a
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, bad_request, internal, not_found, ok};
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
pub async fn list_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
@@ -57,7 +57,7 @@ pub async fn reconcile_invoice(
|
||||
.map_err(internal)?;
|
||||
|
||||
api.billing
|
||||
.attempt_payment(&tenant, &invoice, false)
|
||||
.reconcile_payments(&tenant, &invoice, true, false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import { useInvoicePdf } from "@/lib/useInvoicePdf"
|
||||
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
|
||||
import PaymentSetupCard from "@/components/PaymentSetupCard"
|
||||
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
|
||||
import { invoiceStatus, listDraftInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
|
||||
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
|
||||
import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod"
|
||||
import { account, setToastMessage } from "@/lib/state"
|
||||
import { account } from "@/lib/state"
|
||||
import { formatPeriod } from "@/lib/format"
|
||||
import PaymentMethodRow from "@/components/account/PaymentMethodRow"
|
||||
import InvoiceListItem from "@/components/account/InvoiceListItem"
|
||||
@@ -46,37 +46,15 @@ export default function Account() {
|
||||
// composite: reconcile the subscription, sync a card just added in the portal,
|
||||
// and collect the open invoice if a method is now on file — then refresh. This
|
||||
// is what pays the outstanding invoice after the user adds a card and returns.
|
||||
// Reconciles on landing (including after returning from a Stripe Checkout or
|
||||
// the billing portal): reconcile_tenant settles any out-of-band payment — a
|
||||
// completed Checkout or a bolt11 paid elsewhere — and collects when a method
|
||||
// is on file, then refreshes. No per-invoice return marker needed.
|
||||
createEffect(() => {
|
||||
const pubkey = account()?.pubkey
|
||||
if (pubkey) void billing.autopay(pubkey)
|
||||
})
|
||||
|
||||
// Returning from a per-invoice Stripe Checkout (the success_url carries
|
||||
// ?paid_invoice=ID): reconcile that invoice so it flips to paid promptly —
|
||||
// autopay above only collects when a recurring method is on file, and a
|
||||
// one-off Checkout payment doesn't leave one — then strip the marker.
|
||||
createEffect(() => {
|
||||
const pubkey = account()?.pubkey
|
||||
const paidInvoice = new URLSearchParams(window.location.search).get("paid_invoice")
|
||||
if (!pubkey || !paidInvoice) return
|
||||
void (async () => {
|
||||
try {
|
||||
const invoice = await reconcileInvoice(paidInvoice)
|
||||
setToastMessage(
|
||||
invoice.paid_at != null ? "Payment received. Thank you!" : "Your payment is still processing.",
|
||||
)
|
||||
} catch (e) {
|
||||
setToastMessage(e instanceof Error ? e.message : "Failed to confirm payment")
|
||||
} finally {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.delete("paid_invoice")
|
||||
const qs = params.toString()
|
||||
window.history.replaceState({}, "", `${window.location.pathname}${qs ? `?${qs}` : ""}`)
|
||||
billing.refetch()
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
// Coarse account-health summary for the badge. Same snapshot the inline prompt
|
||||
// consumes, so the two can never disagree.
|
||||
const status = createMemo(() =>
|
||||
|
||||
Reference in New Issue
Block a user