Refactor billing to manage subscriptions/invoices internally
This commit is contained in:
@@ -3,84 +3,41 @@ use std::sync::Arc;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
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 invoices = api
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||
let invoice = query::get_invoice(&id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.reconcile_invoice(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_lightning_invoice(
|
||||
pub async fn get_invoice_bolt11(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(id): Path<String>,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(&invoice.customer)
|
||||
let invoice = query::get_invoice(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
else {
|
||||
return Err(not_found("invoice not found"));
|
||||
};
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
let invoice = api
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.reconcile_invoice(&invoice)
|
||||
.ensure_and_reconcile_bolt11(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let lightning_invoice = api
|
||||
.billing
|
||||
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!(lightning_invoice))
|
||||
ok(serde_json::json!(bolt11))
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ pub mod identity;
|
||||
pub mod invoices;
|
||||
pub mod plans;
|
||||
pub mod relays;
|
||||
pub mod stripe;
|
||||
pub mod tenants;
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::sync::Arc;
|
||||
use axum::extract::{Path, State};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, not_found, ok};
|
||||
|
||||
pub async fn list_plans(State(api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(api.query.list_plans())
|
||||
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
|
||||
ok(query::list_plans())
|
||||
}
|
||||
|
||||
pub async fn get_plan(State(api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match api.query.get_plan(&id) {
|
||||
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||
match query::get_plan(&id) {
|
||||
Some(plan) => ok(plan),
|
||||
None => Err(not_found("plan not found")),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::{command, query};
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
@@ -17,46 +18,13 @@ use crate::web::{
|
||||
parse_bool_default, unprocessable,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let relays = api.query.list_relays().await.map_err(internal)?;
|
||||
let relays = query::list_relays().await.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
@@ -78,9 +46,7 @@ pub async fn list_relay_activity(
|
||||
let relay = api.get_relay_or_404(&id).await?;
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let activity = api
|
||||
.query
|
||||
.list_activity_for_resource(&id)
|
||||
let activity = query::list_activity_for_resource(&id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "activity": activity }))
|
||||
@@ -98,6 +64,23 @@ pub async fn list_relay_members(
|
||||
ok(serde_json::json!({ "members": members }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRelayRequest {
|
||||
pub tenant: String,
|
||||
pub subdomain: String,
|
||||
pub plan: String,
|
||||
pub info_name: String,
|
||||
pub info_icon: String,
|
||||
pub info_description: String,
|
||||
pub policy_public_join: i64,
|
||||
pub policy_strip_signatures: i64,
|
||||
pub groups_enabled: i64,
|
||||
pub management_enabled: i64,
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
}
|
||||
|
||||
pub async fn create_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -129,15 +112,31 @@ pub async fn create_relay(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
api.command
|
||||
.create_relay(&relay)
|
||||
command::create_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
created(relay)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRelayRequest {
|
||||
pub subdomain: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub info_name: Option<String>,
|
||||
pub info_icon: Option<String>,
|
||||
pub info_description: Option<String>,
|
||||
pub policy_public_join: Option<i64>,
|
||||
pub policy_strip_signatures: Option<i64>,
|
||||
pub groups_enabled: Option<i64>,
|
||||
pub management_enabled: Option<i64>,
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn update_relay(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -145,6 +144,7 @@ pub async fn update_relay(
|
||||
Json(payload): Json<UpdateRelayRequest>,
|
||||
) -> ApiResult {
|
||||
let mut relay = api.get_relay_or_404(&id).await?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||
|
||||
let current_plan = relay.plan.clone();
|
||||
@@ -187,17 +187,15 @@ pub async fn update_relay(
|
||||
relay.push_enabled = v;
|
||||
}
|
||||
|
||||
let relay = prepare_relay(&api, relay)?;
|
||||
let relay = prepare_relay(relay)?;
|
||||
|
||||
let plan_changed = requested_plan
|
||||
.as_deref()
|
||||
.is_some_and(|requested| requested != current_plan);
|
||||
|
||||
if plan_changed {
|
||||
let selected_plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
.expect("validated plan must exist");
|
||||
let selected_plan =
|
||||
query::get_plan(&relay.plan).expect("validated plan must exist");
|
||||
if let Some(limit) = selected_plan.members {
|
||||
let current_members = fetch_relay_members(&api, &relay)
|
||||
.await
|
||||
@@ -214,10 +212,10 @@ pub async fn update_relay(
|
||||
}
|
||||
}
|
||||
|
||||
api.command
|
||||
.update_relay(&relay)
|
||||
command::update_relay(&relay)
|
||||
.await
|
||||
.map_err(map_relay_write_error)?;
|
||||
|
||||
ok(relay)
|
||||
}
|
||||
|
||||
@@ -237,10 +235,10 @@ pub async fn deactivate_relay(
|
||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||
}
|
||||
|
||||
api.command
|
||||
.deactivate_relay(&relay)
|
||||
command::deactivate_relay(&relay)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
@@ -260,7 +258,8 @@ pub async fn reactivate_relay(
|
||||
return Err(bad_request("relay-is-active", "relay is already active"));
|
||||
}
|
||||
|
||||
api.command.activate_relay(&relay).await.map_err(internal)?;
|
||||
command::activate_relay(&relay).await.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
|
||||
@@ -279,15 +278,13 @@ const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
|
||||
static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
|
||||
|
||||
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||
}
|
||||
|
||||
let plan = api
|
||||
.query
|
||||
.get_plan(&relay.plan)
|
||||
let plan = query::get_plan(&relay.plan)
|
||||
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
||||
|
||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query as QueryParams, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
|
||||
use crate::web::{ApiResult, bad_request, internal, ok};
|
||||
|
||||
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
|
||||
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
|
||||
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
QueryParams(params): QueryParams<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.stripe
|
||||
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
|
||||
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
|
||||
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
|
||||
pub async fn stripe_webhook(
|
||||
State(api): State<Arc<Api>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult {
|
||||
let signature = headers
|
||||
.get("Stripe-Signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let payload = std::str::from_utf8(&body)
|
||||
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
|
||||
|
||||
handle_webhook(&api, payload, signature)
|
||||
.await
|
||||
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
|
||||
ok(())
|
||||
}
|
||||
|
||||
// --- Webhook event handlers ---
|
||||
//
|
||||
// These translate verified Stripe events into domain actions. The Stripe HTTP
|
||||
// calls and Lightning/NWC payment orchestration they invoke live in
|
||||
// [`crate::stripe`] and [`crate::billing`] respectively.
|
||||
|
||||
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
|
||||
let event = api.stripe.get_webhook_event(payload, signature)?;
|
||||
let obj = &event.data.object;
|
||||
|
||||
match event.event_type.as_str() {
|
||||
"invoice.created" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
|
||||
let currency = obj["currency"].as_str().unwrap_or("usd");
|
||||
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
|
||||
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
|
||||
}
|
||||
"invoice.paid" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_paid(api, customer).await?;
|
||||
}
|
||||
"invoice.payment_failed" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_payment_failed(api, customer).await?;
|
||||
}
|
||||
"invoice.overdue" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_invoice_overdue(api, customer).await?;
|
||||
}
|
||||
"customer.subscription.updated" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
let status = obj["status"].as_str().unwrap_or_default();
|
||||
handle_subscription_updated(api, customer, status).await?;
|
||||
}
|
||||
"customer.subscription.deleted" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_subscription_deleted(api, customer).await?;
|
||||
}
|
||||
"payment_method.attached" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
handle_payment_method_attached(api, customer).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_created(
|
||||
api: &Api,
|
||||
stripe_customer_id: &str,
|
||||
amount_due: i64,
|
||||
currency: &str,
|
||||
stripe_invoice_id: &str,
|
||||
) -> Result<()> {
|
||||
if amount_due == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let invoice = api
|
||||
.billing
|
||||
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
|
||||
.await?;
|
||||
|
||||
let mut nwc_error_for_dm: Option<String> = None;
|
||||
|
||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||
if !tenant.nwc_url.is_empty() {
|
||||
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let error_msg = format!("{e}");
|
||||
api.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id,
|
||||
stripe_invoice_id,
|
||||
"nwc auto-payment failed for invoice.created"
|
||||
);
|
||||
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
|
||||
// Fall through to card / manual payment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
|
||||
if api
|
||||
.stripe
|
||||
.has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Manual payment: DM a link to the in-app payment page for this invoice
|
||||
let url_base = &api.env.app_url;
|
||||
let payment_url = format!("{url_base}/account?invoice={stripe_invoice_id}");
|
||||
let base = format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{payment_url}");
|
||||
let dm_message = match nwc_error_for_dm {
|
||||
Some(error) if !error.is_empty() => {
|
||||
format!("{base}\n\n{NWC_ERROR_DM_PREFIX} {error}")
|
||||
}
|
||||
_ => base,
|
||||
};
|
||||
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if tenant.past_due_at.is_some() {
|
||||
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.activate_relay(&relay).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if tenant.past_due_at.is_none() {
|
||||
api.command.set_tenant_past_due(&tenant.pubkey).await?;
|
||||
api.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_subscription_updated(
|
||||
api: &Api,
|
||||
stripe_customer_id: &str,
|
||||
status: &str,
|
||||
) -> Result<()> {
|
||||
if status != "canceled" && status != "unpaid" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
api.command
|
||||
.clear_tenant_subscription(&tenant.pubkey)
|
||||
.await?;
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
api.command
|
||||
.clear_tenant_subscription(&tenant.pubkey)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
|
||||
api.command.mark_relay_delinquent(&relay).await?;
|
||||
}
|
||||
}
|
||||
|
||||
api.robot
|
||||
.send_dm(
|
||||
&tenant.pubkey,
|
||||
"Your paid relays have been deactivated due to non-payment.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
|
||||
if stripe_customer_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = api
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let invoices = api
|
||||
.stripe
|
||||
.list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
|
||||
for invoice in &invoices {
|
||||
if invoice.status != "open" || invoice.amount_due == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
stripe_invoice_id = %invoice.id,
|
||||
"failed to retry card payment for outstanding invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::models::Tenant;
|
||||
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
||||
use crate::{command, env, query};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantResponse {
|
||||
@@ -17,9 +18,8 @@ pub struct TenantResponse {
|
||||
pub nwc_is_set: bool,
|
||||
pub nwc_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
pub stripe_subscription_id: Option<String>,
|
||||
pub past_due_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Tenant> for TenantResponse {
|
||||
@@ -29,39 +29,30 @@ impl From<Tenant> for TenantResponse {
|
||||
pubkey: t.pubkey,
|
||||
nwc_error: t.nwc_error,
|
||||
created_at: t.created_at,
|
||||
billing_anchor: t.billing_anchor,
|
||||
stripe_customer_id: t.stripe_customer_id,
|
||||
stripe_subscription_id: t.stripe_subscription_id,
|
||||
past_due_at: t.past_due_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_tenants(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
let tenants = api.query.list_tenants().await.map_err(internal)?;
|
||||
let tenants = query::list_tenants().await.map_err(internal)?;
|
||||
ok(tenants
|
||||
.into_iter()
|
||||
.map(TenantResponse::from)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
|
||||
/// already exists (including a unique-constraint race) we return the existing
|
||||
/// row.
|
||||
pub async fn create_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(pubkey): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
if let Some(t) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
|
||||
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
|
||||
return ok(TenantResponse::from(t));
|
||||
}
|
||||
|
||||
@@ -84,10 +75,10 @@ pub async fn create_tenant(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match api.command.create_tenant(&tenant).await {
|
||||
match command::create_tenant(&tenant).await {
|
||||
Ok(()) => ok(TenantResponse::from(tenant)),
|
||||
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
|
||||
match api.query.get_tenant(&pubkey).await {
|
||||
match query::get_tenant(&pubkey).await {
|
||||
Ok(Some(t)) => ok(TenantResponse::from(t)),
|
||||
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
|
||||
Err(e) => Err(internal(e)),
|
||||
@@ -107,6 +98,11 @@ pub async fn get_tenant(
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -121,11 +117,11 @@ pub async fn update_tenant(
|
||||
if nwc_url.is_empty() {
|
||||
tenant.nwc_url = String::new();
|
||||
} else {
|
||||
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
|
||||
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
|
||||
}
|
||||
}
|
||||
|
||||
api.command.update_tenant(&tenant).await.map_err(internal)?;
|
||||
command::update_tenant(&tenant).await.map_err(internal)?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
@@ -136,10 +132,47 @@ pub async fn list_tenant_relays(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let relays = api
|
||||
.query
|
||||
.list_relays_for_tenant(&pubkey)
|
||||
let relays = query::list_relays_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StripeSessionParams {
|
||||
return_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_stripe_session(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
Query(params): Query<StripeSessionParams>,
|
||||
) -> ApiResult {
|
||||
api.require_tenant(&auth, &pubkey)?;
|
||||
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let url = api
|
||||
.stripe
|
||||
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(serde_json::json!({ "url": url }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user