forked from coracle/caravel
feat: encourage payment setup for paid relays without making it required (#40)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com> Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
This commit is contained in:
+33
-3
@@ -26,12 +26,15 @@ Members:
|
||||
|
||||
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
|
||||
|
||||
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment.
|
||||
|
||||
- Fetch the relay and tenant associated with the `activity`
|
||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then run downgrade proration validation by previewing the upcoming invoice and logging proration lines/amounts. Then check cleanup (below). Return early.
|
||||
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
|
||||
- **If relay is `active` and on a paid plan**:
|
||||
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
|
||||
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
|
||||
- **Downgrade validation**: when changing an existing subscription item, detect if the new plan amount is lower than the current one. If yes, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior.
|
||||
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
|
||||
|
||||
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
|
||||
@@ -44,6 +47,7 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
|
||||
- `invoice.overdue` -> `self.handle_invoice_overdue`
|
||||
- `customer.subscription.updated` -> `self.handle_subscription_updated`
|
||||
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
|
||||
- `payment_method.attached` -> `self.handle_payment_method_attached`
|
||||
- Unknown event types are ignored (return Ok)
|
||||
|
||||
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
|
||||
@@ -66,16 +70,37 @@ Manages the Stripe subscription and subscription items for a relay's tenant. Onl
|
||||
- Creates a Stripe Customer Portal session for the given customer
|
||||
- Returns the portal session URL
|
||||
|
||||
## `pub async fn pay_outstanding_nwc_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
Called when a tenant first sets their NWC URL (via `PUT /tenants/:pubkey`). Attempts to pay any currently open invoices for the tenant using their NWC wallet, so that invoices created before NWC was configured are not left unpaid.
|
||||
|
||||
- If `tenant.nwc_url` is empty, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id` via `stripe_list_invoices`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Attempt NWC payment via `nwc_pay_invoice`.
|
||||
- On success: call `stripe_pay_invoice_out_of_band` and `command.clear_tenant_nwc_error`.
|
||||
- On failure: call `command.set_tenant_nwc_error` and log the error; continue to the next invoice.
|
||||
|
||||
## `pub async fn pay_outstanding_card_invoices(&self, tenant: &Tenant) -> Result<()>`
|
||||
|
||||
Attempts Stripe-side collection for open invoices when the tenant has a card on file.
|
||||
|
||||
- If tenant has no card payment method, return early.
|
||||
- List all Stripe invoices for `tenant.stripe_customer_id`.
|
||||
- For each invoice with `status == "open"` and `amount_due > 0`:
|
||||
- Call Stripe `POST /v1/invoices/:id/pay` to retry collection using the card on file.
|
||||
- Log and continue on failures.
|
||||
|
||||
## `fn handle_invoice_created(&self, invoice: &Invoice)`
|
||||
|
||||
Attempts to pay a new subscription invoice. Payment priority:
|
||||
Attempts to pay a new subscription invoice. Because Stripe defaults to pay-in-advance, this webhook fires immediately when a subscription is created (i.e. when a paid relay is added or a plan is upgraded). Payment priority:
|
||||
|
||||
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
|
||||
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
|
||||
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
|
||||
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
|
||||
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
|
||||
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing here — Stripe will charge automatically for this invoice attempt.
|
||||
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
|
||||
|
||||
Skip invoices with `amount_due` of 0.
|
||||
@@ -112,3 +137,8 @@ Skip invoices with `amount_due` of 0.
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
|
||||
|
||||
## `fn handle_payment_method_attached(&self, stripe_customer_id: &str)`
|
||||
|
||||
- Look up tenant by `stripe_customer_id`
|
||||
- Call `pay_outstanding_card_invoices` so invoices that were due before card setup are retried immediately
|
||||
|
||||
+18
-1
@@ -945,12 +945,29 @@ async fn update_tenant(
|
||||
state.api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let mut tenant = state.api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
let nwc_previously_empty = tenant.nwc_url.is_empty();
|
||||
if let Some(nwc_url) = payload.nwc_url {
|
||||
tenant.nwc_url = nwc_url;
|
||||
}
|
||||
|
||||
match state.api.command.update_tenant(&tenant).await {
|
||||
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
|
||||
Ok(()) => {
|
||||
// When NWC is first connected, attempt to pay any outstanding open invoices.
|
||||
if nwc_previously_empty && !tenant.nwc_url.is_empty() {
|
||||
let billing = state.api.billing.clone();
|
||||
let tenant_clone = tenant.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = billing.pay_outstanding_nwc_invoices(&tenant_clone).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
pubkey = %tenant_clone.pubkey,
|
||||
"pay_outstanding_nwc_invoices failed after NWC setup"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ok(StatusCode::OK, tenant))
|
||||
}
|
||||
Err(e) => Ok(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal",
|
||||
|
||||
+254
-2
@@ -167,6 +167,8 @@ impl Billing {
|
||||
self.command
|
||||
.delete_relay_subscription_item(&relay.id)
|
||||
.await?;
|
||||
self.validate_downgrade_proration(&tenant, "free-plan-downgrade")
|
||||
.await;
|
||||
}
|
||||
self.cleanup_empty_subscription(&tenant.pubkey).await?;
|
||||
return Ok(());
|
||||
@@ -206,8 +208,28 @@ impl Billing {
|
||||
// Sync the subscription item: create or update
|
||||
let subscription_id = tenant.stripe_subscription_id.as_ref().unwrap();
|
||||
let item_id = if let Some(ref existing_item_id) = relay.stripe_subscription_item_id {
|
||||
self.stripe_update_subscription_item(existing_item_id, stripe_price_id)
|
||||
.await?
|
||||
let is_downgrade = self
|
||||
.is_subscription_item_downgrade(existing_item_id, plan.amount)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
relay_id = %relay.id,
|
||||
"failed to determine relay plan downgrade direction"
|
||||
);
|
||||
false
|
||||
});
|
||||
|
||||
let updated_item_id = self
|
||||
.stripe_update_subscription_item(existing_item_id, stripe_price_id)
|
||||
.await?;
|
||||
|
||||
if is_downgrade {
|
||||
self.validate_downgrade_proration(&tenant, "paid-plan-downgrade")
|
||||
.await;
|
||||
}
|
||||
|
||||
updated_item_id
|
||||
} else {
|
||||
self.stripe_create_subscription_item(subscription_id, stripe_price_id)
|
||||
.await?
|
||||
@@ -275,6 +297,10 @@ impl Billing {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
self.handle_subscription_deleted(customer).await?;
|
||||
}
|
||||
"payment_method.attached" => {
|
||||
let customer = obj["customer"].as_str().unwrap_or_default();
|
||||
self.handle_payment_method_attached(customer).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -492,6 +518,103 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_payment_method_attached(&self, stripe_customer_id: &str) -> Result<()> {
|
||||
if stripe_customer_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tenant) = self
|
||||
.query
|
||||
.get_tenant_by_stripe_customer_id(stripe_customer_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.pay_outstanding_card_invoices(&tenant).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_subscription_item_downgrade(
|
||||
&self,
|
||||
item_id: &str,
|
||||
next_plan_amount: i64,
|
||||
) -> Result<bool> {
|
||||
let Some(current_price_id) = self.stripe_get_subscription_item_price_id(item_id).await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Some(current_plan_amount) = Self::plan_amount_from_price_id(¤t_price_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(next_plan_amount < current_plan_amount)
|
||||
}
|
||||
|
||||
fn plan_amount_from_price_id(price_id: &str) -> Option<i64> {
|
||||
Query::list_plans().into_iter().find_map(|plan| {
|
||||
if plan.stripe_price_id.as_deref() == Some(price_id) {
|
||||
Some(plan.amount)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_downgrade_proration(&self, tenant: &crate::models::Tenant, context: &str) {
|
||||
match self
|
||||
.stripe_preview_upcoming_invoice(
|
||||
&tenant.stripe_customer_id,
|
||||
tenant.stripe_subscription_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(upcoming) => {
|
||||
let lines = upcoming["lines"]["data"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let proration_lines = lines
|
||||
.iter()
|
||||
.filter(|line| line["proration"].as_bool().unwrap_or(false))
|
||||
.count();
|
||||
let amount_due = upcoming["amount_due"]
|
||||
.as_i64()
|
||||
.unwrap_or_else(|| upcoming["total"].as_i64().unwrap_or(0));
|
||||
let currency = upcoming["currency"].as_str().unwrap_or("usd");
|
||||
let preview_id = upcoming["id"].as_str().unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
stripe_customer_id = %tenant.stripe_customer_id,
|
||||
context,
|
||||
preview_id,
|
||||
proration_lines,
|
||||
amount_due,
|
||||
currency,
|
||||
"validated Stripe proration preview for downgrade"
|
||||
);
|
||||
|
||||
if proration_lines == 0 {
|
||||
tracing::warn!(
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
context,
|
||||
"downgrade proration preview has no proration lines; verify in Stripe dashboard"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
tenant_pubkey = %tenant.pubkey,
|
||||
context,
|
||||
"failed to fetch downgrade proration preview"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public API helpers ---
|
||||
|
||||
pub async fn get_invoice_with_tenant(
|
||||
@@ -599,6 +722,93 @@ impl Billing {
|
||||
Ok(invoice_response.invoice)
|
||||
}
|
||||
|
||||
pub async fn pay_outstanding_nwc_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
if tenant.nwc_url.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
for invoice in &invoices_arr {
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let invoice_id = invoice["id"].as_str().unwrap_or_default();
|
||||
let currency = invoice["currency"].as_str().unwrap_or("usd");
|
||||
|
||||
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self
|
||||
.nwc_pay_invoice(amount_due, currency, &tenant.nwc_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Err(e) = self.stripe_pay_invoice_out_of_band(invoice_id).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
invoice_id,
|
||||
"failed to mark invoice paid out of band"
|
||||
);
|
||||
} else {
|
||||
let _ = self.command.clear_tenant_nwc_error(&tenant.pubkey).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("{e}");
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
invoice_id,
|
||||
"nwc payment failed for outstanding invoice"
|
||||
);
|
||||
let _ = self
|
||||
.command
|
||||
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pay_outstanding_card_invoices(&self, tenant: &crate::models::Tenant) -> Result<()> {
|
||||
if !self
|
||||
.stripe_has_payment_method(&tenant.stripe_customer_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoices = self
|
||||
.stripe_list_invoices(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
let invoices_arr = invoices.as_array().cloned().unwrap_or_default();
|
||||
|
||||
for invoice in &invoices_arr {
|
||||
let status = invoice["status"].as_str().unwrap_or_default();
|
||||
let amount_due = invoice["amount_due"].as_i64().unwrap_or(0);
|
||||
let invoice_id = invoice["id"].as_str().unwrap_or_default();
|
||||
|
||||
if status != "open" || amount_due == 0 || invoice_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) = self.stripe_pay_invoice(invoice_id).await {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
invoice_id,
|
||||
"failed to retry card payment for outstanding invoice"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String> {
|
||||
let resp = self
|
||||
.http
|
||||
@@ -715,6 +925,48 @@ impl Billing {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice(&self, invoice_id: &str) -> Result<()> {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stripe_get_subscription_item_price_id(&self, item_id: &str) -> Result<Option<String>> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/subscription_items/{item_id}"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: serde_json::Value = resp.error_for_status()?.json().await?;
|
||||
Ok(body["price"]["id"].as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
async fn stripe_preview_upcoming_invoice(
|
||||
&self,
|
||||
customer_id: &str,
|
||||
subscription_id: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("{STRIPE_API}/invoices/upcoming"))
|
||||
.bearer_auth(&self.stripe_secret_key)
|
||||
.query(&[("customer", customer_id)]);
|
||||
|
||||
if let Some(subscription_id) = subscription_id {
|
||||
req = req.query(&[("subscription", subscription_id)]);
|
||||
}
|
||||
|
||||
let body: serde_json::Value = req.send().await?.error_for_status()?.json().await?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn stripe_pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
|
||||
self.http
|
||||
.post(format!("{STRIPE_API}/invoices/{invoice_id}/pay"))
|
||||
|
||||
Reference in New Issue
Block a user