619 lines
25 KiB
PL/PgSQL
619 lines
25 KiB
PL/PgSQL
-- =====================================================================
|
|
-- Migration 0005 — Inventory and e-float (roadmap Step 6).
|
|
--
|
|
-- Two parallel stock systems for a cell shop:
|
|
--
|
|
-- 1. Physical inventory: scratch cards (with serials), SIMs, phones,
|
|
-- accessories. Voucher serials track per-card lifecycle so the
|
|
-- same card can never be sold twice and "lost" cards are visible.
|
|
--
|
|
-- 2. Electronic float: OMT cash float, Alfa/touch e-recharge wallet,
|
|
-- whish, etc. Every recharge or transfer must move e-float in
|
|
-- lockstep with cash, otherwise reconciliation fails.
|
|
--
|
|
-- Threat-model rows addressed: 3, 4, 5, 13, 21, 22.
|
|
-- =====================================================================
|
|
|
|
-- =====================================================================
|
|
-- Items and physical stock
|
|
-- =====================================================================
|
|
do $$ begin
|
|
create type app.item_type as enum (
|
|
'scratch_card', 'sim', 'phone', 'accessory', 'consumable'
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
do $$ begin
|
|
create type app.stock_movement_type as enum (
|
|
'purchase_in', -- received from distributor
|
|
'sale_out', -- linked to a transaction
|
|
'return_in', -- customer return
|
|
'damaged_out', -- write-off (manager approval)
|
|
'lost_out', -- write-off (manager approval)
|
|
'transfer_in', -- between shops
|
|
'transfer_out',
|
|
'adjustment_in', -- audited correction
|
|
'adjustment_out'
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
do $$ begin
|
|
create type app.voucher_status as enum (
|
|
'in_stock', 'sold', 'damaged', 'lost', 'returned'
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
create table if not exists app.items (
|
|
sku text primary key,
|
|
name text not null,
|
|
type app.item_type not null,
|
|
operator text, -- 'ALFA','TOUCH', null for non-recharge
|
|
face_value_usd numeric(14,2), -- recharge denomination if applicable
|
|
cost_usd numeric(14,2) not null check (cost_usd >= 0),
|
|
price_usd numeric(14,2) not null check (price_usd >= 0),
|
|
is_active boolean not null default true,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
-- Per-shop stock-on-hand counter (denormalized, kept in sync by trigger).
|
|
create table if not exists app.stock_on_hand (
|
|
sku text not null references app.items(sku),
|
|
shop_id uuid not null references app.shops(id),
|
|
qty integer not null default 0 check (qty >= 0),
|
|
primary key (sku, shop_id)
|
|
);
|
|
|
|
create table if not exists app.stock_lots (
|
|
id uuid primary key default gen_random_uuid(),
|
|
sku text not null references app.items(sku),
|
|
shop_id uuid not null references app.shops(id),
|
|
received_at timestamptz not null default now(),
|
|
qty_received integer not null check (qty_received > 0),
|
|
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
|
|
supplier text,
|
|
invoice_no text,
|
|
received_by uuid not null references auth.users(id) default auth.uid(),
|
|
created_at timestamptz not null default now()
|
|
);
|
|
create index if not exists idx_stock_lots_sku_shop on app.stock_lots(sku, shop_id);
|
|
|
|
-- Append-only stock movements ledger -----------------------------------
|
|
create table if not exists app.stock_movements (
|
|
id uuid primary key default gen_random_uuid(),
|
|
sku text not null references app.items(sku),
|
|
shop_id uuid not null references app.shops(id),
|
|
shift_id uuid references app.shifts(id),
|
|
type app.stock_movement_type not null,
|
|
-- Signed: positive = +stock (purchase_in, return_in, transfer_in, adjustment_in)
|
|
-- negative = -stock (sale_out, damaged_out, lost_out, transfer_out, adjustment_out)
|
|
qty_delta integer not null check (qty_delta <> 0),
|
|
ref_txn_id uuid references app.transactions(id),
|
|
ref_lot_id uuid references app.stock_lots(id),
|
|
approved_by uuid references auth.users(id), -- required for damaged/lost/adjustment
|
|
reason text,
|
|
created_at timestamptz not null default now(),
|
|
created_by uuid not null references auth.users(id) default auth.uid()
|
|
);
|
|
create index if not exists idx_stock_mov_sku_shop on app.stock_movements(sku, shop_id, created_at desc);
|
|
create index if not exists idx_stock_mov_txn on app.stock_movements(ref_txn_id);
|
|
|
|
-- =====================================================================
|
|
-- Voucher inventory (per-serial lifecycle)
|
|
-- =====================================================================
|
|
create table if not exists app.voucher_inventory (
|
|
serial text primary key,
|
|
sku text not null references app.items(sku),
|
|
shop_id uuid not null references app.shops(id),
|
|
lot_id uuid references app.stock_lots(id),
|
|
status app.voucher_status not null default 'in_stock',
|
|
received_at timestamptz not null default now(),
|
|
sold_txn_id uuid references app.transactions(id),
|
|
sold_at timestamptz,
|
|
status_changed_by uuid references auth.users(id),
|
|
status_change_reason text,
|
|
constraint voucher_status_consistency check (
|
|
(status = 'in_stock' and sold_txn_id is null and sold_at is null)
|
|
or (status = 'sold' and sold_txn_id is not null and sold_at is not null)
|
|
or (status in ('damaged','lost','returned')
|
|
and sold_txn_id is null and sold_at is null)
|
|
)
|
|
);
|
|
create index if not exists idx_voucher_status on app.voucher_inventory(status);
|
|
create index if not exists idx_voucher_sku_shop on app.voucher_inventory(sku, shop_id);
|
|
|
|
-- =====================================================================
|
|
-- E-float (OMT cash float, Alfa e-recharge wallet, etc.)
|
|
-- =====================================================================
|
|
do $$ begin
|
|
create type app.float_provider as enum (
|
|
'OMT_CASH', 'OMT_DIGITAL', 'ALFA_ERECHARGE', 'TOUCH_ERECHARGE',
|
|
'OGERO_ERECHARGE', 'WHISH', 'CARD_TERMINAL', 'BANK'
|
|
);
|
|
exception when duplicate_object then null; end $$;
|
|
|
|
create table if not exists app.floats (
|
|
id uuid primary key default gen_random_uuid(),
|
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
|
provider app.float_provider not null,
|
|
currency app.currency_code not null,
|
|
is_active boolean not null default true,
|
|
created_at timestamptz not null default now(),
|
|
unique (shop_id, provider, currency)
|
|
);
|
|
|
|
-- Cached balance per float, kept in sync by the movements trigger.
|
|
create table if not exists app.float_balances (
|
|
float_id uuid primary key references app.floats(id) on delete cascade,
|
|
balance numeric(20,2) not null default 0,
|
|
updated_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists app.float_movements (
|
|
id uuid primary key default gen_random_uuid(),
|
|
float_id uuid not null references app.floats(id) on delete restrict,
|
|
shift_id uuid references app.shifts(id),
|
|
occurred_at timestamptz not null default now(),
|
|
-- Signed: + adds to e-float, - removes from it.
|
|
amount numeric(20,2) not null check (amount <> 0),
|
|
ref_txn_id uuid references app.transactions(id),
|
|
ref_settlement_id uuid, -- FK added in 0007
|
|
reason text,
|
|
created_at timestamptz not null default now(),
|
|
created_by uuid not null references auth.users(id) default auth.uid()
|
|
);
|
|
create index if not exists idx_float_mov_float on app.float_movements(float_id, occurred_at);
|
|
create index if not exists idx_float_mov_txn on app.float_movements(ref_txn_id);
|
|
|
|
-- =====================================================================
|
|
-- Triggers — append-only, balance maintenance, no negative stock
|
|
-- =====================================================================
|
|
|
|
-- Stock movements: append-only.
|
|
create or replace function app._stock_mov_no_update_delete()
|
|
returns trigger language plpgsql as $$
|
|
begin raise exception 'stock_movements is append-only'; end;
|
|
$$;
|
|
drop trigger if exists trg_stock_mov_freeze on app.stock_movements;
|
|
create trigger trg_stock_mov_freeze before update or delete on app.stock_movements
|
|
for each row execute function app._stock_mov_no_update_delete();
|
|
|
|
-- Stock movements: server-stamped, sign matches type, optional manager
|
|
-- approval enforced for write-offs.
|
|
create or replace function app._stock_mov_before_insert()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
new.created_at := now();
|
|
new.created_by := auth.uid();
|
|
|
|
-- Sign / type consistency.
|
|
if new.type in ('purchase_in','return_in','transfer_in','adjustment_in')
|
|
and new.qty_delta <= 0 then
|
|
raise exception '% must have qty_delta > 0', new.type;
|
|
end if;
|
|
if new.type in ('sale_out','damaged_out','lost_out','transfer_out','adjustment_out')
|
|
and new.qty_delta >= 0 then
|
|
raise exception '% must have qty_delta < 0', new.type;
|
|
end if;
|
|
|
|
-- Write-offs and adjustments need manager approval.
|
|
if new.type in ('damaged_out','lost_out','adjustment_in','adjustment_out')
|
|
and new.approved_by is null then
|
|
raise exception '% requires manager approval (approved_by)', new.type;
|
|
end if;
|
|
|
|
-- sale_out must reference a real, completed sale of the same shop.
|
|
if new.type = 'sale_out' then
|
|
if new.ref_txn_id is null then
|
|
raise exception 'sale_out requires ref_txn_id';
|
|
end if;
|
|
if not exists (
|
|
select 1 from app.transactions
|
|
where id = new.ref_txn_id and shop_id = new.shop_id and status = 'completed'
|
|
) then
|
|
raise exception 'sale_out must reference a completed txn in the same shop';
|
|
end if;
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_stock_mov_before_insert on app.stock_movements;
|
|
create trigger trg_stock_mov_before_insert before insert on app.stock_movements
|
|
for each row execute function app._stock_mov_before_insert();
|
|
|
|
-- Maintain stock_on_hand. No negative balance allowed.
|
|
create or replace function app._stock_on_hand_apply()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
insert into app.stock_on_hand(sku, shop_id, qty)
|
|
values (new.sku, new.shop_id, new.qty_delta)
|
|
on conflict (sku, shop_id) do update
|
|
set qty = app.stock_on_hand.qty + new.qty_delta;
|
|
-- Re-check; the CHECK on the table will already reject negatives but
|
|
-- give a clearer error here.
|
|
if (select qty from app.stock_on_hand
|
|
where sku = new.sku and shop_id = new.shop_id) < 0 then
|
|
raise exception 'stock would go negative for sku=% shop=%', new.sku, new.shop_id;
|
|
end if;
|
|
return null;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_stock_on_hand_apply on app.stock_movements;
|
|
create trigger trg_stock_on_hand_apply after insert on app.stock_movements
|
|
for each row execute function app._stock_on_hand_apply();
|
|
|
|
-- Stock_lots: receiving stock auto-creates a purchase_in movement.
|
|
create or replace function app._stock_lot_after_insert()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
insert into app.stock_movements(sku, shop_id, type, qty_delta, ref_lot_id, reason)
|
|
values (new.sku, new.shop_id, 'purchase_in', new.qty_received, new.id,
|
|
coalesce('lot ' || new.invoice_no, 'lot received'));
|
|
return null;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_stock_lot_after_insert on app.stock_lots;
|
|
create trigger trg_stock_lot_after_insert after insert on app.stock_lots
|
|
for each row execute function app._stock_lot_after_insert();
|
|
|
|
-- Float movements: append-only + balance.
|
|
create or replace function app._float_mov_no_update_delete()
|
|
returns trigger language plpgsql as $$
|
|
begin raise exception 'float_movements is append-only'; end;
|
|
$$;
|
|
drop trigger if exists trg_float_mov_freeze on app.float_movements;
|
|
create trigger trg_float_mov_freeze before update or delete on app.float_movements
|
|
for each row execute function app._float_mov_no_update_delete();
|
|
|
|
create or replace function app._float_balance_apply()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
insert into app.float_balances(float_id, balance, updated_at)
|
|
values (new.float_id, new.amount, now())
|
|
on conflict (float_id) do update
|
|
set balance = app.float_balances.balance + new.amount,
|
|
updated_at = now();
|
|
if (select balance from app.float_balances where float_id = new.float_id) < 0 then
|
|
raise exception 'float would go negative for float_id=%', new.float_id;
|
|
end if;
|
|
return null;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_float_balance_apply on app.float_movements;
|
|
create trigger trg_float_balance_apply after insert on app.float_movements
|
|
for each row execute function app._float_balance_apply();
|
|
|
|
-- =====================================================================
|
|
-- Recharge ↔ stock/float coupling
|
|
-- A recharge_details row MUST move either physical stock (voucher) or
|
|
-- e-float, otherwise it is a free recharge — exactly the fraud we want
|
|
-- to make impossible (vectors #3, #21).
|
|
-- Implemented as a deferred constraint trigger so the client can write
|
|
-- the recharge row first, then the movement, in a single transaction.
|
|
-- =====================================================================
|
|
create or replace function app._recharge_require_movement()
|
|
returns trigger
|
|
language plpgsql
|
|
as $$
|
|
declare
|
|
has_voucher_movement boolean;
|
|
has_float_movement boolean;
|
|
v_provider app.float_provider;
|
|
begin
|
|
if new.voucher_serial is not null then
|
|
-- The voucher must be marked sold and tied to this txn.
|
|
select exists(
|
|
select 1 from app.voucher_inventory
|
|
where serial = new.voucher_serial
|
|
and status = 'sold'
|
|
and sold_txn_id = new.txn_id
|
|
) into has_voucher_movement;
|
|
if not has_voucher_movement then
|
|
raise exception
|
|
'recharge with voucher_serial=% must be paired with a sold voucher',
|
|
new.voucher_serial;
|
|
end if;
|
|
else
|
|
-- E-recharge: an e-float debit must exist for this txn against the
|
|
-- matching operator's e-float account.
|
|
v_provider := case new.operator
|
|
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
|
|
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
|
|
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
|
|
else null
|
|
end;
|
|
if v_provider is null then
|
|
-- Unmapped operator (IDM, CYBERIA, TERRANET): require any negative
|
|
-- float movement for this txn.
|
|
select exists(
|
|
select 1 from app.float_movements
|
|
where ref_txn_id = new.txn_id and amount < 0
|
|
) into has_float_movement;
|
|
else
|
|
select exists(
|
|
select 1
|
|
from app.float_movements fm
|
|
join app.floats f on f.id = fm.float_id
|
|
join app.transactions t on t.id = fm.ref_txn_id
|
|
where fm.ref_txn_id = new.txn_id
|
|
and fm.amount < 0
|
|
and f.provider = v_provider
|
|
and f.shop_id = t.shop_id
|
|
) into has_float_movement;
|
|
end if;
|
|
if not has_float_movement then
|
|
raise exception
|
|
'e-recharge txn % must be paired with a negative e-float movement',
|
|
new.txn_id;
|
|
end if;
|
|
end if;
|
|
return null;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists trg_recharge_require_movement on app.recharge_details;
|
|
create constraint trigger trg_recharge_require_movement
|
|
after insert on app.recharge_details
|
|
deferrable initially deferred
|
|
for each row execute function app._recharge_require_movement();
|
|
|
|
-- Goods sale ↔ stock_movement coupling (same idea).
|
|
create or replace function app._goods_sale_require_movement()
|
|
returns trigger
|
|
language plpgsql
|
|
as $$
|
|
declare ok boolean;
|
|
begin
|
|
select exists(
|
|
select 1 from app.stock_movements sm
|
|
where sm.ref_txn_id = new.txn_id
|
|
and sm.sku = new.sku
|
|
and sm.type = 'sale_out'
|
|
and -sm.qty_delta = new.qty
|
|
) into ok;
|
|
if not ok then
|
|
raise exception 'goods sale txn % must be paired with a sale_out stock movement', new.txn_id;
|
|
end if;
|
|
return null;
|
|
end;
|
|
$$;
|
|
drop trigger if exists trg_goods_sale_require_movement on app.goods_sale_details;
|
|
create constraint trigger trg_goods_sale_require_movement
|
|
after insert on app.goods_sale_details
|
|
deferrable initially deferred
|
|
for each row execute function app._goods_sale_require_movement();
|
|
|
|
-- =====================================================================
|
|
-- SECURITY DEFINER helpers used by the cashier UI
|
|
-- =====================================================================
|
|
|
|
-- Sell a scratch card: marks the voucher sold + creates the stock_out.
|
|
-- Called inside the same transaction as inserting the txn + recharge_details.
|
|
create or replace function app.sell_voucher(
|
|
p_txn_id uuid,
|
|
p_serial text
|
|
) returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare v app.voucher_inventory%rowtype;
|
|
t app.transactions%rowtype;
|
|
begin
|
|
select * into t from app.transactions where id = p_txn_id;
|
|
if t.id is null then raise exception 'txn not found'; end if;
|
|
if t.user_id <> auth.uid() then
|
|
raise exception 'only the txn owner may sell a voucher against it';
|
|
end if;
|
|
|
|
select * into v from app.voucher_inventory where serial = p_serial for update;
|
|
if v.serial is null then raise exception 'voucher % not found', p_serial; end if;
|
|
if v.shop_id <> t.shop_id then
|
|
raise exception 'voucher belongs to a different shop';
|
|
end if;
|
|
if v.status <> 'in_stock' then
|
|
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
|
|
end if;
|
|
|
|
update app.voucher_inventory
|
|
set status = 'sold', sold_txn_id = p_txn_id, sold_at = now(),
|
|
status_changed_by = auth.uid()
|
|
where serial = p_serial;
|
|
|
|
insert into app.stock_movements(sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason)
|
|
values (v.sku, v.shop_id, t.shift_id, 'sale_out', -1, p_txn_id, 'voucher ' || p_serial);
|
|
end;
|
|
$$;
|
|
|
|
-- Mark a voucher damaged or lost (manager only, with PIN).
|
|
create or replace function app.write_off_voucher(
|
|
p_serial text,
|
|
p_status app.voucher_status,
|
|
p_reason text,
|
|
p_manager_pin text
|
|
) returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = app, public
|
|
as $$
|
|
declare v app.voucher_inventory%rowtype;
|
|
begin
|
|
if p_status not in ('damaged','lost') then
|
|
raise exception 'only damaged/lost are valid write-off statuses';
|
|
end if;
|
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
|
raise exception 'reason >= 5 chars required';
|
|
end if;
|
|
select * into v from app.voucher_inventory where serial = p_serial for update;
|
|
if v.serial is null then raise exception 'voucher not found'; end if;
|
|
if v.status <> 'in_stock' then
|
|
raise exception 'voucher must be in_stock to write off (was %)', v.status;
|
|
end if;
|
|
if not app.has_role_in_shop(v.shop_id, 'manager') then
|
|
raise exception 'manager role required';
|
|
end if;
|
|
if not app.verify_my_pin(p_manager_pin) then
|
|
raise exception 'invalid manager PIN';
|
|
end if;
|
|
|
|
update app.voucher_inventory
|
|
set status = p_status, status_changed_by = auth.uid(),
|
|
status_change_reason = p_reason
|
|
where serial = p_serial;
|
|
|
|
insert into app.stock_movements(sku, shop_id, type, qty_delta, approved_by, reason)
|
|
values (v.sku, v.shop_id,
|
|
case p_status when 'damaged' then 'damaged_out'::app.stock_movement_type
|
|
when 'lost' then 'lost_out'::app.stock_movement_type end,
|
|
-1, auth.uid(), p_reason);
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function app.sell_voucher(uuid, text) from public;
|
|
revoke all on function app.write_off_voucher(text, app.voucher_status, text, text) from public;
|
|
grant execute on function app.sell_voucher(uuid, text) to authenticated;
|
|
grant execute on function app.write_off_voucher(text, app.voucher_status, text, text) to authenticated;
|
|
|
|
-- =====================================================================
|
|
-- RLS
|
|
-- =====================================================================
|
|
alter table app.items enable row level security;
|
|
alter table app.stock_on_hand enable row level security;
|
|
alter table app.stock_lots enable row level security;
|
|
alter table app.stock_movements enable row level security;
|
|
alter table app.voucher_inventory enable row level security;
|
|
alter table app.floats enable row level security;
|
|
alter table app.float_balances enable row level security;
|
|
alter table app.float_movements enable row level security;
|
|
|
|
alter table app.items force row level security;
|
|
alter table app.stock_on_hand force row level security;
|
|
alter table app.stock_lots force row level security;
|
|
alter table app.stock_movements force row level security;
|
|
alter table app.voucher_inventory force row level security;
|
|
alter table app.floats force row level security;
|
|
alter table app.float_balances force row level security;
|
|
alter table app.float_movements force row level security;
|
|
|
|
-- Block direct UPDATE/DELETE on append-only tables.
|
|
revoke update, delete on app.stock_movements from authenticated;
|
|
revoke update, delete on app.float_movements from authenticated;
|
|
revoke update, delete on app.voucher_inventory from authenticated;
|
|
revoke update, delete on app.stock_on_hand from authenticated;
|
|
revoke update, delete on app.float_balances from authenticated;
|
|
-- Items are reference data: only owners may modify (handled by policy).
|
|
|
|
-- Items: readable by everyone authenticated; writes for owners only.
|
|
drop policy if exists items_select on app.items;
|
|
create policy items_select on app.items for select to authenticated using (true);
|
|
drop policy if exists items_write_owner on app.items;
|
|
create policy items_write_owner on app.items
|
|
for all to authenticated
|
|
using (app.is_owner_anywhere())
|
|
with check (app.is_owner_anywhere());
|
|
|
|
-- Shop-scoped tables: readable to anyone assigned to the shop.
|
|
drop policy if exists soh_select on app.stock_on_hand;
|
|
create policy soh_select on app.stock_on_hand
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
);
|
|
|
|
drop policy if exists lots_select on app.stock_lots;
|
|
create policy lots_select on app.stock_lots
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','auditor']::app.business_role[])
|
|
);
|
|
drop policy if exists lots_insert on app.stock_lots;
|
|
create policy lots_insert on app.stock_lots
|
|
for insert to authenticated
|
|
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
|
|
grant select, insert on app.stock_lots to authenticated;
|
|
|
|
drop policy if exists smov_select on app.stock_movements;
|
|
create policy smov_select on app.stock_movements
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
);
|
|
drop policy if exists smov_insert on app.stock_movements;
|
|
create policy smov_insert on app.stock_movements
|
|
for insert to authenticated
|
|
with check (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','cashier']::app.business_role[])
|
|
);
|
|
grant select, insert on app.stock_movements to authenticated;
|
|
|
|
drop policy if exists vouch_select on app.voucher_inventory;
|
|
create policy vouch_select on app.voucher_inventory
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
);
|
|
drop policy if exists vouch_insert on app.voucher_inventory;
|
|
create policy vouch_insert on app.voucher_inventory
|
|
for insert to authenticated
|
|
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
|
|
grant select, insert on app.voucher_inventory to authenticated;
|
|
-- Voucher status changes go through SECURITY DEFINER functions only.
|
|
|
|
drop policy if exists floats_select on app.floats;
|
|
create policy floats_select on app.floats
|
|
for select to authenticated
|
|
using (
|
|
app.has_any_role_in_shop(shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
);
|
|
drop policy if exists floats_write_owner on app.floats;
|
|
create policy floats_write_owner on app.floats
|
|
for all to authenticated
|
|
using (app.has_role_in_shop(shop_id, 'owner'))
|
|
with check (app.has_role_in_shop(shop_id, 'owner'));
|
|
grant select, insert, update on app.floats to authenticated;
|
|
|
|
drop policy if exists fbal_select on app.float_balances;
|
|
create policy fbal_select on app.float_balances
|
|
for select to authenticated
|
|
using (
|
|
exists (
|
|
select 1 from app.floats f
|
|
where f.id = float_balances.float_id
|
|
and app.has_any_role_in_shop(f.shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
)
|
|
);
|
|
|
|
drop policy if exists fmov_select on app.float_movements;
|
|
create policy fmov_select on app.float_movements
|
|
for select to authenticated
|
|
using (
|
|
exists (
|
|
select 1 from app.floats f
|
|
where f.id = float_movements.float_id
|
|
and app.has_any_role_in_shop(f.shop_id,
|
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
|
)
|
|
);
|
|
drop policy if exists fmov_insert on app.float_movements;
|
|
create policy fmov_insert on app.float_movements
|
|
for insert to authenticated
|
|
with check (
|
|
exists (
|
|
select 1 from app.floats f
|
|
where f.id = float_movements.float_id
|
|
and app.has_any_role_in_shop(f.shop_id,
|
|
array['owner','manager','cashier']::app.business_role[])
|
|
)
|
|
);
|
|
grant select, insert on app.float_movements to authenticated;
|
|
|
|
grant select on app.items, app.stock_on_hand, app.float_balances to authenticated;
|
|
|
|
-- End migration 0005 ----------------------------------------------------
|