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:
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
33
crates/application/src/dto/payment.rs
Normal file
33
crates/application/src/dto/payment.rs
Normal 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,
|
||||
}
|
||||
@ -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.
|
||||
|
||||
34
crates/application/src/ports/delivery_payment_repository.rs
Normal file
34
crates/application/src/ports/delivery_payment_repository.rs
Normal 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>;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
55
crates/application/src/usecases/record_delivery_payment.rs
Normal file
55
crates/application/src/usecases/record_delivery_payment.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user