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

@ -8,7 +8,7 @@
use serde::Serialize;
use holzleitner_domain::{
Article, ContactChannel, ContactSource, Customer, CustomerContact, DeliveryCredit,
Article, ContactChannel, ContactSource, Customer, CustomerContact, DeliveryCredit, DeliveryPayment,
DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse,
};
@ -35,6 +35,8 @@ pub struct DeliveryDetails {
pub notes: Vec<DeliveryNote>,
/// Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv).
pub credit: Option<DeliveryCredit>,
/// Jüngste protokollierte Zahlungsabwicklung (`None` = keine).
pub payment: Option<DeliveryPayment>,
/// Aktive Service-Definitionen (Stammdaten).
pub services: Vec<Service>,
/// Für diese Lieferung gesetzte Service-Werte.

View File

@ -18,6 +18,7 @@ pub mod delivery_details;
pub mod delivery_report;
pub mod delivery_order;
pub mod note;
pub mod payment;
pub mod payment_method;
pub mod scan;
pub mod service;
@ -42,6 +43,7 @@ pub use delivery_order::{
pub use note::{
CreateDeliveryNoteRequest, DeliveryNoteResponse, UpdateDeliveryNoteRequest,
};
pub use payment::{DeliveryPaymentResponse, RecordDeliveryPaymentRequest};
pub use payment_method::{
CreatePaymentMethodRequest, PaymentMethodResponse, PaymentMethodsList,
UpdatePaymentMethodRequest,

View File

@ -0,0 +1,33 @@
//! Request/Response für `POST /deliveries/{id}/payment` — das Protokoll der
//! Zahlungsabwicklung („Abkassieren"). Append-only, idempotent über
//! `client_event_id`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use holzleitner_domain::DeliveryPayment;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct RecordDeliveryPaymentRequest {
/// Idempotenz-Schlüssel — pro Bestätigung genau einmal vergeben.
pub client_event_id: Uuid,
/// Gewählte Zahlungsmethode. Muss existieren und aktiv sein.
pub payment_method_id: Uuid,
/// Betrag in Cent, den der Fahrer in der App gesehen und bestätigt hat.
/// Der Server berechnet den offenen Betrag selbst und lehnt ab, wenn er
/// davon abweicht — so landet nie ein Betrag im Protokoll, den der
/// Fahrer nicht gesehen hat.
pub expected_amount_cents: i64,
/// Fahrzeug des Akteurs (Audit-Spur). Muss zum Account gehören.
#[serde(default)]
pub author_car_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct DeliveryPaymentResponse {
pub payment: DeliveryPayment,
}

View File

@ -11,7 +11,7 @@ use serde::Serialize;
use holzleitner_domain::{
Article, ContactChannel, ContactSource, Customer, CustomerContact, Delivery, DeliveryCredit,
DeliveryItem, DeliveryNote, DeliveryServiceValue, Service, Tour, Warehouse,
DeliveryItem, DeliveryNote, DeliveryPayment, DeliveryServiceValue, Service, Tour, Warehouse,
};
#[derive(Debug, Clone, Serialize)]
@ -31,6 +31,9 @@ pub struct TourDetails {
/// Aktuelle Betrags-Gutschriften (jüngster Stand pro Lieferung), nur für
/// Lieferungen, deren letztes Ereignis `set` war. Join per `delivery_id`.
pub credits: Vec<DeliveryCredit>,
/// Jüngste protokollierte Zahlungsabwicklung pro Lieferung (nur
/// Lieferungen mit mindestens einem Eintrag). Join per `delivery_id`.
pub payments: Vec<DeliveryPayment>,
/// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus
/// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus
/// dem Tour-Aggregat hat.

View File

@ -0,0 +1,34 @@
//! Port für das Zahlungsprotokoll (append-only).
//!
//! Schreibseite: jede Bestätigung hängt eine Zeile an. Die Leseseite
//! (jüngster Eintrag pro Lieferung) läuft als Teil des Tour-Aggregats
//! (`TourDetails.payments`), nicht über diesen Port.
use async_trait::async_trait;
use uuid::Uuid;
use holzleitner_domain::DeliveryPayment;
use crate::error::ApplicationError;
#[async_trait]
pub trait DeliveryPaymentRepository: Send + Sync {
/// Protokolliert eine Zahlungsabwicklung und liefert den Eintrag zurück.
///
/// Unter Zeilen-Lock der Lieferung geprüft:
/// * Lieferung existiert (`NotFound`) und ist `active` (`Validation`),
/// * Methode existiert und ist aktiv (`Validation`),
/// * offener Betrag > 0 (`Validation`, sonst gibt es nichts abzuwickeln),
/// * offener Betrag == `expected_amount_cents` (`Validation`).
///
/// Idempotent über `client_event_id`: bekannte Id ⇒ vorhandener Eintrag.
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>;
}

View File

@ -11,6 +11,7 @@ pub mod attachment_storage;
pub mod auth_service;
pub mod car_repository;
pub mod delivery_credit_repository;
pub mod delivery_payment_repository;
pub mod delivery_note_repository;
pub mod delivery_report_job_repository;
pub mod delivery_report_renderer;
@ -39,6 +40,7 @@ pub use attachment_storage::{AttachmentStorage, PreviewImage};
pub use auth_service::{AuthError, AuthService, Claims};
pub use car_repository::CarRepository;
pub use delivery_credit_repository::DeliveryCreditRepository;
pub use delivery_payment_repository::DeliveryPaymentRepository;
pub use delivery_note_repository::DeliveryNoteRepository;
pub use delivery_report_job_repository::{
DeliveryReportJobRepository, ReportJob, ReportJobStatus,

View File

@ -43,6 +43,7 @@ impl GetDeliveryDetailsUseCase {
warehouses,
notes,
credits,
payments,
services,
delivery_services,
contact_sources,
@ -85,6 +86,7 @@ impl GetDeliveryDetailsUseCase {
.filter(|n| n.delivery_id == delivery_id)
.collect();
let credit = credits.into_iter().find(|c| c.delivery_id == delivery_id);
let payment = payments.into_iter().find(|p| p.delivery_id == delivery_id);
let delivery_services = delivery_services
.into_iter()
.filter(|s| s.delivery_id == delivery_id)
@ -108,6 +110,7 @@ impl GetDeliveryDetailsUseCase {
warehouses,
notes,
credit,
payment,
services,
delivery_services,
contact_sources,

View File

@ -7,6 +7,7 @@
pub mod apply_delivery_action;
pub mod apply_delivery_credit_event;
pub mod record_delivery_payment;
pub mod apply_scans;
pub mod cars;
pub mod complete_delivery;
@ -37,6 +38,7 @@ pub mod upload_delivery_note_image;
pub use apply_delivery_action::ApplyDeliveryActionUseCase;
pub use apply_delivery_credit_event::ApplyDeliveryCreditEventUseCase;
pub use record_delivery_payment::RecordDeliveryPaymentUseCase;
pub use apply_scans::ApplyScansUseCase;
pub use cars::{
AssignCarToDeliveryUseCase, CreateMyCarUseCase, ListMyCarsUseCase, UpdateMyCarUseCase,

View File

@ -0,0 +1,55 @@
use std::sync::Arc;
use uuid::Uuid;
use holzleitner_domain::DeliveryPayment;
use crate::dto::RecordDeliveryPaymentRequest;
use crate::error::ApplicationError;
use crate::ports::{CarRepository, DeliveryPaymentRepository};
/// Protokolliert die Zahlungsabwicklung einer Lieferung („Abkassieren").
///
/// Fachliche Vor-Prüfungen ohne DB hier; Lieferungs-Status, Methode und der
/// server-seitig berechnete offene Betrag prüft das Repository unter Lock.
pub struct RecordDeliveryPaymentUseCase {
repository: Arc<dyn DeliveryPaymentRepository>,
cars: Arc<dyn CarRepository>,
}
impl RecordDeliveryPaymentUseCase {
pub fn new(
repository: Arc<dyn DeliveryPaymentRepository>,
cars: Arc<dyn CarRepository>,
) -> Self {
Self { repository, cars }
}
pub async fn execute(
&self,
delivery_id: Uuid,
author_personalnummer: i64,
request: RecordDeliveryPaymentRequest,
) -> Result<DeliveryPayment, ApplicationError> {
if request.expected_amount_cents <= 0 {
return Err(ApplicationError::Validation(
"expected_amount_cents must be > 0".into(),
));
}
if let Some(car_id) = request.author_car_id {
self.cars
.assert_owned_by_account(&[car_id], author_personalnummer)
.await?;
}
self.repository
.record(
delivery_id,
request.client_event_id,
request.payment_method_id,
request.expected_amount_cents,
author_personalnummer,
request.author_car_id,
)
.await
}
}