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:
@ -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})"
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user