use apiplant_payments::{
Change, CheckoutOutcome, CheckoutSpec, CustomerSpec, CustomerState, Delivery, PaymentRecord,
SubscriptionState,
};
use ntex::web::types::{Json, State};
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::response::{error, ok};
use crate::state::AppState;
pub async fn config(state: State<AppState>) -> HttpResponse {
let Some(payments) = &state.payments else {
return error(404, "this app does not take payments");
};
let config = payments.config();
ok(&json!({
"provider": payments.provider().as_str(),
"publishable_key": payments.publishable_key(),
"currency": config.default_currency(),
"automatic_tax": config.automatic_tax,
"tax_id_collection": config.collects_tax_ids(),
"webhooks_configured": payments.webhooks_enabled(),
}))
}
pub async fn checkout(
req: HttpRequest,
state: State<AppState>,
body: Json<Map<String, Value>>,
) -> HttpResponse {
let Some(payments) = state.payments.clone() else {
return error(404, "this app does not take payments");
};
let (principal, org) = match admin_of_active_org(&req, &state).await {
Ok(caller) => caller,
Err(response) => return response,
};
let body = body.into_inner();
let Some(price_id) = string(&body, "price_id") else {
return error(422, "`price_id` is required — the billing_price to buy");
};
let price = match load_price(&state, &price_id).await {
Ok(Some(price)) => price,
Ok(None) => return error(404, "no such price"),
Err(response) => return response,
};
if !price.active {
return error(409, "that price is no longer on sale");
}
let Some(stripe_price_id) = price.stripe_price_id.clone() else {
return error(
409,
"that price has not been created in Stripe yet; save it again to sync it",
);
};
let existing = match load_customer(&state, org).await {
Ok(customer) => customer,
Err(response) => return response,
};
let email = principal_email(&state, principal).await;
let spec = CheckoutSpec {
stripe_price_id,
price_id: price.id.to_string(),
recurring: price.recurring,
quantity: body.get("quantity").and_then(Value::as_u64).unwrap_or(1),
stripe_customer_id: existing
.as_ref()
.map(|c| c.stripe_customer_id.clone())
.unwrap_or_default(),
customer: CustomerSpec {
email: email.clone(),
organization_id: org.to_string(),
..CustomerSpec::default()
},
organization_id: org.to_string(),
trial_days: price.trial_days,
success_url: string(&body, "success_url").unwrap_or_default(),
cancel_url: string(&body, "cancel_url").unwrap_or_default(),
allow_promotion_codes: body
.get("allow_promotion_codes")
.and_then(Value::as_bool)
.unwrap_or(false),
};
let started = match payments.checkout(spec).await {
Ok(started) => started,
Err(e) => return payment_error(e),
};
if existing.is_none() {
upsert_customer(
&state,
org,
&CustomerState {
stripe_customer_id: started.stripe_customer_id.clone(),
organization_id: org.to_string(),
email,
..CustomerState::default()
},
)
.await;
}
ok(&json!({
"url": started.url,
"session_id": started.session_id,
"mode": started.mode,
}))
}
pub async fn portal(
req: HttpRequest,
state: State<AppState>,
body: Json<Map<String, Value>>,
) -> HttpResponse {
let Some(payments) = state.payments.clone() else {
return error(404, "this app does not take payments");
};
let (_, org) = match admin_of_active_org(&req, &state).await {
Ok(caller) => caller,
Err(response) => return response,
};
let customer = match load_customer(&state, org).await {
Ok(Some(customer)) => customer,
Ok(None) => return error(404, "this organization has no billing to manage yet"),
Err(response) => return response,
};
let return_url = string(&body.into_inner(), "return_url").unwrap_or_default();
match payments
.portal(&customer.stripe_customer_id, &return_url)
.await
{
Ok(url) => ok(&json!({ "url": url })),
Err(e) => payment_error(e),
}
}
pub async fn webhook(
req: HttpRequest,
state: State<AppState>,
body: ntex::util::Bytes,
) -> HttpResponse {
let Some(payments) = state.payments.clone() else {
return error(404, "this app does not take payments");
};
let Ok(payload) = std::str::from_utf8(&body) else {
return error(400, "the webhook body is not valid UTF-8");
};
let signature = req
.headers()
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let delivery = match payments.verify_webhook(payload, signature) {
Ok(delivery) => delivery,
Err(e) => {
tracing::warn!(error = %e, "refused an unverified stripe webhook");
return error(400, "signature verification failed");
}
};
match record_delivery(&state, &delivery).await {
Outcome::Fresh => {}
Outcome::AlreadySeen => {
tracing::debug!(event = %delivery.id, "webhook already processed");
return ok(&json!({ "received": true, "duplicate": true }));
}
Outcome::Unrecorded => {
return error(500, "could not record the event");
}
}
let applied = apply(&state, &delivery.change).await;
finish_delivery(&state, &delivery.id, applied.as_ref().err()).await;
match applied {
Ok(()) => ok(&json!({ "received": true })),
Err(message) => {
tracing::error!(event = %delivery.id, kind = %delivery.kind, %message, "failed to apply a webhook");
error(500, "could not apply the event")
}
}
}
async fn apply(state: &AppState, change: &Change) -> Result<(), String> {
match change {
Change::CheckoutCompleted(outcome) => apply_checkout(state, outcome).await,
Change::Subscription(subscription) => apply_subscription(state, subscription).await,
Change::Payment(payment) => apply_payment(state, payment).await,
Change::Customer(customer) => {
let Some(org) = parse_org(&customer.organization_id) else {
return Ok(());
};
upsert_customer(state, org, customer).await;
Ok(())
}
Change::Ignored => Ok(()),
}
}
async fn apply_checkout(state: &AppState, outcome: &CheckoutOutcome) -> Result<(), String> {
let Some(org) = parse_org(&outcome.organization_id) else {
tracing::warn!(
session = %outcome.session_id,
"a checkout completed with no organisation in its metadata; ignoring"
);
return Ok(());
};
upsert_customer(
state,
org,
&CustomerState {
stripe_customer_id: outcome.stripe_customer_id.clone(),
organization_id: outcome.organization_id.clone(),
email: outcome.customer_email.clone(),
..CustomerState::default()
},
)
.await;
Ok(())
}
async fn apply_subscription(state: &AppState, sub: &SubscriptionState) -> Result<(), String> {
let Some(table) = state.table("billing_subscription") else {
return Ok(());
};
let org = match resolve_org(state, &sub.organization_id, &sub.stripe_customer_id).await {
Some(org) => org,
None => {
tracing::warn!(
subscription = %sub.stripe_subscription_id,
"a subscription event names no organisation we know; ignoring"
);
return Ok(());
}
};
let customer = customer_row_id(state, &sub.stripe_customer_id).await;
let price = price_row_id(state, &sub.stripe_price_id).await;
let sql = format!(
"INSERT INTO {table} \
(organization_id, customer_id, price_id, status, quantity, \
current_period_end, cancel_at_period_end, trial_ends_at, canceled_at, \
stripe_subscription_id) \
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, \
to_timestamp($6), $7, to_timestamp($8), to_timestamp($9), $10) \
ON CONFLICT (stripe_subscription_id) DO UPDATE SET \
organization_id = EXCLUDED.organization_id, \
customer_id = COALESCE(EXCLUDED.customer_id, {table}.customer_id), \
price_id = COALESCE(EXCLUDED.price_id, {table}.price_id), \
status = EXCLUDED.status, \
quantity = EXCLUDED.quantity, \
current_period_end = EXCLUDED.current_period_end, \
cancel_at_period_end = EXCLUDED.cancel_at_period_end, \
trial_ends_at = EXCLUDED.trial_ends_at, \
canceled_at = EXCLUDED.canceled_at"
);
let params = vec![
json!(org.to_string()),
optional_id(customer),
optional_id(price),
json!(sub.status),
json!(sub.quantity),
seconds(sub.current_period_end),
json!(sub.cancel_at_period_end),
seconds(sub.trial_end),
seconds(sub.canceled_at),
json!(sub.stripe_subscription_id),
];
state
.db
.raw_json(&sql, ¶ms)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn apply_payment(state: &AppState, payment: &PaymentRecord) -> Result<(), String> {
let Some(table) = state.table("billing_payment") else {
return Ok(());
};
let org = match resolve_org(state, &payment.organization_id, &payment.stripe_customer_id).await
{
Some(org) => org,
None => {
tracing::warn!(
intent = %payment.stripe_payment_intent_id,
invoice = %payment.stripe_invoice_id,
"a payment event names no organisation we know; ignoring"
);
return Ok(());
}
};
let customer = customer_row_id(state, &payment.stripe_customer_id).await;
let subscription = subscription_row_id(state, &payment.stripe_subscription_id).await;
let sql = if payment.stripe_payment_intent_id.is_empty() {
format!(
"INSERT INTO {table} \
(organization_id, customer_id, subscription_id, amount, tax_amount, \
currency, status, description, receipt_url, paid_at, stripe_invoice_id) \
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, to_timestamp($10), $11)"
)
} else {
format!(
"INSERT INTO {table} \
(organization_id, customer_id, subscription_id, amount, tax_amount, \
currency, status, description, receipt_url, paid_at, stripe_invoice_id, \
stripe_payment_intent_id) \
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, to_timestamp($10), $11, $12) \
ON CONFLICT (stripe_payment_intent_id) DO UPDATE SET \
status = EXCLUDED.status, \
amount = EXCLUDED.amount, \
tax_amount = EXCLUDED.tax_amount, \
receipt_url = EXCLUDED.receipt_url, \
paid_at = EXCLUDED.paid_at"
)
};
let mut params = vec![
json!(org.to_string()),
optional_id(customer),
optional_id(subscription),
json!(payment.amount),
json!(payment.tax_amount),
json!(payment.currency),
json!(payment.status),
json!(payment.description),
json!(payment.receipt_url),
seconds(payment.paid_at),
json!(payment.stripe_invoice_id),
];
if !payment.stripe_payment_intent_id.is_empty() {
params.push(json!(payment.stripe_payment_intent_id));
}
state
.db
.raw_json(&sql, ¶ms)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn upsert_customer(state: &AppState, org: Uuid, customer: &CustomerState) {
let Some(table) = state.table("billing_customer") else {
return;
};
if customer.stripe_customer_id.is_empty() {
return;
}
let sql = format!(
"INSERT INTO {table} \
(organization_id, stripe_customer_id, email, name, tax_id, tax_country, details) \
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7::jsonb) \
ON CONFLICT (stripe_customer_id) DO UPDATE SET \
email = COALESCE(NULLIF(EXCLUDED.email, ''), {table}.email), \
name = COALESCE(NULLIF(EXCLUDED.name, ''), {table}.name), \
tax_id = COALESCE(NULLIF(EXCLUDED.tax_id, ''), {table}.tax_id), \
tax_country = COALESCE(NULLIF(EXCLUDED.tax_country, ''), {table}.tax_country), \
details = EXCLUDED.details"
);
let params = vec![
json!(org.to_string()),
json!(customer.stripe_customer_id),
json!(customer.email),
json!(customer.name),
json!(customer.tax_id),
json!(customer.tax_country),
json!(customer.details.to_string()),
];
if let Err(error) = state.db.raw_json(&sql, ¶ms).await {
tracing::warn!(%error, "could not record the billing customer");
}
}
enum Outcome {
Fresh,
AlreadySeen,
Unrecorded,
}
async fn record_delivery(state: &AppState, delivery: &Delivery) -> Outcome {
let Some(table) = state.table("billing_event") else {
return Outcome::Unrecorded;
};
let sql = format!(
"INSERT INTO {table} (stripe_event_id, kind, payload) \
VALUES ($1, $2, $3::jsonb) \
ON CONFLICT (stripe_event_id) DO NOTHING \
RETURNING id::text AS id"
);
let params = vec![
json!(delivery.id),
json!(delivery.kind),
json!(delivery.payload.to_string()),
];
match state.db.raw_json(&sql, ¶ms).await {
Ok(rows) => match rows.as_array().is_some_and(|rows| rows.is_empty()) {
true => Outcome::AlreadySeen,
false => Outcome::Fresh,
},
Err(error) => {
tracing::error!(%error, "could not record a webhook delivery");
Outcome::Unrecorded
}
}
}
async fn finish_delivery(state: &AppState, event_id: &str, failure: Option<&String>) {
let Some(table) = state.table("billing_event") else {
return;
};
let (sql, params) = match failure {
None => (
format!(
"UPDATE {table} SET processed_at = now(), error = NULL WHERE stripe_event_id = $1"
),
vec![json!(event_id)],
),
Some(message) => (
format!("UPDATE {table} SET error = $2 WHERE stripe_event_id = $1"),
vec![json!(event_id), json!(message)],
),
};
if let Err(error) = state.db.raw_json(&sql, ¶ms).await {
tracing::warn!(%error, "could not close off a webhook delivery");
}
}
struct PriceRow {
id: Uuid,
stripe_price_id: Option<String>,
recurring: bool,
trial_days: u32,
active: bool,
}
async fn load_price(state: &AppState, id: &str) -> Result<Option<PriceRow>, HttpResponse> {
let Some(table) = state.table("billing_price") else {
return Err(error(404, "this app has no price list"));
};
let Ok(id) = Uuid::parse_str(id.trim()) else {
return Err(error(422, "`price_id` must be the id of a billing_price"));
};
let sql = format!(
"SELECT id::text AS id, stripe_price_id, interval, trial_days, active \
FROM {table} WHERE id = $1::uuid LIMIT 1"
);
let rows = match state.db.raw_json(&sql, &[json!(id.to_string())]).await {
Ok(rows) => rows,
Err(e) => return Err(crate::response::db_error(e)),
};
let Some(row) = rows.as_array().and_then(|rows| rows.first()) else {
return Ok(None);
};
Ok(Some(PriceRow {
id,
stripe_price_id: row
.get("stripe_price_id")
.and_then(Value::as_str)
.map(str::to_string)
.filter(|id| !id.is_empty()),
recurring: apiplant_payments::Interval::parse(
row.get("interval").and_then(Value::as_str).unwrap_or(""),
)
.is_recurring(),
trial_days: row
.get("trial_days")
.and_then(Value::as_i64)
.unwrap_or(0)
.clamp(0, u32::MAX as i64) as u32,
active: row.get("active").and_then(Value::as_bool).unwrap_or(true),
}))
}
async fn load_customer(state: &AppState, org: Uuid) -> Result<Option<CustomerState>, HttpResponse> {
let Some(table) = state.table("billing_customer") else {
return Ok(None);
};
let sql = format!(
"SELECT stripe_customer_id, email, name FROM {table} \
WHERE organization_id = $1::uuid LIMIT 1"
);
let rows = match state.db.raw_json(&sql, &[json!(org.to_string())]).await {
Ok(rows) => rows,
Err(e) => return Err(crate::response::db_error(e)),
};
Ok(rows
.as_array()
.and_then(|rows| rows.first())
.map(|row| CustomerState {
stripe_customer_id: text(row, "stripe_customer_id"),
organization_id: org.to_string(),
email: text(row, "email"),
name: text(row, "name"),
..CustomerState::default()
}))
}
async fn customer_row_id(state: &AppState, stripe_customer_id: &str) -> Option<Uuid> {
row_id_by(
state,
"billing_customer",
"stripe_customer_id",
stripe_customer_id,
)
.await
}
async fn price_row_id(state: &AppState, stripe_price_id: &str) -> Option<Uuid> {
row_id_by(state, "billing_price", "stripe_price_id", stripe_price_id).await
}
async fn subscription_row_id(state: &AppState, stripe_subscription_id: &str) -> Option<Uuid> {
row_id_by(
state,
"billing_subscription",
"stripe_subscription_id",
stripe_subscription_id,
)
.await
}
async fn row_id_by(state: &AppState, resource: &str, column: &str, value: &str) -> Option<Uuid> {
if value.trim().is_empty() {
return None;
}
let table = state.table(resource)?;
let sql = format!("SELECT id::text AS id FROM {table} WHERE {column} = $1 LIMIT 1");
let rows = state.db.raw_json(&sql, &[json!(value)]).await.ok()?;
rows.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("id"))
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
}
async fn resolve_org(
state: &AppState,
organization_id: &str,
stripe_customer_id: &str,
) -> Option<Uuid> {
if let Some(org) = parse_org(organization_id) {
return Some(org);
}
let table = state.table("billing_customer")?;
let sql = format!(
"SELECT organization_id::text AS org FROM {table} WHERE stripe_customer_id = $1 LIMIT 1"
);
let rows = state
.db
.raw_json(&sql, &[json!(stripe_customer_id)])
.await
.ok()?;
rows.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("org"))
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
}
async fn principal_email(state: &AppState, principal: Uuid) -> String {
let Some(table) = state.table("user") else {
return String::new();
};
let field = crate::auth_routes::quote(&crate::auth_routes::auth_spec(state).identity_field);
let sql = format!("SELECT {field} AS identity FROM {table} WHERE id = $1::uuid LIMIT 1");
state
.db
.raw_json(&sql, &[json!(principal.to_string())])
.await
.ok()
.and_then(|rows| {
rows.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("identity"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_default()
}
async fn admin_of_active_org(
req: &HttpRequest,
state: &AppState,
) -> Result<(Uuid, Uuid), HttpResponse> {
let principal = state.resolve_principal(req).await;
let Some(caller) = principal.clone() else {
return Err(error(401, "authentication required"));
};
let Some(org) = state.active_org(req, &principal) else {
return Err(error(
400,
"name the organization being billed in the X-Organization header",
));
};
if !caller.has_role_in(org, "admin") {
return Err(error(
403,
"only an admin of this organization can manage billing",
));
}
Ok((caller.user_id, org))
}
fn payment_error(e: apiplant_payments::PaymentsError) -> HttpResponse {
use apiplant_payments::PaymentsError::*;
match e {
Request(message) => error(422, message),
other => {
crate::telemetry::record_error("payments", &other);
tracing::error!(error = %other, "a payments call failed");
error(502, "the payment provider could not be reached")
}
}
}
fn string(body: &Map<String, Value>, key: &str) -> Option<String> {
body.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn text(row: &Value, key: &str) -> String {
row.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn parse_org(value: &str) -> Option<Uuid> {
Uuid::parse_str(value.trim()).ok()
}
fn optional_id(id: Option<Uuid>) -> Value {
match id {
Some(id) => json!(id.to_string()),
None => Value::Null,
}
}
fn seconds(value: Option<i64>) -> Value {
match value {
Some(seconds) => json!(seconds),
None => Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_timestamp_is_null_rather_than_the_epoch() {
assert_eq!(seconds(None), Value::Null);
assert_eq!(seconds(Some(0)), json!(0));
assert_eq!(optional_id(None), Value::Null);
}
#[test]
fn blank_body_fields_read_as_absent() {
let mut body = Map::new();
body.insert("success_url".into(), json!(" "));
body.insert("cancel_url".into(), json!("https://example.com"));
assert_eq!(string(&body, "success_url"), None);
assert_eq!(
string(&body, "cancel_url").as_deref(),
Some("https://example.com")
);
assert_eq!(string(&body, "missing"), None);
}
#[test]
fn provider_failures_are_not_relayed_to_the_caller() {
use apiplant_payments::PaymentsError;
assert_eq!(
payment_error(PaymentsError::Request("no price".into()))
.status()
.as_u16(),
422
);
assert_eq!(
payment_error(PaymentsError::Provider("sk_live_abc is invalid".into()))
.status()
.as_u16(),
502
);
}
}