Files
wyndham-Condon/backend/migrations/001_create_condon_schema.up.sql
2026-08-02 13:33:13 +08:00

714 lines
19 KiB
PL/PgSQL

CREATE SCHEMA condon;
REVOKE ALL ON SCHEMA condon FROM PUBLIC;
COMMENT ON SCHEMA condon IS 'CONDO owner entitlement backend';
CREATE TABLE condon.schema_migrations (
version varchar(64) PRIMARY KEY,
checksum varchar(64) NOT NULL,
applied_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT schema_migrations_checksum_check
CHECK (checksum ~ '^[0-9a-f]{64}$')
);
CREATE TABLE condon.room_types (
code varchar(16) PRIMARY KEY,
entitlement_tier smallint,
requires_manual_multiplier boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT room_types_code_check
CHECK (code ~ '^[A-Z0-9]+$'),
CONSTRAINT room_types_rule_shape_check
CHECK (
(requires_manual_multiplier AND entitlement_tier IS NULL)
OR
(
NOT requires_manual_multiplier
AND entitlement_tier BETWEEN 1 AND 3
)
)
);
CREATE TABLE condon.owner_accounts (
id uuid PRIMARY KEY,
account_no integer,
transfer_date date,
owner_name text NOT NULL,
room_no varchar(32) NOT NULL,
purchased_room_type_code varchar(16) NOT NULL,
unit_no varchar(32) NOT NULL,
member_no varchar(32) NOT NULL,
created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT owner_accounts_account_no_key UNIQUE (account_no),
CONSTRAINT owner_accounts_room_no_key UNIQUE (room_no),
CONSTRAINT owner_accounts_account_no_check
CHECK (account_no IS NULL OR account_no > 0),
CONSTRAINT owner_accounts_owner_name_check
CHECK (pg_catalog.btrim(owner_name) <> ''),
CONSTRAINT owner_accounts_room_no_check
CHECK (pg_catalog.btrim(room_no) <> ''),
CONSTRAINT owner_accounts_unit_no_check
CHECK (pg_catalog.btrim(unit_no) <> ''),
CONSTRAINT owner_accounts_member_no_check
CHECK (pg_catalog.btrim(member_no) <> ''),
CONSTRAINT owner_accounts_purchased_room_type_fk
FOREIGN KEY (purchased_room_type_code)
REFERENCES condon.room_types(code)
ON UPDATE RESTRICT
ON DELETE RESTRICT
);
CREATE TABLE condon.entitlement_periods (
id uuid PRIMARY KEY,
owner_account_id uuid NOT NULL,
period_year smallint NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
annual_grant integer NOT NULL DEFAULT 15,
carry_forward integer NOT NULL DEFAULT 0,
current_balance integer NOT NULL,
row_version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT entitlement_periods_owner_year_key
UNIQUE (owner_account_id, period_year),
CONSTRAINT entitlement_periods_id_owner_key
UNIQUE (id, owner_account_id),
CONSTRAINT entitlement_periods_owner_fk
FOREIGN KEY (owner_account_id)
REFERENCES condon.owner_accounts(id)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT entitlement_periods_year_check
CHECK (period_year BETWEEN 2000 AND 9999),
CONSTRAINT entitlement_periods_dates_check
CHECK (
period_start = pg_catalog.make_date(period_year::integer, 1, 1)
AND period_end = pg_catalog.make_date(period_year::integer, 12, 31)
),
CONSTRAINT entitlement_periods_annual_grant_check
CHECK (annual_grant >= 0),
CONSTRAINT entitlement_periods_carry_forward_check
CHECK (carry_forward >= 0),
CONSTRAINT entitlement_periods_current_balance_check
CHECK (current_balance >= 0),
CONSTRAINT entitlement_periods_row_version_check
CHECK (row_version >= 1)
);
CREATE TABLE condon.usage_records (
id uuid PRIMARY KEY,
owner_account_id uuid NOT NULL,
entitlement_period_id uuid NOT NULL,
confirmation_no varchar(64) NOT NULL,
check_in date NOT NULL,
check_out date NOT NULL,
night_count integer NOT NULL,
used_room_type_code varchar(16) NOT NULL,
applied_multiplier smallint NOT NULL,
rule_version varchar(32) NOT NULL DEFAULT 'v1',
use_nights integer NOT NULL,
balance_before integer NOT NULL,
balance_after integer NOT NULL,
remark text NOT NULL DEFAULT '',
idempotency_key uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT usage_records_confirmation_no_key UNIQUE (confirmation_no),
CONSTRAINT usage_records_idempotency_key_key UNIQUE (idempotency_key),
CONSTRAINT usage_records_id_period_key UNIQUE (id, entitlement_period_id),
CONSTRAINT usage_records_account_fk
FOREIGN KEY (owner_account_id)
REFERENCES condon.owner_accounts(id)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT usage_records_period_account_fk
FOREIGN KEY (entitlement_period_id, owner_account_id)
REFERENCES condon.entitlement_periods(id, owner_account_id)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT usage_records_used_room_type_fk
FOREIGN KEY (used_room_type_code)
REFERENCES condon.room_types(code)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT usage_records_confirmation_no_check
CHECK (confirmation_no ~ '^[0-9]+$'),
CONSTRAINT usage_records_dates_check
CHECK (check_out > check_in),
CONSTRAINT usage_records_same_year_check
CHECK (
EXTRACT(year FROM check_in)
= EXTRACT(year FROM check_out)
),
CONSTRAINT usage_records_night_count_check
CHECK (night_count = check_out - check_in AND night_count > 0),
CONSTRAINT usage_records_multiplier_check
CHECK (applied_multiplier BETWEEN 1 AND 3),
CONSTRAINT usage_records_use_nights_check
CHECK (
use_nights = night_count * applied_multiplier
AND use_nights > 0
),
CONSTRAINT usage_records_balance_check
CHECK (
balance_before >= use_nights
AND balance_after = balance_before - use_nights
AND balance_after >= 0
)
);
CREATE TABLE condon.entitlement_ledger (
id uuid PRIMARY KEY,
entitlement_period_id uuid NOT NULL,
usage_record_id uuid,
entry_type varchar(32) NOT NULL,
delta_nights integer NOT NULL,
balance_before integer NOT NULL,
balance_after integer NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(),
CONSTRAINT entitlement_ledger_usage_record_key
UNIQUE (usage_record_id),
CONSTRAINT entitlement_ledger_period_fk
FOREIGN KEY (entitlement_period_id)
REFERENCES condon.entitlement_periods(id)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT entitlement_ledger_usage_period_fk
FOREIGN KEY (usage_record_id, entitlement_period_id)
REFERENCES condon.usage_records(id, entitlement_period_id)
ON UPDATE RESTRICT
ON DELETE RESTRICT,
CONSTRAINT entitlement_ledger_entry_type_check
CHECK (entry_type IN ('annual_grant', 'carry_forward', 'usage')),
CONSTRAINT entitlement_ledger_balance_check
CHECK (
balance_after = balance_before + delta_nights
AND balance_after >= 0
),
CONSTRAINT entitlement_ledger_entry_shape_check
CHECK (
(
entry_type = 'usage'
AND usage_record_id IS NOT NULL
AND delta_nights < 0
)
OR
(
entry_type IN ('annual_grant', 'carry_forward')
AND usage_record_id IS NULL
AND delta_nights > 0
)
)
);
CREATE INDEX owner_accounts_member_no_idx
ON condon.owner_accounts (member_no);
CREATE INDEX owner_accounts_purchased_room_type_idx
ON condon.owner_accounts (purchased_room_type_code);
CREATE INDEX usage_records_owner_created_idx
ON condon.usage_records (owner_account_id, created_at DESC, id DESC);
CREATE INDEX usage_records_period_idx
ON condon.usage_records (entitlement_period_id);
CREATE INDEX usage_records_used_type_check_in_idx
ON condon.usage_records (used_room_type_code, check_in);
CREATE INDEX entitlement_ledger_period_occurred_idx
ON condon.entitlement_ledger (entitlement_period_id, occurred_at, id);
INSERT INTO condon.room_types (
code,
entitlement_tier,
requires_manual_multiplier
)
VALUES
('RM1', 1, false),
('RM2', 1, false),
('RM3', 1, false),
('RM4', 1, false),
('UG1', 1, false),
('UG2', 1, false),
('SU1', 2, false),
('SU2', 2, false),
('SU6', 2, false),
('SU3', 3, false),
('AC2', NULL, true);
CREATE FUNCTION condon.calculate_multiplier(
p_purchased_room_type_code varchar,
p_used_room_type_code varchar,
p_manual_multiplier smallint
)
RETURNS smallint
LANGUAGE plpgsql
STABLE
SECURITY INVOKER
SET search_path = pg_catalog
AS $function$
DECLARE
v_purchased_tier smallint;
v_used_tier smallint;
v_purchased_manual boolean;
v_used_manual boolean;
BEGIN
SELECT
entitlement_tier,
requires_manual_multiplier
INTO
v_purchased_tier,
v_purchased_manual
FROM condon.room_types
WHERE code = p_purchased_room_type_code;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_PURCHASED_ROOM_TYPE_NOT_FOUND';
END IF;
SELECT
entitlement_tier,
requires_manual_multiplier
INTO
v_used_tier,
v_used_manual
FROM condon.room_types
WHERE code = p_used_room_type_code;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_USED_ROOM_TYPE_NOT_FOUND';
END IF;
IF v_purchased_manual OR v_used_manual THEN
IF p_manual_multiplier IS NULL OR p_manual_multiplier NOT BETWEEN 1 AND 3 THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_MANUAL_MULTIPLIER_REQUIRED';
END IF;
RETURN p_manual_multiplier;
END IF;
IF p_manual_multiplier IS NOT NULL THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_MANUAL_MULTIPLIER_NOT_ALLOWED';
END IF;
RETURN GREATEST(
1,
v_used_tier - v_purchased_tier + 1
)::smallint;
END;
$function$;
CREATE FUNCTION condon.open_entitlement_period(
p_period_id uuid,
p_owner_account_id uuid,
p_period_year smallint,
p_annual_grant_ledger_id uuid,
p_carry_forward_ledger_id uuid
)
RETURNS condon.entitlement_periods
LANGUAGE plpgsql
VOLATILE
SECURITY INVOKER
SET search_path = pg_catalog
AS $function$
DECLARE
v_carry_forward integer := 0;
v_period condon.entitlement_periods%ROWTYPE;
v_now timestamptz := pg_catalog.clock_timestamp();
BEGIN
IF p_period_id IS NULL
OR p_owner_account_id IS NULL
OR p_annual_grant_ledger_id IS NULL
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_REQUIRED_ID_MISSING';
END IF;
IF p_period_year NOT BETWEEN 2000 AND 9999 THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_INVALID_PERIOD_YEAR';
END IF;
PERFORM 1
FROM condon.owner_accounts
WHERE id = p_owner_account_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'P0002',
MESSAGE = 'CONDON_OWNER_ACCOUNT_NOT_FOUND';
END IF;
IF EXISTS (
SELECT 1
FROM condon.entitlement_periods
WHERE owner_account_id = p_owner_account_id
AND period_year = p_period_year
) THEN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_EXISTS';
END IF;
SELECT current_balance
INTO v_carry_forward
FROM condon.entitlement_periods
WHERE owner_account_id = p_owner_account_id
AND period_year = p_period_year - 1
FOR UPDATE;
v_carry_forward := COALESCE(v_carry_forward, 0);
IF v_carry_forward > 0 AND p_carry_forward_ledger_id IS NULL THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_CARRY_FORWARD_LEDGER_ID_REQUIRED';
END IF;
IF v_carry_forward = 0 AND p_carry_forward_ledger_id IS NOT NULL THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_CARRY_FORWARD_LEDGER_ID_NOT_ALLOWED';
END IF;
INSERT INTO condon.entitlement_periods (
id,
owner_account_id,
period_year,
period_start,
period_end,
annual_grant,
carry_forward,
current_balance,
row_version,
created_at,
updated_at
)
VALUES (
p_period_id,
p_owner_account_id,
p_period_year,
pg_catalog.make_date(p_period_year::integer, 1, 1),
pg_catalog.make_date(p_period_year::integer, 12, 31),
15,
v_carry_forward,
15 + v_carry_forward,
1,
v_now,
v_now
)
RETURNING * INTO v_period;
INSERT INTO condon.entitlement_ledger (
id,
entitlement_period_id,
usage_record_id,
entry_type,
delta_nights,
balance_before,
balance_after,
occurred_at
)
VALUES (
p_annual_grant_ledger_id,
p_period_id,
NULL,
'annual_grant',
15,
0,
15,
v_now
);
IF v_carry_forward > 0 THEN
INSERT INTO condon.entitlement_ledger (
id,
entitlement_period_id,
usage_record_id,
entry_type,
delta_nights,
balance_before,
balance_after,
occurred_at
)
VALUES (
p_carry_forward_ledger_id,
p_period_id,
NULL,
'carry_forward',
v_carry_forward,
15,
15 + v_carry_forward,
v_now
);
END IF;
RETURN v_period;
END;
$function$;
CREATE FUNCTION condon.create_usage_record(
p_usage_record_id uuid,
p_ledger_id uuid,
p_owner_account_id uuid,
p_confirmation_no varchar,
p_check_in date,
p_check_out date,
p_used_room_type_code varchar,
p_manual_multiplier smallint,
p_remark text,
p_idempotency_key uuid
)
RETURNS condon.usage_records
LANGUAGE plpgsql
VOLATILE
SECURITY INVOKER
SET search_path = pg_catalog
AS $function$
DECLARE
v_purchased_room_type_code varchar(16);
v_multiplier smallint;
v_night_count integer;
v_use_nights integer;
v_balance_before integer;
v_balance_after integer;
v_period condon.entitlement_periods%ROWTYPE;
v_existing condon.usage_records%ROWTYPE;
v_usage condon.usage_records%ROWTYPE;
v_now timestamptz := pg_catalog.clock_timestamp();
v_updated_count integer;
BEGIN
IF p_usage_record_id IS NULL
OR p_ledger_id IS NULL
OR p_owner_account_id IS NULL
OR p_idempotency_key IS NULL
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_REQUIRED_ID_MISSING';
END IF;
IF p_confirmation_no IS NULL
OR p_confirmation_no !~ '^[0-9]+$'
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_INVALID_CONFIRMATION_NO';
END IF;
IF p_check_in IS NULL
OR p_check_out IS NULL
OR p_check_out <= p_check_in
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_INVALID_STAY_DATES';
END IF;
IF EXTRACT(year FROM p_check_in)
<> EXTRACT(year FROM p_check_out)
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_CROSS_YEAR_STAY_NOT_SUPPORTED';
END IF;
SELECT purchased_room_type_code
INTO v_purchased_room_type_code
FROM condon.owner_accounts
WHERE id = p_owner_account_id
FOR SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'P0002',
MESSAGE = 'CONDON_OWNER_ACCOUNT_NOT_FOUND';
END IF;
v_multiplier := condon.calculate_multiplier(
v_purchased_room_type_code,
p_used_room_type_code,
p_manual_multiplier
);
v_night_count := p_check_out - p_check_in;
v_use_nights := v_night_count * v_multiplier;
PERFORM pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_idempotency_key::text, 0)
);
SELECT *
INTO v_existing
FROM condon.usage_records
WHERE idempotency_key = p_idempotency_key;
IF FOUND THEN
IF v_existing.owner_account_id IS DISTINCT FROM p_owner_account_id
OR v_existing.confirmation_no IS DISTINCT FROM p_confirmation_no
OR v_existing.check_in IS DISTINCT FROM p_check_in
OR v_existing.check_out IS DISTINCT FROM p_check_out
OR v_existing.used_room_type_code IS DISTINCT FROM p_used_room_type_code
OR v_existing.applied_multiplier IS DISTINCT FROM v_multiplier
OR v_existing.remark IS DISTINCT FROM COALESCE(p_remark, '')
THEN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = 'CONDON_IDEMPOTENCY_KEY_REUSED';
END IF;
RETURN v_existing;
END IF;
PERFORM pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended('confirmation:' || p_confirmation_no, 0)
);
IF EXISTS (
SELECT 1
FROM condon.usage_records
WHERE confirmation_no = p_confirmation_no
) THEN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = 'CONDON_CONFIRMATION_NO_EXISTS';
END IF;
SELECT *
INTO v_period
FROM condon.entitlement_periods
WHERE owner_account_id = p_owner_account_id
AND period_year = EXTRACT(year FROM p_check_in)::smallint
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'P0002',
MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_NOT_FOUND';
END IF;
IF p_check_in < v_period.period_start
OR p_check_out > v_period.period_end
THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_STAY_OUTSIDE_ENTITLEMENT_PERIOD';
END IF;
v_balance_before := v_period.current_balance;
v_balance_after := v_balance_before - v_use_nights;
IF v_balance_after < 0 THEN
RAISE EXCEPTION USING
ERRCODE = 'P0001',
MESSAGE = 'CONDON_INSUFFICIENT_BALANCE';
END IF;
INSERT INTO condon.usage_records (
id,
owner_account_id,
entitlement_period_id,
confirmation_no,
check_in,
check_out,
night_count,
used_room_type_code,
applied_multiplier,
rule_version,
use_nights,
balance_before,
balance_after,
remark,
idempotency_key,
created_at
)
VALUES (
p_usage_record_id,
p_owner_account_id,
v_period.id,
p_confirmation_no,
p_check_in,
p_check_out,
v_night_count,
p_used_room_type_code,
v_multiplier,
'v1',
v_use_nights,
v_balance_before,
v_balance_after,
COALESCE(p_remark, ''),
p_idempotency_key,
v_now
)
RETURNING * INTO v_usage;
INSERT INTO condon.entitlement_ledger (
id,
entitlement_period_id,
usage_record_id,
entry_type,
delta_nights,
balance_before,
balance_after,
occurred_at
)
VALUES (
p_ledger_id,
v_period.id,
p_usage_record_id,
'usage',
-v_use_nights,
v_balance_before,
v_balance_after,
v_now
);
UPDATE condon.entitlement_periods
SET
current_balance = v_balance_after,
row_version = row_version + 1,
updated_at = v_now
WHERE id = v_period.id
AND row_version = v_period.row_version;
GET DIAGNOSTICS v_updated_count = ROW_COUNT;
IF v_updated_count <> 1 THEN
RAISE EXCEPTION USING
ERRCODE = '40001',
MESSAGE = 'CONDON_BALANCE_CONCURRENTLY_CHANGED';
END IF;
RETURN v_usage;
END;
$function$;
REVOKE ALL ON FUNCTION condon.calculate_multiplier(
varchar,
varchar,
smallint
) FROM PUBLIC;
REVOKE ALL ON FUNCTION condon.open_entitlement_period(
uuid,
uuid,
smallint,
uuid,
uuid
) FROM PUBLIC;
REVOKE ALL ON FUNCTION condon.create_usage_record(
uuid,
uuid,
uuid,
varchar,
date,
date,
varchar,
smallint,
text,
uuid
) FROM PUBLIC;