From ae3e1c316ee7524e3dba33a2f7d899f88806f5be Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 29 May 2026 12:24:39 -0700 Subject: [PATCH] Track payment method --- backend/migrations/0001_init.sql | 1 + backend/src/billing.rs | 6 +++--- backend/src/models.rs | 3 +++ backend/src/query.rs | 2 +- backend/src/routes/tenants.rs | 4 +++- frontend/src/lib/api.ts | 1 + frontend/src/lib/hooks.ts | 2 +- frontend/src/pages/relays/RelayDetail.tsx | 2 +- 8 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index fa1ba3f..9c37d27 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS tenant ( created_at INTEGER NOT NULL, billing_anchor INTEGER, stripe_customer_id TEXT NOT NULL, + stripe_payment_method_id TEXT, renewed_at INTEGER, churned_at INTEGER ); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 7f91cf1..d8b0ea6 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -233,7 +233,7 @@ impl Billing { /// a relay created/activated *within* the period isn't active before the /// boundary, so it's covered by its own prorated charge instead. async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> { - let relays = query::list_relays(&tenant.pubkey).await?; + let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; let mut line_items = Vec::new(); for relay in relays { @@ -419,7 +419,7 @@ impl Billing { async fn churn_tenant(&self, tenant: &Tenant, now: i64) -> Result<()> { command::set_tenant_churned_at(&tenant.pubkey, Some(now)).await?; - for relay in query::list_relays(&tenant.pubkey).await? { + for relay in query::list_relays_for_tenant(&tenant.pubkey).await? { if relay.status == RELAY_STATUS_ACTIVE { command::mark_relay_delinquent(&relay).await?; } @@ -434,7 +434,7 @@ impl Billing { async fn reactivate_tenant(&self, tenant: &Tenant) -> Result<()> { command::set_tenant_churned_at(&tenant.pubkey, None).await?; - for relay in query::list_relays(&tenant.pubkey).await? { + for relay in query::list_relays_for_tenant(&tenant.pubkey).await? { if relay.status == RELAY_STATUS_DELINQUENT { command::unmark_relay_delinquent(&relay).await?; } diff --git a/backend/src/models.rs b/backend/src/models.rs index ce7d49d..fcd0b8b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -46,6 +46,9 @@ pub struct Tenant { pub created_at: i64, pub billing_anchor: Option, pub stripe_customer_id: String, + /// The tenant's saved Stripe payment method, or `None` if they have not set + /// up a card yet. Set when the tenant adds a card via the Stripe portal. + pub stripe_payment_method_id: Option, /// `period_start` of the most recent period this tenant was renewed for, or /// `None` if never renewed. The per-period renewal idempotency marker. pub renewed_at: Option, diff --git a/backend/src/query.rs b/backend/src/query.rs index 44fe0da..cfc3a47 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -84,7 +84,7 @@ pub async fn list_relays_pending_sync() -> Result> { ) } -pub async fn list_relays(tenant_pubkey: &str) -> Result> { +pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result> { Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?")) .bind(tenant_pubkey) .fetch_all(pool()) diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index fd30e79..f45daac 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -22,6 +22,7 @@ pub struct TenantResponse { pub created_at: i64, pub billing_anchor: Option, pub stripe_customer_id: String, + pub stripe_payment_method_id: Option, /// Set when billing has churned the tenant; the UI uses it to warn that the /// account is delinquent until billing is re-activated. pub churned_at: Option, @@ -37,6 +38,7 @@ impl From for TenantResponse { created_at: t.created_at, billing_anchor: t.billing_anchor, stripe_customer_id: t.stripe_customer_id, + stripe_payment_method_id: t.stripe_payment_method_id, churned_at: t.churned_at, } } @@ -142,7 +144,7 @@ pub async fn list_tenant_relays( ) -> ApiResult { api.require_admin_or_tenant(&auth, &pubkey)?; - let relays = query::list_relays(&pubkey) + let relays = query::list_relays_for_tenant(&pubkey) .await .map_err(internal)?; ok(relays) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0bb2c8a..19020bb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -98,6 +98,7 @@ export type Tenant = { nwc_is_set: boolean created_at: number stripe_customer_id: string + stripe_payment_method_id: string | null nwc_error: string | null stripe_error: string | null churned_at: number | null diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 072581c..9cac0c5 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -135,7 +135,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id) export async function tenantNeedsPaymentSetup(): Promise { const tenant = await getTenant(account()!.pubkey) - return !tenant.nwc_is_set + return !tenant.nwc_is_set && !tenant.stripe_payment_method_id } export async function getLatestOpenInvoice(): Promise { diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index eebb676..9443d3a 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -59,7 +59,7 @@ export default function RelayDetail() { if (!isPaidRelay()) return false const t = tenant() if (!t) return false - return !t.nwc_is_set + return !t.nwc_is_set && !t.stripe_payment_method_id }) return (