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>
This commit is contained in:
Dennis Nemec
2026-09-25 14:04:35 +02:00
parent 5731fea501
commit 954c5f52b2
22 changed files with 741 additions and 58 deletions

View File

@ -0,0 +1,174 @@
//! 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);
}