- 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>
176 lines
16 KiB
Python
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")
|