Files
Holzleitner---Backend--aktu…/crates/infrastructure/tests/payment_flow.rs
Dennis Nemec 954c5f52b2 feat(zahlung): Zahlungsabwicklung als eigenes Protokoll (POST /deliveries/{id}/payment)
- Neue Tabelle delivery_payments (append-only, idempotent ueber
  client_event_id): Methode + Code-Snapshot, server-seitig berechneter
  offener Betrag, Fahrer, Fahrzeug, Zeitpunkt
- Endpoint prueft unter Zeilen-Lock: Lieferung aktiv, Methode aktiv,
  offener Betrag > 0 und identisch mit dem vom Fahrer bestaetigten Betrag
- Offener Betrag als gemeinsamer Helper (open_amount_cents) fuer
  Zahlungsprotokoll und Abschluss
- Abschluss-Gate: gueltige protokollierte Zahlung erfuellt die
  Inkasso-Pflicht und liefert die Methode; delivery_completions.payment_id
  verknuepft den Abschluss mit der Zahlung. Altes payment_collected-Flag
  bleibt fuer aeltere App-Versionen gueltig
- Tour-Aggregat und Admin-Belegdetails liefern die juengste Zahlung
- Einzel-Reset loescht auch das Zahlungsprotokoll
- Integrationstest (ignored, braucht Wegwerf-DB) fuer Protokoll + Gate

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

175 lines
8.4 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,
}
}
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.
let d4 = seed(&pool, CASH).await;
completions.complete(input(d4, true)).await.unwrap();
// 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);
}