-- Extend the empty CONDO schema for the confirmed booking 1:N semantics and -- lossless legacy history import. This migration does not touch other schemas. CREATE TABLE condon.bookings ( confirmation_no varchar(64) PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), CONSTRAINT bookings_confirmation_no_check CHECK (confirmation_no ~ '^[0-9]+$') ); COMMENT ON TABLE condon.bookings IS 'One row per booking/Confirmation; one booking may have many usage records'; ALTER TABLE condon.usage_records DROP CONSTRAINT usage_records_confirmation_no_key, DROP CONSTRAINT usage_records_multiplier_check, DROP CONSTRAINT usage_records_use_nights_check, DROP CONSTRAINT usage_records_balance_check; ALTER TABLE condon.usage_records ALTER COLUMN used_room_type_code DROP NOT NULL, ALTER COLUMN applied_multiplier DROP NOT NULL; ALTER TABLE condon.usage_records ADD COLUMN raw_used_room_type text NOT NULL DEFAULT '', ADD COLUMN room_count integer NOT NULL DEFAULT 1, ADD COLUMN source_sheet varchar(128), ADD COLUMN source_row integer, ADD COLUMN source_sequence integer, ADD COLUMN import_batch varchar(128); ALTER TABLE condon.usage_records ADD CONSTRAINT usage_records_confirmation_booking_fk FOREIGN KEY (confirmation_no) REFERENCES condon.bookings(confirmation_no) ON UPDATE RESTRICT ON DELETE RESTRICT, ADD CONSTRAINT usage_records_room_count_check CHECK (room_count >= 1), ADD CONSTRAINT usage_records_source_location_check CHECK ( (source_sheet IS NULL AND source_row IS NULL AND source_sequence IS NULL) OR (source_sheet IS NOT NULL AND source_row > 0 AND source_sequence IS NOT NULL) ), ADD CONSTRAINT usage_records_multiplier_check CHECK ( (rule_version = 'legacy-source' AND applied_multiplier IS NULL) OR (rule_version <> 'legacy-source' AND applied_multiplier BETWEEN 1 AND 3) ), ADD CONSTRAINT usage_records_use_nights_check CHECK ( ( rule_version = 'legacy-source' AND use_nights > 0 ) OR ( rule_version <> 'legacy-source' AND used_room_type_code IS NOT NULL AND applied_multiplier BETWEEN 1 AND 3 AND use_nights = night_count * applied_multiplier AND use_nights > 0 ) ), ADD CONSTRAINT usage_records_balance_check CHECK ( balance_before >= use_nights AND balance_after = balance_before - use_nights AND balance_after >= 0 ); CREATE INDEX bookings_created_idx ON condon.bookings (created_at DESC, confirmation_no); CREATE UNIQUE INDEX usage_records_source_location_key ON condon.usage_records (import_batch, source_sheet, source_row) WHERE import_batch IS NOT NULL AND source_sheet IS NOT NULL AND source_row IS NOT NULL; CREATE FUNCTION condon.ensure_booking_for_usage() RETURNS trigger LANGUAGE plpgsql VOLATILE SECURITY INVOKER SET search_path = pg_catalog AS $function$ BEGIN INSERT INTO condon.bookings (confirmation_no) VALUES (NEW.confirmation_no) ON CONFLICT (confirmation_no) DO NOTHING; RETURN NEW; END; $function$; CREATE TRIGGER usage_records_booking_trg BEFORE INSERT ON condon.usage_records FOR EACH ROW EXECUTE FUNCTION condon.ensure_booking_for_usage(); -- New-record path: same business contract as v1, but Confirmation is a -- booking key and may legitimately appear on multiple usage rows. CREATE FUNCTION condon.create_usage_record_v2( 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) ); INSERT INTO condon.bookings (confirmation_no) VALUES (p_confirmation_no) ON CONFLICT (confirmation_no) DO NOTHING; 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, raw_used_room_type, applied_multiplier, rule_version, use_nights, balance_before, balance_after, room_count, 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, p_used_room_type_code, v_multiplier, 'v1', v_use_nights, v_balance_before, v_balance_after, 1, 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.ensure_booking_for_usage() FROM PUBLIC; REVOKE ALL ON FUNCTION condon.create_usage_record_v2( uuid, uuid, uuid, varchar, date, date, varchar, smallint, text, uuid ) FROM PUBLIC;