use backbone_orm::org_scope;
use rust_decimal::{Decimal, RoundingStrategy};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use crate::infrastructure::persistence::{
NewOpeningEntryRow, PosCashMovementRepository, PosClosingEntryRepository, PosDiscountRepository,
PosInvoiceItemRepository, PosInvoiceRepository, PosManagerPinRepository, PosOpeningEntryRepository,
PosPaymentRepository, PosProfileRepository, PosTableRepository,
};
use super::pos_events::{PosEvent, PosEventSink, PosSessionOpened, LoggingSink};
pub(super) fn money(v: Decimal) -> Decimal {
v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
}
pub(super) fn legacy_company_echo() -> Uuid {
org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or(Uuid::nil())
}
pub(super) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(conn, &scope).await?;
}
Ok(())
}
pub(super) fn round_to(v: Decimal, step: Decimal) -> Decimal {
if step <= Decimal::ZERO { return money(v); }
let n = (v / step).round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
money(n * step)
}
#[derive(Debug, Clone)]
pub struct NewSession {
pub pos_profile_id: Uuid,
pub branch_id: Option<Uuid>,
pub cashier_party_id: Uuid,
pub opened_at: chrono::NaiveDateTime,
pub opening_balances: Vec<(String, Decimal)>,
}
#[derive(Debug, Clone)]
pub struct NewSaleLine {
pub item_id: Uuid,
pub revenue_account_id: Option<Uuid>,
pub description: Option<String>,
pub course: Option<i32>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub discount_amount: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewSale {
pub pos_profile_id: Uuid,
pub opening_entry_id: Uuid,
pub branch_id: Option<Uuid>,
pub customer_id: Option<Uuid>,
pub pos_table_id: Option<Uuid>,
pub discount_id: Option<Uuid>,
pub receipt_number: String,
pub posting_at: chrono::NaiveDateTime,
pub lines: Vec<NewSaleLine>,
}
#[derive(Debug, Clone)]
pub struct CartSaleLine {
pub item_id: Uuid,
pub item_group_id: Option<Uuid>,
pub brand_id: Option<Uuid>,
pub revenue_account_id: Option<Uuid>,
pub description: Option<String>,
pub course: Option<i32>,
pub list_price: Decimal,
pub quantity: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewCartSale {
pub pos_profile_id: Uuid,
pub opening_entry_id: Uuid,
pub branch_id: Option<Uuid>,
pub customer_id: Option<Uuid>,
pub customer_group_id: Option<Uuid>,
pub coupon_code: Option<String>,
pub pos_table_id: Option<Uuid>,
pub discount_id: Option<Uuid>,
pub receipt_number: String,
pub posting_at: chrono::NaiveDateTime,
pub lines: Vec<CartSaleLine>,
}
#[derive(Debug, Clone)]
pub struct ManagerAuth {
pub employee_party_id: Uuid,
pub pin: String,
}
#[derive(Debug, Clone)]
pub struct NewClose {
pub opening_entry_id: Uuid,
pub cashier_party_id: Uuid,
pub closed_at: chrono::NaiveDateTime,
pub counted: Vec<(String, Decimal)>,
pub manager: ManagerAuth,
pub source_ip: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SyncSaleLine {
pub client_uuid: Uuid,
pub item_id: Uuid,
pub revenue_account_id: Option<Uuid>,
pub description: Option<String>,
pub course: Option<i32>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub discount_amount: Decimal,
}
#[derive(Debug, Clone)]
pub struct SyncTender {
pub client_uuid: Uuid,
pub method: String,
pub amount: Decimal,
pub reference_no: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NewSyncSale {
pub client_uuid: Uuid,
pub pos_profile_id: Uuid,
pub opening_entry_id: Uuid,
pub rescue_opening_entry_id: Option<Uuid>,
pub branch_id: Option<Uuid>,
pub customer_id: Option<Uuid>,
pub pos_table_id: Option<Uuid>,
pub discount_id: Option<Uuid>,
pub posting_at: chrono::NaiveDateTime,
pub lines: Vec<SyncSaleLine>,
pub tenders: Vec<SyncTender>,
pub refund_of_client_uuid: Option<Uuid>,
pub manager: Option<ManagerAuth>,
pub source_ip: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncAction {
Created,
Updated,
ReplayFinalized,
}
#[derive(Debug, Clone)]
pub struct SyncOutcome {
pub pos_invoice_id: Uuid,
pub action: SyncAction,
pub totals: TicketTotals,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TicketTotals {
pub net_total: Decimal,
pub tax_total: Decimal,
pub grand_total: Decimal,
pub rounding_adjustment: Decimal,
pub rounded_total: Decimal,
pub paid_total: Decimal,
pub change_due: Decimal,
}
#[derive(Debug, Clone)]
pub struct NewCashMovement {
pub pos_profile_id: Uuid,
pub opening_entry_id: Uuid,
pub cashier_party_id: Uuid,
pub movement_type: String,
pub amount: Decimal,
pub reason: Option<String>,
pub moved_at: chrono::NaiveDateTime,
}
#[derive(Debug, Clone)]
pub struct TenderOutcome {
pub paid_total: Decimal,
pub change_due: Decimal,
pub fully_tendered: bool,
}
#[derive(Debug, Clone)]
pub struct RecognizeOutcome {
pub pos_invoice_id: Uuid,
pub billing_invoice_id: Uuid,
pub payment_id: Uuid,
}
#[derive(Debug, Clone)]
pub struct ReturnOutcome {
pub pos_invoice_id: Uuid,
pub return_ticket_id: Uuid,
pub billing_invoice_id: Uuid,
}
#[derive(Debug, Clone)]
pub struct MethodRecon {
pub method: String,
pub expected: Decimal,
pub counted: Decimal,
pub difference: Decimal,
}
#[derive(Debug, Clone)]
pub struct VarianceBooking {
pub journal_id: Uuid,
pub amount: Decimal,
pub direction: super::pos_ports::CashVarianceDirection,
}
#[derive(Debug, Clone)]
pub struct CloseOutcome {
pub closing_id: Uuid,
pub difference_total: Decimal,
pub by_method: Vec<MethodRecon>,
pub variance: Option<VarianceBooking>,
}
#[derive(Debug, Clone)]
pub struct MethodExpected {
pub method: String,
pub expected: Decimal,
}
#[derive(Debug, Clone)]
pub struct XReport {
pub opening_entry_id: Uuid,
pub by_method: Vec<MethodExpected>,
pub grand_total: Decimal,
pub invoice_count: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReceiptLine {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub course: Option<i32>,
pub quantity: Decimal,
pub unit_price: Decimal,
pub discount_amount: Decimal,
pub net_amount: Decimal,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReceiptTender {
pub method: String,
pub amount: Decimal,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Receipt {
pub pos_invoice_id: Uuid,
pub receipt_number: String,
pub register_name: String,
pub posting_at: chrono::DateTime<chrono::Utc>,
pub currency: String,
pub status: String,
pub is_invoiced: bool,
pub lines: Vec<ReceiptLine>,
pub net_total: Decimal,
pub tax_rate: Decimal,
pub tax_total: Decimal,
pub rounding_adjustment: Decimal,
pub grand_total: Decimal,
pub tenders: Vec<ReceiptTender>,
pub paid_total: Decimal,
pub change_due: Decimal,
}
impl Receipt {
pub fn render_text(&self) -> String {
const W: usize = 32;
let money = |d: Decimal| format!("{:.2}", d);
let row = |left: &str, right: &str| -> String {
let r = right.to_string();
let l_max = W.saturating_sub(r.len() + 1);
let l: String = left.chars().take(l_max).collect();
format!("{l}{}{r}", " ".repeat(W.saturating_sub(l.chars().count() + r.len())))
};
let center = |s: &str| -> String {
let pad = W.saturating_sub(s.chars().count()) / 2;
format!("{}{s}", " ".repeat(pad))
};
let rule = "-".repeat(W);
let mut o = String::new();
o.push_str(¢er(&self.register_name)); o.push('\n');
o.push_str(¢er(&format!("Receipt {}", self.receipt_number))); o.push('\n');
o.push_str(¢er(&self.posting_at.format("%Y-%m-%d %H:%M").to_string())); o.push('\n');
o.push_str(&rule); o.push('\n');
for l in &self.lines {
o.push_str(&l.description); o.push('\n');
let qty_price = format!(" {} x {}", l.quantity.normalize(), money(l.unit_price));
o.push_str(&row(&qty_price, &money(l.net_amount))); o.push('\n');
if l.discount_amount > Decimal::ZERO {
o.push_str(&row(" disc", &format!("-{}", money(l.discount_amount)))); o.push('\n');
}
}
o.push_str(&rule); o.push('\n');
o.push_str(&row("Subtotal", &money(self.net_total))); o.push('\n');
if self.tax_total > Decimal::ZERO {
let pct = (self.tax_rate * Decimal::from(100)).normalize();
o.push_str(&row(&format!("PPN {pct}%"), &money(self.tax_total))); o.push('\n');
}
if self.rounding_adjustment != Decimal::ZERO {
o.push_str(&row("Rounding", &money(self.rounding_adjustment))); o.push('\n');
}
o.push_str(&row("TOTAL", &money(self.grand_total))); o.push('\n');
o.push_str(&rule); o.push('\n');
for t in &self.tenders {
o.push_str(&row(&t.method, &money(t.amount))); o.push('\n');
}
if self.change_due > Decimal::ZERO {
o.push_str(&row("Change", &money(self.change_due))); o.push('\n');
}
o.push_str(&rule); o.push('\n');
o.push_str(¢er("Terima kasih")); o.push('\n');
o
}
}
#[derive(Debug)]
pub enum PosError {
EmptyDocument,
NegativeAmount,
SessionNotOpen,
SessionRegisterMismatch,
NotDraft,
NotFullyTendered { rounded_total: Decimal, paid_total: Decimal },
NotReturnable(String),
MissingAccount(&'static str),
DuplicateNumber(String),
ProfileNotFound(Uuid),
InvoiceNotFound(Uuid),
SessionNotFound(Uuid),
BillingRejected { code: String, message: String },
PaymentRejected { code: String, message: String },
PricingRejected { code: String, message: String },
InventoryRejected { code: String, message: String },
InvalidCashMovement(&'static str),
PartialReturnsNotImplemented,
ProfileTaxTemplatesMissing(Uuid),
TaxRejected { code: String, message: String },
SessionClosedRescueRequired(Uuid),
SyncPartnerMismatch,
SyncSessionMismatch,
RefundParentNotFound(Uuid),
RefundLineageConflict,
InvalidTenderMethod(String),
DuplicateClientUuid(Uuid),
SessionHasDraftOrders(i64),
SessionHasUnpostedInvoices(i64),
SessionAlreadyOpen,
VarianceRejected { code: String, message: String },
ManagerAuthRequired,
PinNotFound,
PinInvalid,
PinLocked { until: chrono::DateTime<chrono::Utc> },
PinThrottled,
WeakPin(&'static str),
TableNotFound(Uuid),
TableOccupied { pos_table_id: Uuid, draft_invoice_id: Uuid },
DiscountNotFound(Uuid),
DiscountNotAllowed,
DiscountInvalid(&'static str),
Db(sqlx::Error),
}
impl PosError {
pub fn code(&self) -> String {
match self {
PosError::EmptyDocument => "empty_document".into(),
PosError::NegativeAmount => "negative_amount".into(),
PosError::SessionNotOpen => "session_not_open".into(),
PosError::SessionRegisterMismatch => "session_register_mismatch".into(),
PosError::NotDraft => "not_draft".into(),
PosError::NotFullyTendered { .. } => "not_fully_tendered".into(),
PosError::NotReturnable(_) => "not_returnable".into(),
PosError::MissingAccount(_) => "missing_account".into(),
PosError::DuplicateNumber(_) => "duplicate_number".into(),
PosError::ProfileNotFound(_) => "profile_not_found".into(),
PosError::InvoiceNotFound(_) => "invoice_not_found".into(),
PosError::SessionNotFound(_) => "session_not_found".into(),
PosError::BillingRejected { code, .. } => code.clone(),
PosError::PaymentRejected { code, .. } => code.clone(),
PosError::PricingRejected { code, .. } => code.clone(),
PosError::InventoryRejected { code, .. } => code.clone(),
PosError::InvalidCashMovement(_) => "invalid_cash_movement".into(),
PosError::PartialReturnsNotImplemented => "partial_returns_not_implemented".into(),
PosError::ProfileTaxTemplatesMissing(_) => "profile_tax_templates_missing".into(),
PosError::TaxRejected { code, .. } => code.clone(),
PosError::SessionClosedRescueRequired(_) => "session_closed_rescue_required".into(),
PosError::SyncPartnerMismatch => "sync_partner_mismatch".into(),
PosError::SyncSessionMismatch => "sync_session_mismatch".into(),
PosError::RefundParentNotFound(_) => "refund_parent_not_found".into(),
PosError::RefundLineageConflict => "refund_lineage_conflict".into(),
PosError::InvalidTenderMethod(_) => "invalid_tender_method".into(),
PosError::DuplicateClientUuid(_) => "duplicate_client_uuid".into(),
PosError::SessionHasDraftOrders(_) => "session_has_draft_orders".into(),
PosError::SessionHasUnpostedInvoices(_) => "session_has_unposted_invoices".into(),
PosError::SessionAlreadyOpen => "session_already_open".into(),
PosError::VarianceRejected { code, .. } => code.clone(),
PosError::ManagerAuthRequired => "manager_authorization_required".into(),
PosError::PinNotFound => "manager_pin_not_set".into(),
PosError::PinInvalid => "manager_pin_invalid".into(),
PosError::PinLocked { .. } => "manager_pin_locked".into(),
PosError::PinThrottled => "manager_pin_throttled".into(),
PosError::WeakPin(_) => "weak_pin".into(),
PosError::TableNotFound(_) => "table_not_found".into(),
PosError::TableOccupied { .. } => "table_occupied".into(),
PosError::DiscountNotFound(_) => "discount_not_found".into(),
PosError::DiscountNotAllowed => "discount_not_allowed".into(),
PosError::DiscountInvalid(_) => "discount_invalid".into(),
PosError::Db(_) => "internal_error".into(),
}
}
pub fn http_status(&self) -> u16 {
match self {
PosError::ProfileNotFound(_)
| PosError::InvoiceNotFound(_)
| PosError::SessionNotFound(_)
| PosError::TableNotFound(_)
| PosError::DiscountNotFound(_) => 404,
PosError::ManagerAuthRequired | PosError::PinInvalid => 403,
PosError::PinLocked { .. } | PosError::PinThrottled => 429,
PosError::TableOccupied { .. } => 409,
PosError::Db(_) => 500,
_ => 422,
}
}
}
impl std::fmt::Display for PosError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PosError::BillingRejected { code, message }
| PosError::PaymentRejected { code, message }
| PosError::TaxRejected { code, message }
| PosError::VarianceRejected { code, message } => write!(f, "{code}: {message}"),
PosError::PinLocked { until } => write!(f, "manager_pin_locked until {until}"),
other => write!(f, "{}", other.code()),
}
}
}
impl std::error::Error for PosError {}
impl From<sqlx::Error> for PosError {
fn from(e: sqlx::Error) -> Self { PosError::Db(e) }
}
pub(super) fn dup_constraint(e: &sqlx::Error) -> Option<String> {
let db = e.as_database_error()?;
if !db.is_unique_violation() {
return None;
}
db.constraint().map(|c| c.to_string())
}
pub(super) async fn map_invoice_dup(
e: sqlx::Error,
receipt_number: &str,
client_uuid: Option<Uuid>,
pos_table_id: Option<Uuid>,
invoices: &crate::infrastructure::persistence::PosInvoiceRepository,
pool: &sqlx::PgPool,
exclude_invoice_id: Option<Uuid>,
) -> PosError {
match dup_constraint(&e).as_deref() {
Some(c) if c.contains("receipt_number") => PosError::DuplicateNumber(receipt_number.to_string()),
Some(c) if c.contains("client_uuid") => PosError::DuplicateClientUuid(client_uuid.unwrap_or_default()),
Some(c) if c.contains("pos_table_id") => {
let table = pos_table_id.unwrap_or_default();
let draft = invoices
.find_draft_on_table_scoped(pool, table, exclude_invoice_id)
.await
.ok()
.flatten()
.unwrap_or_default();
PosError::TableOccupied { pos_table_id: table, draft_invoice_id: draft }
}
_ => PosError::Db(e),
}
}
#[derive(Debug, Clone)]
pub struct PinPolicy {
pub max_attempts: u32,
pub lockout_secs: i64,
pub min_digits: usize,
pub max_digits: usize,
pub ip_window_secs: i64,
pub ip_max_attempts: u32,
}
impl Default for PinPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
lockout_secs: 15,
min_digits: 4,
max_digits: 8,
ip_window_secs: 60,
ip_max_attempts: 20,
}
}
}
impl PinPolicy {
pub fn from_env() -> Self {
let d = Self::default();
let get = |k: &str| std::env::var(k).ok();
Self {
max_attempts: get("POS_PIN_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_attempts),
lockout_secs: get("POS_PIN_LOCKOUT_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.lockout_secs),
min_digits: get("POS_PIN_MIN_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.min_digits),
max_digits: get("POS_PIN_MAX_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_digits),
ip_window_secs: get("POS_PIN_IP_WINDOW_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_window_secs),
ip_max_attempts: get("POS_PIN_IP_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_max_attempts),
}
}
}
#[derive(Clone)]
pub struct PosWriteService {
pub(super) db_pool: PgPool,
pub(super) sink: Arc<dyn PosEventSink>,
pub(super) outbox_schema: Option<String>,
pub(super) invoices: Arc<PosInvoiceRepository>,
pub(super) items: Arc<PosInvoiceItemRepository>,
pub(super) payments: Arc<PosPaymentRepository>,
pub(super) profiles: Arc<PosProfileRepository>,
pub(super) openings: Arc<PosOpeningEntryRepository>,
pub(super) closings: Arc<PosClosingEntryRepository>,
pub(super) movements: Arc<PosCashMovementRepository>,
pub(super) pins: Arc<PosManagerPinRepository>,
pub(super) tables: Arc<PosTableRepository>,
pub(super) discounts: Arc<PosDiscountRepository>,
pub(super) pin_policy: PinPolicy,
pub(super) ip_attempts: Arc<Mutex<HashMap<String, (i64, u32)>>>,
}
impl PosWriteService {
pub fn new(db_pool: PgPool) -> Self {
Self::with_sink(db_pool, Arc::new(LoggingSink))
}
pub fn with_sink(db_pool: PgPool, sink: Arc<dyn PosEventSink>) -> Self {
Self {
invoices: Arc::new(PosInvoiceRepository::new(db_pool.clone())),
items: Arc::new(PosInvoiceItemRepository::new(db_pool.clone())),
payments: Arc::new(PosPaymentRepository::new(db_pool.clone())),
profiles: Arc::new(PosProfileRepository::new(db_pool.clone())),
openings: Arc::new(PosOpeningEntryRepository::new(db_pool.clone())),
closings: Arc::new(PosClosingEntryRepository::new(db_pool.clone())),
movements: Arc::new(PosCashMovementRepository::new(db_pool.clone())),
pins: Arc::new(PosManagerPinRepository::new(db_pool.clone())),
tables: Arc::new(PosTableRepository::new(db_pool.clone())),
discounts: Arc::new(PosDiscountRepository::new(db_pool.clone())),
db_pool,
sink,
outbox_schema: None,
pin_policy: PinPolicy::default(),
ip_attempts: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(super) fn rpool(&self) -> PgPool {
crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
}
pub fn with_outbox(mut self, schema: impl Into<String>) -> Self {
self.outbox_schema = Some(schema.into());
self
}
pub fn with_pin_policy(mut self, policy: PinPolicy) -> Self {
self.pin_policy = policy;
self
}
pub async fn open_session(&self, s: NewSession) -> Result<Uuid, PosError> {
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let profile_known = self.profiles.exists(&mut *tx, s.pos_profile_id).await.map_err(PosError::from)?;
if !profile_known {
return Err(PosError::ProfileNotFound(s.pos_profile_id));
}
let already_open = self
.openings
.has_open_session(&mut *tx, s.pos_profile_id)
.await
.map_err(PosError::from)?;
if already_open {
return Err(PosError::SessionAlreadyOpen);
}
let id = Uuid::new_v4();
let opening = serde_json::Value::Array(s.opening_balances.iter().map(|(m, a)| {
serde_json::json!({ "method": m, "amount": a.to_string() })
}).collect());
let r = self.openings.insert_opening_entry(&mut *tx, &NewOpeningEntryRow {
id,
pos_profile_id: s.pos_profile_id,
branch_id: s.branch_id,
cashier_party_id: s.cashier_party_id,
opened_at: s.opened_at,
opening_balances: opening,
}).await;
if let Err(e) = r {
return Err(match dup_constraint(&e).as_deref() {
Some(c) if c.contains("pos_profile_id") => PosError::SessionAlreadyOpen,
_ => e.into(),
});
}
tx.commit().await?;
self.sink.publish(PosEvent::PosSessionOpened(PosSessionOpened {
opening_entry_id: id,
pos_profile_id: s.pos_profile_id,
company_id: legacy_company_echo(),
}));
Ok(id)
}
}