1use chrono::Local;
2use console::style;
3use std::fs;
4use std::path::Path;
5
6fn stripe_mod_template(connect: bool) -> String {
11 let connect_mod = if connect {
12 "\npub mod connect_webhook;"
13 } else {
14 ""
15 };
16
17 format!(
18 r#"pub mod webhook;
19pub mod listeners;{connect_mod}
20
21use ferro::Stripe;
22
23/// Initialize Stripe. Call from bootstrap.rs.
24pub fn init() {{
25 let config = ferro::StripeConfig::from_env()
26 .expect("Stripe configuration missing. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.");
27 Stripe::init(config);
28}}
29"#
30 )
31}
32
33fn stripe_webhook_template() -> String {
34 r#"use ferro::{handler, HttpResponse, Request, Response, Stripe};
35use ferro::ProcessStripeWebhook;
36
37#[handler]
38pub async fn stripe_webhook(req: Request) -> Response {
39 let sig = req
40 .header("stripe-signature")
41 .ok_or_else(|| HttpResponse::text("Missing stripe-signature").status(400))?;
42 let body = req
43 .body_string()
44 .await
45 .map_err(|_| HttpResponse::text("Failed to read body").status(400))?;
46
47 let event = ferro::verify_webhook(&body, &sig, &Stripe::config().webhook_secret)
48 .map_err(|_| HttpResponse::text("Invalid signature").status(400))?;
49
50 let job = ProcessStripeWebhook {
51 event_type: event.type_.to_string(),
52 event_json: body.clone(),
53 connect_account_id: None,
54 };
55 ferro::queue_dispatch(job)
56 .await
57 .map_err(|e| HttpResponse::text(format!("Queue error: {e}")).status(500))?;
58
59 Ok(HttpResponse::json(serde_json::json!({"received": true})))
60}
61"#
62 .to_string()
63}
64
65fn stripe_connect_webhook_template() -> String {
66 r#"use ferro::{handler, HttpResponse, Request, Response, Stripe};
67use ferro::ProcessStripeWebhook;
68
69#[handler]
70pub async fn stripe_connect_webhook(req: Request) -> Response {
71 let sig = req
72 .header("stripe-signature")
73 .ok_or_else(|| HttpResponse::text("Missing stripe-signature").status(400))?;
74 let body = req
75 .body_string()
76 .await
77 .map_err(|_| HttpResponse::text("Failed to read body").status(400))?;
78
79 let event = ferro::verify_webhook(
80 &body,
81 &sig,
82 Stripe::config()
83 .connect_webhook_secret
84 .as_deref()
85 .unwrap_or_default(),
86 )
87 .map_err(|_| HttpResponse::text("Invalid signature").status(400))?;
88
89 let job = ProcessStripeWebhook {
90 event_type: event.type_.to_string(),
91 event_json: body.clone(),
92 connect_account_id: event.account.map(|id| id.to_string()),
93 };
94 ferro::queue_dispatch(job)
95 .await
96 .map_err(|e| HttpResponse::text(format!("Queue error: {e}")).status(500))?;
97
98 Ok(HttpResponse::json(serde_json::json!({"received": true})))
99}
100"#
101 .to_string()
102}
103
104fn stripe_listeners_template() -> String {
105 r#"use ferro::{async_trait, EventError, Listener};
106use ferro::{StripeCheckoutCompleted, StripeSubscriptionDeleted, StripeSubscriptionUpdated};
107
108pub struct SyncSubscriptionPlan;
109
110#[async_trait]
111impl Listener<StripeSubscriptionUpdated> for SyncSubscriptionPlan {
112 async fn handle(&self, event: &StripeSubscriptionUpdated) -> Result<(), EventError> {
113 // TODO: Update tenant_billing table with new subscription state.
114 // TODO: Invalidate tenant cache: lookup.invalidate(&slug, tenant_id)
115 println!("Subscription updated: {}", event.subscription_id);
116 Ok(())
117 }
118}
119
120pub struct HandleSubscriptionDeleted;
121
122#[async_trait]
123impl Listener<StripeSubscriptionDeleted> for HandleSubscriptionDeleted {
124 async fn handle(&self, event: &StripeSubscriptionDeleted) -> Result<(), EventError> {
125 // TODO: Mark tenant_billing as cancelled.
126 println!("Subscription deleted: {}", event.subscription_id);
127 Ok(())
128 }
129}
130
131pub struct HandleCheckoutCompleted;
132
133#[async_trait]
134impl Listener<StripeCheckoutCompleted> for HandleCheckoutCompleted {
135 async fn handle(&self, event: &StripeCheckoutCompleted) -> Result<(), EventError> {
136 // TODO: Provision access for the new subscriber.
137 println!("Checkout completed: {}", event.session_id);
138 Ok(())
139 }
140}
141"#
142 .to_string()
143}
144
145fn stripe_migration_template(timestamp: &str) -> String {
146 format!(
147 r#"use sea_orm_migration::prelude::*;
148
149pub struct Migration;
150
151impl MigrationName for Migration {{
152 fn name(&self) -> &str {{
153 "m{timestamp}_create_tenant_billing_table"
154 }}
155}}
156
157#[async_trait::async_trait]
158impl MigrationTrait for Migration {{
159 async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
160 manager
161 .get_connection()
162 .execute_unprepared(
163 "CREATE TABLE tenant_billing (
164 id INTEGER PRIMARY KEY AUTOINCREMENT,
165 tenant_id INTEGER NOT NULL UNIQUE,
166 stripe_customer_id TEXT NOT NULL,
167 stripe_subscription_id TEXT,
168 plan TEXT NOT NULL DEFAULT 'free',
169 subscription_status TEXT NOT NULL DEFAULT 'active',
170 trial_ends_at TIMESTAMP,
171 current_period_end TIMESTAMP,
172 cancel_at_period_end BOOLEAN NOT NULL DEFAULT 0,
173 stripe_connect_account_id TEXT,
174 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
175 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
176 );
177 CREATE INDEX idx_tenant_billing_tenant_id ON tenant_billing(tenant_id);",
178 )
179 .await?;
180 Ok(())
181 }}
182
183 async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {{
184 manager
185 .get_connection()
186 .execute_unprepared(
187 "DROP INDEX IF EXISTS idx_tenant_billing_tenant_id;
188 DROP TABLE IF EXISTS tenant_billing;",
189 )
190 .await?;
191 Ok(())
192 }}
193}}
194"#
195 )
196}
197
198fn write_if_not_exists(path: &Path, content: &str, label: &str) -> bool {
205 if path.exists() {
206 println!(
207 "{} {} already exists, skipping",
208 style("Skip:").yellow().bold(),
209 label
210 );
211 return false;
212 }
213 if let Err(e) = fs::write(path, content) {
214 eprintln!(
215 "{} Failed to write {}: {}",
216 style("Error:").red().bold(),
217 label,
218 e
219 );
220 return false;
221 }
222 println!("{} {}", style("Created:").green().bold(), label);
223 true
224}
225
226fn ensure_dir(path: &Path) -> bool {
227 if path.exists() {
228 return true;
229 }
230 if let Err(e) = fs::create_dir_all(path) {
231 eprintln!(
232 "{} Failed to create directory {}: {}",
233 style("Error:").red().bold(),
234 path.display(),
235 e
236 );
237 return false;
238 }
239 println!(
240 "{} Created directory {}",
241 style("Created:").green().bold(),
242 path.display()
243 );
244 true
245}
246
247fn find_migrations_dir() -> Option<&'static Path> {
248 if Path::new("src/migrations").exists() {
249 Some(Path::new("src/migrations"))
250 } else if Path::new("src/database/migrations").exists() {
251 Some(Path::new("src/database/migrations"))
252 } else {
253 None
254 }
255}
256
257pub fn execute(connect: bool) {
265 println!("Scaffolding Stripe integration...\n");
266
267 let stripe_dir = Path::new("src/stripe");
268
269 if !ensure_dir(stripe_dir) {
270 std::process::exit(1);
271 }
272
273 write_if_not_exists(
275 &stripe_dir.join("mod.rs"),
276 &stripe_mod_template(connect),
277 "src/stripe/mod.rs",
278 );
279
280 write_if_not_exists(
282 &stripe_dir.join("webhook.rs"),
283 &stripe_webhook_template(),
284 "src/stripe/webhook.rs",
285 );
286
287 write_if_not_exists(
289 &stripe_dir.join("listeners.rs"),
290 &stripe_listeners_template(),
291 "src/stripe/listeners.rs",
292 );
293
294 if connect {
296 write_if_not_exists(
297 &stripe_dir.join("connect_webhook.rs"),
298 &stripe_connect_webhook_template(),
299 "src/stripe/connect_webhook.rs",
300 );
301 }
302
303 generate_migration(connect);
305
306 println!("\n{}", style("Add to your .env file:").bold());
308 println!(" STRIPE_SECRET_KEY=sk_test_xxx");
309 println!(" STRIPE_WEBHOOK_SECRET=whsec_xxx");
310 if connect {
311 println!(" STRIPE_CONNECT_WEBHOOK_SECRET=whsec_xxx");
312 println!(" STRIPE_APPLICATION_FEE_PERCENT=10");
313 }
314
315 print_next_steps(connect);
317}
318
319fn generate_migration(connect: bool) {
320 let migrations_dir = match find_migrations_dir() {
321 Some(dir) => dir,
322 None => {
323 println!(
324 "{} No migrations directory found — skipping migration generation.",
325 style("Note:").yellow().bold()
326 );
327 println!(
328 "{}",
329 style(" Create src/migrations/ and re-run make:stripe to generate the migration.")
330 .dim()
331 );
332 return;
333 }
334 };
335
336 if let Ok(entries) = fs::read_dir(migrations_dir) {
338 for entry in entries.flatten() {
339 let name = entry.file_name().to_string_lossy().to_string();
340 if name.contains("tenant_billing") {
341 println!(
342 "{} Billing migration already exists: {}",
343 style("Skip:").yellow().bold(),
344 name
345 );
346 return;
347 }
348 }
349 }
350
351 let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string();
352 let migration_name = format!("m{timestamp}_create_tenant_billing_table");
353 let file_path = migrations_dir.join(format!("{migration_name}.rs"));
354 let content = stripe_migration_template(×tamp);
355
356 write_if_not_exists(&file_path, &content, &format!("{}", file_path.display()));
357
358 register_migration(migrations_dir, &migration_name, connect);
360}
361
362fn register_migration(migrations_dir: &Path, migration_name: &str, _connect: bool) {
363 let mod_path = migrations_dir.join("mod.rs");
364
365 if !mod_path.exists() {
366 return;
367 }
368
369 let content = match fs::read_to_string(&mod_path) {
370 Ok(c) => c,
371 Err(_) => return,
372 };
373
374 let mod_decl = format!("mod {migration_name};");
375 if content.contains(&mod_decl) {
376 return;
377 }
378
379 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
380
381 let mut last_mod_idx = None;
383 for (i, line) in lines.iter().enumerate() {
384 if (line.trim().starts_with("mod ") || line.trim().starts_with("pub mod m"))
385 && !line.contains("mod tests")
386 {
387 last_mod_idx = Some(i);
388 }
389 }
390
391 let insert_idx = match last_mod_idx {
392 Some(idx) => idx + 1,
393 None => {
394 let mut idx = 0;
395 for (i, line) in lines.iter().enumerate() {
396 if line.contains("sea_orm_migration") || line.is_empty() {
397 idx = i + 1;
398 } else if line.starts_with("mod ") || line.starts_with("pub struct") {
399 break;
400 }
401 }
402 idx
403 }
404 };
405 lines.insert(insert_idx, mod_decl);
406
407 let box_new_line = format!(" Box::new({migration_name}::Migration),");
409 let mut insert_vec_idx = None;
410
411 for (i, line) in lines.iter().enumerate() {
412 if line.contains("vec![]") {
413 lines[i] = line.replace("vec![]", &format!("vec![\n{box_new_line}\n ]"));
414 let _ = fs::write(&mod_path, lines.join("\n"));
415 return;
416 }
417 if line.contains("vec![") && !line.contains("vec![]") {
418 for (j, inner_line) in lines.iter().enumerate().skip(i + 1) {
419 if inner_line.trim() == "]" || inner_line.trim().starts_with(']') {
420 insert_vec_idx = Some(j);
421 break;
422 }
423 }
424 break;
425 }
426 }
427
428 if let Some(idx) = insert_vec_idx {
429 lines.insert(idx, box_new_line);
430 }
431
432 let _ = fs::write(&mod_path, lines.join("\n"));
433}
434
435fn print_next_steps(connect: bool) {
436 println!("\n{}", style("Next steps:").bold());
437 println!(
438 "\n {} Call Stripe::init() from your bootstrap.rs:",
439 style("1.").dim()
440 );
441 println!(" {}", style("crate::stripe::init();").cyan());
442
443 println!(
444 "\n {} Register webhook routes in src/routes.rs:",
445 style("2.").dim()
446 );
447 println!(
448 " {}",
449 style("use crate::stripe::webhook::stripe_webhook;").cyan()
450 );
451 println!(
452 " {}",
453 style("post!(\"/stripe/webhook\", stripe_webhook)").cyan()
454 );
455 if connect {
456 println!(
457 " {}",
458 style("use crate::stripe::connect_webhook::stripe_connect_webhook;").cyan()
459 );
460 println!(
461 " {}",
462 style("post!(\"/stripe/connect/webhook\", stripe_connect_webhook)").cyan()
463 );
464 }
465
466 println!("\n {} Run the migration:", style("3.").dim());
467 println!(" {}", style("ferro db:migrate").cyan());
468}
469
470#[cfg(test)]
476pub fn generate_in_dir(base_dir: &Path, connect: bool) {
477 let stripe_dir = base_dir.join("src/stripe");
478 fs::create_dir_all(&stripe_dir).unwrap();
479
480 fs::write(stripe_dir.join("mod.rs"), stripe_mod_template(connect)).unwrap();
481 fs::write(stripe_dir.join("webhook.rs"), stripe_webhook_template()).unwrap();
482 fs::write(stripe_dir.join("listeners.rs"), stripe_listeners_template()).unwrap();
483
484 if connect {
485 fs::write(
486 stripe_dir.join("connect_webhook.rs"),
487 stripe_connect_webhook_template(),
488 )
489 .unwrap();
490 }
491
492 let migrations_dir = base_dir.join("src/migrations");
493 fs::create_dir_all(&migrations_dir).unwrap();
494 let timestamp = "20260101_000000";
495 let migration_name = format!("m{timestamp}_create_tenant_billing_table");
496 fs::write(
497 migrations_dir.join(format!("{migration_name}.rs")),
498 stripe_migration_template(timestamp),
499 )
500 .unwrap();
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use tempfile::TempDir;
507
508 fn read_file(path: &Path) -> String {
509 fs::read_to_string(path).unwrap_or_else(|e| panic!("Failed to read {path:?}: {e}"))
510 }
511
512 #[test]
515 fn test_mod_template_without_connect() {
516 let tmpl = stripe_mod_template(false);
517 assert!(tmpl.contains("pub mod webhook;"));
518 assert!(tmpl.contains("pub mod listeners;"));
519 assert!(!tmpl.contains("pub mod connect_webhook;"));
520 assert!(tmpl.contains("use ferro::Stripe;"));
521 assert!(tmpl.contains("pub fn init()"));
522 assert!(tmpl.contains("ferro::StripeConfig::from_env()"));
523 assert!(tmpl.contains("Stripe::init(config);"));
524 }
525
526 #[test]
527 fn test_mod_template_with_connect() {
528 let tmpl = stripe_mod_template(true);
529 assert!(tmpl.contains("pub mod webhook;"));
530 assert!(tmpl.contains("pub mod listeners;"));
531 assert!(tmpl.contains("pub mod connect_webhook;"));
532 }
533
534 #[test]
537 fn test_webhook_template_uses_queue_dispatch() {
538 let tmpl = stripe_webhook_template();
539 assert!(tmpl.contains("ferro::queue_dispatch(job)"));
541 assert!(!tmpl.contains("dispatch_event"));
542 assert!(tmpl.contains("ferro::verify_webhook("));
543 assert!(tmpl.contains("stripe-signature"));
544 assert!(tmpl.contains(r#"{"received": true}"#));
545 }
546
547 #[test]
548 fn test_webhook_template_uses_ferro_imports() {
549 let tmpl = stripe_webhook_template();
550 assert!(tmpl.contains("use ferro::{"));
551 assert!(tmpl.contains("use ferro::ProcessStripeWebhook;"));
552 }
553
554 #[test]
557 fn test_connect_webhook_template() {
558 let tmpl = stripe_connect_webhook_template();
559 assert!(tmpl.contains("stripe_connect_webhook"));
560 assert!(tmpl.contains("ProcessStripeWebhook {"));
561 assert!(tmpl.contains("ferro::queue_dispatch(job)"));
562 assert!(tmpl.contains("connect_webhook_secret"));
563 }
564
565 #[test]
568 fn test_listeners_template() {
569 let tmpl = stripe_listeners_template();
570 assert!(tmpl.contains("StripeSubscriptionUpdated"));
571 assert!(tmpl.contains("StripeSubscriptionDeleted"));
572 assert!(tmpl.contains("StripeCheckoutCompleted"));
573 assert!(tmpl.contains("impl Listener<StripeSubscriptionUpdated> for SyncSubscriptionPlan"));
574 assert!(tmpl.contains("async fn handle("));
575 assert!(tmpl.contains("use ferro::{async_trait, EventError, Listener};"));
576 }
577
578 #[test]
581 fn test_migration_sql_schema() {
582 let tmpl = stripe_migration_template("20260101_000000");
583 assert!(tmpl.contains("CREATE TABLE tenant_billing"));
584 assert!(tmpl.contains("tenant_id INTEGER NOT NULL UNIQUE"));
585 assert!(tmpl.contains("stripe_customer_id TEXT NOT NULL"));
586 assert!(tmpl.contains("stripe_subscription_id TEXT"));
587 assert!(tmpl.contains("plan TEXT NOT NULL DEFAULT 'free'"));
588 assert!(tmpl.contains("subscription_status TEXT NOT NULL DEFAULT 'active'"));
589 assert!(tmpl.contains("trial_ends_at TIMESTAMP"));
590 assert!(tmpl.contains("current_period_end TIMESTAMP"));
591 assert!(tmpl.contains("cancel_at_period_end BOOLEAN NOT NULL DEFAULT 0"));
592 assert!(tmpl.contains("stripe_connect_account_id TEXT"));
593 assert!(tmpl.contains("created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"));
594 assert!(tmpl.contains("updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"));
595 assert!(
596 tmpl.contains("CREATE INDEX idx_tenant_billing_tenant_id ON tenant_billing(tenant_id)")
597 );
598 assert!(tmpl.contains("DROP TABLE IF EXISTS tenant_billing"));
600 }
601
602 #[test]
603 fn test_migration_uses_timestamp() {
604 let ts = "20260315_120000";
605 let tmpl = stripe_migration_template(ts);
606 assert!(tmpl.contains(&format!("m{ts}_create_tenant_billing_table")));
607 }
608
609 #[test]
612 fn test_generates_required_files_without_connect() {
613 let tmp = TempDir::new().unwrap();
614 generate_in_dir(tmp.path(), false);
615
616 let stripe_dir = tmp.path().join("src/stripe");
617 assert!(
618 stripe_dir.exists(),
619 "src/stripe directory should be created"
620 );
621 assert!(
622 stripe_dir.join("mod.rs").exists(),
623 "mod.rs should be created"
624 );
625 assert!(
626 stripe_dir.join("webhook.rs").exists(),
627 "webhook.rs should be created"
628 );
629 assert!(
630 stripe_dir.join("listeners.rs").exists(),
631 "listeners.rs should be created"
632 );
633 assert!(
634 !stripe_dir.join("connect_webhook.rs").exists(),
635 "connect_webhook.rs should NOT be created without --connect"
636 );
637 }
638
639 #[test]
640 fn test_generates_connect_webhook_with_connect_flag() {
641 let tmp = TempDir::new().unwrap();
642 generate_in_dir(tmp.path(), true);
643
644 let stripe_dir = tmp.path().join("src/stripe");
645 assert!(
646 stripe_dir.join("connect_webhook.rs").exists(),
647 "connect_webhook.rs should be created with --connect"
648 );
649
650 let content = read_file(&stripe_dir.join("connect_webhook.rs"));
652 assert!(content.contains("stripe_connect_webhook"));
653 assert!(content.contains("ProcessStripeWebhook {"));
654 }
655
656 #[test]
657 fn test_does_not_overwrite_existing_files() {
658 let tmp = TempDir::new().unwrap();
659 let stripe_dir = tmp.path().join("src/stripe");
660 fs::create_dir_all(&stripe_dir).unwrap();
661
662 let existing_content = "// Custom user content that should not be overwritten\n";
664 fs::write(stripe_dir.join("mod.rs"), existing_content).unwrap();
665
666 generate_in_dir(tmp.path(), false);
668
669 let content = read_file(&stripe_dir.join("mod.rs"));
671 let out_path = tmp.path().join("test_file.txt");
674 write_if_not_exists(&out_path, "new content", "test_file.txt");
675 assert_eq!(fs::read_to_string(&out_path).unwrap(), "new content");
676
677 write_if_not_exists(&out_path, "overwritten content", "test_file.txt");
679 assert_eq!(
680 fs::read_to_string(&out_path).unwrap(),
681 "new content",
682 "write_if_not_exists must not overwrite existing files"
683 );
684 drop(content); }
686
687 #[test]
688 fn test_migration_created() {
689 let tmp = TempDir::new().unwrap();
690 generate_in_dir(tmp.path(), false);
691
692 let migrations_dir = tmp.path().join("src/migrations");
693 assert!(migrations_dir.exists());
694
695 let entries: Vec<_> = fs::read_dir(&migrations_dir)
697 .unwrap()
698 .filter_map(|e| e.ok())
699 .collect();
700 assert!(
701 !entries.is_empty(),
702 "At least one migration file should be created"
703 );
704
705 let has_billing = entries.iter().any(|e| {
706 let name = e.file_name().to_string_lossy().to_string();
707 name.contains("tenant_billing")
708 });
709 assert!(has_billing, "A tenant_billing migration should be created");
710 }
711
712 #[test]
713 fn test_generated_webhook_uses_queue_not_events() {
714 let tmp = TempDir::new().unwrap();
715 generate_in_dir(tmp.path(), false);
716
717 let webhook_path = tmp.path().join("src/stripe/webhook.rs");
718 let content = read_file(&webhook_path);
719
720 assert!(
722 content.contains("queue_dispatch"),
723 "webhook.rs must use queue_dispatch (not dispatch_event)"
724 );
725 assert!(
726 !content.contains("dispatch_event"),
727 "webhook.rs must NOT use dispatch_event"
728 );
729 }
730}