feat: loyalty/discount system with milestone campaigns, auto-redemption, and admin UI

- Database: loyalty_redemptions, discount_campaigns, booking_discounts tables
- Backend: auto-create pending redemption at 10 stamps, apply discounts at completion
- Backend: discount_eligible flag on booking creation (user + admin flows)
- Backend: campaign CRUD handlers (GET/POST/PUT/DELETE + stats)
- Backend: milestone campaigns (per-user, global, anniversary)
- Frontend: customer account page shows 'card full' status at 10 stamps
- Frontend: admin discounts page with campaign management UI
- Frontend: TypeScript types for all discount entities
- Tests: 9 integration tests covering loyalty, campaigns, milestones, edge cases
This commit is contained in:
2026-05-08 17:32:20 +01:00
parent bec4100e4d
commit 1f54d8565c
9 changed files with 2864 additions and 8 deletions
+83 -1
View File
@@ -217,7 +217,8 @@ CREATE TABLE bookings (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12),
idempotency_key VARCHAR(64) UNIQUE
idempotency_key VARCHAR(64) UNIQUE,
discount_eligible BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_bookings_userid ON bookings(user_id);
@@ -387,6 +388,75 @@ CREATE INDEX idx_payments_bookingid ON payments(booking_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_createdat ON payments(created_at);
-- =======================================
-- LOYALTY REDEMPTIONS TABLE
-- =======================================
CREATE TABLE loyalty_redemptions (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stamps_redeemed INT NOT NULL DEFAULT 10,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '6 months')
);
CREATE INDEX idx_loyalty_redemptions_user ON loyalty_redemptions(user_id);
CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status);
-- =======================================
-- DISCOUNT CAMPAIGNS TABLE
-- =======================================
CREATE TABLE discount_campaigns (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'),
name VARCHAR(100) NOT NULL,
description TEXT,
campaign_type campaign_type NOT NULL DEFAULT 'time_based',
discount_percent NUMERIC(5,2) NOT NULL,
scope discount_campaign_scope,
start_date TIMESTAMPTZ,
end_date TIMESTAMPTZ,
milestone_type milestone_type,
milestone_value INT,
milestone_unit milestone_unit,
status discount_campaign_status NOT NULL DEFAULT 'draft',
max_redemptions INT,
times_redeemed INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)),
CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100),
CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND milestone_unit IS NOT NULL))
);
CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;
CREATE INDEX idx_discount_campaigns_status ON discount_campaigns(status);
CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type);
-- =======================================
-- BOOKING DISCOUNTS TABLE
-- =======================================
CREATE TABLE booking_discounts (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
discount_source VARCHAR(30) NOT NULL,
source_id CHAR(12),
campaign_type campaign_type,
milestone_type milestone_type,
discount_percent NUMERIC(5,2) NOT NULL,
original_total NUMERIC(10,2) NOT NULL,
discount_amount NUMERIC(10,2) NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id);
CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id);
CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id);
CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id);
-- =======================================
-- BUSINESS SETTINGS TABLE (FOR COMPLIANCE)
-- Stores legal business info for receipts, VAT status, currency, etc.
@@ -420,6 +490,11 @@ CREATE TRIGGER trigger_update_business_settings_timestamp
FOR EACH ROW
EXECUTE FUNCTION update_business_settings_timestamp();
CREATE TRIGGER trigger_update_discount_campaigns_timestamp
BEFORE UPDATE ON discount_campaigns
FOR EACH ROW
EXECUTE FUNCTION update_business_settings_timestamp();
-- Insert default row
INSERT INTO business_settings (
business_name,
@@ -445,6 +520,13 @@ INSERT INTO business_settings (
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request');
-- Loyalty/Discount System ENUMs
CREATE TYPE campaign_type AS ENUM ('time_based', 'milestone');
CREATE TYPE milestone_type AS ENUM ('per_user_booking_count', 'global_booking_count', 'anniversary');
CREATE TYPE milestone_unit AS ENUM ('bookings', 'months', 'years');
CREATE TYPE discount_campaign_scope AS ENUM ('all_bookings', 'first_booking_only', 'new_customers_only');
CREATE TYPE discount_campaign_status AS ENUM ('draft', 'active', 'completed', 'cancelled');
CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY,
reason admin_notification_reason NOT NULL,