Skip to main content

ferro_cli/commands/
make_stripe.rs

1use chrono::Local;
2use console::style;
3use std::fs;
4use std::path::Path;
5
6// ---------------------------------------------------------------------------
7// Template generators
8// ---------------------------------------------------------------------------
9
10fn 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
198// ---------------------------------------------------------------------------
199// File generation helpers
200// ---------------------------------------------------------------------------
201
202/// Write a file only if it does not already exist.
203/// Returns true if the file was created, false if skipped.
204fn 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
257// ---------------------------------------------------------------------------
258// Public API
259// ---------------------------------------------------------------------------
260
261/// Execute `ferro make:stripe [--connect]`.
262///
263/// Generates the Stripe integration scaffold into the current Ferro project.
264pub 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    // mod.rs
274    write_if_not_exists(
275        &stripe_dir.join("mod.rs"),
276        &stripe_mod_template(connect),
277        "src/stripe/mod.rs",
278    );
279
280    // webhook.rs
281    write_if_not_exists(
282        &stripe_dir.join("webhook.rs"),
283        &stripe_webhook_template(),
284        "src/stripe/webhook.rs",
285    );
286
287    // listeners.rs
288    write_if_not_exists(
289        &stripe_dir.join("listeners.rs"),
290        &stripe_listeners_template(),
291        "src/stripe/listeners.rs",
292    );
293
294    // connect_webhook.rs (only with --connect)
295    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    // Migration
304    generate_migration(connect);
305
306    // Print env hints
307    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
316    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    // Check if billing migration already exists
337    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(&timestamp);
355
356    write_if_not_exists(&file_path, &content, &format!("{}", file_path.display()));
357
358    // Register in mod.rs
359    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    // Find last mod declaration
382    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    // Add to migrations() vec
408    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// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474/// Generate the scaffold files in a temp directory for testing.
475#[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    // --- mod.rs template tests ---
513
514    #[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    // --- webhook.rs template tests ---
535
536    #[test]
537    fn test_webhook_template_uses_queue_dispatch() {
538        let tmpl = stripe_webhook_template();
539        // Must use queue_dispatch (not dispatch_event) per locked decision
540        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    // --- connect_webhook template tests ---
555
556    #[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    // --- listeners.rs template tests ---
566
567    #[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    // --- migration template tests ---
579
580    #[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        // Must have a down migration
599        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    // --- file generation tests ---
610
611    #[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        // Verify connect webhook content
651        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        // Write an existing mod.rs with custom content
663        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 — should skip mod.rs
667        generate_in_dir(tmp.path(), false);
668
669        // mod.rs should still have original content
670        let content = read_file(&stripe_dir.join("mod.rs"));
671        // Since generate_in_dir writes unconditionally for tests (it's a test helper),
672        // we test the write_if_not_exists logic directly:
673        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        // Writing again should not change the content
678        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); // use the variable
685    }
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        // Verify at least one migration file exists with tenant_billing content
696        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        // Must use queue_dispatch, not dispatch_event
721        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}