codoseo_web/billing/
webhook.rs1use 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 let id = headers
33 .get("webhook-id")
34 .and_then(|v| v.to_str().ok())
35 .unwrap_or_default();
36 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 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 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}