Skip to main content

Schema — cash_transactions

New table recording offline cash credit sales in the hierarchy. Separate from payment_transactions (gateway/online audit) but still linked to payment + wallet ledger when creating transactions.

Related: Spec Admin → SA · Tasks


Role in the system

┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ cash_transactions │────▶│ payment_transactions │ │ wallet_transactions │
│ (cash ledger — │ │ (audit provider │ │ (credit/debit CDN │
│ UI source of │ │ admin_cash, etc.) │ │ wallet) │
│ truth) │ │ │ │ │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘


┌─────────────────────┐
│ cash_transaction_ │
│ events (audit) │
└─────────────────────┘
TableRole
cash_transactionsPrimary record for list/detail UI, filter, export CSV
payment_transactionsKeeps legacy flow / reporting; link via payment_transaction_id
wallet_transactionsCredit/debit wallet entries; link via credit_wallet_transaction_id / debit_wallet_transaction_id
cash_transaction_eventsAudit timeline (create, status change later)

Partitioning (monthly)

Both tables are partitioned like other high-volume backend tables (payment_transactions, wallet_transactions, …):

TablePartition keyRange
cash_transactionscreated_atMonthly RANGE
cash_transaction_eventscreated_atMonthly RANGE

Pre-create: 2025-012030-12 (72 partitions per table), via partitionManager.js or inline in migration (pattern gamify_reward_webhook_logs).

PK & UNIQUE on partitioned tables

PostgreSQL requires PK / UNIQUE to include partition key:

TablePrimary key
cash_transactions(id, created_at)
cash_transaction_events(id, created_at)

transaction_code — globally unique at app layer; DB uses UNIQUE (transaction_code, created_at) to satisfy PG constraint (each code inserted once with same created_at).

No FK across partitioned tables

Like credit_transactions / wallet_transactions:

  • No REFERENCES wallet_transactions(id) — wallet already partitioned by created_at
  • No REFERENCES payment_transactions(id) — same reason
  • No REFERENCES cash_transactions(id) from events — use cash_transaction_id + cash_transaction_created_at (denormalize parent partition key) + validate in biz layer

cash_transaction_events.cash_transaction_created_at = snapshot of cash_transactions.created_at at event insert → join with partition pruning:

SELECT e.*
FROM cash_transaction_events e
JOIN cash_transactions t
ON t.id = e.cash_transaction_id
AND t.created_at = e.cash_transaction_created_at
WHERE e.cash_transaction_id = $1;

Operations

  • Add both tables to PARTITIONED_TABLES in partitionManager.js
  • Cron/ops: partition:create-year before new year — insert into month without partition → runtime fail
  • List/filter UI defaults to created_at / received_at → good partition pruning

Enums

cash_transaction_flow

ValueFlow
admin_super_agentAdmin → Super Agent
super_agent_agentSuper Agent → Agent
agent_shopAgent → Shop
shop_player_depositShop/Cashier → Player (counter deposit) — Phase 2
shop_player_redemptionPlayer → Shop/Cashier (counter redemption) — Phase 3

hierarchy_owner_type (seller / buyer / performer)

ValueMeaning
platformAdmin / platform (seller when Admin sells)
super_agentSuper Agent
agentAgent
shopShop
playerPlayer (end user) — Phase 2 counter

cash_transaction_status

ValueUI labelV1
paid_fullPaid in full — credits issued✅ Default on create
pending_paymentPending payment — credits issuedLater phase
partially_paidPartially paidLater phase
cancelledCancelledLater phase

cash_payment_method

ValueUI
cashCash
bank_transferBank transfer
checkCheck
otherOther

cash_transaction_event_type

ValueWhen
createdTransaction created
status_changedStatus changed (later phase)
amount_updatedCash received changed (later phase)
note_addedNote added (later phase)
cancelledTransaction cancelled (later phase)

Table cash_transactions

Columns

ColumnTypeNullDescription
idUUIDNOPK (composite with created_at)
transaction_codeVARCHAR(32)NODisplay code, e.g. CTX-20260620-001 — app unique + UNIQUE (transaction_code, created_at)
flowENUM(cash_transaction_flow)NOHierarchy flow
seller_typeENUM(hierarchy_owner_type)NOplatform | super_agent | agent
seller_idUUIDYESNULL when seller_type = platform
buyer_typeENUM(hierarchy_owner_type)NOsuper_agent | agent | shop
buyer_idUUIDNOEntity receiving credits
credit_amountDECIMAL(18, 4)NOCredits issued to buyer (> 0)
buyer_cost_rateDECIMAL(5, 4)NOBuyer cost rate snapshot at time of sale
expected_cashDECIMAL(15, 2)NOcredit_amount × buyer_cost_rate
cash_receivedDECIMAL(15, 2)NOActual amount received
currencyVARCHAR(3)NODefault USD
payment_methodENUM(cash_payment_method)NOReceipt method
received_atTIMESTAMPTZNOWhen payment received was confirmed
statusENUM(cash_transaction_status)NODefault paid_full (V1)
external_referenceVARCHAR(128)YESExternal receipt / transfer code
noteTEXTYESNote
performed_by_typeVARCHAR(32)NOadmin | super_agent | agent | cashier | shop
performed_by_idUUIDNOUser/admin who performed action
payment_transaction_idINTEGERYESpayment_transactions.id (app-level, no FK)
credit_wallet_transaction_idUUIDYESwallet_transactions.id (app-level, no FK)
debit_wallet_transaction_idUUIDYESwallet_transactions.id (app-level, no FK)
buyer_balance_afterDECIMAL(18, 4)YESBuyer CDN balance snapshot after issuance
cashier_transaction_idUUIDYEScashier_transactions.id (counter path) — Phase 2
shift_idUUIDYESDenormalized shift — Phase 2
metadataJSONBNODefault {} — buyer name snapshot, IP, grossMargin, etc.
created_atTIMESTAMPTZNO
updated_atTIMESTAMPTZNO

Constraints (CHECK)

-- credit_amount > 0
-- buyer_cost_rate > 0 AND buyer_cost_rate <= 1
-- expected_cash > 0
-- cash_received >= 0
-- V1: status = paid_full AND cash_received = expected_cash (enforce app layer or temporary CHECK)
-- seller_id IS NULL IFF seller_type = 'platform'
-- buyer_type must match flow (app validation)

Rules by flow

Flowseller_typeseller_idbuyer_typedebit_wallet_tx
admin_super_agentplatformNULLsuper_agentNULL
super_agent_agentsuper_agentSA uuidagentyes
agent_shopagentagent uuidshopyes
shop_player_depositshopshop uuidplayeryes (shop debit)
shop_player_redemptionplayerplayer uuidshopyes (player debit)

Indexes

IndexColumnsPurpose
PK(id, created_at)Partitioned PK
UNIQUE(transaction_code, created_at)Code lookup (PG partition rule)
(flow, created_at DESC)List filter by flow
(buyer_type, buyer_id, created_at DESC)History by buyer
(seller_type, seller_id, created_at DESC)History by seller
(status, created_at DESC)Filter status
(received_at DESC)Filter date range
(payment_transaction_id)Join payment
(credit_wallet_transaction_id)Join wallet credit

Table cash_transaction_events

ColumnTypeNullDescription
idUUIDNOPK (composite with created_at)
cash_transaction_idUUIDNOcash_transactions.id (app-level, no FK)
cash_transaction_created_atTIMESTAMPTZNOSnapshot of cash_transactions.created_at — join + partition pruning
event_typeENUM(cash_transaction_event_type)NO
actor_typeVARCHAR(32)NOadmin / super_agent / agent / system
actor_idUUIDYESNULL if system
payloadJSONBNODefault {} — before/after values
created_atTIMESTAMPTZNOPartition key (event time)

Index:

IndexColumnsPurpose
PK(id, created_at)Partitioned PK
(cash_transaction_id, cash_transaction_created_at, created_at ASC)Timeline by transaction

V1: insert only 1 created row when creating transaction (cash_transaction_created_at = parent created_at).


Reference DDL (PostgreSQL, partitioned)

CREATE TYPE cash_transaction_flow AS ENUM (
'admin_super_agent',
'super_agent_agent',
'agent_shop'
);

CREATE TYPE hierarchy_owner_type AS ENUM (
'platform',
'super_agent',
'agent',
'shop'
);

CREATE TYPE cash_transaction_status AS ENUM (
'paid_full',
'pending_payment',
'partially_paid',
'cancelled'
);

CREATE TYPE cash_payment_method AS ENUM (
'cash',
'bank_transfer',
'check',
'other'
);

CREATE TYPE cash_transaction_event_type AS ENUM (
'created',
'status_changed',
'amount_updated',
'note_added',
'cancelled'
);

-- Parent: monthly RANGE on created_at
CREATE TABLE cash_transactions (
id UUID NOT NULL DEFAULT gen_random_uuid(),
transaction_code VARCHAR(32) NOT NULL,
flow cash_transaction_flow NOT NULL,
seller_type hierarchy_owner_type NOT NULL,
seller_id UUID NULL,
buyer_type hierarchy_owner_type NOT NULL,
buyer_id UUID NOT NULL,
credit_amount DECIMAL(18, 4) NOT NULL,
buyer_cost_rate DECIMAL(5, 4) NOT NULL,
expected_cash DECIMAL(15, 2) NOT NULL,
cash_received DECIMAL(15, 2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
payment_method cash_payment_method NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
status cash_transaction_status NOT NULL DEFAULT 'paid_full',
external_reference VARCHAR(128) NULL,
note TEXT NULL,
performed_by_type VARCHAR(32) NOT NULL,
performed_by_id UUID NOT NULL,
payment_transaction_id INTEGER NULL,
credit_wallet_transaction_id UUID NULL,
debit_wallet_transaction_id UUID NULL,
buyer_balance_after DECIMAL(18, 4) NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at),
CONSTRAINT cash_tx_transaction_code_created_at_unique UNIQUE (transaction_code, created_at),
CONSTRAINT cash_tx_credit_amount_positive CHECK (credit_amount > 0),
CONSTRAINT cash_tx_cost_rate_range CHECK (buyer_cost_rate > 0 AND buyer_cost_rate <= 1),
CONSTRAINT cash_tx_expected_cash_positive CHECK (expected_cash > 0),
CONSTRAINT cash_tx_cash_received_non_negative CHECK (cash_received >= 0),
CONSTRAINT cash_tx_platform_seller CHECK (
(seller_type = 'platform' AND seller_id IS NULL)
OR (seller_type <> 'platform' AND seller_id IS NOT NULL)
)
) PARTITION BY RANGE (created_at);

-- Partitions 2025-01 .. 2030-12 (repeat per month)
CREATE TABLE cash_transactions_2025_01 PARTITION OF cash_transactions
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- ... cash_transactions_YYYY_MM ...

CREATE INDEX cash_transactions_flow_created_at_idx
ON cash_transactions (flow, created_at DESC);

CREATE INDEX cash_transactions_buyer_idx
ON cash_transactions (buyer_type, buyer_id, created_at DESC);

CREATE INDEX cash_transactions_seller_idx
ON cash_transactions (seller_type, seller_id, created_at DESC);

CREATE INDEX cash_transactions_status_created_at_idx
ON cash_transactions (status, created_at DESC);

CREATE INDEX cash_transactions_received_at_idx
ON cash_transactions (received_at DESC);

CREATE INDEX cash_transactions_payment_transaction_id_idx
ON cash_transactions (payment_transaction_id);

CREATE INDEX cash_transactions_credit_wallet_tx_id_idx
ON cash_transactions (credit_wallet_transaction_id);

CREATE TABLE cash_transaction_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
cash_transaction_id UUID NOT NULL,
cash_transaction_created_at TIMESTAMPTZ NOT NULL,
event_type cash_transaction_event_type NOT NULL,
actor_type VARCHAR(32) NOT NULL,
actor_id UUID NULL,
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE cash_transaction_events_2025_01 PARTITION OF cash_transaction_events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- ... cash_transaction_events_YYYY_MM ...

CREATE INDEX cash_transaction_events_tx_timeline_idx
ON cash_transaction_events (cash_transaction_id, cash_transaction_created_at, created_at ASC);

Provision partitions: add cash_transactions and cash_transaction_events to PARTITIONED_TABLES in kioskgaming_backend/src/database/scripts/partitionManager.js, then run npm run partition:create-year -- 2031 when extending.


Generating transaction_code

Suggested format: CTX-YYYYMMDD-NNNN

  • NNNN = daily sequence (pad 4) or random suffix
  • Unique constraint on transaction_code column
  • Generate in biz layer on INSERT

Sample data

Admin → Super Agent

FieldValue
flowadmin_super_agent
sellerplatform / NULL
buyersuper_agent / {sa-alpha-uuid}
credit_amount10000
buyer_cost_rate0.10
expected_cash1000.00
cash_received1000.00
payment_methodcash
statuspaid_full
debit_wallet_transaction_idNULL

Super Agent → Agent

FieldValue
flowsuper_agent_agent
sellersuper_agent / {sa-uuid}
buyeragent / {agent-uuid}
buyer_cost_rate0.15 (Agent rate)
debit_wallet_transaction_id{debit-sa-wallet-tx}

Agent → Shop

FieldValue
flowagent_shop
selleragent / {agent-uuid}
buyershop / {shop-uuid}
buyer_cost_rate0.80 (Shop rate)

Shop → Player (counter deposit) — Phase 2

FieldValue
flowshop_player_deposit
sellershop / {shop-uuid}
buyerplayer / {player-uuid}
credit_amount50
buyer_cost_rateN/A or snapshot shop.costRate for margin metadata
expected_cash50.00 (V1: 1:1 face value)
cash_received50.00
payment_methodcash (cashier) or card / bank_transfer (shop portal)
metadata.grossMargin50 × (1 − 0.80) = 10.00
cashier_transaction_id{cashier-tx-uuid} if via Cashier portal

Player → Shop (counter redemption) — Phase 2

FieldValue
flowshop_player_redemption
sellerplayer / {player-uuid}
buyershop / {shop-uuid}
credit_amount30
expected_cash30.00 (V1: 1:1)
cash_received30.00
payment_methodcash
debit_wallet_transaction_idplayer wallet debit

Counter flows — design notes

Phase 2 — Deposit (shop_player_deposit)

  1. Auto-settled: V1 only status = paid_full.
  2. Margin: metadata.grossMargin = credit_amount × (1 − shop.costRate).
  3. Entry points: Cashier (has cashier_transaction_id) vs Shop Manager manual.

Spec: shop-cashier-to-player · Tasks: tasks-shop-cashier-to-player

Phase 3 — Redemption (shop_player_redemption)

  1. Auto-settled when wallet transfer completed.
  2. No margin — 1:1 face value cash paid.
  3. Approval: Do not create cash_transactions at pending_approval; only after approve + transfer.
  4. Shift: negative cashDelta → Cash Out in reconciliation.

Spec: player-to-shop-cashier · Tasks: tasks-player-to-shop-cashier


metadata JSON (suggested)

{
"buyerName": "SA-Alpha",
"sellerName": "Platform",
"playerId": "uuid",
"playerUsername": "player_john88",
"cashierId": "uuid",
"shiftId": "uuid",
"grossMargin": 10.0,
"ipAddress": "203.0.113.1",
"userAgent": "Mozilla/5.0 …",
"legacyProvider": "admin_cash"
}

Do not store image files / proof attachments (out of scope V1).


Migration & Sequelize

TaskSuggested file
Migrationkioskgaming_backend/src/database/migrations/YYYYMMDDHHMMSS-create-cash-transactions-partitioned.js
Partition opsAdd to partitionManager.jsPARTITIONED_TABLES
ModelCashTransaction.js, CashTransactionEvent.js
Constantssrc/constants/cashTransaction.js

Implementation order:

  1. Migration creates ENUM + 2 partitioned tables + pre-create partitions 2025-012030-12
  2. Register in partitionManager.js
  3. Sequelize models + associations (constraints: false for wallet/payment refs)
  4. Biz service creates row + created event (with cash_transaction_created_at) in same DB transaction as wallet transfer
  5. Backfill not required V1 — old transactions still queried via payment_transactions

Sequelize relationships (draft)

CashTransaction.belongsTo(PaymentTransaction, {
foreignKey: 'paymentTransactionId',
constraints: false,
});
CashTransaction.belongsTo(WalletTransaction, {
as: 'creditWalletTransaction',
foreignKey: 'creditWalletTransactionId',
constraints: false,
});
CashTransaction.belongsTo(WalletTransaction, {
as: 'debitWalletTransaction',
foreignKey: 'debitWalletTransactionId',
constraints: false,
});
CashTransaction.hasMany(CashTransactionEvent, {
foreignKey: 'cashTransactionId',
as: 'events',
constraints: false,
});
// Join events: WHERE cash_transaction_id + cash_transaction_created_at

Design notes

  1. Does not replace payment_transactions — still creates audit row (admin_cash, super_agent_cash, agent_cash) and links payment_transaction_id.
  2. expected_cash stored as snapshot — not recalculated from buyer's current cost rate.
  3. V1 only creates with status = paid_full; status column + events prepare for later receivables / cancel phases.
  4. Detail view-only reads from cash_transactions + events + join wallet/payment as needed.
  5. Monthly partition on created_at — same pattern as 8 existing tables; no FK to other partitioned tables.
  6. Lookup by id should include created_at (or use transaction_code) for partition pruning.