use std::sync::Arc;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::service::pos_cart_pricing::CartPricingPort;
use crate::application::service::pos_events::PosEventSink;
use crate::application::service::pos_ports::{BillingPort, PaymentPort, PosCashVariancePort, PosTaxComputePort};
use crate::application::service::pos_write_service::{
CartSaleLine, ManagerAuth, NewCartSale, NewCashMovement, NewClose, NewSale, NewSaleLine,
NewSession, NewSyncSale, PosError, PosWriteService, SyncAction, SyncSaleLine, SyncTender,
};
use crate::application::service::pos_manager_pin::SetPin;
use crate::PosModule;
use backbone_auth::org::OrgContext;
use super::{
create_pos_discount_read_routes, create_pos_floor_plan_read_routes, create_pos_invoice_read_routes,
create_pos_opening_entry_read_routes, create_pos_profile_read_routes, create_pos_table_read_routes,
};
#[derive(Debug, Serialize)]
struct ErrorBody { error: String, message: String }
#[derive(Debug, Serialize)]
struct IdResponse { id: Uuid }
fn err(e: PosError) -> axum::response::Response {
let s = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(s, Json(ErrorBody { error: e.code(), message: e.to_string() })).into_response()
}
fn source_ip(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(|s| format!("ua:{}", utf8_prefix(s, 60)))
})
}
fn utf8_prefix(s: &str, max: usize) -> &str {
let mut cut = s.len().min(max);
while !s.is_char_boundary(cut) {
cut -= 1;
}
&s[..cut]
}
#[cfg(test)]
mod tests {
use super::utf8_prefix;
#[test]
fn utf8_prefix_never_splits_a_character() {
let emoji = "😀".repeat(16);
assert_eq!(utf8_prefix(&emoji, 60).len(), 60);
assert_eq!(utf8_prefix(&emoji, 61).len(), 60);
let tailed = "a".repeat(59) + "é";
assert_eq!(utf8_prefix(&tailed, 60), "a".repeat(59).as_str());
assert_eq!(utf8_prefix("short", 60), "short");
assert_eq!(utf8_prefix("", 60), "");
}
}
#[derive(Clone)]
struct WriteState {
svc: Arc<PosWriteService>,
tax: Arc<dyn PosTaxComputePort>,
variance: Arc<dyn PosCashVariancePort>,
billing: Arc<dyn BillingPort>,
payment: Arc<dyn PaymentPort>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningBalanceBody { method: String, amount: Decimal }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenSessionBody {
pos_profile_id: Uuid,
cashier_party_id: Uuid,
opened_at: chrono::NaiveDateTime,
#[serde(default)] opening_balances: Vec<OpeningBalanceBody>,
}
async fn open_session(State(st): State<WriteState>, org: OrgContext, Json(b): Json<OpenSessionBody>) -> axum::response::Response {
let s = NewSession {
pos_profile_id: b.pos_profile_id, branch_id: Some(org.acting_unit_id),
cashier_party_id: b.cashier_party_id, opened_at: b.opened_at,
opening_balances: b.opening_balances.into_iter().map(|o| (o.method, o.amount)).collect(),
};
match st.svc.open_session(s).await {
Ok(id) => (StatusCode::CREATED, Json(IdResponse { id })).into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SaleLineBody {
item_id: Uuid,
#[serde(default)] revenue_account_id: Option<Uuid>,
#[serde(default)] description: Option<String>,
#[serde(default)] course: Option<i32>,
quantity: Decimal,
unit_price: Decimal,
#[serde(default)] discount_amount: Decimal,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RingSaleBody {
pos_profile_id: Uuid,
opening_entry_id: Uuid,
#[serde(default)] customer_id: Option<Uuid>,
#[serde(default)] pos_table_id: Option<Uuid>,
#[serde(default)] discount_id: Option<Uuid>,
receipt_number: String,
posting_at: chrono::NaiveDateTime,
lines: Vec<SaleLineBody>,
}
async fn ring_sale(State(st): State<WriteState>, org: OrgContext, Json(b): Json<RingSaleBody>) -> axum::response::Response {
let sale = NewSale {
pos_profile_id: b.pos_profile_id, opening_entry_id: b.opening_entry_id,
branch_id: Some(org.acting_unit_id), customer_id: b.customer_id, pos_table_id: b.pos_table_id,
discount_id: b.discount_id, receipt_number: b.receipt_number, posting_at: b.posting_at,
lines: b.lines.into_iter().map(|l| NewSaleLine {
item_id: l.item_id, revenue_account_id: l.revenue_account_id, description: l.description,
course: l.course, quantity: l.quantity, unit_price: l.unit_price, discount_amount: l.discount_amount,
}).collect(),
};
match st.svc.ring_sale(sale, &*st.tax).await {
Ok(id) => (StatusCode::CREATED, Json(IdResponse { id })).into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TenderBody {
pos_invoice_id: Uuid,
payment_method: String,
amount: Decimal,
#[serde(default)] reference_no: Option<String>,
}
async fn add_tender(State(st): State<WriteState>, Json(b): Json<TenderBody>) -> axum::response::Response {
match st.svc.add_tender(b.pos_invoice_id, &b.payment_method, b.amount, b.reference_no).await {
Ok(o) => (StatusCode::OK, Json(serde_json::json!({
"paidTotal": o.paid_total, "changeDue": o.change_due, "fullyTendered": o.fully_tendered,
}))).into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CountedBody { method: String, amount: Decimal }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManagerBody {
employee_party_id: Uuid,
pin: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CloseBody {
opening_entry_id: Uuid,
cashier_party_id: Uuid,
closed_at: chrono::NaiveDateTime,
#[serde(default)] counted: Vec<CountedBody>,
manager: ManagerBody,
}
async fn close_session(State(st): State<WriteState>, headers: axum::http::HeaderMap, Json(b): Json<CloseBody>) -> axum::response::Response {
let c = NewClose {
opening_entry_id: b.opening_entry_id, cashier_party_id: b.cashier_party_id,
closed_at: b.closed_at, counted: b.counted.into_iter().map(|x| (x.method, x.amount)).collect(),
manager: ManagerAuth { employee_party_id: b.manager.employee_party_id, pin: b.manager.pin },
source_ip: source_ip(&headers),
};
match st.svc.close_session(c, &*st.variance).await {
Ok(o) => (StatusCode::OK, Json(serde_json::json!({
"closingId": o.closing_id, "differenceTotal": o.difference_total,
"byMethod": o.by_method.iter().map(|m| serde_json::json!({
"method": m.method, "expected": m.expected, "counted": m.counted, "difference": m.difference,
})).collect::<Vec<_>>(),
"variance": match &o.variance {
None => serde_json::Value::Null,
Some(v) => serde_json::json!({ "journalId": v.journal_id, "amount": v.amount, "direction": match v.direction {
crate::application::service::pos_ports::CashVarianceDirection::Over => "over",
crate::application::service::pos_ports::CashVarianceDirection::Short => "short",
}}),
},
}))).into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncSaleLineBody {
client_uuid: Uuid,
item_id: Uuid,
#[serde(default)] revenue_account_id: Option<Uuid>,
#[serde(default)] description: Option<String>,
#[serde(default)] course: Option<i32>,
quantity: Decimal,
unit_price: Decimal,
#[serde(default)] discount_amount: Decimal,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncTenderBody {
client_uuid: Uuid,
method: String,
amount: Decimal,
#[serde(default)] reference_no: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncSaleBody {
client_uuid: Uuid,
pos_profile_id: Uuid,
opening_entry_id: Uuid,
#[serde(default)] rescue_opening_entry_id: Option<Uuid>,
#[serde(default)] customer_id: Option<Uuid>,
#[serde(default)] pos_table_id: Option<Uuid>,
#[serde(default)] discount_id: Option<Uuid>,
posting_at: chrono::NaiveDateTime,
#[serde(default)] lines: Vec<SyncSaleLineBody>,
#[serde(default)] tenders: Vec<SyncTenderBody>,
#[serde(default)] refund_of_client_uuid: Option<Uuid>,
#[serde(default)] manager: Option<ManagerBody>,
}
async fn sync_from_ui(State(st): State<WriteState>, headers: axum::http::HeaderMap, org: OrgContext, Json(b): Json<SyncSaleBody>) -> axum::response::Response {
let s = NewSyncSale {
client_uuid: b.client_uuid,
pos_profile_id: b.pos_profile_id,
opening_entry_id: b.opening_entry_id,
rescue_opening_entry_id: b.rescue_opening_entry_id,
branch_id: Some(org.acting_unit_id),
customer_id: b.customer_id,
pos_table_id: b.pos_table_id,
discount_id: b.discount_id,
posting_at: b.posting_at,
lines: b.lines.into_iter().map(|l| SyncSaleLine {
client_uuid: l.client_uuid, item_id: l.item_id, revenue_account_id: l.revenue_account_id,
description: l.description, course: l.course, quantity: l.quantity, unit_price: l.unit_price,
discount_amount: l.discount_amount,
}).collect(),
tenders: b.tenders.into_iter().map(|t| SyncTender {
client_uuid: t.client_uuid, method: t.method, amount: t.amount, reference_no: t.reference_no,
}).collect(),
refund_of_client_uuid: b.refund_of_client_uuid,
manager: b.manager.map(|m| ManagerAuth { employee_party_id: m.employee_party_id, pin: m.pin }),
source_ip: source_ip(&headers),
};
match st.svc.sync_from_ui(s, &*st.tax, &*st.billing, &*st.payment).await {
Ok(o) => (StatusCode::OK, Json(serde_json::json!({
"posInvoiceId": o.pos_invoice_id,
"action": match o.action {
SyncAction::Created => "created",
SyncAction::Updated => "updated",
SyncAction::ReplayFinalized => "replay_finalized",
},
"totals": {
"netTotal": o.totals.net_total, "taxTotal": o.totals.tax_total,
"grandTotal": o.totals.grand_total, "roundingAdjustment": o.totals.rounding_adjustment,
"roundedTotal": o.totals.rounded_total, "paidTotal": o.totals.paid_total,
"changeDue": o.totals.change_due,
},
}))).into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetPinBody {
employee_party_id: Uuid,
new_pin: String,
#[serde(default)] current: Option<ManagerBody>,
}
async fn set_pin(State(st): State<WriteState>, headers: axum::http::HeaderMap, Json(b): Json<SetPinBody>) -> axum::response::Response {
let s = SetPin {
employee_party_id: b.employee_party_id,
new_pin: b.new_pin,
current: b.current.map(|m| ManagerAuth { employee_party_id: m.employee_party_id, pin: m.pin }),
source_ip: source_ip(&headers),
};
match st.svc.set_pin(s).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VerifyPinBody { employee_party_id: Uuid, pin: String }
async fn verify_pin(State(st): State<WriteState>, headers: axum::http::HeaderMap, Json(b): Json<VerifyPinBody>) -> axum::response::Response {
match st.svc.verify_pin(b.employee_party_id, &b.pin, source_ip(&headers).as_deref()).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => err(e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PricedSaleLineBody {
item_id: Uuid,
#[serde(default)] item_group_id: Option<Uuid>,
#[serde(default)] brand_id: Option<Uuid>,
#[serde(default)] revenue_account_id: Option<Uuid>,
#[serde(default)] description: Option<String>,
#[serde(default)] course: Option<i32>,
list_price: Decimal,
quantity: Decimal,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RingSalePricedBody {
pos_profile_id: Uuid,
opening_entry_id: Uuid,
#[serde(default)] customer_id: Option<Uuid>,
#[serde(default)] customer_group_id: Option<Uuid>,
#[serde(default)] coupon_code: Option<String>,
#[serde(default)] pos_table_id: Option<Uuid>,
#[serde(default)] discount_id: Option<Uuid>,
receipt_number: String,
posting_at: chrono::NaiveDateTime,
lines: Vec<PricedSaleLineBody>,
}
#[derive(Clone)]
struct PricedState {
write: WriteState,
pricing: Arc<dyn CartPricingPort>,
}
async fn ring_sale_priced(State(st): State<PricedState>, org: OrgContext, Json(b): Json<RingSalePricedBody>) -> axum::response::Response {
let cart = NewCartSale {
pos_profile_id: b.pos_profile_id, opening_entry_id: b.opening_entry_id,
branch_id: Some(org.acting_unit_id), customer_id: b.customer_id, customer_group_id: b.customer_group_id,
coupon_code: b.coupon_code, pos_table_id: b.pos_table_id, discount_id: b.discount_id,
receipt_number: b.receipt_number, posting_at: b.posting_at,
lines: b.lines.into_iter().map(|l| CartSaleLine {
item_id: l.item_id, item_group_id: l.item_group_id, brand_id: l.brand_id,
revenue_account_id: l.revenue_account_id, description: l.description, course: l.course,
list_price: l.list_price, quantity: l.quantity,
}).collect(),
};
match st.write.svc.ring_sale_priced(cart, &*st.pricing, &*st.write.tax).await {
Ok(id) => (StatusCode::CREATED, Json(IdResponse { id })).into_response(),
Err(e) => err(e),
}
}
pub fn create_guarded_pos_priced_route(
pool: PgPool,
pricing: Arc<dyn CartPricingPort>,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
) -> Router {
create_guarded_pos_priced_route_inner(pool, pricing, sink, tax, None)
}
pub fn create_guarded_pos_priced_route_with_outbox(
pool: PgPool,
pricing: Arc<dyn CartPricingPort>,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
outbox_schema: String,
) -> Router {
create_guarded_pos_priced_route_inner(pool, pricing, sink, tax, Some(outbox_schema))
}
fn create_guarded_pos_priced_route_inner(
pool: PgPool,
pricing: Arc<dyn CartPricingPort>,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
outbox_schema: Option<String>,
) -> Router {
let mut svc = PosWriteService::with_sink(pool, sink);
if let Some(schema) = outbox_schema {
svc = svc.with_outbox(schema);
}
let write = WriteState {
svc: Arc::new(svc),
tax,
variance: Arc::new(NoVariance),
billing: Arc::new(NoBilling),
payment: Arc::new(NoPayment),
};
let st = PricedState { write, pricing };
Router::new()
.route("/sales/priced", post(ring_sale_priced))
.with_state(st)
.layer(axum::middleware::from_fn(
crate::request_pool::bind_request_pool,
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CashMovementBody {
pos_profile_id: Uuid,
opening_entry_id: Uuid,
cashier_party_id: Uuid,
movement_type: String,
#[serde(default)] amount: Decimal,
#[serde(default)] reason: Option<String>,
moved_at: chrono::NaiveDateTime,
}
async fn record_cash_movement(State(st): State<WriteState>, Json(b): Json<CashMovementBody>) -> axum::response::Response {
let m = NewCashMovement {
pos_profile_id: b.pos_profile_id, opening_entry_id: b.opening_entry_id,
cashier_party_id: b.cashier_party_id, movement_type: b.movement_type, amount: b.amount,
reason: b.reason, moved_at: b.moved_at,
};
match st.svc.record_cash_movement(m).await {
Ok(id) => (StatusCode::CREATED, Json(IdResponse { id })).into_response(),
Err(e) => err(e),
}
}
async fn x_report(State(st): State<WriteState>, Path(opening_entry_id): Path<Uuid>) -> axum::response::Response {
match st.svc.x_report(opening_entry_id).await {
Ok(r) => (StatusCode::OK, Json(serde_json::json!({
"openingEntryId": r.opening_entry_id,
"grandTotal": r.grand_total,
"invoiceCount": r.invoice_count,
"byMethod": r.by_method.iter().map(|m| serde_json::json!({ "method": m.method, "expected": m.expected })).collect::<Vec<_>>(),
}))).into_response(),
Err(e) => err(e),
}
}
async fn receipt(State(st): State<WriteState>, Path(pos_invoice_id): Path<Uuid>) -> axum::response::Response {
match st.svc.receipt(pos_invoice_id).await {
Ok(r) => {
let text = r.render_text();
let mut body = serde_json::to_value(&r).unwrap_or_else(|_| serde_json::json!({}));
if let Some(obj) = body.as_object_mut() {
obj.insert("text".to_string(), serde_json::Value::String(text));
}
(StatusCode::OK, Json(body)).into_response()
}
Err(e) => err(e),
}
}
fn write_routes(st: WriteState) -> Router {
Router::new()
.route("/sessions", post(open_session))
.route("/sessions/close", post(close_session))
.route("/sessions/:opening_entry_id/x-report", get(x_report))
.route("/sales", post(ring_sale))
.route("/sales/sync", post(sync_from_ui))
.route("/tenders", post(add_tender))
.route("/cash-movements", post(record_cash_movement))
.route("/receipts/:pos_invoice_id", get(receipt))
.route("/manager-pins", post(set_pin))
.route("/manager-pins/verify", post(verify_pin))
.with_state(st)
}
pub fn create_guarded_pos_routes(
m: &PosModule,
pool: PgPool,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
variance: Arc<dyn PosCashVariancePort>,
billing: Arc<dyn BillingPort>,
payment: Arc<dyn PaymentPort>,
) -> Router {
create_guarded_pos_routes_inner(m, pool, sink, tax, variance, billing, payment, None)
}
pub fn create_guarded_pos_routes_with_outbox(
m: &PosModule,
pool: PgPool,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
variance: Arc<dyn PosCashVariancePort>,
billing: Arc<dyn BillingPort>,
payment: Arc<dyn PaymentPort>,
outbox_schema: String,
) -> Router {
create_guarded_pos_routes_inner(m, pool, sink, tax, variance, billing, payment, Some(outbox_schema))
}
fn create_guarded_pos_routes_inner(
m: &PosModule,
pool: PgPool,
sink: Arc<dyn PosEventSink>,
tax: Arc<dyn PosTaxComputePort>,
variance: Arc<dyn PosCashVariancePort>,
billing: Arc<dyn BillingPort>,
payment: Arc<dyn PaymentPort>,
outbox_schema: Option<String>,
) -> Router {
let mut svc = PosWriteService::with_sink(pool, sink);
if let Some(schema) = outbox_schema {
svc = svc.with_outbox(schema);
}
let write = WriteState { svc: Arc::new(svc), tax, variance, billing, payment };
let reads = Router::new()
.merge(create_pos_profile_read_routes(m.pos_profile_service.clone()))
.merge(create_pos_opening_entry_read_routes(m.pos_opening_entry_service.clone()))
.merge(create_pos_invoice_read_routes(m.pos_invoice_service.clone()))
.merge(create_pos_floor_plan_read_routes(m.pos_floor_plan_service.clone()))
.merge(create_pos_table_read_routes(m.pos_table_service.clone()))
.merge(create_pos_discount_read_routes(m.pos_discount_service.clone()));
Router::new()
.merge(reads)
.merge(write_routes(write))
.layer(axum::middleware::from_fn(
crate::request_pool::bind_request_pool,
))
}
struct NoVariance;
#[async_trait::async_trait]
impl PosCashVariancePort for NoVariance {
async fn book_cash_variance(&self, _req: &crate::application::service::pos_ports::CashVarianceRequest) -> Result<crate::application::service::pos_ports::CashVarianceAck, crate::application::service::pos_ports::PosRejected> {
Err(crate::application::service::pos_ports::PosRejected {
code: "variance_not_mounted".into(),
message: "this constructor mounts only the priced ring; variance booking is not mounted here".into(),
})
}
}
struct NoBilling;
#[async_trait::async_trait]
impl BillingPort for NoBilling {
async fn raise_and_post(&self, _req: &crate::application::service::pos_ports::SaleInvoiceRequest) -> Result<crate::application::service::pos_ports::InvoiceAck, crate::application::service::pos_ports::PosRejected> {
Err(crate::application::service::pos_ports::PosRejected { code: "billing_not_mounted".into(), message: "this constructor mounts only the priced ring".into() })
}
async fn credit_note(&self, _req: &crate::application::service::pos_ports::CreditNoteRequest) -> Result<crate::application::service::pos_ports::ReversalAck, crate::application::service::pos_ports::PosRejected> {
Err(crate::application::service::pos_ports::PosRejected { code: "billing_not_mounted".into(), message: "this constructor mounts only the priced ring".into() })
}
}
struct NoPayment;
#[async_trait::async_trait]
impl PaymentPort for NoPayment {
async fn settle(&self, _req: &crate::application::service::pos_ports::SettlementRequest) -> Result<crate::application::service::pos_ports::SettlementAck, crate::application::service::pos_ports::PosRejected> {
Err(crate::application::service::pos_ports::PosRejected { code: "payment_not_mounted".into(), message: "this constructor mounts only the priced ring".into() })
}
async fn refund(&self, _req: &crate::application::service::pos_ports::RefundRequest) -> Result<crate::application::service::pos_ports::ReversalAck, crate::application::service::pos_ports::PosRejected> {
Err(crate::application::service::pos_ports::PosRejected { code: "payment_not_mounted".into(), message: "this constructor mounts only the priced ring".into() })
}
}