Skip to content

Commit 0692c6f

Browse files
m13vGemini
andcommitted
Add Stripe subscription checkout and status routes
Co-Authored-By: Gemini <noreply@google.com>
1 parent cadedbf commit 0692c6f

1 file changed

Lines changed: 345 additions & 0 deletions

File tree

Backend/src/routes/stripe.rs

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
use axum::{
2+
body::Bytes,
3+
extract::Extension,
4+
http::{HeaderMap, StatusCode},
5+
response::IntoResponse,
6+
Json,
7+
};
8+
use serde::{Deserialize, Serialize};
9+
use std::sync::Arc;
10+
11+
use crate::auth::AuthDevice;
12+
use crate::config::Config;
13+
14+
// ---------- Create Checkout Session ----------
15+
16+
#[derive(Deserialize)]
17+
pub struct CreateCheckoutRequest {
18+
/// Where to redirect after successful payment
19+
pub success_url: Option<String>,
20+
/// Where to redirect if user cancels
21+
pub cancel_url: Option<String>,
22+
}
23+
24+
#[derive(Serialize)]
25+
pub struct CreateCheckoutResponse {
26+
pub checkout_url: String,
27+
pub session_id: String,
28+
}
29+
30+
/// POST /api/stripe/create-checkout-session
31+
/// Creates a Stripe Checkout Session for the Fazm subscription.
32+
/// First month $9, then $49/month using a coupon on the first payment.
33+
pub async fn create_checkout_session(
34+
Extension(config): Extension<Arc<Config>>,
35+
Extension(auth): Extension<AuthDevice>,
36+
Json(body): Json<CreateCheckoutRequest>,
37+
) -> Result<impl IntoResponse, (StatusCode, String)> {
38+
let stripe_secret = &config.stripe_secret_key;
39+
if stripe_secret.is_empty() {
40+
return Err((
41+
StatusCode::INTERNAL_SERVER_ERROR,
42+
"Stripe not configured".to_string(),
43+
));
44+
}
45+
46+
let firebase_uid = auth.firebase_uid.unwrap_or_default();
47+
let success_url = body
48+
.success_url
49+
.unwrap_or_else(|| "fazm://subscription/success".to_string());
50+
let cancel_url = body
51+
.cancel_url
52+
.unwrap_or_else(|| "fazm://subscription/cancel".to_string());
53+
54+
let client = reqwest::Client::new();
55+
56+
// First, ensure a Stripe customer exists for this user (idempotent lookup/create)
57+
let customer_id = get_or_create_customer(&client, stripe_secret, &firebase_uid, &auth.device_id)
58+
.await
59+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
60+
61+
// Create checkout session with the subscription
62+
// Uses a trial-like approach: first invoice gets a coupon ($40 off = $9 first month)
63+
let mut params = vec![
64+
("mode", "subscription".to_string()),
65+
("customer", customer_id.clone()),
66+
("success_url", success_url),
67+
("cancel_url", cancel_url),
68+
("line_items[0][price]", config.stripe_price_id.clone()),
69+
("line_items[0][quantity]", "1".to_string()),
70+
("subscription_data[metadata][firebase_uid]", firebase_uid),
71+
("subscription_data[metadata][device_id]", auth.device_id),
72+
];
73+
74+
// Apply intro coupon if configured
75+
if !config.stripe_intro_coupon_id.is_empty() {
76+
params.push(("discounts[0][coupon]", config.stripe_intro_coupon_id.clone()));
77+
}
78+
79+
let resp = client
80+
.post("https://api.stripe.com/v1/checkout/sessions")
81+
.bearer_auth(stripe_secret)
82+
.form(&params)
83+
.send()
84+
.await
85+
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Stripe API error: {e}")))?;
86+
87+
let status = resp.status();
88+
let body: serde_json::Value = resp
89+
.json()
90+
.await
91+
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Stripe parse error: {e}")))?;
92+
93+
if !status.is_success() {
94+
tracing::error!("Stripe checkout error: {body}");
95+
return Err((
96+
StatusCode::BAD_GATEWAY,
97+
format!("Stripe error: {}", body["error"]["message"]),
98+
));
99+
}
100+
101+
let checkout_url = body["url"]
102+
.as_str()
103+
.unwrap_or_default()
104+
.to_string();
105+
let session_id = body["id"]
106+
.as_str()
107+
.unwrap_or_default()
108+
.to_string();
109+
110+
tracing::info!(customer = %customer_id, session = %session_id, "Checkout session created");
111+
112+
Ok(Json(CreateCheckoutResponse {
113+
checkout_url,
114+
session_id,
115+
}))
116+
}
117+
118+
// ---------- Subscription Status ----------
119+
120+
#[derive(Serialize)]
121+
pub struct SubscriptionStatusResponse {
122+
pub active: bool,
123+
pub status: String, // "active", "trialing", "past_due", "canceled", "none"
124+
pub current_period_end: Option<i64>,
125+
}
126+
127+
/// GET /api/stripe/subscription-status
128+
/// Returns the subscription status for the authenticated user.
129+
pub async fn subscription_status(
130+
Extension(config): Extension<Arc<Config>>,
131+
Extension(auth): Extension<AuthDevice>,
132+
) -> Result<impl IntoResponse, (StatusCode, String)> {
133+
let stripe_secret = &config.stripe_secret_key;
134+
if stripe_secret.is_empty() {
135+
return Err((
136+
StatusCode::INTERNAL_SERVER_ERROR,
137+
"Stripe not configured".to_string(),
138+
));
139+
}
140+
141+
let firebase_uid = auth.firebase_uid.unwrap_or_default();
142+
let client = reqwest::Client::new();
143+
144+
// Look up customer by metadata
145+
let customer_id = find_customer(&client, stripe_secret, &firebase_uid)
146+
.await
147+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
148+
149+
let Some(customer_id) = customer_id else {
150+
return Ok(Json(SubscriptionStatusResponse {
151+
active: false,
152+
status: "none".to_string(),
153+
current_period_end: None,
154+
}));
155+
};
156+
157+
// List active subscriptions for this customer
158+
let resp = client
159+
.get("https://api.stripe.com/v1/subscriptions")
160+
.bearer_auth(stripe_secret)
161+
.query(&[("customer", &customer_id), ("limit", &"1".to_string())])
162+
.send()
163+
.await
164+
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Stripe API error: {e}")))?;
165+
166+
let body: serde_json::Value = resp
167+
.json()
168+
.await
169+
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Stripe parse error: {e}")))?;
170+
171+
let subs = body["data"].as_array();
172+
if let Some(subs) = subs {
173+
if let Some(sub) = subs.first() {
174+
let status = sub["status"].as_str().unwrap_or("none").to_string();
175+
let active = matches!(status.as_str(), "active" | "trialing");
176+
let period_end = sub["current_period_end"].as_i64();
177+
return Ok(Json(SubscriptionStatusResponse {
178+
active,
179+
status,
180+
current_period_end: period_end,
181+
}));
182+
}
183+
}
184+
185+
Ok(Json(SubscriptionStatusResponse {
186+
active: false,
187+
status: "none".to_string(),
188+
current_period_end: None,
189+
}))
190+
}
191+
192+
// ---------- Webhook ----------
193+
194+
/// POST /api/stripe/webhook
195+
/// Handles Stripe webhook events (subscription created, updated, deleted, etc.)
196+
pub async fn webhook(
197+
Extension(config): Extension<Arc<Config>>,
198+
headers: HeaderMap,
199+
body: Bytes,
200+
) -> Result<impl IntoResponse, (StatusCode, String)> {
201+
let stripe_secret = &config.stripe_webhook_secret;
202+
203+
// Verify webhook signature if secret is configured
204+
if !stripe_secret.is_empty() {
205+
let sig = headers
206+
.get("stripe-signature")
207+
.and_then(|v| v.to_str().ok())
208+
.unwrap_or_default();
209+
210+
if !verify_stripe_signature(&body, sig, stripe_secret) {
211+
return Err((StatusCode::BAD_REQUEST, "Invalid signature".to_string()));
212+
}
213+
}
214+
215+
let event: serde_json::Value = serde_json::from_slice(&body)
216+
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON: {e}")))?;
217+
218+
let event_type = event["type"].as_str().unwrap_or_default();
219+
tracing::info!(event_type, "Stripe webhook received");
220+
221+
match event_type {
222+
"checkout.session.completed" => {
223+
let session = &event["data"]["object"];
224+
let customer = session["customer"].as_str().unwrap_or_default();
225+
let subscription = session["subscription"].as_str().unwrap_or_default();
226+
tracing::info!(customer, subscription, "Checkout completed");
227+
}
228+
"customer.subscription.created"
229+
| "customer.subscription.updated"
230+
| "customer.subscription.deleted" => {
231+
let sub = &event["data"]["object"];
232+
let customer = sub["customer"].as_str().unwrap_or_default();
233+
let status = sub["status"].as_str().unwrap_or_default();
234+
let firebase_uid = sub["metadata"]["firebase_uid"]
235+
.as_str()
236+
.unwrap_or_default();
237+
tracing::info!(
238+
customer,
239+
status,
240+
firebase_uid,
241+
event_type,
242+
"Subscription event"
243+
);
244+
}
245+
_ => {
246+
tracing::debug!(event_type, "Unhandled webhook event");
247+
}
248+
}
249+
250+
Ok(StatusCode::OK)
251+
}
252+
253+
// ---------- Helpers ----------
254+
255+
/// Find or create a Stripe customer by Firebase UID
256+
async fn get_or_create_customer(
257+
client: &reqwest::Client,
258+
secret: &str,
259+
firebase_uid: &str,
260+
device_id: &str,
261+
) -> Result<String, String> {
262+
// Search for existing customer
263+
if let Some(id) = find_customer(client, secret, firebase_uid).await? {
264+
return Ok(id);
265+
}
266+
267+
// Create new customer
268+
let resp = client
269+
.post("https://api.stripe.com/v1/customers")
270+
.bearer_auth(secret)
271+
.form(&[
272+
("metadata[firebase_uid]", firebase_uid),
273+
("metadata[device_id]", device_id),
274+
])
275+
.send()
276+
.await
277+
.map_err(|e| format!("Stripe customer create error: {e}"))?;
278+
279+
let body: serde_json::Value = resp
280+
.json()
281+
.await
282+
.map_err(|e| format!("Stripe parse error: {e}"))?;
283+
284+
body["id"]
285+
.as_str()
286+
.map(|s| s.to_string())
287+
.ok_or_else(|| format!("No customer ID in response: {body}"))
288+
}
289+
290+
/// Find a Stripe customer by Firebase UID metadata
291+
async fn find_customer(
292+
client: &reqwest::Client,
293+
secret: &str,
294+
firebase_uid: &str,
295+
) -> Result<Option<String>, String> {
296+
let resp = client
297+
.get("https://api.stripe.com/v1/customers/search")
298+
.bearer_auth(secret)
299+
.query(&[("query", &format!("metadata['firebase_uid']:'{firebase_uid}'"))])
300+
.send()
301+
.await
302+
.map_err(|e| format!("Stripe search error: {e}"))?;
303+
304+
let body: serde_json::Value = resp
305+
.json()
306+
.await
307+
.map_err(|e| format!("Stripe parse error: {e}"))?;
308+
309+
Ok(body["data"]
310+
.as_array()
311+
.and_then(|arr| arr.first())
312+
.and_then(|c| c["id"].as_str())
313+
.map(|s| s.to_string()))
314+
}
315+
316+
/// Verify Stripe webhook signature (v1 scheme)
317+
fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
318+
use hmac::{Hmac, Mac};
319+
use sha2::Sha256;
320+
321+
// Parse signature header: t=timestamp,v1=signature
322+
let mut timestamp = "";
323+
let mut signature = "";
324+
for part in sig_header.split(',') {
325+
if let Some(t) = part.strip_prefix("t=") {
326+
timestamp = t;
327+
} else if let Some(s) = part.strip_prefix("v1=") {
328+
signature = s;
329+
}
330+
}
331+
332+
if timestamp.is_empty() || signature.is_empty() {
333+
return false;
334+
}
335+
336+
// Compute expected signature
337+
let signed_payload = format!("{timestamp}.{}", String::from_utf8_lossy(payload));
338+
let mut mac =
339+
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
340+
mac.update(signed_payload.as_bytes());
341+
let expected = hex::encode(mac.finalize().into_bytes());
342+
343+
// Constant-time comparison
344+
expected == signature
345+
}

0 commit comments

Comments
 (0)