use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rust_decimal::Decimal;
use sqlx::PgPool;
use uuid::Uuid;
use argon2::PasswordHasher;
use backbone_pos::application::service::pos_events::{PosEvent, PosEventSink};
use backbone_pos::application::service::pos_ports::{
BillingPort, CashVarianceAck, CashVarianceDirection, CashVarianceRequest, CreditNoteRequest,
InvoiceAck, PaymentPort, PosCashVariancePort, PosRejected, PosTaxComponent,
PosTaxComputePort, PosTaxComputeRequest, PosTaxComputeResult, RefundRequest, ReversalAck,
SaleInvoiceRequest, SettlementAck, SettlementRequest,
};
use backbone_pos::application::service::pos_write_service::ManagerAuth;
pub fn d(s: &str) -> Decimal {
Decimal::from_str_exact(s).unwrap()
}
pub fn at() -> chrono::NaiveDateTime {
chrono::NaiveDate::from_ymd_opt(2026, 7, 5).unwrap().and_hms_opt(9, 0, 0).unwrap()
}
pub fn uq(p: &str) -> String {
format!("{p}-{}", &Uuid::new_v4().simple().to_string()[..8])
}
pub async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5433/backbone_pos".to_string());
PgPool::connect(&url).await.expect("connect DB")
}
pub async fn seed_profile(
pool: &PgPool,
templates: &[Uuid],
rounding: Option<(&str, Decimal)>,
) -> Uuid {
let id = Uuid::new_v4();
let templates_json = serde_json::Value::Array(
templates.iter().map(|t| serde_json::Value::String(t.to_string())).collect(),
);
match rounding {
None => {
sqlx::query(
r#"INSERT INTO pos.pos_profiles (id, name, currency, tax_template_ids, allow_discount, status)
VALUES ($1,'Register 1','IDR',$2,true,'active')"#,
)
.bind(id)
.bind(templates_json)
.execute(pool)
.await
.unwrap();
}
Some((strategy, unit)) => {
sqlx::query(
r#"INSERT INTO pos.pos_profiles (id, name, currency, tax_template_ids, cash_rounding_strategy, cash_rounding_unit, allow_discount, status)
VALUES ($1,'Register 1','IDR',$2,$3::pos_cash_rounding_strategy,$4,true,'active')"#,
)
.bind(id)
.bind(templates_json)
.bind(strategy)
.bind(unit)
.execute(pool)
.await
.unwrap();
}
}
id
}
pub async fn profile_at_rate(pool: &PgPool, rate: &str) -> (Uuid, TestTax) {
let template = Uuid::new_v4();
let profile = seed_profile(pool, &[template], None).await;
(profile, TestTax::with_rate(template, rate))
}
pub async fn zero_tax_profile(pool: &PgPool) -> (Uuid, TestTax) {
profile_at_rate(pool, "0").await
}
pub struct TestTax {
rates: HashMap<Uuid, Decimal>,
shift_cents: i64,
}
impl TestTax {
pub fn with_rate(template: Uuid, rate: &str) -> Self {
let mut rates = HashMap::new();
rates.insert(template, d(rate));
Self { rates, shift_cents: 0 }
}
pub fn with_rates(entries: Vec<(Uuid, &str)>) -> Self {
Self { rates: entries.into_iter().map(|(t, r)| (t, d(r))).collect(), shift_cents: 0 }
}
pub fn with_shift(mut self, cents: i64) -> Self {
self.shift_cents = cents;
self
}
}
#[async_trait::async_trait]
impl PosTaxComputePort for TestTax {
async fn compute_document(&self, req: &PosTaxComputeRequest) -> Result<PosTaxComputeResult, PosRejected> {
let n = req.lines.len();
let mut components = Vec::with_capacity(n);
let mut net_amounts = Vec::with_capacity(n);
let mut tax_total = Decimal::ZERO;
for (i, l) in req.lines.iter().enumerate() {
let rate = *self.rates.get(&l.template_id).ok_or_else(|| PosRejected {
code: "unknown_template".into(),
message: format!("no rate mapped for template {}", l.template_id),
})?;
let tax = (l.net_amount * rate).round_dp(2);
tax_total += tax;
components.push(PosTaxComponent {
line_ref: l.line_ref,
template_id: l.template_id,
account_id: None,
real_account_id: None,
rate,
tax_amount: tax,
description: Some(format!("test tax {}", rate)),
});
if self.shift_cents != 0 && n >= 2 {
let cents = if i == 0 {
self.shift_cents
} else if i + 1 == n {
-self.shift_cents
} else {
0
};
if cents != 0 {
net_amounts
.push((l.line_ref, (l.net_amount + Decimal::from(cents) / Decimal::from(100)).round_dp(2)));
}
}
}
Ok(PosTaxComputeResult {
net_amounts,
components,
excluded_total: Decimal::ZERO,
tax_total: tax_total.round_dp(2),
included_total: Decimal::ZERO,
})
}
}
#[derive(Default, Clone)]
pub struct RecordingVariance {
pub booked: Arc<Mutex<Vec<(CashVarianceRequest, Uuid)>>>,
}
#[async_trait::async_trait]
impl PosCashVariancePort for RecordingVariance {
async fn book_cash_variance(&self, req: &CashVarianceRequest) -> Result<CashVarianceAck, PosRejected> {
let mut booked = self.booked.lock().unwrap();
if let Some((_, journal_id)) = booked.iter().find(|(r, _)| r.opening_entry_id == req.opening_entry_id) {
return Ok(CashVarianceAck { journal_id: *journal_id });
}
let journal_id = Uuid::new_v4();
booked.push((req.clone(), journal_id));
Ok(CashVarianceAck { journal_id })
}
}
impl RecordingVariance {
pub fn booking_for(&self, opening_entry_id: Uuid) -> Option<(Decimal, CashVarianceDirection)> {
self.booked
.lock()
.unwrap()
.iter()
.find(|(r, _)| r.opening_entry_id == opening_entry_id)
.map(|(r, _)| (r.amount, r.direction))
}
}
#[derive(Default, Clone)]
pub struct StubBilling {
pub raised: Arc<Mutex<usize>>,
pub invoice: Uuid,
}
#[async_trait::async_trait]
impl BillingPort for StubBilling {
async fn raise_and_post(&self, _req: &SaleInvoiceRequest) -> Result<InvoiceAck, PosRejected> {
*self.raised.lock().unwrap() += 1;
Ok(InvoiceAck { invoice_id: self.invoice, journal_id: Uuid::new_v4(), grand_total: d("0") })
}
async fn credit_note(&self, _req: &CreditNoteRequest) -> Result<ReversalAck, PosRejected> {
Ok(ReversalAck { journal_id: Uuid::new_v4() })
}
}
#[derive(Default, Clone)]
pub struct StubPayment;
#[async_trait::async_trait]
impl PaymentPort for StubPayment {
async fn settle(&self, _req: &SettlementRequest) -> Result<SettlementAck, PosRejected> {
Ok(SettlementAck { payment_id: Uuid::new_v4(), journal_id: Uuid::new_v4() })
}
async fn refund(&self, _req: &RefundRequest) -> Result<ReversalAck, PosRejected> {
Ok(ReversalAck { journal_id: Uuid::new_v4() })
}
}
#[derive(Default, Clone)]
pub struct Recorder {
pub events: Arc<Mutex<Vec<PosEvent>>>,
}
impl PosEventSink for Recorder {
fn publish(&self, e: PosEvent) {
self.events.lock().unwrap().push(e);
}
}
pub async fn manager_with_pin(pool: &PgPool, pin: &str) -> ManagerAuth {
let employee = Uuid::new_v4();
let salt = argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
let hash = argon2::Argon2::default()
.hash_password(pin.as_bytes(), &salt)
.expect("hash fixture pin")
.to_string();
sqlx::query(
r#"INSERT INTO pos.pos_manager_pins
(id, employee_party_id, pin_hash, failed_attempts, locked_until, last_attempt_at, last_attempt_ip)
VALUES ($1,$2,$3,0,NULL,$4,NULL)"#,
)
.bind(Uuid::new_v4())
.bind(employee)
.bind(&hash)
.bind(chrono::Utc::now())
.execute(pool)
.await
.expect("seed fixture pin");
ManagerAuth { employee_party_id: employee, pin: pin.to_string() }
}