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

@ -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,

View File

@ -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,

View File

@ -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<AppState> {
.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<AppState>,
AuthenticatedUser(claims): AuthenticatedUser,
Path(delivery_id): Path<Uuid>,
Json(req): Json<RecordDeliveryPaymentRequest>,
) -> Result<Json<DeliveryPaymentResponse>, 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.

View File

@ -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<ListPendingReviewsUseCase>,
pub resolve_review: Arc<ResolveReviewUseCase>,
pub apply_delivery_credit_event: Arc<ApplyDeliveryCreditEventUseCase>,
pub record_delivery_payment: Arc<RecordDeliveryPaymentUseCase>,
pub create_delivery_note: Arc<CreateDeliveryNoteUseCase>,
pub update_delivery_note: Arc<UpdateDeliveryNoteUseCase>,
pub delete_delivery_note: Arc<DeleteDeliveryNoteUseCase>,

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

View File

@ -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<Uuid>,
pub recorded_at: DateTime<Utc>,
}

View File

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

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