mod support;
use support::{at, d, manager_with_pin, pool, seed_profile, uq, RecordingVariance, StubBilling, StubPayment, TestTax};
use rust_decimal::Decimal;
use uuid::Uuid;
use backbone_pos::application::service::pos_write_service::{
ManagerAuth, NewClose, NewSession, NewSyncSale, PosError, PosWriteService, SyncAction,
};
fn sync_line(client: Uuid, item: Uuid, qty: &str, price: &str) -> backbone_pos::application::service::pos_write_service::SyncSaleLine {
backbone_pos::application::service::pos_write_service::SyncSaleLine {
client_uuid: client, item_id: item, revenue_account_id: None, description: None,
course: None, quantity: d(qty), unit_price: d(price), discount_amount: Decimal::ZERO,
}
}
fn tender(client: Uuid, method: &str, amount: &str) -> backbone_pos::application::service::pos_write_service::SyncTender {
backbone_pos::application::service::pos_write_service::SyncTender {
client_uuid: client, method: method.into(), amount: d(amount), reference_no: None,
}
}
fn sync(client: Uuid, prof: Uuid, session: Uuid, lines: Vec<backbone_pos::application::service::pos_write_service::SyncSaleLine>, tenders: Vec<backbone_pos::application::service::pos_write_service::SyncTender>) -> NewSyncSale {
NewSyncSale {
client_uuid: client, pos_profile_id: prof, opening_entry_id: session,
rescue_opening_entry_id: None, branch_id: None, customer_id: None,
pos_table_id: None, discount_id: None, posting_at: at(),
lines, tenders, refund_of_client_uuid: None, manager: None, source_ip: None,
}
}
async fn full_profile(pool: &sqlx::PgPool, rate: &str) -> (Uuid, TestTax) {
let template = Uuid::new_v4();
let id = Uuid::new_v4();
sqlx::query(r#"INSERT INTO pos.pos_profiles (id, name, currency, tax_template_ids, receivable_account_id, income_account_id, cash_account_id, default_customer_id, tax_account_id, allow_discount, status)
VALUES ($1,'Register 1','IDR',$2,$3,$4,$5,$6,$7,true,'active')"#)
.bind(id).bind(serde_json::json!([template.to_string()]))
.bind(Uuid::new_v4()).bind(Uuid::new_v4()).bind(Uuid::new_v4()).bind(Uuid::new_v4()).bind(Uuid::new_v4())
.execute(pool).await.unwrap();
(id, TestTax::with_rate(template, rate))
}
async fn open(w: &PosWriteService, prof: Uuid) -> Uuid {
w.open_session(NewSession {
pos_profile_id: prof, branch_id: None, cashier_party_id: Uuid::new_v4(),
opened_at: at(), opening_balances: vec![],
}).await.unwrap()
}
#[tokio::test]
async fn first_replay_creates_with_server_totals() {
let pool = pool().await;
let rec = support::Recorder::default();
let w = PosWriteService::with_sink(pool.clone(), std::sync::Arc::new(rec.clone()));
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0.11").await;
let session = open(&w, prof).await;
let client = Uuid::new_v4();
let out = w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "2", "50000")],
vec![tender(Uuid::new_v4(), "cash", "111000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
assert_eq!(out.action, SyncAction::Created);
assert_eq!(out.totals.net_total, d("100000.00"));
assert_eq!(out.totals.tax_total, d("11000.00"));
assert_eq!(out.totals.rounded_total, d("111000.00"));
assert_eq!(out.totals.paid_total, d("111000.00"));
assert_eq!(out.totals.change_due, d("0.00"));
let (cu, st, rn): (Option<Uuid>, String, String) = sqlx::query_as(
"SELECT client_uuid, status::text, receipt_number FROM pos.pos_invoices WHERE id=$1",
).bind(out.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!(cu, Some(client));
assert_eq!(st, "draft");
assert!(rn.starts_with("SYNC-"), "the server mints the receipt number from the sync identity");
let fired = rec.events.lock().unwrap().iter().any(|e| matches!(
e, backbone_pos::application::service::pos_events::PosEvent::PosTenderCompleted(t) if t.pos_invoice_id == out.pos_invoice_id));
assert!(fired, "a fully-tendered replay fires the recognition trigger");
}
#[tokio::test]
async fn replay_updates_the_draft_it_names() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let session = open(&w, prof).await;
let client = Uuid::new_v4();
let first = w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "2", "50000"), sync_line(Uuid::new_v4(), item, "1", "20000")],
vec![tender(Uuid::new_v4(), "cash", "120000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
assert_eq!(first.action, SyncAction::Created);
assert_eq!(first.totals.net_total, d("120000.00"));
let second = w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "2", "50000")],
vec![tender(Uuid::new_v4(), "card", "100000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
assert_eq!(second.pos_invoice_id, first.pos_invoice_id, "the same ticket, not a second one");
assert_eq!(second.action, SyncAction::Updated);
assert_eq!(second.totals.net_total, d("100000.00"));
assert_eq!(second.totals.paid_total, d("100000.00"));
let live_lines: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND metadata->>'deleted_at' IS NULL")
.bind(first.pos_invoice_id).fetch_one(&pool).await.unwrap();
let dead_lines: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND metadata->>'deleted_at' IS NOT NULL")
.bind(first.pos_invoice_id).fetch_one(&pool).await.unwrap();
let live_tenders: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM pos.pos_payments WHERE pos_invoice_id=$1 AND metadata->>'deleted_at' IS NULL")
.bind(first.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!((live_lines, dead_lines, live_tenders), (1, 2, 1));
let (st, paid, method): (String, Decimal, String) = sqlx::query_as(
"SELECT i.status::text, i.paid_total, p.payment_method::text FROM pos.pos_invoices i JOIN pos.pos_payments p ON p.pos_invoice_id=i.id AND p.metadata->>'deleted_at' IS NULL WHERE i.id=$1")
.bind(first.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!((st.as_str(), paid, method.as_str()), ("draft", d("100000.00"), "card"));
let tickets: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pos.pos_invoices WHERE client_uuid=$1")
.bind(client).fetch_one(&pool).await.unwrap();
assert_eq!(tickets, 1, "identity is the uuid — replays never double-ring");
}
#[tokio::test]
async fn replay_of_a_finalized_ticket_is_inert() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0.11").await;
let session = open(&w, prof).await;
let client = Uuid::new_v4();
let first = w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "100000")],
vec![tender(Uuid::new_v4(), "cash", "111000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
w.recognize_sale(first.pos_invoice_id, &support::StubBilling::default(), &support::StubPayment, None).await.unwrap();
let replay = w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "5", "100000")],
vec![tender(Uuid::new_v4(), "cash", "555000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
assert_eq!(replay.action, SyncAction::ReplayFinalized);
assert_eq!(replay.pos_invoice_id, first.pos_invoice_id);
assert_eq!(replay.totals.net_total, d("100000.00"), "the SERVER's persisted money is the answer");
let (st, net): (String, Decimal) = sqlx::query_as("SELECT status::text, net_total FROM pos.pos_invoices WHERE id=$1")
.bind(first.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!((st.as_str(), net), ("paid", d("100000.00")));
}
#[tokio::test]
async fn closed_session_is_rescue_or_refuse() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let manager = manager_with_pin(&pool, "4321").await;
let variance = RecordingVariance::default();
let s1 = open(&w, prof).await;
w.close_session(NewClose {
opening_entry_id: s1, cashier_party_id: Uuid::new_v4(), closed_at: at(),
counted: vec![], manager: manager.clone(), source_ip: None,
}, &variance).await.unwrap();
let s2 = open(&w, prof).await;
let client = Uuid::new_v4();
let mut req = sync(client, prof, s1,
vec![sync_line(Uuid::new_v4(), item, "1", "50000")],
vec![tender(Uuid::new_v4(), "cash", "50000")]);
let e = w.sync_from_ui(req.clone(), &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::SessionClosedRescueRequired(_)));
req.rescue_opening_entry_id = Some(s2);
let out = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap();
assert_eq!(out.action, SyncAction::Created);
let landed: Uuid = sqlx::query_scalar("SELECT opening_entry_id FROM pos.pos_invoices WHERE id=$1")
.bind(out.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!(landed, s2, "the rescued ticket rides the open shift");
}
#[tokio::test]
async fn rescue_must_stay_on_the_tickets_register() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof_a, tax) = full_profile(&pool, "0").await;
let (prof_b, _) = support::profile_at_rate(&pool, "0").await;
let manager = manager_with_pin(&pool, "4321").await;
let variance = RecordingVariance::default();
let s1 = open(&w, prof_a).await;
w.close_session(NewClose {
opening_entry_id: s1, cashier_party_id: Uuid::new_v4(), closed_at: at(),
counted: vec![], manager, source_ip: None,
}, &variance).await.unwrap();
let other_register_session = open(&w, prof_b).await;
let client = Uuid::new_v4();
let req = sync(client, prof_a, s1,
vec![sync_line(Uuid::new_v4(), item, "1", "50000")],
vec![tender(Uuid::new_v4(), "cash", "50000")]);
let mut req = req;
req.rescue_opening_entry_id = Some(other_register_session);
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::SessionRegisterMismatch), "cross-register rescue refused, got {e:?}");
}
#[tokio::test]
async fn plain_open_replay_must_stay_on_its_register() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof_a, tax) = full_profile(&pool, "0").await;
let (prof_b, _) = support::profile_at_rate(&pool, "0").await;
let session_b = open(&w, prof_b).await;
let client = Uuid::new_v4();
let e = w.sync_from_ui(
sync(client, prof_a, session_b,
vec![sync_line(Uuid::new_v4(), item, "1", "50000")],
vec![tender(Uuid::new_v4(), "cash", "50000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap_err();
assert!(matches!(e, PosError::SessionRegisterMismatch), "cross-register plain-open replay refused, got {e:?}");
let tickets: i64 = sqlx::query_scalar("SELECT count(*) FROM pos.pos_invoices WHERE client_uuid=$1")
.bind(client).fetch_one(&pool).await.unwrap();
assert_eq!(tickets, 0, "nothing was written");
}
#[tokio::test]
async fn refund_replay_is_privileged_and_idempotent() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let session = open(&w, prof).await;
let manager = manager_with_pin(&pool, "4321").await;
let parent_uuid = Uuid::new_v4();
let parent = w.sync_from_ui(
sync(parent_uuid, prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "100000")],
vec![tender(Uuid::new_v4(), "cash", "100000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
w.recognize_sale(parent.pos_invoice_id, &support::StubBilling::default(), &support::StubPayment, None).await.unwrap();
let mut req = sync(Uuid::new_v4(), prof, session, vec![], vec![]);
req.refund_of_client_uuid = Some(parent_uuid);
let e = w.sync_from_ui(req.clone(), &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::ManagerAuthRequired));
req.manager = Some(ManagerAuth { employee_party_id: manager.employee_party_id, pin: "9999".into() });
let e = w.sync_from_ui(req.clone(), &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::PinInvalid));
req.manager = Some(manager.clone());
let out = w.sync_from_ui(req.clone(), &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap();
assert_eq!(out.action, SyncAction::Created);
let (pst, is_ret, against): (String, bool, Option<Uuid>) = sqlx::query_as(
"SELECT status::text, is_return, return_against FROM pos.pos_invoices WHERE id=$1")
.bind(parent.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!((pst.as_str(), is_ret, against), ("returned", false, None));
let (rst, rret, ragainst): (String, bool, Option<Uuid>) = sqlx::query_as(
"SELECT status::text, is_return, return_against FROM pos.pos_invoices WHERE id=$1")
.bind(out.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!(rst, "returned");
assert!(rret);
assert_eq!(ragainst, Some(parent.pos_invoice_id));
let replay = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap();
assert_eq!(replay.action, SyncAction::ReplayFinalized);
assert_eq!(replay.pos_invoice_id, out.pos_invoice_id, "the same return ticket");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pos.pos_invoices WHERE return_against=$1 AND is_return")
.bind(parent.pos_invoice_id).fetch_one(&pool).await.unwrap();
assert_eq!(n, 1, "exactly one return ticket per parent");
}
#[tokio::test]
async fn refund_parent_must_exist_and_be_finalized() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let session = open(&w, prof).await;
let manager = manager_with_pin(&pool, "4321").await;
let mut req = sync(Uuid::new_v4(), prof, session, vec![], vec![]);
req.refund_of_client_uuid = Some(Uuid::new_v4());
req.manager = Some(manager.clone());
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::RefundParentNotFound(_)));
let parent_uuid = Uuid::new_v4();
w.sync_from_ui(
sync(parent_uuid, prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "100000")],
vec![tender(Uuid::new_v4(), "cash", "100000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
let mut req = sync(Uuid::new_v4(), prof, session, vec![], vec![]);
req.refund_of_client_uuid = Some(parent_uuid);
req.manager = Some(manager);
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::NotReturnable(_)));
}
#[tokio::test]
async fn update_path_fences_lineage_and_partner() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let session = open(&w, prof).await;
let manager = manager_with_pin(&pool, "4321").await;
let client = Uuid::new_v4();
w.sync_from_ui(
sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "50000")],
vec![tender(Uuid::new_v4(), "cash", "50000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
let mut req = sync(client, prof, session, vec![], vec![]);
req.refund_of_client_uuid = Some(Uuid::new_v4());
req.manager = Some(manager);
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::RefundLineageConflict));
let mut req = sync(client, prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "50000")],
vec![tender(Uuid::new_v4(), "cash", "50000")]);
req.customer_id = Some(Uuid::new_v4());
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
assert!(matches!(e, PosError::SyncPartnerMismatch));
}
#[tokio::test]
async fn unknown_tender_method_is_a_typed_refusal() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let item = Uuid::new_v4();
let (prof, tax) = full_profile(&pool, "0").await;
let session = open(&w, prof).await;
let req = sync(Uuid::new_v4(), prof, session,
vec![sync_line(Uuid::new_v4(), item, "1", "1000")],
vec![tender(Uuid::new_v4(), "crypto", "1000")]);
let e = w.sync_from_ui(req, &tax, &support::StubBilling::default(), &support::StubPayment).await.unwrap_err();
match e {
PosError::InvalidTenderMethod(m) => assert_eq!(m, "crypto"),
other => panic!("expected InvalidTenderMethod, got {other:?}"),
}
}
#[tokio::test]
async fn same_uuid_never_double_rings_across_registers() {
let pool = pool().await;
let w = PosWriteService::new(pool.clone());
let (item, client) = (Uuid::new_v4(), Uuid::new_v4());
let (prof_a, tax) = full_profile(&pool, "0").await;
let (prof_b, tax_b) = support::profile_at_rate(&pool, "0").await;
let s_a = open(&w, prof_a).await;
let s_b = open(&w, prof_b).await;
let a = w.sync_from_ui(
sync(client, prof_a, s_a,
vec![sync_line(Uuid::new_v4(), item, "1", "100000")],
vec![tender(Uuid::new_v4(), "cash", "100000")]),
&tax, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap();
assert_eq!(a.action, SyncAction::Created);
let e = w.sync_from_ui(
sync(client, prof_b, s_b,
vec![sync_line(Uuid::new_v4(), item, "1", "1000")],
vec![tender(Uuid::new_v4(), "cash", "1000")]),
&tax_b, &support::StubBilling::default(), &support::StubPayment,
).await.unwrap_err();
assert!(matches!(e, PosError::SyncSessionMismatch), "the collision may not rewrite across registers, got {e:?}");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pos.pos_invoices WHERE client_uuid=$1")
.bind(client).fetch_one(&pool).await.unwrap();
assert_eq!(n, 1, "the replay never double-rings the uuid");
}