Files
orion/app/modules/loyalty/migrations/versions/loyalty_001_initial.py
Samir Boulahtit f20266167d
Some checks failed
CI / ruff (push) Failing after 7s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 9s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 8s
CI / docs (push) Has been skipped
fix(lint): auto-fix ruff violations and tune lint rules
- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.)
- Added ignore rules for patterns intentional in this codebase:
  E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from),
  SIM108/SIM105/SIM117 (readability preferences)
- Added per-file ignores for tests and scripts
- Excluded broken scripts/rename_terminology.py (has curly quotes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:10:42 +01:00

176 lines
16 KiB
Python

"""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")