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