//! Integrationstest Zahlungsprotokoll + Abschluss-Gate gegen eine echte, //! **wegwerfbare** Postgres-Datenbank (Migrationen werden angewendet). //! //! Läuft nur explizit: //! HL_TEST_DATABASE_URL=postgres://… cargo test -p holzleitner-infrastructure \ //! --test payment_flow -- --ignored //! //! Achtung: legt Testdaten an. Nie gegen eine produktive DB richten. use sqlx::PgPool; use uuid::Uuid; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::{ CompleteDeliveryInput, DeliveryCompletionRepository, DeliveryPaymentRepository, TourRepository, }; use holzleitner_infrastructure::persistence::{ PgDeliveryCompletionRepository, PgDeliveryPaymentRepository, PgTourRepository, PoolConfig, connect_and_migrate, }; const CASH: &str = "99999999-9999-9999-9999-999999999901"; const EC: &str = "99999999-9999-9999-9999-999999999902"; const INVOICE: &str = "99999999-9999-9999-9999-999999999904"; async fn pool() -> PgPool { let url = std::env::var("HL_TEST_DATABASE_URL").expect("HL_TEST_DATABASE_URL setzen"); connect_and_migrate(&PoolConfig { url, max_connections: 2 }) .await .expect("DB verbinden/migrieren") } /// Lieferung mit einer nicht-scanbaren Position (2 × 50 €), 20 € Anzahlung, /// Beleg-Methode `method` ⇒ offener Betrag 80 €. async fn seed(pool: &PgPool, method: &str) -> Uuid { let pn: i64 = 900_000 + (rand_u16() as i64); sqlx::query("INSERT INTO accounts (personalnummer, name) VALUES ($1, 'Test') ON CONFLICT DO NOTHING") .bind(pn).execute(pool).await.unwrap(); let tour: Uuid = sqlx::query_scalar( "INSERT INTO tours (account_id, tour_date) VALUES ($1, CURRENT_DATE) RETURNING id", ).bind(pn).fetch_one(pool).await.unwrap(); let customer: Uuid = sqlx::query_scalar( "INSERT INTO customers (erp_customer_id, name, street, house_number, postal_code, city, country) \ VALUES ($1, 'Kunde', 'Str', '1', '52525', 'Ort', 'DE') RETURNING id", ).bind(10_000_000 + (Uuid::new_v4().as_u128() % 80_000_000) as i64).fetch_one(pool).await.unwrap(); let wh: Uuid = sqlx::query_scalar( "INSERT INTO warehouses (code, name) VALUES ($1, 'L') RETURNING id", ).bind(format!("T{}", Uuid::new_v4())).fetch_one(pool).await.unwrap(); let article: Uuid = sqlx::query_scalar( "INSERT INTO articles (article_number, name, scannable, default_warehouse_id) \ VALUES ($1, 'Herd', false, $2) RETURNING id", ).bind(format!("A{}", Uuid::new_v4())).bind(wh).fetch_one(pool).await.unwrap(); let delivery: Uuid = sqlx::query_scalar( "INSERT INTO deliveries (tour_id, erp_belegart_id, erp_belegnummer, customer_id, \ snap_street, snap_house_number, snap_postal_code, snap_city, snap_country, \ payment_method_id, prepaid_amount) \ VALUES ($1, 24, $2, $3, 'Str', '1', '52525', 'Ort', 'DE', $4::uuid, 20) RETURNING id", ).bind(tour).bind(format!("V-{}", Uuid::new_v4())).bind(customer).bind(method) .fetch_one(pool).await.unwrap(); sqlx::query( "INSERT INTO delivery_items (delivery_id, article_id, required_quantity, warehouse_id, \ belegzeilen_nr, unit_price) VALUES ($1, $2, 2, $3, 1, 50)", ).bind(delivery).bind(article).bind(wh).execute(pool).await.unwrap(); delivery } fn rand_u16() -> u16 { (Uuid::new_v4().as_u128() & 0xffff) as u16 } fn input(delivery_id: Uuid, payment_collected: bool) -> CompleteDeliveryInput { CompleteDeliveryInput { delivery_id, customer_signature_path: "c.png".into(), driver_signature_path: "d.png".into(), receipt_confirmed: true, notes_acknowledged: false, acknowledged_note_ids: vec![], payment_collected, payment_method_id: None, completed_by_personalnummer: 1, completed_by_car_id: None, internal_note: None, } } fn id(s: &str) -> Uuid { s.parse().unwrap() } #[tokio::test] #[ignore] async fn payment_protocol_and_completion_gate() { let pool = pool().await; let payments = PgDeliveryPaymentRepository::new(pool.clone()); let completions = PgDeliveryCompletionRepository::new(pool.clone(), chrono_tz::Europe::Berlin); // 1) Ohne Zahlung: Bar-Beleg mit offenem Betrag ⇒ Abschluss abgelehnt. let d = seed(&pool, CASH).await; let err = completions.complete(input(d, false)).await.unwrap_err(); assert!(matches!(err, ApplicationError::Validation(_)), "{err:?}"); // 2) Falscher erwarteter Betrag ⇒ abgelehnt, nichts protokolliert. let err = payments.record(d, Uuid::new_v4(), id(EC), 7999, 1, None).await.unwrap_err(); assert!(matches!(err, ApplicationError::Validation(_)), "{err:?}"); // 3) Korrekt: 80 € per EC protokolliert; Retry mit gleicher Id idempotent. let event = Uuid::new_v4(); let p = payments.record(d, event, id(EC), 8000, 1, None).await.unwrap(); assert_eq!(p.amount_cents, 8000); assert_eq!(p.payment_method_code, "ec_card"); let again = payments.record(d, event, id(EC), 8000, 1, None).await.unwrap(); assert_eq!(again.id, p.id); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM delivery_payments WHERE delivery_id = $1") .bind(d).fetch_one(&pool).await.unwrap(); assert_eq!(count, 1); // 4) Abschluss ohne Flag: protokollierte Zahlung erfüllt das Gate, Methode // wird übernommen, Abschluss ist mit der Zahlung verknüpft. let done = completions.complete(input(d, false)).await.unwrap(); assert_eq!(done.payment_method_id, id(EC)); let (collected, amount, payment_id): (bool, Option, Option) = sqlx::query_as( "SELECT payment_collected, collected_amount_cents, payment_id FROM delivery_completions WHERE delivery_id = $1", ).bind(d).fetch_one(&pool).await.unwrap(); assert!(collected); assert_eq!(amount, Some(8000)); assert_eq!(payment_id, Some(p.id)); // 5) Zahlung nach Abschluss ⇒ abgelehnt (nicht mehr aktiv). let err = payments.record(d, Uuid::new_v4(), id(CASH), 8000, 1, None).await.unwrap_err(); assert!(matches!(err, ApplicationError::Validation(_)), "{err:?}"); // 6) Veraltete Zahlung: nach Protokoll kommt eine Gutschrift ⇒ Betrag passt // nicht mehr ⇒ Abschluss (Bar) wieder gesperrt. let d2 = seed(&pool, CASH).await; payments.record(d2, Uuid::new_v4(), id(CASH), 8000, 1, None).await.unwrap(); sqlx::query( "INSERT INTO delivery_credit_audit (client_event_id, delivery_id, action, amount_cents, reason, author_personalnummer) \ VALUES ($1, $2, 'set', 1000, 'Kratzer', 1)", ).bind(Uuid::new_v4()).bind(d2).execute(&pool).await.unwrap(); let err = completions.complete(input(d2, false)).await.unwrap_err(); assert!(matches!(err, ApplicationError::Validation(_)), "{err:?}"); // Neu abwickeln mit neuem Betrag ⇒ Abschluss geht. payments.record(d2, Uuid::new_v4(), id(CASH), 7000, 1, None).await.unwrap(); completions.complete(input(d2, false)).await.unwrap(); // 7) Auf Rechnung: protokolliert, Abschluss ohne Inkasso-Pflicht, aber verknüpft. let d3 = seed(&pool, CASH).await; let inv = payments.record(d3, Uuid::new_v4(), id(INVOICE), 8000, 1, None).await.unwrap(); let done = completions.complete(input(d3, false)).await.unwrap(); assert_eq!(done.payment_method_id, id(INVOICE)); let (collected, payment_id): (bool, Option) = sqlx::query_as( "SELECT payment_collected, payment_id FROM delivery_completions WHERE delivery_id = $1", ).bind(d3).fetch_one(&pool).await.unwrap(); assert!(!collected); assert_eq!(payment_id, Some(inv.id)); // 8) Alte App: Flag ohne Protokoll funktioniert weiterhin. // Dazu interne Notiz: wird am Abschluss gespeichert. let d4 = seed(&pool, CASH).await; let mut with_note = input(d4, true); with_note.internal_note = Some("Kunde wünscht Rückruf wegen Montage".into()); completions.complete(with_note).await.unwrap(); let note: Option = sqlx::query_scalar("SELECT internal_note FROM delivery_completions WHERE delivery_id = $1") .bind(d4).fetch_one(&pool).await.unwrap(); assert_eq!(note.as_deref(), Some("Kunde wünscht Rückruf wegen Montage")); // 9) Tour-Aggregat liefert die jüngste Zahlung; Einzel-Reset löscht sie. let tours = PgTourRepository::new(pool.clone()); let (tour_id, beleg): (Uuid, String) = sqlx::query_as("SELECT tour_id, erp_belegnummer FROM deliveries WHERE id = $1") .bind(d2).fetch_one(&pool).await.unwrap(); let details = tours.find_details_by_id(tour_id).await.unwrap().unwrap(); let latest = details.payments.iter().find(|p| p.delivery_id == d2).unwrap(); assert_eq!(latest.amount_cents, 7000); tours.reset_delivery_by_belegnummer(&beleg).await.unwrap(); let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM delivery_payments WHERE delivery_id = $1") .bind(d2).fetch_one(&pool).await.unwrap(); assert_eq!(left, 0); }