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 PosInvoiceItem 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 ticket-line SQL (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<PosInvoiceItem, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.
//!
//! Tenancy is composition-installed (ADR-0029): the line write runs on the CALLER'S scope-relayed
//! transaction; the pool reads ride the ambient org scope's request-dedicated connection — the
//! fence is the whole guard, no statement keys on tenancy.

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

// The multi-row read twin rides the org-scope module: the request-dedicated connection
// when the composing service bound one, plain pool otherwise, no scope invented. The
// legacy `company_scope` twin would open a transaction and bind a company this module
// no longer sets (ADR-0029 stripped it), so the org-scope twin is the honest read lane.
use backbone_orm::org_scope::fetch_all_rows_scoped;

use crate::domain::entity::PosInvoiceItem;

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

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

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

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

/// The exact row a ticket line writes. Mirrors the raw column shape rather than the `PosInvoiceItem`
/// entity: `net_amount` is the already-computed line net (qty x price - discount), not re-derived here.
pub struct NewInvoiceItemRow<'a> {
    pub id: Uuid,
    pub pos_invoice_id: Uuid,
    /// The line's offline-sync identity (None for server-originated rings).
    pub client_uuid: Option<Uuid>,
    pub item_id: Uuid,
    pub description: Option<&'a str>,
    /// Course grouping for kitchen routing (restaurant): lines sharing a course number fire
    /// together; NULL = not course-grouped (counter sale).
    pub course: Option<i32>,
    pub quantity: Decimal,
    pub unit_price: Decimal,
    pub discount_amount: Decimal,
    pub net_amount: Decimal,
    pub revenue_account_id: Option<Uuid>,
}

/// A ticket line as the billing hand-off reads it: the net to invoice + its revenue account.
pub struct RevenueLineRow {
    pub item_id: Uuid,
    pub net_amount: Decimal,
    pub revenue_account_id: Option<Uuid>,
}

/// A ticket line as the stock issue reads it: what to relieve from the warehouse.
pub struct QuantityLineRow {
    pub item_id: Uuid,
    pub quantity: Decimal,
}

/// A ticket line as the printed receipt reads it.
pub struct ReceiptLineRow {
    pub description: Option<String>,
    pub item_id: Uuid,
    /// Course grouping (restaurant) — surfaced so kitchen-display / receipt consumers can group.
    pub course: Option<i32>,
    pub quantity: Decimal,
    pub unit_price: Decimal,
    pub discount_amount: Decimal,
    pub net_amount: Decimal,
}

/// Hand-written ticket-line 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 PosInvoiceItemRepository {
    /// Insert one ticket line.
    ///
    /// Takes the CALLER'S connection so every line and its invoice header commit as one unit — a ticket
    /// is never half-rung. The caller has already relayed the ambient org scope onto it — don't
    /// re-bind here.
    pub async fn insert_line(
        &self,
        conn: &mut sqlx::PgConnection,
        l: &NewInvoiceItemRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO pos.pos_invoice_items
                (id, pos_invoice_id, client_uuid, item_id, description, course, quantity, unit_price, discount_amount, net_amount, revenue_account_id)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#,
        )
        .bind(l.id).bind(l.pos_invoice_id).bind(l.client_uuid).bind(l.item_id).bind(l.description).bind(l.course).bind(l.quantity)
        .bind(l.unit_price).bind(l.discount_amount).bind(l.net_amount).bind(l.revenue_account_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Read the lines the billing hand-off invoices.
    ///
    /// ID-only: rides the ambient org scope's request-dedicated connection, so another unit's lines
    /// simply are not found. A non-request caller (the recognition sink driven by
    /// `PosTenderCompleted`) MUST wrap this in the composing service's org request scope — bound
    /// from the event's `company_id` twin (the acting unit) — otherwise it fails closed and returns
    /// no lines.
    pub async fn fetch_revenue_lines(
        &self,
        pool: &PgPool,
        pos_invoice_id: Uuid,
    ) -> Result<Vec<RevenueLineRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                "SELECT item_id, net_amount, revenue_account_id FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
            )
            .bind(pos_invoice_id),
        )
        .await?;
        Ok(rows.iter().map(|r| RevenueLineRow {
            item_id: r.get("item_id"), net_amount: r.get("net_amount"),
            revenue_account_id: r.get("revenue_account_id"),
        }).collect())
    }

    /// Read the lines the stock issue relieves. Same ID-only scope contract as
    /// [`Self::fetch_revenue_lines`].
    pub async fn fetch_quantity_lines(
        &self,
        pool: &PgPool,
        pos_invoice_id: Uuid,
    ) -> Result<Vec<QuantityLineRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                "SELECT item_id, quantity FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
            )
            .bind(pos_invoice_id),
        )
        .await?;
        Ok(rows.iter().map(|r| QuantityLineRow {
            item_id: r.get("item_id"), quantity: r.get("quantity"),
        }).collect())
    }

    /// Retire every live line of a ticket (soft delete — the audit trail stays, the offline-sync
    /// uniqueness on `client_uuid` is freed for the replay's replacement lines).
    ///
    /// Takes the CALLER'S connection so the retire and its replacements commit as one unit. The
    /// caller has already relayed the ambient org scope onto it.
    pub async fn soft_delete_lines_for_ticket(
        &self,
        conn: &mut sqlx::PgConnection,
        pos_invoice_id: Uuid,
        now: chrono::DateTime<chrono::Utc>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"UPDATE pos.pos_invoice_items
               SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{deleted_at}', to_jsonb($2::timestamptz))
               WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(pos_invoice_id)
        .bind(now)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Read the lines for the printed receipt, in ring order. Same ID-only scope contract as
    /// [`Self::fetch_revenue_lines`].
    pub async fn fetch_receipt_lines(
        &self,
        pool: &PgPool,
        pos_invoice_id: Uuid,
    ) -> Result<Vec<ReceiptLineRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                "SELECT description, item_id, course, quantity, unit_price, discount_amount, net_amount
                 FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL
                 ORDER BY (metadata->>'created_at')",
            )
            .bind(pos_invoice_id),
        )
        .await?;
        Ok(rows.iter().map(|r| ReceiptLineRow {
            description: r.get("description"), item_id: r.get("item_id"), course: r.get("course"),
            quantity: r.get("quantity"),
            unit_price: r.get("unit_price"), discount_amount: r.get("discount_amount"),
            net_amount: r.get("net_amount"),
        }).collect())
    }
}

backbone_core::impl_crud_repository!(PosInvoiceItemRepository, PosInvoiceItem, soft_delete);