Skip to main content

codoseo_web/billing/
webhook.rs

1//! `POST /billing/webhook`: Dodo's subscription events. No session and no `Origin` check; the
2//! signature is the authentication. Once a request is verified it is answered 200 whatever it
3//! contains (unknown events, unknown accounts), so Dodo doesn't retry what we can't use.
4
5use axum::body::Bytes;
6use axum::extract::State;
7use axum::http::{HeaderMap, StatusCode};
8use axum::response::{IntoResponse, Response};
9use codoseo_store::billing::{self, Outcome, SubscriptionEvent};
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use super::dodo;
14use crate::auth::email;
15use crate::error::AppError;
16use crate::state::AppState;
17
18pub async fn receive(
19    State(state): State<AppState>,
20    headers: HeaderMap,
21    body: Bytes,
22) -> Result<Response, AppError> {
23    let Some(cfg) = state.config.billing.as_ref() else {
24        return Err(AppError::NotFound);
25    };
26    let now = OffsetDateTime::now_utc();
27    if let Err(error) = dodo::verify(&cfg.webhook_secret, &headers, &body, now) {
28        tracing::warn!(%error, "billing webhook refused");
29        return Ok((StatusCode::UNAUTHORIZED, "invalid signature").into_response());
30    }
31    // `verify` found the header, so this is present.
32    let id = headers
33        .get("webhook-id")
34        .and_then(|v| v.to_str().ok())
35        .unwrap_or_default();
36    // Past this point the request is authentic: answer 200 unless the body isn't a JSON object
37    // at all. Fields of the wrong type were already treated as absent by `parse_event`.
38    let (Ok(event), Ok(payload)) = (
39        dodo::parse_event(id, &body),
40        serde_json::from_slice::<serde_json::Value>(&body),
41    ) else {
42        tracing::warn!(%id, "billing webhook body is not a JSON object");
43        return Ok((StatusCode::BAD_REQUEST, "invalid body").into_response());
44    };
45
46    let sub = event.subscription.clone();
47    let stored = SubscriptionEvent {
48        event_id: event.id.clone(),
49        kind: event.kind.clone(),
50        // The body's own time orders events; the delivery time changes on every retry.
51        at: event.timestamp.unwrap_or(now),
52        payload,
53        account_hint: sub
54            .as_ref()
55            .and_then(|s| s.metadata_account_id.as_deref())
56            .and_then(|a| Uuid::parse_str(a).ok()),
57        subscription_id: sub.as_ref().and_then(|s| s.subscription_id.clone()),
58        customer_id: sub.as_ref().and_then(|s| s.customer_id.clone()),
59        email_canonical: sub
60            .as_ref()
61            .and_then(|s| s.customer_email.as_deref())
62            .map(email::canonical),
63        plan: sub
64            .as_ref()
65            .and_then(|s| s.product_id.as_deref())
66            .and_then(|p| cfg.plan_for_product(p)),
67        status: sub.as_ref().and_then(|s| s.status.clone()),
68        next_billing_date: sub.as_ref().and_then(|s| s.next_billing_date),
69    };
70    // A database error is a 500 on purpose: nothing was recorded, and Dodo will retry.
71    let outcome = billing::handle_event(&state.pool, &stored, now).await?;
72    match &outcome {
73        Outcome::Duplicate => tracing::info!(%id, kind = %event.kind, "billing webhook replayed"),
74        Outcome::NoAccount => {
75            tracing::warn!(%id, kind = %event.kind, "billing webhook matches no account")
76        }
77        Outcome::Stale => {
78            tracing::info!(%id, kind = %event.kind, "billing webhook older than the last applied")
79        }
80        Outcome::Applied(plan) => {
81            tracing::info!(%id, kind = %event.kind, ?plan, "billing webhook applied")
82        }
83        Outcome::Downgraded => {
84            tracing::info!(%id, kind = %event.kind, "billing webhook downgraded the account")
85        }
86        Outcome::Unchanged(why) => {
87            tracing::info!(%id, kind = %event.kind, why, "billing webhook changed nothing")
88        }
89    }
90    Ok((StatusCode::OK, "ok").into_response())
91}