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

@ -3,7 +3,8 @@
//! Eine Transaktion, ein Abschluss. Ablauf:
//! 1. `SELECT … FOR UPDATE` auf die Lieferung (Lock + aktueller State).
//! 2. Idempotenz: schon `completed` mit Abschluss-Zeile → Erfolg zurück.
//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt.
//! 3. Gates: `active`, alle scanbaren Positionen fertig, Notizen bestätigt,
//! Inkasso (protokollierte Zahlung oder Bestätigungs-Flag).
//! 4. `INSERT INTO delivery_completions` + `UPDATE deliveries SET state`.
//! 5. Frische `Delivery` bauen.
@ -19,6 +20,8 @@ use holzleitner_application::ports::{
};
use holzleitner_domain::{Address, Delivery, DeliveryState};
use super::open_amount::open_amount_cents;
pub struct PgDeliveryCompletionRepository {
pool: PgPool,
/// Fach-Zeitzone (`server.timezone`) für Kalendertag-Filter auf
@ -205,8 +208,30 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
));
}
// Offener Betrag (server-autoritativ, gemeinsame Formel mit dem
// Zahlungsprotokoll) und die jüngste protokollierte Zahlung. Diese gilt
// nur, solange ihr Betrag dem aktuellen offenen Betrag entspricht —
// eine spätere Gutschrift o. ä. macht sie ungültig.
let open_cents = open_amount_cents(&mut tx, delivery_id, row.prepaid_amount).await?;
let latest_payment: Option<(Uuid, Uuid, i64)> = sqlx::query_as(
r#"
SELECT id, payment_method_id, amount_cents
FROM delivery_payments
WHERE delivery_id = $1
ORDER BY recorded_at DESC, id DESC
LIMIT 1
"#,
)
.bind(delivery_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
let valid_payment =
latest_payment.filter(|(_, _, amount)| open_cents > 0 && *amount == open_cents);
// Gate 3: Zahlungsmethode-Override (falls gesetzt) muss existieren UND
// aktiv sein. `None` lässt die am Beleg hinterlegte Methode unangetastet.
// aktiv sein. Ohne Override gilt die Methode der gültigen protokollierten
// Zahlung, sonst die am Beleg hinterlegte.
let effective_payment_method_id = match input.payment_method_id {
Some(pm_id) => {
let active: Option<bool> =
@ -231,48 +256,16 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
Some(true) => pm_id,
}
}
None => row.payment_method_id,
None => valid_payment
.map(|(_, method_id, _)| method_id)
.unwrap_or(row.payment_method_id),
};
// Gate 4: Inkasso-Bestätigung. Besteht beim Abschluss ein offener
// Betrag (> 0) UND ist die Methode ein Vor-Ort-Inkasso (Bar/EC), muss
// der Fahrer bestätigt haben, dass kassiert wurde. „Auf Rechnung"
// (oder offen == 0) ⇒ kein Inkasso, keine Pflicht.
//
// Offener Betrag = Σ unit_price·(required − credited) − Anzahlung −
// Gutschrift — exakt dieselbe Formel wie App-Übersicht & PDF-Report.
let warenwert: f64 = sqlx::query_scalar(
r#"
SELECT COALESCE(
SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)),
0
)::float8
FROM delivery_items
WHERE delivery_id = $1
"#,
)
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0).
let credit_cents: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE((
SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at DESC
LIMIT 1
), 0)
"#,
)
.bind(delivery_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
// Gate 4: Inkasso. Besteht ein offener Betrag (> 0) UND ist die Methode
// ein Vor-Ort-Inkasso (Bar/EC), muss kassiert worden sein: entweder über
// eine gültige protokollierte Zahlung mit genau dieser Methode (Zahlungs-
// Step) oder — für ältere App-Versionen — über `payment_collected`.
// „Auf Rechnung" (oder offen == 0) ⇒ kein Inkasso, keine Pflicht.
let method_code: String =
sqlx::query_scalar("SELECT code FROM payment_methods WHERE id = $1")
.bind(effective_payment_method_id)
@ -280,12 +273,13 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
.await
.map_err(db)?;
let open_euros =
(warenwert - row.prepaid_amount - (credit_cents as f64) / 100.0).max(0.0);
let open_cents = (open_euros * 100.0).round() as i64;
let logged_payment_id = valid_payment
.filter(|(_, method_id, _)| *method_id == effective_payment_method_id)
.map(|(id, _, _)| id);
let requires_collection =
open_cents > 0 && (method_code == "cash" || method_code == "ec_card");
if requires_collection && !input.payment_collected {
let collected = input.payment_collected || logged_payment_id.is_some();
if requires_collection && !collected {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"offener Betrag nicht als kassiert bestätigt; Abschluss nicht möglich".into(),
@ -301,8 +295,8 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
delivery_id, customer_signature_path, driver_signature_path,
receipt_confirmed, notes_acknowledged, acknowledged_note_ids,
completed_by_personalnummer, completed_by_car_id,
payment_collected, collected_amount_cents
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
payment_collected, collected_amount_cents, payment_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
"#,
)
.bind(delivery_id)
@ -313,8 +307,9 @@ impl DeliveryCompletionRepository for PgDeliveryCompletionRepository {
.bind(&input.acknowledged_note_ids)
.bind(input.completed_by_personalnummer)
.bind(input.completed_by_car_id)
.bind(requires_collection && input.payment_collected)
.bind(requires_collection && collected)
.bind(collected_amount_cents)
.bind(logged_payment_id)
.execute(&mut *tx)
.await
.map_err(db)?;

View File

@ -0,0 +1,170 @@
//! Postgres-Implementierung des `DeliveryPaymentRepository`-Ports.
//!
//! Append-only: `record` hängt eine Zeile an `delivery_payments`. Der Betrag
//! wird unter Lock der Lieferung server-seitig berechnet und muss mit dem
//! vom Fahrer bestätigten Betrag übereinstimmen.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::DeliveryPaymentRepository;
use holzleitner_domain::DeliveryPayment;
use super::open_amount::open_amount_cents;
pub struct PgDeliveryPaymentRepository {
pool: PgPool,
}
impl PgDeliveryPaymentRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
#[derive(sqlx::FromRow)]
pub(crate) struct PaymentRow {
pub id: Uuid,
pub delivery_id: Uuid,
pub payment_method_id: Uuid,
pub payment_method_code: String,
pub amount_cents: i64,
pub recorded_by_personalnummer: i64,
pub recorded_by_car_id: Option<Uuid>,
pub recorded_at: DateTime<Utc>,
}
impl From<PaymentRow> for DeliveryPayment {
fn from(r: PaymentRow) -> Self {
DeliveryPayment {
id: r.id,
delivery_id: r.delivery_id,
payment_method_id: r.payment_method_id,
payment_method_code: r.payment_method_code,
amount_cents: r.amount_cents,
recorded_by_personalnummer: r.recorded_by_personalnummer,
recorded_by_car_id: r.recorded_by_car_id,
recorded_at: r.recorded_at,
}
}
}
pub(crate) const PAYMENT_COLUMNS: &str = "id, delivery_id, payment_method_id, payment_method_code, \
amount_cents, recorded_by_personalnummer, recorded_by_car_id, recorded_at";
#[async_trait]
impl DeliveryPaymentRepository for PgDeliveryPaymentRepository {
async fn record(
&self,
delivery_id: Uuid,
client_event_id: Uuid,
payment_method_id: Uuid,
expected_amount_cents: i64,
author_personalnummer: i64,
author_car_id: Option<Uuid>,
) -> Result<DeliveryPayment, ApplicationError> {
let mut tx = self.pool.begin().await.map_err(db)?;
// Idempotenz: bekannte client_event_id ⇒ vorhandenen Eintrag liefern.
let existing: Option<PaymentRow> = sqlx::query_as(&format!(
"SELECT {PAYMENT_COLUMNS} FROM delivery_payments WHERE client_event_id = $1"
))
.bind(client_event_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
if let Some(row) = existing {
tx.rollback().await.map_err(db)?;
if row.delivery_id != delivery_id {
return Err(ApplicationError::Validation(
"client_event_id belongs to another delivery".into(),
));
}
return Ok(row.into());
}
// Lieferung sperren: Status + Anzahlung für die Betragsberechnung.
let delivery: Option<(String, f64)> = sqlx::query_as(
"SELECT state, prepaid_amount::float8 FROM deliveries WHERE id = $1 FOR UPDATE",
)
.bind(delivery_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
let Some((state, prepaid_amount)) = delivery else {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::NotFound);
};
if state != "active" {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"delivery is not active; cannot record payment".into(),
));
}
let method: Option<(String, bool)> =
sqlx::query_as("SELECT code, active FROM payment_methods WHERE id = $1")
.bind(payment_method_id)
.fetch_optional(&mut *tx)
.await
.map_err(db)?;
let method_code = match method {
None => {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation("unknown payment method".into()));
}
Some((_, false)) => {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"payment method is not active".into(),
));
}
Some((code, true)) => code,
};
let open_cents = open_amount_cents(&mut tx, delivery_id, prepaid_amount).await?;
if open_cents == 0 {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(
"kein offener Betrag; es ist keine Zahlung abzuwickeln".into(),
));
}
if open_cents != expected_amount_cents {
tx.rollback().await.map_err(db)?;
return Err(ApplicationError::Validation(format!(
"offener Betrag hat sich geändert (Server: {open_cents} ct, App: \
{expected_amount_cents} ct); bitte Tour aktualisieren"
)));
}
let row: PaymentRow = sqlx::query_as(&format!(
r#"
INSERT INTO delivery_payments (
client_event_id, delivery_id, payment_method_id, payment_method_code,
amount_cents, recorded_by_personalnummer, recorded_by_car_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING {PAYMENT_COLUMNS}
"#
))
.bind(client_event_id)
.bind(delivery_id)
.bind(payment_method_id)
.bind(&method_code)
.bind(open_cents)
.bind(author_personalnummer)
.bind(author_car_id)
.fetch_one(&mut *tx)
.await
.map_err(db)?;
tx.commit().await.map_err(db)?;
Ok(row.into())
}
}

View File

@ -9,6 +9,8 @@ pub mod attachment_repository;
pub mod car_repository;
pub mod delivery_completion_repository;
pub mod delivery_credit_repository;
pub mod delivery_payment_repository;
mod open_amount;
pub mod delivery_note_repository;
pub mod delivery_report_job_repository;
pub mod delivery_repository;
@ -26,6 +28,7 @@ pub use attachment_repository::PgAttachmentRepository;
pub use car_repository::PgCarRepository;
pub use delivery_completion_repository::PgDeliveryCompletionRepository;
pub use delivery_credit_repository::PgDeliveryCreditRepository;
pub use delivery_payment_repository::PgDeliveryPaymentRepository;
pub use delivery_note_repository::PgDeliveryNoteRepository;
pub use delivery_report_job_repository::PgDeliveryReportJobRepository;
pub use delivery_repository::PgDeliveryRepository;

View File

@ -0,0 +1,58 @@
//! Server-autoritative Berechnung des offenen Betrags einer Lieferung.
//!
//! Offener Betrag = Σ unit_price·(required − credited) − Anzahlung −
//! aktuelle Geld-Gutschrift, nie negativ — exakt dieselbe Formel wie
//! App-Übersicht und PDF-Report. Gemeinsam genutzt von Zahlungsprotokoll
//! und Abschluss, damit beide garantiert denselben Betrag sehen.
use sqlx::PgConnection;
use uuid::Uuid;
use holzleitner_application::error::ApplicationError;
fn db<E: std::fmt::Display>(e: E) -> ApplicationError {
ApplicationError::Repository(e.to_string())
}
/// Offener Betrag in Cent. Innerhalb der Transaktion des Aufrufers lesen,
/// die die Lieferungszeile bereits gelockt hat.
pub(crate) async fn open_amount_cents(
conn: &mut PgConnection,
delivery_id: Uuid,
prepaid_amount: f64,
) -> Result<i64, ApplicationError> {
let warenwert: f64 = sqlx::query_scalar(
r#"
SELECT COALESCE(
SUM(unit_price * GREATEST(required_quantity - credited_quantity, 0)),
0
)::float8
FROM delivery_items
WHERE delivery_id = $1
"#,
)
.bind(delivery_id)
.fetch_one(&mut *conn)
.await
.map_err(db)?;
// Aktuelle Geld-Gutschrift: jüngstes Audit-Event ('set' → Betrag, sonst 0).
let credit_cents: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE((
SELECT CASE WHEN action = 'set' THEN amount_cents ELSE 0 END
FROM delivery_credit_audit
WHERE delivery_id = $1
ORDER BY recorded_at DESC, id DESC
LIMIT 1
), 0)
"#,
)
.bind(delivery_id)
.fetch_one(&mut *conn)
.await
.map_err(db)?;
let open_euros = (warenwert - prepaid_amount - (credit_cents as f64) / 100.0).max(0.0);
Ok((open_euros * 100.0).round() as i64)
}

View File

@ -27,10 +27,12 @@ use holzleitner_application::error::ApplicationError;
use holzleitner_application::ports::TourRepository;
use holzleitner_domain::{
Address, Article, ContactChannel, ContactKind, ContactRole, ContactSource, Customer,
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryServiceValue,
DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
CustomerContact, Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryPayment,
DeliveryServiceValue, DeliveryState, ScanState, ScanStatus, Service, ServiceKind, Tour, Warehouse,
};
use super::delivery_payment_repository::{PAYMENT_COLUMNS, PaymentRow};
pub struct PgTourRepository {
pool: PgPool,
}
@ -681,6 +683,23 @@ impl TourRepository for PgTourRepository {
.filter_map(map_credit)
.collect::<Vec<_>>();
// 8b. Jüngste protokollierte Zahlungsabwicklung pro Lieferung.
let payments = sqlx::query_as::<_, PaymentRow>(&format!(
r#"
SELECT DISTINCT ON (delivery_id) {PAYMENT_COLUMNS}
FROM delivery_payments
WHERE delivery_id = ANY($1)
ORDER BY delivery_id, recorded_at DESC, id DESC
"#
))
.bind(&delivery_ids)
.fetch_all(&self.pool)
.await
.map_err(db)?
.into_iter()
.map(DeliveryPayment::from)
.collect::<Vec<_>>();
// 9. Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
// Phase 4.
let services = sqlx::query_as::<_, ServiceRow>(
@ -764,6 +783,7 @@ impl TourRepository for PgTourRepository {
warehouses,
notes,
credits,
payments,
services,
delivery_services,
contact_sources,
@ -904,7 +924,8 @@ impl TourRepository for PgTourRepository {
async fn delete_all_tours(&self) -> Result<u64, ApplicationError> {
// DELETE FROM tours cascadet per FK auf deliveries → delivery_items →
// scan_audit, delivery_notes, delivery_credit_audit, delivery_services,
// scan_audit, delivery_notes, delivery_credit_audit, delivery_payments,
// delivery_services,
// delivery_completions, attachments, delivery_contact_persons.
let res = sqlx::query("DELETE FROM tours")
.execute(&self.pool)
@ -951,6 +972,15 @@ impl TourRepository for PgTourRepository {
.await
.map_err(db)?;
// Nach den Abschlüssen (FK payment_id), vor dem Status-Reset.
sqlx::query(&format!(
"DELETE FROM delivery_payments WHERE delivery_id IN ({BY_BELEG})"
))
.bind(belegnummer)
.execute(&mut *tx)
.await
.map_err(db)?;
sqlx::query(&format!(
"DELETE FROM delivery_credit_audit WHERE delivery_id IN ({BY_BELEG})"
))

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);
}