From 954c5f52b248a94e53e09ef478d52a1a2466fe32 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 14:04:35 +0200 Subject: [PATCH] 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 --- crates/api/src/main.rs | 9 +- crates/api/src/openapi.rs | 4 + crates/api/src/routes/deliveries.rs | 44 ++++- crates/api/src/state.rs | 2 + .../application/src/dto/delivery_details.rs | 4 +- crates/application/src/dto/mod.rs | 2 + crates/application/src/dto/payment.rs | 33 ++++ crates/application/src/dto/tour_details.rs | 5 +- .../src/ports/delivery_payment_repository.rs | 34 ++++ crates/application/src/ports/mod.rs | 2 + .../src/usecases/get_delivery_details.rs | 3 + crates/application/src/usecases/mod.rs | 2 + .../src/usecases/record_delivery_payment.rs | 55 ++++++ crates/domain/src/delivery.rs | 23 +++ crates/domain/src/lib.rs | 3 +- .../delivery_completion_repository.rs | 93 +++++----- .../delivery_payment_repository.rs | 170 +++++++++++++++++ crates/infrastructure/src/persistence/mod.rs | 3 + .../src/persistence/open_amount.rs | 58 ++++++ .../src/persistence/tour_repository.rs | 36 +++- crates/infrastructure/tests/payment_flow.rs | 174 ++++++++++++++++++ migrations/0032_delivery_payments.sql | 40 ++++ 22 files changed, 741 insertions(+), 58 deletions(-) create mode 100644 crates/application/src/dto/payment.rs create mode 100644 crates/application/src/ports/delivery_payment_repository.rs create mode 100644 crates/application/src/usecases/record_delivery_payment.rs create mode 100644 crates/infrastructure/src/persistence/delivery_payment_repository.rs create mode 100644 crates/infrastructure/src/persistence/open_amount.rs create mode 100644 crates/infrastructure/tests/payment_flow.rs create mode 100644 migrations/0032_delivery_payments.sql diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index d7ddad5..9fa7605 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -33,6 +33,7 @@ use axum::Router; use axum::middleware::from_fn_with_state; use holzleitner_application::usecases::{ ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase, + RecordDeliveryPaymentUseCase, AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, @@ -64,7 +65,7 @@ use holzleitner_infrastructure::report::{ }; use holzleitner_infrastructure::persistence::{ PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository, - PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository, + PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryPaymentRepository, PgDeliveryReportJobRepository, PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgReviewRepository, PgScanRepository, PgServiceRepository, PgSyncRunRepository, PgTourRepository, PoolConfig, connect_and_migrate, @@ -215,6 +216,7 @@ pub(crate) async fn run_app( let car_repository = Arc::new(PgCarRepository::new(pool.clone())); let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone())); let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone())); + let delivery_payment_repository = Arc::new(PgDeliveryPaymentRepository::new(pool.clone())); let delivery_completion_repository = Arc::new(PgDeliveryCompletionRepository::new( pool.clone(), cfg.server.timezone, @@ -412,6 +414,10 @@ pub(crate) async fn run_app( delivery_credit_repository, car_repository.clone(), )); + let record_delivery_payment = Arc::new(RecordDeliveryPaymentUseCase::new( + delivery_payment_repository, + car_repository.clone(), + )); let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new( delivery_note_repository.clone(), car_repository.clone(), @@ -486,6 +492,7 @@ pub(crate) async fn run_app( list_pending_reviews, resolve_review, apply_delivery_credit_event, + record_delivery_payment, create_delivery_note, update_delivery_note, delete_delivery_note, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 13b6953..9d1562a 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -36,6 +36,7 @@ use utoipa::openapi::security::{ crate::routes::deliveries::delete_note, crate::routes::deliveries::upload_note_image, crate::routes::deliveries::apply_credit, + crate::routes::deliveries::record_payment, crate::routes::attachments::get_attachment, crate::routes::deliveries::assign_car, crate::routes::deliveries::set_service, @@ -78,6 +79,7 @@ use utoipa::openapi::security::{ holzleitner_domain::DeliveryItem, holzleitner_domain::DeliveryNote, holzleitner_domain::DeliveryCredit, + holzleitner_domain::DeliveryPayment, holzleitner_domain::DeliveryState, holzleitner_domain::ScanState, holzleitner_domain::ScanStatus, @@ -114,6 +116,8 @@ use utoipa::openapi::security::{ holzleitner_application::dto::CreditAction, holzleitner_application::dto::DeliveryCreditEventRequest, holzleitner_application::dto::DeliveryCreditResponse, + holzleitner_application::dto::RecordDeliveryPaymentRequest, + holzleitner_application::dto::DeliveryPaymentResponse, holzleitner_application::dto::CreateCarRequest, holzleitner_application::dto::UpdateCarRequest, holzleitner_application::dto::CarResponse, diff --git a/crates/api/src/routes/deliveries.rs b/crates/api/src/routes/deliveries.rs index d2ff0ed..96e1185 100644 --- a/crates/api/src/routes/deliveries.rs +++ b/crates/api/src/routes/deliveries.rs @@ -6,8 +6,9 @@ use axum::routing::{patch, post, put}; use holzleitner_application::dto::{ AssignCarRequest, CancelDeliveryRequest, CompleteDeliveryAcknowledgements, CreateDeliveryNoteRequest, DeliveryCreditEventRequest, DeliveryCreditResponse, - DeliveryNoteResponse, DeliveryResponse, DeliveryServiceResponse, HoldDeliveryRequest, - SetDeliveryServiceRequest, UpdateDeliveryNoteRequest, + DeliveryNoteResponse, DeliveryPaymentResponse, DeliveryResponse, DeliveryServiceResponse, + HoldDeliveryRequest, RecordDeliveryPaymentRequest, SetDeliveryServiceRequest, + UpdateDeliveryNoteRequest, }; use holzleitner_application::error::ApplicationError; use holzleitner_application::ports::DeliveryAction; @@ -39,6 +40,7 @@ pub fn router() -> Router { .route("/deliveries/{delivery_id}/hold", post(hold)) .route("/deliveries/{delivery_id}/resume", post(resume)) .route("/deliveries/{delivery_id}/cancel", post(cancel)) + .route("/deliveries/{delivery_id}/payment", post(record_payment)) .route("/deliveries/{delivery_id}/notes", post(create_note)) .route( "/deliveries/{delivery_id}/notes/{note_id}", @@ -475,6 +477,44 @@ pub async fn apply_credit( Ok(Json(DeliveryCreditResponse { credit })) } +/// Protokolliert die Zahlungsabwicklung („Abkassieren") einer Lieferung. +/// Append-only, idempotent über `clientEventId`. Der Server berechnet den +/// offenen Betrag selbst; er muss > 0 sein und `expectedAmountCents` +/// entsprechen. Nur bei aktiver Lieferung und aktiver Zahlungsmethode. +#[utoipa::path( + post, + path = "/deliveries/{delivery_id}/payment", + tag = "deliveries", + params(("delivery_id" = Uuid, Path)), + request_body = RecordDeliveryPaymentRequest, + responses( + (status = 200, description = "Zahlung protokolliert", body = DeliveryPaymentResponse), + (status = 400, description = "Lieferung nicht aktiv, Methode ungültig, kein offener Betrag oder Betrag abweichend"), + (status = 401, description = "Authentifizierung fehlgeschlagen"), + (status = 404, description = "Lieferung nicht gefunden") + ), + security(("bearer_auth" = [])) +)] +pub async fn record_payment( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(delivery_id): Path, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!( + actor = claims.personalnummer, + %delivery_id, + payment_method_id = %req.payment_method_id, + expected_amount_cents = req.expected_amount_cents, + "delivery.record_payment" + ); + let payment = state + .record_delivery_payment + .execute(delivery_id, claims.personalnummer, req) + .await?; + Ok(Json(DeliveryPaymentResponse { payment })) +} + /// Setzt (Upsert) den Wert eines Service für eine Lieferung. Genau das zum /// Service-Typ passende Feld (`boolValue`/`numericValue`) muss gesetzt sein; /// numerische Werte werden gegen min/max geprüft. Nur bei aktiver Lieferung. diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index b1d6a66..9270cb9 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use holzleitner_application::ports::AuthService; use holzleitner_application::usecases::{ ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase, + RecordDeliveryPaymentUseCase, AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase, CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase, DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase, @@ -67,6 +68,7 @@ pub struct AppState { pub list_pending_reviews: Arc, pub resolve_review: Arc, pub apply_delivery_credit_event: Arc, + pub record_delivery_payment: Arc, pub create_delivery_note: Arc, pub update_delivery_note: Arc, pub delete_delivery_note: Arc, diff --git a/crates/application/src/dto/delivery_details.rs b/crates/application/src/dto/delivery_details.rs index 07475b9..bd708a1 100644 --- a/crates/application/src/dto/delivery_details.rs +++ b/crates/application/src/dto/delivery_details.rs @@ -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, /// Aktuelle Betrags-Gutschrift (`null`, wenn keine aktiv). pub credit: Option, + /// Jüngste protokollierte Zahlungsabwicklung (`None` = keine). + pub payment: Option, /// Aktive Service-Definitionen (Stammdaten). pub services: Vec, /// Für diese Lieferung gesetzte Service-Werte. diff --git a/crates/application/src/dto/mod.rs b/crates/application/src/dto/mod.rs index 4b6d07d..e020bc2 100644 --- a/crates/application/src/dto/mod.rs +++ b/crates/application/src/dto/mod.rs @@ -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, diff --git a/crates/application/src/dto/payment.rs b/crates/application/src/dto/payment.rs new file mode 100644 index 0000000..34c2339 --- /dev/null +++ b/crates/application/src/dto/payment.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryPaymentResponse { + pub payment: DeliveryPayment, +} diff --git a/crates/application/src/dto/tour_details.rs b/crates/application/src/dto/tour_details.rs index 2849d0d..867f59b 100644 --- a/crates/application/src/dto/tour_details.rs +++ b/crates/application/src/dto/tour_details.rs @@ -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, + /// Jüngste protokollierte Zahlungsabwicklung pro Lieferung (nur + /// Lieferungen mit mindestens einem Eintrag). Join per `delivery_id`. + pub payments: Vec, /// Aktive Service-Definitionen (Stammdaten) — die App rendert daraus /// Phase 4. Bewusst hier mitgeliefert, damit die Detailseite alles aus /// dem Tour-Aggregat hat. diff --git a/crates/application/src/ports/delivery_payment_repository.rs b/crates/application/src/ports/delivery_payment_repository.rs new file mode 100644 index 0000000..6349518 --- /dev/null +++ b/crates/application/src/ports/delivery_payment_repository.rs @@ -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, + ) -> Result; +} diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 1b2c5f1..a16e956 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -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, diff --git a/crates/application/src/usecases/get_delivery_details.rs b/crates/application/src/usecases/get_delivery_details.rs index c6e8d75..1f5b24f 100644 --- a/crates/application/src/usecases/get_delivery_details.rs +++ b/crates/application/src/usecases/get_delivery_details.rs @@ -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, diff --git a/crates/application/src/usecases/mod.rs b/crates/application/src/usecases/mod.rs index 1ecdf48..0624751 100644 --- a/crates/application/src/usecases/mod.rs +++ b/crates/application/src/usecases/mod.rs @@ -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, diff --git a/crates/application/src/usecases/record_delivery_payment.rs b/crates/application/src/usecases/record_delivery_payment.rs new file mode 100644 index 0000000..487cd8c --- /dev/null +++ b/crates/application/src/usecases/record_delivery_payment.rs @@ -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, + cars: Arc, +} + +impl RecordDeliveryPaymentUseCase { + pub fn new( + repository: Arc, + cars: Arc, + ) -> Self { + Self { repository, cars } + } + + pub async fn execute( + &self, + delivery_id: Uuid, + author_personalnummer: i64, + request: RecordDeliveryPaymentRequest, + ) -> Result { + 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 + } +} diff --git a/crates/domain/src/delivery.rs b/crates/domain/src/delivery.rs index 39090c1..2c06677 100644 --- a/crates/domain/src/delivery.rs +++ b/crates/domain/src/delivery.rs @@ -204,3 +204,26 @@ pub struct DeliveryCredit { pub amount_cents: i64, pub reason: String, } + +/// Protokollierte Zahlungsabwicklung („Abkassieren") einer Lieferung. +/// +/// Append-only: jede Bestätigung des Fahrers ist ein eigener Eintrag; im +/// Tour-Aggregat wird pro Lieferung nur der jüngste mitgeliefert. Der Betrag +/// ist der server-seitig berechnete offene Betrag zum Zeitpunkt der +/// Bestätigung. Weicht der aktuelle offene Betrag davon ab (z. B. nach einer +/// neuen Gutschrift), ist der Eintrag veraltet und muss erneuert werden. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeliveryPayment { + pub id: Uuid, + pub delivery_id: Uuid, + pub payment_method_id: Uuid, + /// Snapshot des Methoden-Codes (`cash`, `ec_card`, `invoice`, …). + pub payment_method_code: String, + /// Abgewickelter Betrag in Cent (offener Betrag bei Bestätigung). + pub amount_cents: i64, + pub recorded_by_personalnummer: i64, + pub recorded_by_car_id: Option, + pub recorded_at: DateTime, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index e4628a8..9936659 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -37,7 +37,8 @@ pub use common::Address; pub use contact::{ContactChannel, ContactKind, ContactRole, ContactSource}; pub use customer::{Customer, CustomerContact}; pub use delivery::{ - Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryState, ScanState, ScanStatus, + Delivery, DeliveryCredit, DeliveryItem, DeliveryNote, DeliveryPayment, DeliveryState, ScanState, + ScanStatus, }; pub use payment::PaymentMethod; pub use process_state::{DeliveryPhase, DeliveryProcessState}; diff --git a/crates/infrastructure/src/persistence/delivery_completion_repository.rs b/crates/infrastructure/src/persistence/delivery_completion_repository.rs index e2be472..de20508 100644 --- a/crates/infrastructure/src/persistence/delivery_completion_repository.rs +++ b/crates/infrastructure/src/persistence/delivery_completion_repository.rs @@ -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 = @@ -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)?; diff --git a/crates/infrastructure/src/persistence/delivery_payment_repository.rs b/crates/infrastructure/src/persistence/delivery_payment_repository.rs new file mode 100644 index 0000000..24c9f0e --- /dev/null +++ b/crates/infrastructure/src/persistence/delivery_payment_repository.rs @@ -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: 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, + pub recorded_at: DateTime, +} + +impl From 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, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(db)?; + + // Idempotenz: bekannte client_event_id ⇒ vorhandenen Eintrag liefern. + let existing: Option = 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()) + } +} diff --git a/crates/infrastructure/src/persistence/mod.rs b/crates/infrastructure/src/persistence/mod.rs index 78c0a8f..62613b9 100644 --- a/crates/infrastructure/src/persistence/mod.rs +++ b/crates/infrastructure/src/persistence/mod.rs @@ -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; diff --git a/crates/infrastructure/src/persistence/open_amount.rs b/crates/infrastructure/src/persistence/open_amount.rs new file mode 100644 index 0000000..a7ed1f7 --- /dev/null +++ b/crates/infrastructure/src/persistence/open_amount.rs @@ -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: 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 { + 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) +} diff --git a/crates/infrastructure/src/persistence/tour_repository.rs b/crates/infrastructure/src/persistence/tour_repository.rs index 21a1ceb..4688cd8 100644 --- a/crates/infrastructure/src/persistence/tour_repository.rs +++ b/crates/infrastructure/src/persistence/tour_repository.rs @@ -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::>(); + // 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::>(); + // 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 { // 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})" )) diff --git a/crates/infrastructure/tests/payment_flow.rs b/crates/infrastructure/tests/payment_flow.rs new file mode 100644 index 0000000..f81d6ea --- /dev/null +++ b/crates/infrastructure/tests/payment_flow.rs @@ -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, Option) = 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) = 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); +} diff --git a/migrations/0032_delivery_payments.sql b/migrations/0032_delivery_payments.sql new file mode 100644 index 0000000..7fbd17d --- /dev/null +++ b/migrations/0032_delivery_payments.sql @@ -0,0 +1,40 @@ +-- 0032_delivery_payments.sql +-- +-- Zahlungsprotokoll („Abkassieren") als eigener Schritt vor dem Abschluss. +-- +-- Bisher gab es nur das Ja/Nein-Feld `delivery_completions.payment_collected`, +-- gesetzt erst mit den Unterschriften. Jetzt wickelt der Fahrer die Zahlung in +-- einem eigenen Step ab; jede Bestätigung ist eine eigene Zeile (append-only, +-- analog `delivery_credit_audit`). Der aktuelle Stand einer Lieferung ist die +-- jüngste Zeile. +-- +-- * amount_cents — server-seitig berechneter offener Betrag zum +-- Zeitpunkt der Bestätigung (nicht vom Client +-- übernommen). Ändert sich danach der offene Betrag +-- (z. B. neue Gutschrift), passt die Zeile nicht mehr +-- und der Abschluss verlangt eine neue Abwicklung. +-- * payment_method_code — Snapshot des Methoden-Codes (cash/ec_card/invoice), +-- damit das Protokoll auch nach Umbenennen/Deaktivieren +-- der Stammdaten lesbar bleibt. +-- +-- Idempotenz: client_event_id ist UNIQUE (Netz-Retry erzeugt keine Dublette). + +CREATE TABLE delivery_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_event_id UUID NOT NULL UNIQUE, + delivery_id UUID NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE, + payment_method_id UUID NOT NULL REFERENCES payment_methods(id) ON DELETE RESTRICT, + payment_method_code TEXT NOT NULL, + amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0), + recorded_by_personalnummer BIGINT NOT NULL, + recorded_by_car_id UUID, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX delivery_payments_delivery + ON delivery_payments (delivery_id, recorded_at DESC); + +-- Verknüpfung Abschluss → die Zahlung, die beim Abschluss gültig war. +-- NULL bei Abschlüssen ohne Zahlungsprotokoll (alt, offen == 0, alte App). +ALTER TABLE delivery_completions + ADD COLUMN payment_id UUID REFERENCES delivery_payments(id) ON DELETE SET NULL;