use super::map_db_error;
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use sqlx::FromRow;
use sqlx::postgres::PgPool;
use stateset_core::{
CommerceError, CreatePaymentObligation, CurrencyCode, PaymentObligation,
PaymentObligationDashboard, PaymentObligationFilter, PaymentObligationId,
PaymentObligationRepository, PaymentObligationStatus, Result,
};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct PgPaymentObligationRepository {
pool: PgPool,
}
#[derive(FromRow)]
struct PaymentObligationRow {
id: Uuid,
number: String,
supplier_id: Uuid,
purchase_order_id: Option<Uuid>,
amount: Decimal,
amount_paid: Decimal,
currency: String,
due_date: NaiveDate,
status: String,
linked_bill_ids: serde_json::Value,
notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl PgPaymentObligationRepository {
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
fn row_to_obligation(row: PaymentObligationRow) -> Result<PaymentObligation> {
let currency: CurrencyCode = row.currency.parse().map_err(|e| {
CommerceError::DatabaseError(format!(
"Invalid payment_obligation.currency '{}': {}",
row.currency, e
))
})?;
let status: PaymentObligationStatus = row.status.parse().map_err(|e| {
CommerceError::DatabaseError(format!(
"Invalid payment_obligation.status '{}': {}",
row.status, e
))
})?;
let linked_bill_ids: Vec<Uuid> =
serde_json::from_value(row.linked_bill_ids).map_err(|e| {
CommerceError::DatabaseError(format!(
"Invalid payment_obligation.linked_bill_ids: {e}"
))
})?;
Ok(PaymentObligation {
id: row.id.into(),
number: row.number,
supplier_id: row.supplier_id,
purchase_order_id: row.purchase_order_id,
amount: row.amount,
amount_paid: row.amount_paid,
currency,
due_date: row.due_date,
status,
linked_bill_ids,
notes: row.notes,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
async fn fetch_async(&self, id: Uuid) -> Result<Option<PaymentObligation>> {
let row = sqlx::query_as::<_, PaymentObligationRow>(
"SELECT * FROM payment_obligations WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(map_db_error)?;
row.map(Self::row_to_obligation).transpose()
}
async fn fetch_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: Uuid,
) -> Result<PaymentObligation> {
let row = sqlx::query_as::<_, PaymentObligationRow>(
"SELECT * FROM payment_obligations WHERE id = $1 FOR UPDATE",
)
.bind(id)
.fetch_optional(tx.as_mut())
.await
.map_err(map_db_error)?
.ok_or(CommerceError::NotFound)?;
Self::row_to_obligation(row)
}
pub async fn create_async(&self, input: CreatePaymentObligation) -> Result<PaymentObligation> {
if input.amount <= Decimal::ZERO {
return Err(CommerceError::ValidationError(
"payment obligation amount must be positive".into(),
));
}
let id = PaymentObligationId::new();
let id_str = id.to_string();
let number = format!("OBL-{}", &id_str[..8]);
let now = Utc::now();
let currency = input.currency.unwrap_or(CurrencyCode::USD);
sqlx::query(
"INSERT INTO payment_obligations (id, number, supplier_id, purchase_order_id, amount, amount_paid, currency, due_date, status, linked_bill_ids, notes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 0, $6, $7, 'pending', '[]'::jsonb, $8, $9, $9)",
)
.bind(Uuid::from(id))
.bind(&number)
.bind(input.supplier_id)
.bind(input.purchase_order_id)
.bind(input.amount)
.bind(currency.to_string())
.bind(input.due_date)
.bind(&input.notes)
.bind(now)
.execute(&self.pool)
.await
.map_err(map_db_error)?;
self.fetch_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn get_async(&self, id: PaymentObligationId) -> Result<Option<PaymentObligation>> {
self.fetch_async(id.into()).await
}
pub async fn list_async(
&self,
filter: PaymentObligationFilter,
) -> Result<Vec<PaymentObligation>> {
let limit = super::effective_limit(filter.limit);
let offset = i64::from(filter.offset.unwrap_or(0));
let mut query = String::from("SELECT * FROM payment_obligations WHERE 1=1");
let mut param_idx = 1;
if filter.supplier_id.is_some() {
query.push_str(&format!(" AND supplier_id = ${param_idx}"));
param_idx += 1;
}
if filter.status.is_some() {
query.push_str(&format!(" AND status = ${param_idx}"));
param_idx += 1;
}
if filter.due_before.is_some() {
query.push_str(&format!(" AND due_date <= ${param_idx}"));
param_idx += 1;
}
query.push_str(&format!(
" ORDER BY due_date ASC LIMIT ${} OFFSET ${}",
param_idx,
param_idx + 1
));
let mut q = sqlx::query_as::<_, PaymentObligationRow>(&query);
if let Some(supplier_id) = filter.supplier_id {
q = q.bind(supplier_id);
}
if let Some(status) = filter.status {
q = q.bind(status.to_string());
}
if let Some(due_before) = filter.due_before {
q = q.bind(due_before);
}
let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await.map_err(map_db_error)?;
rows.into_iter().map(Self::row_to_obligation).collect()
}
pub async fn record_payment_async(
&self,
id: PaymentObligationId,
amount: Decimal,
) -> Result<PaymentObligation> {
if amount <= Decimal::ZERO {
return Err(CommerceError::ValidationError("payment amount must be positive".into()));
}
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
let mut obligation = Self::fetch_in_tx(&mut tx, id.into()).await?;
if obligation.status == PaymentObligationStatus::Cancelled {
return Err(CommerceError::Conflict("cannot pay a cancelled obligation".into()));
}
if amount > obligation.outstanding() {
return Err(CommerceError::ValidationError(format!(
"payment {amount} exceeds outstanding balance {}",
obligation.outstanding()
)));
}
obligation.amount_paid += amount;
let new_status = obligation.derive_status();
let now = Utc::now();
sqlx::query(
"UPDATE payment_obligations SET amount_paid = $1, status = $2, updated_at = $3 WHERE id = $4",
)
.bind(obligation.amount_paid)
.bind(new_status.to_string())
.bind(now)
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
self.fetch_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn set_status_async(
&self,
id: PaymentObligationId,
status: PaymentObligationStatus,
) -> Result<PaymentObligation> {
let now = Utc::now();
sqlx::query("UPDATE payment_obligations SET status = $1, updated_at = $2 WHERE id = $3")
.bind(status.to_string())
.bind(now)
.bind(Uuid::from(id))
.execute(&self.pool)
.await
.map_err(map_db_error)?;
self.fetch_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn link_bill_async(
&self,
id: PaymentObligationId,
bill_id: Uuid,
) -> Result<PaymentObligation> {
let mut tx = self.pool.begin().await.map_err(map_db_error)?;
let obligation = Self::fetch_in_tx(&mut tx, id.into()).await?;
let mut ids = obligation.linked_bill_ids;
if !ids.contains(&bill_id) {
ids.push(bill_id);
}
let json =
serde_json::to_value(&ids).map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let now = Utc::now();
sqlx::query(
"UPDATE payment_obligations SET linked_bill_ids = $1, updated_at = $2 WHERE id = $3",
)
.bind(json)
.bind(now)
.bind(Uuid::from(id))
.execute(tx.as_mut())
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
self.fetch_async(id.into()).await?.ok_or(CommerceError::NotFound)
}
pub async fn dashboard_async(&self, today: NaiveDate) -> Result<PaymentObligationDashboard> {
let mut all = Vec::new();
for status in [
PaymentObligationStatus::Pending,
PaymentObligationStatus::Scheduled,
PaymentObligationStatus::PartiallyPaid,
] {
let batch = self
.list_async(PaymentObligationFilter { status: Some(status), ..Default::default() })
.await?;
all.extend(batch);
}
let mut dash = PaymentObligationDashboard::default();
for o in &all {
dash.open_count += 1;
dash.total_outstanding += o.outstanding();
if o.is_overdue(today) {
dash.overdue_count += 1;
dash.overdue_amount += o.outstanding();
}
}
Ok(dash)
}
}
impl PaymentObligationRepository for PgPaymentObligationRepository {
fn create(&self, input: CreatePaymentObligation) -> Result<PaymentObligation> {
super::block_on(self.create_async(input))
}
fn get(&self, id: PaymentObligationId) -> Result<Option<PaymentObligation>> {
super::block_on(self.get_async(id))
}
fn list(&self, filter: PaymentObligationFilter) -> Result<Vec<PaymentObligation>> {
super::block_on(self.list_async(filter))
}
fn record_payment(
&self,
id: PaymentObligationId,
amount: Decimal,
) -> Result<PaymentObligation> {
super::block_on(self.record_payment_async(id, amount))
}
fn set_status(
&self,
id: PaymentObligationId,
status: PaymentObligationStatus,
) -> Result<PaymentObligation> {
super::block_on(self.set_status_async(id, status))
}
fn link_bill(&self, id: PaymentObligationId, bill_id: Uuid) -> Result<PaymentObligation> {
super::block_on(self.link_bill_async(id, bill_id))
}
fn dashboard(&self, today: NaiveDate) -> Result<PaymentObligationDashboard> {
super::block_on(self.dashboard_async(today))
}
}