use rust_decimal::Decimal;
use sqlx::PgPool;
use uuid::Uuid;
use backbone_accounting::application::service::posting_service::{
PostingLine, PostingRequest, PostingService,
};
fn dec(s: &str) -> Decimal {
Decimal::from_str_exact(s).unwrap()
}
fn today() -> chrono::NaiveDate {
chrono::Utc::now().date_naive()
}
async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgresql://postgres:postgres@localhost:5433/backbone_accounting".to_string()
});
PgPool::connect(&url).await.expect("connect DB")
}
async fn account(
pool: &PgPool,
company: Uuid,
code: &str,
atype: &str,
subtype: &str,
normal: &str,
) -> Uuid {
let id = Uuid::new_v4();
let _ = company; sqlx::query(
r#"INSERT INTO accounting.accounts
(id, account_number, account_code, name, account_type, account_subtype,
normal_balance, is_header, is_detail, status)
VALUES ($1,$2,$2,$2,$3::account_type,$4::account_subtype,$5::normal_balance,
false,true,'active'::account_status)"#,
)
.bind(id)
.bind(code)
.bind(atype)
.bind(subtype)
.bind(normal)
.execute(pool)
.await
.expect("seed account");
id
}
fn balanced(company: Uuid, source_id: Uuid, dr: Uuid, cr: Uuid, amount: Decimal) -> PostingRequest {
let mut r = PostingRequest::original(company, "manual", source_id, today());
r.lines = vec![
PostingLine {
account_id: dr,
debit: amount,
credit: Decimal::ZERO,
party_type: None,
party_id: None,
cost_center_id: None,
project_id: None,
department_id: None,
description: None,
},
PostingLine {
account_id: cr,
debit: Decimal::ZERO,
credit: amount,
party_type: None,
party_id: None,
cost_center_id: None,
project_id: None,
department_id: None,
description: None,
},
];
r
}
#[tokio::test]
async fn ikp1_same_key_dedups() {
let pool = pool().await;
let company = Uuid::new_v4();
let exp = account(
&pool,
company,
"5000-K",
"expense",
"operating_expense",
"debit",
)
.await;
let bank = account(&pool, company, "1000-K", "asset", "bank", "debit").await;
let svc = PostingService::new(std::sync::Arc::new(
backbone_accounting::infrastructure::persistence::SqlxPostingRepository::new(pool.clone()),
));
let key = format!("run:{}", Uuid::new_v4());
let a = svc
.post(
balanced(company, Uuid::new_v4(), exp, bank, dec("1000")).with_idempotency_key(&key),
None,
)
.await
.unwrap();
let b = svc
.post(
balanced(company, Uuid::new_v4(), exp, bank, dec("1000")).with_idempotency_key(&key),
None,
)
.await
.unwrap();
assert!(!a.idempotent_reuse);
assert!(b.idempotent_reuse, "same idempotency_key ⇒ deduped");
assert_eq!(a.journal_id, b.journal_id, "one journal, not two");
}
#[tokio::test]
async fn ikp2_distinct_keys_same_source_id_both_post() {
let pool = pool().await;
let company = Uuid::new_v4();
let exp = account(
&pool,
company,
"5000-K",
"expense",
"operating_expense",
"debit",
)
.await;
let bank = account(&pool, company, "1000-K", "asset", "bank", "debit").await;
let svc = PostingService::new(std::sync::Arc::new(
backbone_accounting::infrastructure::persistence::SqlxPostingRepository::new(pool.clone()),
));
let one_document = Uuid::new_v4();
let a = svc
.post(
balanced(company, one_document, exp, bank, dec("1000"))
.with_idempotency_key(format!("{one_document}:consume")),
None,
)
.await
.unwrap();
let b = svc
.post(
balanced(company, one_document, exp, bank, dec("2000"))
.with_idempotency_key(format!("{one_document}:receive")),
None,
)
.await
.unwrap();
assert!(!a.idempotent_reuse);
assert!(
!b.idempotent_reuse,
"a distinct key posts a distinct journal even for the same source_id"
);
assert_ne!(
a.journal_id, b.journal_id,
"TWO journals — the multi-post producer no longer loses the 2nd"
);
}
#[tokio::test]
async fn ikp3_keyless_still_dedups_on_tuple() {
let pool = pool().await;
let company = Uuid::new_v4();
let exp = account(
&pool,
company,
"5000-K",
"expense",
"operating_expense",
"debit",
)
.await;
let bank = account(&pool, company, "1000-K", "asset", "bank", "debit").await;
let svc = PostingService::new(std::sync::Arc::new(
backbone_accounting::infrastructure::persistence::SqlxPostingRepository::new(pool.clone()),
));
let source = Uuid::new_v4();
let a = svc
.post(balanced(company, source, exp, bank, dec("1000")), None)
.await
.unwrap();
let b = svc
.post(balanced(company, source, exp, bank, dec("1000")), None)
.await
.unwrap();
assert!(!a.idempotent_reuse);
assert!(
b.idempotent_reuse,
"keyless: same tuple still dedups (backward compatible)"
);
assert_eq!(a.journal_id, b.journal_id);
}