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

@ -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})"
))