"""loyalty initial - programs, cards, transactions, staff pins, apple devices, settings Revision ID: loyalty_001 Revises: messaging_001 Create Date: 2026-02-07 """ import sqlalchemy as sa from alembic import op revision = "loyalty_001" down_revision = "messaging_001" branch_labels = None depends_on = None def upgrade() -> None: # --- loyalty_programs --- op.create_table( "loyalty_programs", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant that owns this program (chain-wide)"), sa.Column("loyalty_type", sa.String(20), nullable=False, server_default="points"), sa.Column("stamps_target", sa.Integer(), nullable=False, server_default="10", comment="Number of stamps needed for reward"), sa.Column("stamps_reward_description", sa.String(255), nullable=False, server_default="Free item", comment="Description of stamp reward"), sa.Column("stamps_reward_value_cents", sa.Integer(), nullable=True, comment="Value of stamp reward in cents (for analytics)"), sa.Column("points_per_euro", sa.Integer(), nullable=False, server_default="1", comment="Points earned per euro spent (1 euro = X points)"), sa.Column("points_rewards", sa.JSON(), nullable=False, comment="List of point rewards: [{id, name, points_required, description}]"), sa.Column("points_expiration_days", sa.Integer(), nullable=True, comment="Days of inactivity before points expire (None = never expire)"), sa.Column("welcome_bonus_points", sa.Integer(), nullable=False, server_default="0", comment="Bonus points awarded on enrollment"), sa.Column("minimum_redemption_points", sa.Integer(), nullable=False, server_default="100", comment="Minimum points required for any redemption"), sa.Column("minimum_purchase_cents", sa.Integer(), nullable=False, server_default="0", comment="Minimum purchase amount (cents) to earn points (0 = no minimum)"), sa.Column("tier_config", sa.JSON(), nullable=True, comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}'), sa.Column("cooldown_minutes", sa.Integer(), nullable=False, server_default="15", comment="Minutes between stamps for same card"), sa.Column("max_daily_stamps", sa.Integer(), nullable=False, server_default="5", comment="Maximum stamps per card per day"), sa.Column("require_staff_pin", sa.Boolean(), nullable=False, server_default="true", comment="Require staff PIN for stamp/points operations"), sa.Column("card_name", sa.String(100), nullable=True, comment="Display name for loyalty card"), sa.Column("card_color", sa.String(7), nullable=False, server_default="#4F46E5", comment="Primary color for card (hex)"), sa.Column("card_secondary_color", sa.String(7), nullable=True, comment="Secondary color for card (hex)"), sa.Column("logo_url", sa.String(500), nullable=True, comment="URL to merchant logo for card"), sa.Column("hero_image_url", sa.String(500), nullable=True, comment="URL to hero image for card"), sa.Column("google_issuer_id", sa.String(100), nullable=True, comment="Google Wallet Issuer ID"), sa.Column("google_class_id", sa.String(200), nullable=True, comment="Google Wallet Loyalty Class ID"), sa.Column("apple_pass_type_id", sa.String(100), nullable=True, comment="Apple Wallet Pass Type ID"), sa.Column("terms_text", sa.Text(), nullable=True, comment="Loyalty program terms and conditions"), sa.Column("privacy_url", sa.String(500), nullable=True, comment="URL to privacy policy"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True, comment="When program was first activated"), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_loyalty_program_merchant_active", "loyalty_programs", ["merchant_id", "is_active"]) # --- staff_pins --- op.create_table( "staff_pins", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"), sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, comment="Store (location) where this staff member works"), sa.Column("name", sa.String(100), nullable=False, comment="Staff member name"), sa.Column("staff_id", sa.String(50), nullable=True, index=True, comment="Optional staff ID/employee number"), sa.Column("pin_hash", sa.String(255), nullable=False, comment="bcrypt hash of PIN"), sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0", comment="Consecutive failed PIN attempts"), sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True, comment="Lockout expires at this time"), sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True, comment="Last successful use of PIN"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_staff_pin_merchant_active", "staff_pins", ["merchant_id", "is_active"]) op.create_index("idx_staff_pin_store_active", "staff_pins", ["store_id", "is_active"]) op.create_index("idx_staff_pin_program_active", "staff_pins", ["program_id", "is_active"]) # --- loyalty_cards --- op.create_table( "loyalty_cards", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant whose program this card belongs to"), sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("enrolled_at_store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store where customer enrolled (for analytics)"), sa.Column("card_number", sa.String(20), unique=True, nullable=False, index=True, comment="Human-readable card number (XXXX-XXXX-XXXX)"), sa.Column("qr_code_data", sa.String(50), unique=True, nullable=False, index=True, comment="Data encoded in QR code for scanning"), sa.Column("stamp_count", sa.Integer(), nullable=False, server_default="0", comment="Current stamps toward next reward"), sa.Column("total_stamps_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime stamps earned"), sa.Column("stamps_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Total rewards redeemed (stamps reset on redemption)"), sa.Column("points_balance", sa.Integer(), nullable=False, server_default="0", comment="Current available points"), sa.Column("total_points_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points earned"), sa.Column("points_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points redeemed"), sa.Column("google_object_id", sa.String(200), nullable=True, index=True, comment="Google Wallet Loyalty Object ID"), sa.Column("google_object_jwt", sa.String(2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"), sa.Column("apple_serial_number", sa.String(100), unique=True, nullable=True, index=True, comment="Apple Wallet pass serial number"), sa.Column("apple_auth_token", sa.String(100), nullable=True, comment="Apple Wallet authentication token for updates"), sa.Column("last_stamp_at", sa.DateTime(timezone=True), nullable=True, comment="Last stamp added (for cooldown)"), sa.Column("last_points_at", sa.DateTime(timezone=True), nullable=True, comment="Last points earned (for expiration tracking)"), sa.Column("last_redemption_at", sa.DateTime(timezone=True), nullable=True, comment="Last reward redemption"), sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True, comment="Any activity (for expiration calculation)"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_loyalty_card_merchant_customer", "loyalty_cards", ["merchant_id", "customer_id"], unique=True) op.create_index("idx_loyalty_card_merchant_active", "loyalty_cards", ["merchant_id", "is_active"]) op.create_index("idx_loyalty_card_customer_program", "loyalty_cards", ["customer_id", "program_id"], unique=True) # --- loyalty_transactions --- op.create_table( "loyalty_transactions", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"), sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store (location) that processed this transaction"), sa.Column("staff_pin_id", sa.Integer(), sa.ForeignKey("staff_pins.id", ondelete="SET NULL"), nullable=True, index=True, comment="Staff PIN used for this operation"), sa.Column("related_transaction_id", sa.Integer(), sa.ForeignKey("loyalty_transactions.id", ondelete="SET NULL"), nullable=True, index=True, comment="Original transaction (for voids/returns)"), sa.Column("transaction_type", sa.String(30), nullable=False, index=True), sa.Column("stamps_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in stamps (+1 for earn, -N for redeem)"), sa.Column("points_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in points (+N for earn, -N for redeem)"), sa.Column("stamps_balance_after", sa.Integer(), nullable=True, comment="Stamp count after this transaction"), sa.Column("points_balance_after", sa.Integer(), nullable=True, comment="Points balance after this transaction"), sa.Column("purchase_amount_cents", sa.Integer(), nullable=True, comment="Purchase amount in cents (for points calculation)"), sa.Column("order_reference", sa.String(100), nullable=True, index=True, comment="Reference to order that triggered points"), sa.Column("reward_id", sa.String(50), nullable=True, comment="ID of redeemed reward (from program.points_rewards)"), sa.Column("reward_description", sa.String(255), nullable=True, comment="Description of redeemed reward"), sa.Column("ip_address", sa.String(45), nullable=True, comment="IP address of requester (IPv4 or IPv6)"), sa.Column("user_agent", sa.String(500), nullable=True, comment="User agent string"), sa.Column("notes", sa.Text(), nullable=True, comment="Additional notes (e.g., reason for adjustment)"), sa.Column("transaction_at", sa.DateTime(timezone=True), nullable=False, index=True, comment="When the transaction occurred (may differ from created_at)"), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_loyalty_tx_card_type", "loyalty_transactions", ["card_id", "transaction_type"]) op.create_index("idx_loyalty_tx_store_date", "loyalty_transactions", ["store_id", "transaction_at"]) op.create_index("idx_loyalty_tx_type_date", "loyalty_transactions", ["transaction_type", "transaction_at"]) op.create_index("idx_loyalty_tx_merchant_date", "loyalty_transactions", ["merchant_id", "transaction_at"]) op.create_index("idx_loyalty_tx_merchant_store", "loyalty_transactions", ["merchant_id", "store_id"]) # --- apple_device_registrations --- op.create_table( "apple_device_registrations", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True), sa.Column("device_library_identifier", sa.String(100), nullable=False, index=True, comment="Unique identifier for the device/library"), sa.Column("push_token", sa.String(100), nullable=False, comment="APNs push token for this device"), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_apple_device_card", "apple_device_registrations", ["device_library_identifier", "card_id"], unique=True) # --- merchant_loyalty_settings --- op.create_table( "merchant_loyalty_settings", sa.Column("id", sa.Integer(), primary_key=True, index=True), sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant these settings apply to"), sa.Column("staff_pin_policy", sa.String(20), nullable=False, server_default="required", comment="Staff PIN policy: required, optional, disabled"), sa.Column("staff_pin_lockout_attempts", sa.Integer(), nullable=False, server_default="5", comment="Max failed PIN attempts before lockout"), sa.Column("staff_pin_lockout_minutes", sa.Integer(), nullable=False, server_default="30", comment="Lockout duration in minutes"), sa.Column("allow_self_enrollment", sa.Boolean(), nullable=False, server_default="true", comment="Allow customers to self-enroll via QR code"), sa.Column("allow_void_transactions", sa.Boolean(), nullable=False, server_default="true", comment="Allow voiding points for returns"), sa.Column("allow_cross_location_redemption", sa.Boolean(), nullable=False, server_default="true", comment="Allow redemption at any merchant location"), sa.Column("require_order_reference", sa.Boolean(), nullable=False, server_default="false", comment="Require order reference when earning points"), sa.Column("log_ip_addresses", sa.Boolean(), nullable=False, server_default="true", comment="Log IP addresses for transactions"), sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), ) op.create_index("idx_merchant_loyalty_settings_merchant", "merchant_loyalty_settings", ["merchant_id"]) def downgrade() -> None: op.drop_table("merchant_loyalty_settings") op.drop_table("apple_device_registrations") op.drop_table("loyalty_transactions") op.drop_table("loyalty_cards") op.drop_table("staff_pins") op.drop_table("loyalty_programs")