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:
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
58
crates/infrastructure/src/persistence/open_amount.rs
Normal file
58
crates/infrastructure/src/persistence/open_amount.rs
Normal 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)
|
||||
}
|
||||
@ -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})"
|
||||
))
|
||||
|
||||
174
crates/infrastructure/tests/payment_flow.rs
Normal file
174
crates/infrastructure/tests/payment_flow.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user