backbone-pos 0.7.0

Point of sale — cashier session, ticket + multi-tender; orchestrates billing (revenue) + payment (settlement), posts no GL directly
Documentation
//! Repository for PosProfile entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written register-configuration SQL (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.
//!
//! Tenancy is composition-installed (ADR-0029): both reads below execute on the CALLER'S connection
//! — the ticket compute and the session-open guard run inside the caller's scope-relayed
//! transaction, and the fence on that connection is the whole guard (a register another unit owns
//! reads as plain absence).

use anyhow::Result;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use crate::domain::entity::PosProfile;

/// Table name for PosProfile entities
pub const TABLE_NAME: &str = "pos.pos_profiles";

/// Repository for PosProfile entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct PosProfileRepository(
    backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for PosProfileRepository {
    type Target = backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl PosProfileRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// A register's pricing-side configuration: the document-grade tax templates + the cash rounding
/// setup + the discount permission. Everything the ticket compute core and the order-discount gate
/// need from the profile.
pub struct TaxConfigRow {
    /// The register's tax template refs (`[template id, ...]`, logical refs to tax.TaxTemplate).
    pub tax_template_ids: Option<serde_json::Value>,
    /// Rounding strategy code: `none` or `half_up`.
    pub rounding_strategy: String,
    /// The rounding step (e.g. 100 for IDR); 0 = no rounding.
    pub rounding_unit: Decimal,
    /// Whether this register permits discounts. Gates the ORDER-LEVEL discount lane (a register
    /// configured discount-off refuses a ring that names a discount master row).
    pub allow_discount: bool,
}

/// Hand-written register-configuration SQL. Lives here (not in the write service) per the module's
/// 4-layer rule: services orchestrate and own the unit of work, repositories hold the SQL.
impl PosProfileRepository {
    /// Read a register's pricing-side configuration (tax templates + cash rounding).
    /// `Ok(None)` = no such register visible to the caller's scope.
    ///
    /// Takes the caller's EXECUTOR, not a pool: the ticket compute core prices a ticket inside the
    /// caller's scope-relayed transaction, and this config read MUST ride that same connection — a
    /// plain pool read under a composed fence sees zero rows and the register would falsely vanish
    /// (ADR-0029).
    ///
    /// Tax is server-owned: these templates — not any client-supplied total — are what the ticket is
    /// taxed through (`PosTaxComputePort`).
    pub async fn fetch_tax_config(
        &self,
        exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
        pos_profile_id: Uuid,
    ) -> Result<Option<TaxConfigRow>, sqlx::Error> {
        let row = sqlx::query(
            r#"SELECT tax_template_ids, cash_rounding_strategy::text AS strategy, cash_rounding_unit, allow_discount
               FROM pos.pos_profiles WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(pos_profile_id)
        .fetch_optional(exec)
        .await?;
        Ok(row.map(|r| TaxConfigRow {
            tax_template_ids: r.get("tax_template_ids"),
            rounding_strategy: r.get("strategy"),
            rounding_unit: r.get("cash_rounding_unit"),
            allow_discount: r.get("allow_discount"),
        }))
    }

    /// Does this register exist (and is it not soft-deleted)?
    ///
    /// The pre-write validation for opening a cashier session: a session must never open against a
    /// register uuid the caller's scope does not own — the id may be unknown outright or belong to
    /// another unit, and the fence deliberately cannot tell the two apart (the caller gets one typed
    /// "no such register" refusal either way). Runs on the caller's scope-relayed transaction so the
    /// check and the session insert it guards see the same world.
    pub async fn exists(
        &self,
        conn: &mut sqlx::PgConnection,
        pos_profile_id: Uuid,
    ) -> Result<bool, sqlx::Error> {
        let found: Option<i32> = sqlx::query_scalar(
            "SELECT 1 FROM pos.pos_profiles WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(pos_profile_id)
        .fetch_optional(conn)
        .await?;
        Ok(found.is_some())
    }
}

backbone_core::impl_crud_repository!(PosProfileRepository, PosProfile, soft_delete);