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

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