backbone_pos/infrastructure/persistence/pos_profile_repository.rs
1//! Repository for PosProfile entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written register-configuration SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9//!
10//! Tenancy is composition-installed (ADR-0029): both reads below execute on the CALLER'S connection
11//! — the ticket compute and the session-open guard run inside the caller's scope-relayed
12//! transaction, and the fence on that connection is the whole guard (a register another unit owns
13//! reads as plain absence).
14
15use anyhow::Result;
16use rust_decimal::Decimal;
17use sqlx::{PgPool, Row};
18use uuid::Uuid;
19
20use crate::domain::entity::PosProfile;
21
22/// Table name for PosProfile entities
23pub const TABLE_NAME: &str = "pos.pos_profiles";
24
25/// Repository for PosProfile entities.
26///
27/// All standard CRUD, soft-delete, pagination, and bulk methods are
28/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
29pub struct PosProfileRepository(
30 backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>,
31);
32
33impl std::ops::Deref for PosProfileRepository {
34 type Target = backbone_orm::GenericCrudRepository<PosProfile, backbone_orm::SoftDelete>;
35 fn deref(&self) -> &Self::Target { &self.0 }
36}
37
38impl PosProfileRepository {
39 /// Create a new repository instance.
40 pub fn new(pool: PgPool) -> Self {
41 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
42 }
43}
44
45/// A register's pricing-side configuration: the document-grade tax templates + the cash rounding
46/// setup + the discount permission. Everything the ticket compute core and the order-discount gate
47/// need from the profile.
48pub struct TaxConfigRow {
49 /// The register's tax template refs (`[template id, ...]`, logical refs to tax.TaxTemplate).
50 pub tax_template_ids: Option<serde_json::Value>,
51 /// Rounding strategy code: `none` or `half_up`.
52 pub rounding_strategy: String,
53 /// The rounding step (e.g. 100 for IDR); 0 = no rounding.
54 pub rounding_unit: Decimal,
55 /// Whether this register permits discounts. Gates the ORDER-LEVEL discount lane (a register
56 /// configured discount-off refuses a ring that names a discount master row).
57 pub allow_discount: bool,
58}
59
60/// Hand-written register-configuration SQL. Lives here (not in the write service) per the module's
61/// 4-layer rule: services orchestrate and own the unit of work, repositories hold the SQL.
62impl PosProfileRepository {
63 /// Read a register's pricing-side configuration (tax templates + cash rounding).
64 /// `Ok(None)` = no such register visible to the caller's scope.
65 ///
66 /// Takes the caller's EXECUTOR, not a pool: the ticket compute core prices a ticket inside the
67 /// caller's scope-relayed transaction, and this config read MUST ride that same connection — a
68 /// plain pool read under a composed fence sees zero rows and the register would falsely vanish
69 /// (ADR-0029).
70 ///
71 /// Tax is server-owned: these templates — not any client-supplied total — are what the ticket is
72 /// taxed through (`PosTaxComputePort`).
73 pub async fn fetch_tax_config(
74 &self,
75 exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
76 pos_profile_id: Uuid,
77 ) -> Result<Option<TaxConfigRow>, sqlx::Error> {
78 let row = sqlx::query(
79 r#"SELECT tax_template_ids, cash_rounding_strategy::text AS strategy, cash_rounding_unit, allow_discount
80 FROM pos.pos_profiles WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
81 )
82 .bind(pos_profile_id)
83 .fetch_optional(exec)
84 .await?;
85 Ok(row.map(|r| TaxConfigRow {
86 tax_template_ids: r.get("tax_template_ids"),
87 rounding_strategy: r.get("strategy"),
88 rounding_unit: r.get("cash_rounding_unit"),
89 allow_discount: r.get("allow_discount"),
90 }))
91 }
92
93 /// Does this register exist (and is it not soft-deleted)?
94 ///
95 /// The pre-write validation for opening a cashier session: a session must never open against a
96 /// register uuid the caller's scope does not own — the id may be unknown outright or belong to
97 /// another unit, and the fence deliberately cannot tell the two apart (the caller gets one typed
98 /// "no such register" refusal either way). Runs on the caller's scope-relayed transaction so the
99 /// check and the session insert it guards see the same world.
100 pub async fn exists(
101 &self,
102 conn: &mut sqlx::PgConnection,
103 pos_profile_id: Uuid,
104 ) -> Result<bool, sqlx::Error> {
105 let found: Option<i32> = sqlx::query_scalar(
106 "SELECT 1 FROM pos.pos_profiles WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
107 )
108 .bind(pos_profile_id)
109 .fetch_optional(conn)
110 .await?;
111 Ok(found.is_some())
112 }
113}
114
115backbone_core::impl_crud_repository!(PosProfileRepository, PosProfile, soft_delete);