Handle stripe's 50c minimum, avoid lost write
This commit is contained in:
@@ -88,7 +88,10 @@ CREATE TABLE IF NOT EXISTS bolt11 (
|
|||||||
CREATE TABLE IF NOT EXISTS intent (
|
CREATE TABLE IF NOT EXISTS intent (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
invoice_id TEXT NOT NULL,
|
invoice_id TEXT NOT NULL,
|
||||||
|
payment_method_id TEXT NOT NULL,
|
||||||
|
payment_intent_id TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
settled_at INTEGER,
|
||||||
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,3 +128,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (act
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checkout_invoice_created ON checkout (invoice_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_checkout_invoice_created ON checkout (invoice_id, created_at);
|
||||||
|
|
||||||
|
-- At most one unsettled write-ahead intent per invoice: enforces the invariant
|
||||||
|
-- and is the ON CONFLICT target for the get-or-create in `ensure_pending_intent`.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_intent_unsettled ON intent (invoice_id) WHERE settled_at IS NULL;
|
||||||
|
|||||||
+28
-20
@@ -450,28 +450,36 @@ impl Billing {
|
|||||||
invoice: &Invoice,
|
invoice: &Invoice,
|
||||||
payment_method_id: &str,
|
payment_method_id: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let result: Result<()> = async {
|
let intent = command::ensure_pending_intent(&invoice.id, payment_method_id).await?;
|
||||||
let intent_id = self
|
|
||||||
.stripe
|
|
||||||
.create_payment_intent(
|
|
||||||
&tenant.stripe_customer_id,
|
|
||||||
payment_method_id,
|
|
||||||
&invoice.id,
|
|
||||||
invoice.amount,
|
|
||||||
"usd",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
command::settle_invoice_via_intent(&tenant.pubkey, &intent_id, &invoice.id).await
|
let payment_intent_id = match self
|
||||||
}
|
.stripe
|
||||||
.await;
|
.create_payment_intent(
|
||||||
|
&tenant.stripe_customer_id,
|
||||||
|
&intent.payment_method_id,
|
||||||
|
&invoice.id,
|
||||||
|
invoice.amount,
|
||||||
|
"usd",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(id) => id,
|
||||||
|
// Drop the attempt so the next pass retries cleanly on the tenant's
|
||||||
|
// current method, and record the failure to warn the user in the UI.
|
||||||
|
Err(error) => {
|
||||||
|
command::delete_intent(&intent.id).await?;
|
||||||
|
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Record the failure on the tenant (to warn them in the UI) but still
|
command::settle_invoice_via_intent(
|
||||||
// surface it, so the cascade can fall through and summarize it in the DM.
|
&tenant.pubkey,
|
||||||
if let Err(error) = &result {
|
&intent.id,
|
||||||
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
|
&payment_intent_id,
|
||||||
}
|
&invoice.id,
|
||||||
result
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attempt_payment_using_dm(
|
async fn attempt_payment_using_dm(
|
||||||
|
|||||||
+66
-21
@@ -5,8 +5,8 @@ use sqlx::{Sqlite, Transaction};
|
|||||||
use crate::billing::BillingPeriod;
|
use crate::billing::BillingPeriod;
|
||||||
use crate::db::{pool, publish, with_tx};
|
use crate::db::{pool, publish, with_tx};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
Activity, Bolt11, Checkout, Intent, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE,
|
||||||
RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
|
RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tenants ---
|
// --- Tenants ---
|
||||||
@@ -350,9 +350,8 @@ pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
|
|||||||
|
|
||||||
// --- Invoices ---
|
// --- Invoices ---
|
||||||
|
|
||||||
/// Claim all of a tenant's outstanding items onto a new invoice. A non-positive
|
/// Claim a tenant's outstanding items onto a new invoice once the balance clears
|
||||||
/// balance leaves the items outstanding so the credit carries to the next positive
|
/// the minimum.
|
||||||
/// invoice. Returns the invoice, or `None` when there's nothing to bill.
|
|
||||||
pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<Option<Invoice>> {
|
pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<Option<Invoice>> {
|
||||||
with_tx(async |tx| {
|
with_tx(async |tx| {
|
||||||
let total = sqlx::query_scalar::<_, i64>(
|
let total = sqlx::query_scalar::<_, i64>(
|
||||||
@@ -363,7 +362,10 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
|||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if total <= 0 {
|
// Stripe's minimum charge is $0.50 USD; $1 leaves margin so a later
|
||||||
|
// small credit can't drop a fresh invoice under that floor. Leave
|
||||||
|
// items outstanding and carry to a later invoice.
|
||||||
|
if total <= 100 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,16 +413,19 @@ pub async fn settle_invoice_via_nwc(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear
|
/// Atomically settle an invoice paid off-session: stamp the write-ahead intent
|
||||||
/// the tenant's stored Stripe error, and mark the invoice paid.
|
/// with the Stripe PaymentIntent that confirmed it, clear the tenant's stored
|
||||||
|
/// Stripe error, and mark the invoice paid. `intent_id` is our row id (from
|
||||||
|
/// [`insert_pending_intent`]); `payment_intent_id` is the Stripe `pi_…`.
|
||||||
pub async fn settle_invoice_via_intent(
|
pub async fn settle_invoice_via_intent(
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
intent_id: &str,
|
intent_id: &str,
|
||||||
|
payment_intent_id: &str,
|
||||||
invoice_id: &str,
|
invoice_id: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
with_tx(async |tx| {
|
with_tx(async |tx| {
|
||||||
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
|
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
|
||||||
insert_settled_intent_tx(tx, intent_id, invoice_id).await?;
|
mark_intent_settled_tx(tx, intent_id, payment_intent_id).await?;
|
||||||
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
|
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -778,24 +783,64 @@ async fn mark_checkout_settled_tx(
|
|||||||
|
|
||||||
// --- Intents ---
|
// --- Intents ---
|
||||||
|
|
||||||
/// Record the Stripe PaymentIntent that paid an invoice off-session. Keyed by
|
/// Write-ahead an off-session charge attempt before confirming it with Stripe,
|
||||||
/// the Stripe PaymentIntent id, so it's an idempotent audit record of what paid
|
/// returning the stored [`Intent`]. Records the payment method so a retry after a
|
||||||
/// the invoice.
|
/// lost settle re-confirms the same (idempotent) PaymentIntent; settled later by
|
||||||
async fn insert_settled_intent_tx(
|
/// [`settle_invoice_via_intent`], or dropped by [`delete_intent`] if it declines.
|
||||||
tx: &mut Transaction<'_, Sqlite>,
|
///
|
||||||
intent_id: &str,
|
/// Get-or-create, atomically: if the invoice already has an unsettled intent,
|
||||||
invoice_id: &str,
|
/// returns it unchanged (keeping its original `payment_method_id`, so the retry
|
||||||
) -> Result<()> {
|
/// re-confirms the same charge); otherwise inserts a fresh one. The partial
|
||||||
|
/// unique index on (invoice_id) WHERE settled_at IS NULL makes this race-free —
|
||||||
|
/// concurrent reconciles converge on one intent instead of two.
|
||||||
|
pub async fn ensure_pending_intent(invoice_id: &str, payment_method_id: &str) -> Result<Intent> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let created_at = chrono::Utc::now().timestamp();
|
let created_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
sqlx::query(
|
Ok(sqlx::query_as::<_, Intent>(
|
||||||
"INSERT INTO intent (id, invoice_id, created_at)
|
"INSERT INTO intent (id, invoice_id, payment_method_id, created_at)
|
||||||
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(invoice_id) WHERE settled_at IS NULL
|
||||||
|
DO UPDATE SET payment_method_id = intent.payment_method_id
|
||||||
|
RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(intent_id)
|
.bind(id)
|
||||||
.bind(invoice_id)
|
.bind(invoice_id)
|
||||||
|
.bind(payment_method_id)
|
||||||
.bind(created_at)
|
.bind(created_at)
|
||||||
|
.fetch_one(pool())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp an off-session intent settled with the Stripe PaymentIntent that
|
||||||
|
/// confirmed it, but don't overwrite an existing settled_at — so reconciling the
|
||||||
|
/// same attempt twice is a no-op.
|
||||||
|
async fn mark_intent_settled_tx(
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
intent_id: &str,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let settled_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE intent SET settled_at = ?, payment_intent_id = ?
|
||||||
|
WHERE id = ? AND settled_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(settled_at)
|
||||||
|
.bind(payment_intent_id)
|
||||||
|
.bind(intent_id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a write-ahead intent whose charge didn't go through, so the next attempt
|
||||||
|
/// starts clean (on the tenant's current method). A charge that confirmed but
|
||||||
|
/// whose settle failed is instead left unsettled, for reconcile to re-confirm.
|
||||||
|
pub async fn delete_intent(intent_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM intent WHERE id = ?")
|
||||||
|
.bind(intent_id)
|
||||||
|
.execute(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,3 +185,18 @@ pub struct Checkout {
|
|||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
pub settled_at: Option<i64>,
|
pub settled_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A write-ahead record of an off-session card charge: inserted before the Stripe
|
||||||
|
/// call and stamped `settled_at` once the charge confirms and the invoice is paid.
|
||||||
|
/// `payment_method_id` is the method it charges, so a retry after a lost settle
|
||||||
|
/// re-confirms the same (idempotent) PaymentIntent rather than charging a second
|
||||||
|
/// one; `payment_intent_id` is the Stripe `pi_…` that settled it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Intent {
|
||||||
|
pub id: String,
|
||||||
|
pub invoice_id: String,
|
||||||
|
pub payment_method_id: String,
|
||||||
|
pub payment_intent_id: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub settled_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user