Files
Holzleitner---Backend--aktu…/crates/infrastructure/tests/payment_flow.rs
Dennis Nemec 695ac39c19 feat(abschluss): interne Notiz des Fahrers beim Abschluss
- CompleteDeliveryAcknowledgements.internalNote (optional, getrimmt,
  max. 2000 Zeichen) wird atomar mit dem Abschluss gespeichert
  (delivery_completions.internal_note)
- Bewusst keine delivery_notes-Zeile: loest weder die Kunden-Bestaetigung
  der Notizen aus noch erscheint sie im Notizen-Step der App
- Lieferbericht: eigener Abschnitt "Interne Notiz" mit Zeitpunkt und Fahrer
- Bericht: Betrag in "Betrag erhalten" ans Zeilenende (Helvetica setzt nach
  dem Euro-Zeichen zu eng)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:41:05 +02:00

183 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<i64>, Option<Uuid>) = 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<Uuid>) = 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<String> =
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);
}