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