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::query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::routes::identity::get_identity;
|
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::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,
|
||||||
@@ -40,7 +42,7 @@ use crate::routes::relays::{
|
|||||||
};
|
};
|
||||||
use crate::routes::tenants::{
|
use crate::routes::tenants::{
|
||||||
create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items,
|
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::stripe::Stripe;
|
||||||
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
|
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
|
||||||
@@ -77,6 +79,7 @@ impl Api {
|
|||||||
"/tenants/:pubkey/invoices/draft/items",
|
"/tenants/:pubkey/invoices/draft/items",
|
||||||
get(list_draft_invoice_items),
|
get(list_draft_invoice_items),
|
||||||
)
|
)
|
||||||
|
.route("/tenants/:pubkey/reconcile", post(reconcile_tenant))
|
||||||
.route(
|
.route(
|
||||||
"/tenants/:pubkey/stripe/session",
|
"/tenants/:pubkey/stripe/session",
|
||||||
get(create_stripe_session),
|
get(create_stripe_session),
|
||||||
@@ -89,7 +92,8 @@ impl Api {
|
|||||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||||
.route("/invoices", get(list_invoices))
|
.route("/invoices", get(list_invoices))
|
||||||
.route("/invoices/:id", get(get_invoice))
|
.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))
|
.route("/invoices/:id/items", get(list_invoice_items))
|
||||||
.with_state(api)
|
.with_state(api)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-42
@@ -122,7 +122,7 @@ impl Billing {
|
|||||||
|
|
||||||
// Attempt payment on every open invoice after syncing with stripe.
|
// Attempt payment on every open invoice after syncing with stripe.
|
||||||
if attempt_payment {
|
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?;
|
self.collect_open_invoices(&tenant).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,17 +311,24 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for invoice in &open {
|
for invoice in &open {
|
||||||
self.attempt_payment(tenant, invoice).await?;
|
self.attempt_payment(tenant, invoice, true).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect an invoice via NWC, then a saved card, then a manual DM. A failing
|
/// Collect an invoice via NWC, then a saved card, then (when `notify`) a
|
||||||
/// method's error is stored on the tenant (to warn them in the UI) but never
|
/// manual DM. A failing method's error is stored on the tenant (to warn them
|
||||||
/// aborts the cascade or future retries; a method's error is cleared when it
|
/// in the UI) but never aborts the cascade or future retries; a method's
|
||||||
/// next succeeds.
|
/// error is cleared when it next succeeds. Caller-initiated payments pass
|
||||||
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
/// `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;
|
let mut error_message: Option<String> = None;
|
||||||
|
|
||||||
// 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first.
|
// 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.
|
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||||
if let Err(e) = self
|
if notify
|
||||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
&& let Err(e) = self
|
||||||
.await
|
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
tenant = %tenant.pubkey,
|
tenant = %tenant.pubkey,
|
||||||
@@ -493,44 +501,18 @@ impl Billing {
|
|||||||
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
.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 ---
|
// --- Stripe utils ---
|
||||||
|
|
||||||
/// Copy down any stripe-related stuff to our local tenant model. Fail gracefully.
|
/// Refresh stripe-related state for a tenant, returning the synced payment
|
||||||
pub async fn sync_stripe_customer(&self, tenant: &mut Tenant) -> Result<()> {
|
/// 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 {
|
match self.sync_stripe_payment_method(tenant).await {
|
||||||
Ok(payment_method_id) => {
|
Ok(payment_method_id) => Ok(payment_method_id),
|
||||||
tenant.stripe_payment_method_id = payment_method_id;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
|
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
|
/// 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 ---
|
// --- 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>> {
|
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
|
||||||
Ok(sqlx::query_as::<_, Bolt11>(
|
Ok(sqlx::query_as::<_, Bolt11>(
|
||||||
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
|
"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)?)
|
ok(query::list_invoices().await.map_err(internal)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a single invoice
|
||||||
pub async fn get_invoice(
|
pub async fn get_invoice(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
@@ -27,18 +28,50 @@ 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
|
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
|
api.billing
|
||||||
.reconcile_bolt11_for_invoice(&invoice)
|
.ensure_bolt11_for_invoice(&invoice)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.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)
|
ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
|
/// Idempotently create a payable Lightning invoice (bolt11)
|
||||||
/// needed and first settling it if it was already paid out of band.
|
pub async fn ensure_invoice_bolt11(
|
||||||
pub async fn get_invoice_bolt11(
|
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
Path(invoice_id): Path<String>,
|
Path(invoice_id): Path<String>,
|
||||||
@@ -50,24 +83,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
|
let bolt11 = api
|
||||||
api.billing
|
.billing
|
||||||
.ensure_bolt11_for_invoice(&invoice)
|
.ensure_bolt11_for_invoice(&invoice)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
// Check to see whether it was reconciled out of band
|
ok(bolt11)
|
||||||
let bolt11 = api
|
|
||||||
.billing
|
|
||||||
.reconcile_bolt11_for_invoice(&invoice)
|
|
||||||
.await
|
|
||||||
.map_err(internal)?;
|
|
||||||
|
|
||||||
ok(serde_json::json!(bolt11))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The line items billed on an invoice, for rendering its contents and a
|
/// The line items billed on an invoice
|
||||||
/// downloadable copy.
|
|
||||||
pub async fn list_invoice_items(
|
pub async fn list_invoice_items(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub async fn list_tenants(
|
|||||||
.collect::<Vec<_>>())
|
.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(
|
pub async fn get_tenant(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
@@ -65,12 +65,7 @@ pub async fn get_tenant(
|
|||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||||
|
|
||||||
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
|
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||||
|
|
||||||
api.billing
|
|
||||||
.sync_stripe_customer(&mut tenant)
|
|
||||||
.await
|
|
||||||
.map_err(internal)?;
|
|
||||||
|
|
||||||
ok(TenantResponse::from(tenant))
|
ok(TenantResponse::from(tenant))
|
||||||
}
|
}
|
||||||
@@ -159,7 +154,7 @@ pub async fn list_tenant_relays(
|
|||||||
ok(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(
|
pub async fn list_tenant_invoices(
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
@@ -167,13 +162,6 @@ pub async fn list_tenant_invoices(
|
|||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
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)
|
let invoices = query::list_invoices_for_tenant(&pubkey)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
@@ -181,14 +169,8 @@ pub async fn list_tenant_invoices(
|
|||||||
ok(invoices)
|
ok(invoices)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
|
/// Reconcile a tenant's subscription
|
||||||
/// outstanding line items for the current period (reconciled first so it reflects
|
pub async fn reconcile_tenant(
|
||||||
/// 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(
|
|
||||||
State(api): State<Arc<Api>>,
|
State(api): State<Arc<Api>>,
|
||||||
AuthedPubkey(auth): AuthedPubkey,
|
AuthedPubkey(auth): AuthedPubkey,
|
||||||
Path(pubkey): Path<String>,
|
Path(pubkey): Path<String>,
|
||||||
@@ -197,13 +179,32 @@ pub async fn get_draft_invoice(
|
|||||||
|
|
||||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||||
|
|
||||||
|
api.billing
|
||||||
|
.sync_stripe_customer(&tenant)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
|
||||||
api.billing
|
api.billing
|
||||||
.reconcile_subscription(&tenant, false)
|
.reconcile_subscription(&tenant, false)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
// Re-read so the draft sees a billing anchor that reconcile may have just set
|
// Re-read so the response reflects the synced method and any billing anchor.
|
||||||
// (it persists the anchor but mutates only its own clone).
|
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 tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||||
|
|
||||||
let draft = match BillingPeriod::current(&tenant) {
|
let draft = match BillingPeriod::current(&tenant) {
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ impl Stripe {
|
|||||||
/// A decline or an issuer authentication demand (`authentication_required`,
|
/// A decline or an issuer authentication demand (`authentication_required`,
|
||||||
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
|
/// 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.
|
/// error, so the caller naturally falls through to another payment method.
|
||||||
/// The charge is made idempotent on `invoice_id`, so a retried collection
|
/// The charge is made idempotent on `invoice_id` and `payment_method_id`,
|
||||||
/// reuses the same charge instead of billing the payment method twice.
|
/// 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(
|
pub async fn create_payment_intent(
|
||||||
&self,
|
&self,
|
||||||
customer_id: &str,
|
customer_id: &str,
|
||||||
@@ -119,7 +121,7 @@ impl Stripe {
|
|||||||
.post("/payment_intents")
|
.post("/payment_intents")
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["payment_intent", invoice_id]),
|
self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]),
|
||||||
)
|
)
|
||||||
.form(&[
|
.form(&[
|
||||||
("amount", amount.as_str()),
|
("amount", amount.as_str()),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PaymentDialog from "@/components/PaymentDialog"
|
|||||||
import PaymentSetup from "@/components/PaymentSetup"
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import { getInvoice, type Invoice } from "@/lib/api"
|
import { getInvoice, type Invoice } from "@/lib/api"
|
||||||
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
|
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
|
||||||
import { billingFlowActive } from "@/lib/state"
|
import { account, billingFlowActive } from "@/lib/state"
|
||||||
|
|
||||||
type BillingPromptsProps = {
|
type BillingPromptsProps = {
|
||||||
// "banner" sits in the dashboard shell (mounted on every page except the
|
// "banner" sits in the dashboard shell (mounted on every page except the
|
||||||
@@ -84,6 +84,13 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After paying or saving a method, reconcile + sync + (auto)collect, then
|
||||||
|
// refresh — so a card added inside the dialog actually settles the invoice.
|
||||||
|
function refreshBilling() {
|
||||||
|
const pubkey = account()?.pubkey
|
||||||
|
if (pubkey) void status.autopay(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
|
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -110,7 +117,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
const wasDeepLink = !payInvoice()
|
const wasDeepLink = !payInvoice()
|
||||||
setPayInvoice(undefined)
|
setPayInvoice(undefined)
|
||||||
if (wasDeepLink) clearDeepLink()
|
if (wasDeepLink) clearDeepLink()
|
||||||
status.refetch()
|
refreshBilling()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -121,7 +128,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
initialTab={setupTab()}
|
initialTab={setupTab()}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSetupOpen(false)
|
setSetupOpen(false)
|
||||||
status.refetch()
|
refreshBilling()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LightningPayBody from "@/components/payment/LightningPayBody"
|
|||||||
import { setToastMessage } from "@/lib/state"
|
import { setToastMessage } from "@/lib/state"
|
||||||
import { copyToClipboard } from "@/lib/clipboard"
|
import { copyToClipboard } from "@/lib/clipboard"
|
||||||
import { useCardPortal } from "@/lib/usePaymentSetup"
|
import { useCardPortal } from "@/lib/usePaymentSetup"
|
||||||
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
|
import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
|
||||||
import { autopayConfigured } from "@/lib/paymentMethod"
|
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||||
import { billingTenant } from "@/lib/state"
|
import { billingTenant } from "@/lib/state"
|
||||||
import { formatUsd, formatPeriod } from "@/lib/format"
|
import { formatUsd, formatPeriod } from "@/lib/format"
|
||||||
@@ -57,7 +57,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
setQrDataUrl("")
|
setQrDataUrl("")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { lnbc } = await getInvoiceBolt11(props.invoice.id)
|
const { lnbc } = await ensureInvoiceBolt11(props.invoice.id)
|
||||||
setBolt11(lnbc)
|
setBolt11(lnbc)
|
||||||
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
|
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
|
||||||
setBolt11Status("ready")
|
setBolt11Status("ready")
|
||||||
@@ -86,7 +86,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
async function checkPayment() {
|
async function checkPayment() {
|
||||||
setPayStatus("loading")
|
setPayStatus("loading")
|
||||||
try {
|
try {
|
||||||
const invoice = await getInvoice(props.invoice.id)
|
const invoice = await reconcileInvoice(props.invoice.id)
|
||||||
if (invoice.paid_at != null) {
|
if (invoice.paid_at != null) {
|
||||||
setPayStatus("success")
|
setPayStatus("success")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+31
-2
@@ -154,6 +154,16 @@ export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">):
|
|||||||
return "open"
|
return "open"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The single invoice autopay collects and the dashboard surfaces as "Pay now":
|
||||||
|
// the OLDEST open invoice with a positive balance, matching the backend's
|
||||||
|
// dunning order so the UI pays the same one collection targets. undefined when
|
||||||
|
// nothing is due. Canonical pick shared by useBillingStatus and autopayBilling.
|
||||||
|
export function selectPayableInvoice(invoices: Invoice[]): Invoice | undefined {
|
||||||
|
return invoices
|
||||||
|
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||||
|
.sort((a, b) => a.created_at - b.created_at)[0]
|
||||||
|
}
|
||||||
|
|
||||||
export type Activity = {
|
export type Activity = {
|
||||||
id: string
|
id: string
|
||||||
tenant_pubkey: string
|
tenant_pubkey: string
|
||||||
@@ -265,6 +275,14 @@ export function listTenantInvoices(pubkey: string) {
|
|||||||
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
|
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile a tenant's billing: sync the Stripe payment method (picking up a
|
||||||
|
// card added via the portal), fold billable activity into invoice items, renew
|
||||||
|
// the current period, and cut an invoice for any outstanding balance. Does not
|
||||||
|
// attempt payment. Returns the reconciled tenant.
|
||||||
|
export function reconcileTenant(pubkey: string) {
|
||||||
|
return callApi<undefined, Tenant>("POST", `/tenants/${pubkey}/reconcile`)
|
||||||
|
}
|
||||||
|
|
||||||
// The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing
|
// The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing
|
||||||
// the current period's not-yet-billed items, or null when there's nothing due.
|
// the current period's not-yet-billed items, or null when there's nothing due.
|
||||||
export function getDraftInvoice(pubkey: string) {
|
export function getDraftInvoice(pubkey: string) {
|
||||||
@@ -310,8 +328,19 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
|
|||||||
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
|
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInvoiceBolt11(invoiceId: string) {
|
// Idempotently create a payable bolt11 for an invoice (reusing a valid existing
|
||||||
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
|
// one) and return it. No reconciliation — settlement is detected by
|
||||||
|
// reconcileInvoice. The lnbc string is the data the QR needs.
|
||||||
|
export function ensureInvoiceBolt11(invoiceId: string) {
|
||||||
|
return callApi<undefined, Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile and collect an open invoice: ensure a payable bolt11 exists, then
|
||||||
|
// run the payment cascade (NWC, then an out-of-band Lightning settle, then a
|
||||||
|
// saved card). Caller-initiated, so no dunning DM and no churn. Returns the
|
||||||
|
// refreshed invoice (paid_at set once collected).
|
||||||
|
export function reconcileInvoice(invoiceId: string) {
|
||||||
|
return callApi<undefined, Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listInvoiceItems(invoiceId: string) {
|
export function listInvoiceItems(invoiceId: string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { indexBy } from "@welshman/lib"
|
import { indexBy } from "@welshman/lib"
|
||||||
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
|
import { invoiceStatus, selectPayableInvoice, type Invoice, type Tenant } from "@/lib/api"
|
||||||
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
|
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
|
||||||
import { billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
import { autopayBilling, billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
||||||
|
|
||||||
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
||||||
|
|
||||||
@@ -30,11 +30,10 @@ export function useBillingStatus() {
|
|||||||
const draftInvoice = () => billingDraftInvoice() ?? undefined
|
const draftInvoice = () => billingDraftInvoice() ?? undefined
|
||||||
|
|
||||||
const openInvoices = createMemo(() =>
|
const openInvoices = createMemo(() =>
|
||||||
invoices()
|
invoices().filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0),
|
||||||
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
|
|
||||||
.sort((a, b) => a.created_at - b.created_at),
|
|
||||||
)
|
)
|
||||||
const openInvoice = () => openInvoices()[0]
|
// The autopay/dunning target — the same pick autopayBilling collects.
|
||||||
|
const openInvoice = () => selectPayableInvoice(invoices())
|
||||||
|
|
||||||
// Amount due: the total of all open invoices.
|
// Amount due: the total of all open invoices.
|
||||||
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
||||||
@@ -49,7 +48,7 @@ export function useBillingStatus() {
|
|||||||
|
|
||||||
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
|
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
|
||||||
|
|
||||||
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
|
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling, autopay: autopayBilling }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure priority selector: returns the single highest-priority billing prompt to
|
// Pure priority selector: returns the single highest-priority billing prompt to
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
listTenantInvoices,
|
listTenantInvoices,
|
||||||
listTenantRelays,
|
listTenantRelays,
|
||||||
listTenants,
|
listTenants,
|
||||||
|
reconcileTenant,
|
||||||
updateRelay,
|
updateRelay,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
type Activity,
|
type Activity,
|
||||||
@@ -208,6 +209,11 @@ export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
|||||||
// none is available). Shared by RelayNew, Home's signup-and-create path, and the
|
// none is available). Shared by RelayNew, Home's signup-and-create path, and the
|
||||||
// plan-upgrade toggle so the post-paid ladder stays identical across all three.
|
// plan-upgrade toggle so the post-paid ladder stays identical across all three.
|
||||||
export async function resolvePostPaidFlow(): Promise<PaidFlowDecision> {
|
export async function resolvePostPaidFlow(): Promise<PaidFlowDecision> {
|
||||||
|
const pubkey = account()!.pubkey
|
||||||
|
// The reads below are pure GETs now, so explicitly materialize the just-created
|
||||||
|
// invoice and pick up any portal-added card before deciding the post-paid ladder.
|
||||||
|
await reconcileTenant(pubkey)
|
||||||
|
|
||||||
const needsSetup = await tenantNeedsPaymentSetup()
|
const needsSetup = await tenantNeedsPaymentSetup()
|
||||||
const invoice = needsSetup ? await getLatestOpenInvoice() : null
|
const invoice = needsSetup ? await getLatestOpenInvoice() : null
|
||||||
return decidePostPaidFlow({ needsSetup, invoice })
|
return decidePostPaidFlow({ needsSetup, invoice })
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { EventStore } from "applesauce-core"
|
|||||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||||
import { RelayPool } from "applesauce-relay"
|
import { RelayPool } from "applesauce-relay"
|
||||||
import { NostrConnectSigner } from "applesauce-signers"
|
import { NostrConnectSigner } from "applesauce-signers"
|
||||||
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
|
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, reconcileInvoice, reconcileTenant, registerAccountGetter, selectPayableInvoice, type Plan } from "@/lib/api"
|
||||||
|
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||||
|
|
||||||
export type UnsignedEvent = {
|
export type UnsignedEvent = {
|
||||||
kind: number
|
kind: number
|
||||||
@@ -101,6 +102,50 @@ export function refetchBilling() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-flight autopay run, keyed by pubkey, so concurrent triggers (a mount
|
||||||
|
// double-fire, two dialog onClose handlers) collapse into one run.
|
||||||
|
let autopayInFlight: { pubkey: string; promise: Promise<void> } | undefined
|
||||||
|
|
||||||
|
// The side-effecting billing refresh, layered above the pure refetchBilling: on
|
||||||
|
// load of the billing surface it reconciles the subscription (materializing the
|
||||||
|
// current invoice), syncs the Stripe payment method (picking up a portal-added
|
||||||
|
// card), and — when a method is on file and an invoice is due — collects it,
|
||||||
|
// then refreshes all billing reads. This is what makes "add a card, return to
|
||||||
|
// the app" actually pay the open invoice. Payment is skipped while the
|
||||||
|
// create/upgrade flow owns the invoice (billingFlowActive).
|
||||||
|
export function autopayBilling(pubkey: string): Promise<void> {
|
||||||
|
if (autopayInFlight?.pubkey === pubkey) return autopayInFlight.promise
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
// The tenant row is provisioned lazily on first login; make sure it exists
|
||||||
|
// before the tenant-scoped POSTs, since autopay can fire (on the Account
|
||||||
|
// landing effect) before provisioning has completed.
|
||||||
|
if (billingPubkey() !== pubkey) await ensureSessionTenant()
|
||||||
|
|
||||||
|
// Sync the payment method + reconcile the subscription, then collect the
|
||||||
|
// oldest open invoice when a method is on file (and the create/upgrade
|
||||||
|
// flow isn't already driving its own invoice).
|
||||||
|
const tenant = await reconcileTenant(pubkey)
|
||||||
|
|
||||||
|
const invoice = selectPayableInvoice(await listTenantInvoices(pubkey))
|
||||||
|
if (invoice && autopayConfigured(tenant) && !billingFlowActive()) {
|
||||||
|
await reconcileInvoice(invoice.id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Autopay billing failed", e)
|
||||||
|
setToastMessage(e instanceof Error ? e.message : "Failed to update billing")
|
||||||
|
} finally {
|
||||||
|
// Reflect the final state (paid invoice, cleared errors, relay changes).
|
||||||
|
refetchBilling()
|
||||||
|
if (autopayInFlight?.pubkey === pubkey) autopayInFlight = undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
autopayInFlight = { pubkey, promise }
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
|
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
|
||||||
// tenant is created lazily on first login, so this must run before any
|
// tenant is created lazily on first login, so this must run before any
|
||||||
// tenant-scoped read. The in-flight promise is shared so the login flow (which
|
// tenant-scoped read. The in-flight promise is shared so the login flow (which
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import QRCode from "qrcode"
|
import QRCode from "qrcode"
|
||||||
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
||||||
import { methodLabel } from "@/lib/paymentMethod"
|
import { methodLabel } from "@/lib/paymentMethod"
|
||||||
import { formatUsd } from "@/lib/format"
|
import { formatUsd } from "@/lib/format"
|
||||||
import { PLATFORM_NAME } from "@/lib/state"
|
import { PLATFORM_NAME } from "@/lib/state"
|
||||||
@@ -36,7 +36,7 @@ export function useInvoicePdf() {
|
|||||||
let qrDataUrl: string | undefined
|
let qrDataUrl: string | undefined
|
||||||
if (invoice.method !== "stripe" && invoice.voided_at == null) {
|
if (invoice.method !== "stripe" && invoice.voided_at == null) {
|
||||||
try {
|
try {
|
||||||
const bolt11 = await getInvoiceBolt11(invoice.id)
|
const bolt11 = await ensureInvoiceBolt11(invoice.id)
|
||||||
sats = Math.round(bolt11.msats / 1000)
|
sats = Math.round(bolt11.msats / 1000)
|
||||||
if (invoice.paid_at == null) {
|
if (invoice.paid_at == null) {
|
||||||
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
|
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ export default function Account() {
|
|||||||
const invoicesLoading = useMinLoading(() => billing.loading())
|
const invoicesLoading = useMinLoading(() => billing.loading())
|
||||||
const { printInvoice, printing } = useInvoicePdf()
|
const { printInvoice, printing } = useInvoicePdf()
|
||||||
|
|
||||||
// On landing here (the billing portal returns to this page), refresh billing so
|
// On landing here (the billing portal returns to this page), run the autopay
|
||||||
// a card just added in the portal — which get_tenant syncs onto the tenant — and
|
// composite: reconcile the subscription, sync a card just added in the portal,
|
||||||
// any cleared error show immediately rather than only on the next reconcile.
|
// 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.
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (account()?.pubkey) billing.refetch()
|
const pubkey = account()?.pubkey
|
||||||
|
if (pubkey) void billing.autopay(pubkey)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Coarse account-health summary for the badge. Same snapshot the inline prompt
|
// Coarse account-health summary for the badge. Same snapshot the inline prompt
|
||||||
@@ -176,7 +178,7 @@ export default function Account() {
|
|||||||
open={true}
|
open={true}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSelectedInvoice(undefined)
|
setSelectedInvoice(undefined)
|
||||||
billing.refetch()
|
void billing.autopay(account()!.pubkey)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +189,7 @@ export default function Account() {
|
|||||||
isUpdate={nwc().kind !== "not_set_up"}
|
isUpdate={nwc().kind !== "not_set_up"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setNwcModalOpen(false)
|
setNwcModalOpen(false)
|
||||||
billing.refetch()
|
void billing.autopay(account()!.pubkey)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ export default function Account() {
|
|||||||
isUpdate={card().kind !== "not_set_up"}
|
isUpdate={card().kind !== "not_set_up"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCardModalOpen(false)
|
setCardModalOpen(false)
|
||||||
billing.refetch()
|
void billing.autopay(account()!.pubkey)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user