feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
650
alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py
Normal file
650
alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
"""add loyalty module tables
|
||||||
|
|
||||||
|
Revision ID: 0fb5d6d6ff97
|
||||||
|
Revises: zd3n4o5p6q7r8
|
||||||
|
Create Date: 2026-01-28 22:55:34.074321
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.dialects import sqlite
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0fb5d6d6ff97'
|
||||||
|
down_revision: Union[str, None] = 'zd3n4o5p6q7r8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('loyalty_programs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('loyalty_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('stamps_target', sa.Integer(), nullable=False, comment='Number of stamps needed for reward'),
|
||||||
|
sa.Column('stamps_reward_description', sa.String(length=255), nullable=False, 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, comment='Points earned per euro spent'),
|
||||||
|
sa.Column('points_rewards', sqlite.JSON(), nullable=False, comment='List of point rewards: [{id, name, points_required, description}]'),
|
||||||
|
sa.Column('cooldown_minutes', sa.Integer(), nullable=False, comment='Minutes between stamps for same card'),
|
||||||
|
sa.Column('max_daily_stamps', sa.Integer(), nullable=False, comment='Maximum stamps per card per day'),
|
||||||
|
sa.Column('require_staff_pin', sa.Boolean(), nullable=False, comment='Require staff PIN for stamp/points operations'),
|
||||||
|
sa.Column('card_name', sa.String(length=100), nullable=True, comment='Display name for loyalty card'),
|
||||||
|
sa.Column('card_color', sa.String(length=7), nullable=False, comment='Primary color for card (hex)'),
|
||||||
|
sa.Column('card_secondary_color', sa.String(length=7), nullable=True, comment='Secondary color for card (hex)'),
|
||||||
|
sa.Column('logo_url', sa.String(length=500), nullable=True, comment='URL to vendor logo for card'),
|
||||||
|
sa.Column('hero_image_url', sa.String(length=500), nullable=True, comment='URL to hero image for card'),
|
||||||
|
sa.Column('google_issuer_id', sa.String(length=100), nullable=True, comment='Google Wallet Issuer ID'),
|
||||||
|
sa.Column('google_class_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Class ID'),
|
||||||
|
sa.Column('apple_pass_type_id', sa.String(length=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(length=500), nullable=True, comment='URL to privacy policy'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True, comment='When program was first activated'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_loyalty_program_vendor_active', 'loyalty_programs', ['vendor_id', 'is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_programs_id'), 'loyalty_programs', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_programs_is_active'), 'loyalty_programs', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_programs_vendor_id'), 'loyalty_programs', ['vendor_id'], unique=True)
|
||||||
|
op.create_table('loyalty_cards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('customer_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('program_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vendor_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
|
||||||
|
sa.Column('card_number', sa.String(length=20), nullable=False, comment='Human-readable card number'),
|
||||||
|
sa.Column('qr_code_data', sa.String(length=50), nullable=False, comment='Data encoded in QR code for scanning'),
|
||||||
|
sa.Column('stamp_count', sa.Integer(), nullable=False, comment='Current stamps toward next reward'),
|
||||||
|
sa.Column('total_stamps_earned', sa.Integer(), nullable=False, comment='Lifetime stamps earned'),
|
||||||
|
sa.Column('stamps_redeemed', sa.Integer(), nullable=False, comment='Total rewards redeemed (stamps reset on redemption)'),
|
||||||
|
sa.Column('points_balance', sa.Integer(), nullable=False, comment='Current available points'),
|
||||||
|
sa.Column('total_points_earned', sa.Integer(), nullable=False, comment='Lifetime points earned'),
|
||||||
|
sa.Column('points_redeemed', sa.Integer(), nullable=False, comment='Lifetime points redeemed'),
|
||||||
|
sa.Column('google_object_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Object ID'),
|
||||||
|
sa.Column('google_object_jwt', sa.String(length=2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"),
|
||||||
|
sa.Column('apple_serial_number', sa.String(length=100), nullable=True, comment='Apple Wallet pass serial number'),
|
||||||
|
sa.Column('apple_auth_token', sa.String(length=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'),
|
||||||
|
sa.Column('last_redemption_at', sa.DateTime(timezone=True), nullable=True, comment='Last reward redemption'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_loyalty_card_customer_program', 'loyalty_cards', ['customer_id', 'program_id'], unique=True)
|
||||||
|
op.create_index('idx_loyalty_card_vendor_active', 'loyalty_cards', ['vendor_id', 'is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_apple_serial_number'), 'loyalty_cards', ['apple_serial_number'], unique=True)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_card_number'), 'loyalty_cards', ['card_number'], unique=True)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_customer_id'), 'loyalty_cards', ['customer_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_google_object_id'), 'loyalty_cards', ['google_object_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_id'), 'loyalty_cards', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_is_active'), 'loyalty_cards', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_program_id'), 'loyalty_cards', ['program_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_qr_code_data'), 'loyalty_cards', ['qr_code_data'], unique=True)
|
||||||
|
op.create_index(op.f('ix_loyalty_cards_vendor_id'), 'loyalty_cards', ['vendor_id'], unique=False)
|
||||||
|
op.create_table('staff_pins',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('program_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vendor_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False, comment='Staff member name'),
|
||||||
|
sa.Column('staff_id', sa.String(length=50), nullable=True, comment='Optional staff ID/employee number'),
|
||||||
|
sa.Column('pin_hash', sa.String(length=255), nullable=False, comment='bcrypt hash of PIN'),
|
||||||
|
sa.Column('failed_attempts', sa.Integer(), nullable=False, 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),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_staff_pin_program_active', 'staff_pins', ['program_id', 'is_active'], unique=False)
|
||||||
|
op.create_index('idx_staff_pin_vendor_active', 'staff_pins', ['vendor_id', 'is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_staff_pins_id'), 'staff_pins', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_staff_pins_is_active'), 'staff_pins', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_staff_pins_program_id'), 'staff_pins', ['program_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_staff_pins_staff_id'), 'staff_pins', ['staff_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_staff_pins_vendor_id'), 'staff_pins', ['vendor_id'], unique=False)
|
||||||
|
op.create_table('apple_device_registrations',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('device_library_identifier', sa.String(length=100), nullable=False, comment='Unique identifier for the device/library'),
|
||||||
|
sa.Column('push_token', sa.String(length=100), nullable=False, comment='APNs push token for this device'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_apple_device_card', 'apple_device_registrations', ['device_library_identifier', 'card_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_apple_device_registrations_card_id'), 'apple_device_registrations', ['card_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_apple_device_registrations_device_library_identifier'), 'apple_device_registrations', ['device_library_identifier'], unique=False)
|
||||||
|
op.create_index(op.f('ix_apple_device_registrations_id'), 'apple_device_registrations', ['id'], unique=False)
|
||||||
|
op.create_table('loyalty_transactions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vendor_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
|
||||||
|
sa.Column('staff_pin_id', sa.Integer(), nullable=True, comment='Staff PIN used for this operation'),
|
||||||
|
sa.Column('transaction_type', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('stamps_delta', sa.Integer(), nullable=False, comment='Change in stamps (+1 for earn, -N for redeem)'),
|
||||||
|
sa.Column('points_delta', sa.Integer(), nullable=False, 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(length=100), nullable=True, comment='Reference to order that triggered points'),
|
||||||
|
sa.Column('reward_id', sa.String(length=50), nullable=True, comment='ID of redeemed reward (from program.points_rewards)'),
|
||||||
|
sa.Column('reward_description', sa.String(length=255), nullable=True, comment='Description of redeemed reward'),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=True, comment='IP address of requester (IPv4 or IPv6)'),
|
||||||
|
sa.Column('user_agent', sa.String(length=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, comment='When the transaction occurred (may differ from created_at)'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['staff_pin_id'], ['staff_pins.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_loyalty_tx_card_type', 'loyalty_transactions', ['card_id', 'transaction_type'], unique=False)
|
||||||
|
op.create_index('idx_loyalty_tx_type_date', 'loyalty_transactions', ['transaction_type', 'transaction_at'], unique=False)
|
||||||
|
op.create_index('idx_loyalty_tx_vendor_date', 'loyalty_transactions', ['vendor_id', 'transaction_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_card_id'), 'loyalty_transactions', ['card_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_id'), 'loyalty_transactions', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_order_reference'), 'loyalty_transactions', ['order_reference'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_staff_pin_id'), 'loyalty_transactions', ['staff_pin_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_transaction_at'), 'loyalty_transactions', ['transaction_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_transaction_type'), 'loyalty_transactions', ['transaction_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_loyalty_transactions_vendor_id'), 'loyalty_transactions', ['vendor_id'], unique=False)
|
||||||
|
op.alter_column('admin_menu_configs', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Platform scope - applies to users/vendors of this platform',
|
||||||
|
existing_comment='Platform scope - applies to all platform admins of this platform',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('admin_menu_configs', 'user_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='User scope - applies to this specific super admin (admin frontend only)',
|
||||||
|
existing_comment='User scope - applies to this specific super admin',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('admin_menu_configs', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('admin_menu_configs', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.drop_index('idx_admin_menu_configs_frontend_type', table_name='admin_menu_configs')
|
||||||
|
op.drop_index('idx_admin_menu_configs_menu_item_id', table_name='admin_menu_configs')
|
||||||
|
op.drop_index('idx_admin_menu_configs_platform_id', table_name='admin_menu_configs')
|
||||||
|
op.drop_index('idx_admin_menu_configs_user_id', table_name='admin_menu_configs')
|
||||||
|
op.create_index(op.f('ix_admin_menu_configs_frontend_type'), 'admin_menu_configs', ['frontend_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_menu_configs_id'), 'admin_menu_configs', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_menu_configs_menu_item_id'), 'admin_menu_configs', ['menu_item_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_menu_configs_platform_id'), 'admin_menu_configs', ['platform_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_menu_configs_user_id'), 'admin_menu_configs', ['user_id'], unique=False)
|
||||||
|
op.alter_column('admin_platforms', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('admin_platforms', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.drop_index('idx_admin_platforms_platform_id', table_name='admin_platforms')
|
||||||
|
op.drop_index('idx_admin_platforms_user_id', table_name='admin_platforms')
|
||||||
|
op.create_index(op.f('ix_admin_platforms_id'), 'admin_platforms', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_platforms_platform_id'), 'admin_platforms', ['platform_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_admin_platforms_user_id'), 'admin_platforms', ['user_id'], unique=False)
|
||||||
|
op.alter_column('content_pages', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Platform this page belongs to',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('content_pages', 'vendor_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Vendor this page belongs to (NULL for platform/default pages)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('content_pages', 'is_platform_page',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment='True = platform marketing page (homepage, pricing); False = vendor default or override',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.alter_column('platform_modules', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('platform_modules', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.create_index(op.f('ix_platform_modules_id'), 'platform_modules', ['id'], unique=False)
|
||||||
|
op.alter_column('platforms', 'code',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('platforms', 'name',
|
||||||
|
existing_type=sa.VARCHAR(length=100),
|
||||||
|
comment="Display name (e.g., 'Wizamart OMS')",
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('platforms', 'description',
|
||||||
|
existing_type=sa.TEXT(),
|
||||||
|
comment='Platform description for admin/marketing purposes',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'domain',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'path_prefix',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'logo',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment='Logo URL for light mode',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'logo_dark',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment='Logo URL for dark mode',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'favicon',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment='Favicon URL',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'theme_config',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment='Theme configuration (colors, fonts, etc.)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'default_language',
|
||||||
|
existing_type=sa.VARCHAR(length=5),
|
||||||
|
comment="Default language code (e.g., 'fr', 'en', 'de')",
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'fr'::character varying"))
|
||||||
|
op.alter_column('platforms', 'supported_languages',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment='List of supported language codes',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('platforms', 'is_active',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment='Whether the platform is active and accessible',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('platforms', 'is_public',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment='Whether the platform is visible in public listings',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('platforms', 'settings',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment='Platform-specific settings and feature flags',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('platforms', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.create_index(op.f('ix_platforms_id'), 'platforms', ['id'], unique=False)
|
||||||
|
op.alter_column('subscription_tiers', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Platform this tier belongs to (NULL = global tier)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('subscription_tiers', 'cms_pages_limit',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Total CMS pages limit (NULL = unlimited)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('subscription_tiers', 'cms_custom_pages_limit',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Custom pages limit, excluding overrides (NULL = unlimited)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.drop_index('ix_subscription_tiers_code', table_name='subscription_tiers')
|
||||||
|
op.create_index(op.f('ix_subscription_tiers_code'), 'subscription_tiers', ['code'], unique=False)
|
||||||
|
op.alter_column('users', 'is_super_admin',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Whether this admin has access to all platforms (super admin)',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.alter_column('vendor_platforms', 'vendor_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Reference to the vendor',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('vendor_platforms', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Reference to the platform',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('vendor_platforms', 'tier_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Platform-specific subscription tier',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'is_active',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment='Whether the vendor is active on this platform',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('vendor_platforms', 'is_primary',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment="Whether this is the vendor's primary platform",
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.alter_column('vendor_platforms', 'custom_subdomain',
|
||||||
|
existing_type=sa.VARCHAR(length=100),
|
||||||
|
comment='Platform-specific subdomain (if different from main subdomain)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'settings',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment='Platform-specific vendor settings',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'joined_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
comment='When the vendor joined this platform',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('vendor_platforms', 'created_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('vendor_platforms', 'updated_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
type_=sa.DateTime(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.create_index(op.f('ix_vendor_platforms_id'), 'vendor_platforms', ['id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_vendor_platforms_id'), table_name='vendor_platforms')
|
||||||
|
op.alter_column('vendor_platforms', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('vendor_platforms', 'created_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('vendor_platforms', 'joined_at',
|
||||||
|
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='When the vendor joined this platform',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('vendor_platforms', 'settings',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform-specific vendor settings',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'custom_subdomain',
|
||||||
|
existing_type=sa.VARCHAR(length=100),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform-specific subdomain (if different from main subdomain)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'is_primary',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Whether this is the vendor's primary platform",
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.alter_column('vendor_platforms', 'is_active',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Whether the vendor is active on this platform',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('vendor_platforms', 'tier_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform-specific subscription tier',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('vendor_platforms', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Reference to the platform',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('vendor_platforms', 'vendor_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Reference to the vendor',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('users', 'is_super_admin',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment='Whether this admin has access to all platforms (super admin)',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.drop_index(op.f('ix_subscription_tiers_code'), table_name='subscription_tiers')
|
||||||
|
op.create_index('ix_subscription_tiers_code', 'subscription_tiers', ['code'], unique=True)
|
||||||
|
op.alter_column('subscription_tiers', 'cms_custom_pages_limit',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Custom pages limit, excluding overrides (NULL = unlimited)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('subscription_tiers', 'cms_pages_limit',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Total CMS pages limit (NULL = unlimited)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('subscription_tiers', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform this tier belongs to (NULL = global tier)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.drop_index(op.f('ix_platforms_id'), table_name='platforms')
|
||||||
|
op.alter_column('platforms', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('platforms', 'created_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('platforms', 'settings',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform-specific settings and feature flags',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'is_public',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Whether the platform is visible in public listings',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('platforms', 'is_active',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Whether the platform is active and accessible',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('true'))
|
||||||
|
op.alter_column('platforms', 'supported_languages',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='List of supported language codes',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('platforms', 'default_language',
|
||||||
|
existing_type=sa.VARCHAR(length=5),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Default language code (e.g., 'fr', 'en', 'de')",
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'fr'::character varying"))
|
||||||
|
op.alter_column('platforms', 'theme_config',
|
||||||
|
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Theme configuration (colors, fonts, etc.)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'favicon',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Favicon URL',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'logo_dark',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Logo URL for dark mode',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'logo',
|
||||||
|
existing_type=sa.VARCHAR(length=500),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Logo URL for light mode',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'path_prefix',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'domain',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'description',
|
||||||
|
existing_type=sa.TEXT(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform description for admin/marketing purposes',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('platforms', 'name',
|
||||||
|
existing_type=sa.VARCHAR(length=100),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Display name (e.g., 'Wizamart OMS')",
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('platforms', 'code',
|
||||||
|
existing_type=sa.VARCHAR(length=50),
|
||||||
|
comment=None,
|
||||||
|
existing_comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
|
||||||
|
existing_nullable=False)
|
||||||
|
op.drop_index(op.f('ix_platform_modules_id'), table_name='platform_modules')
|
||||||
|
op.alter_column('platform_modules', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('platform_modules', 'created_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('content_pages', 'is_platform_page',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='True = platform marketing page (homepage, pricing); False = vendor default or override',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('false'))
|
||||||
|
op.alter_column('content_pages', 'vendor_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Vendor this page belongs to (NULL for platform/default pages)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('content_pages', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment=None,
|
||||||
|
existing_comment='Platform this page belongs to',
|
||||||
|
existing_nullable=False)
|
||||||
|
op.drop_index(op.f('ix_admin_platforms_user_id'), table_name='admin_platforms')
|
||||||
|
op.drop_index(op.f('ix_admin_platforms_platform_id'), table_name='admin_platforms')
|
||||||
|
op.drop_index(op.f('ix_admin_platforms_id'), table_name='admin_platforms')
|
||||||
|
op.create_index('idx_admin_platforms_user_id', 'admin_platforms', ['user_id'], unique=False)
|
||||||
|
op.create_index('idx_admin_platforms_platform_id', 'admin_platforms', ['platform_id'], unique=False)
|
||||||
|
op.alter_column('admin_platforms', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('admin_platforms', 'created_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.drop_index(op.f('ix_admin_menu_configs_user_id'), table_name='admin_menu_configs')
|
||||||
|
op.drop_index(op.f('ix_admin_menu_configs_platform_id'), table_name='admin_menu_configs')
|
||||||
|
op.drop_index(op.f('ix_admin_menu_configs_menu_item_id'), table_name='admin_menu_configs')
|
||||||
|
op.drop_index(op.f('ix_admin_menu_configs_id'), table_name='admin_menu_configs')
|
||||||
|
op.drop_index(op.f('ix_admin_menu_configs_frontend_type'), table_name='admin_menu_configs')
|
||||||
|
op.create_index('idx_admin_menu_configs_user_id', 'admin_menu_configs', ['user_id'], unique=False)
|
||||||
|
op.create_index('idx_admin_menu_configs_platform_id', 'admin_menu_configs', ['platform_id'], unique=False)
|
||||||
|
op.create_index('idx_admin_menu_configs_menu_item_id', 'admin_menu_configs', ['menu_item_id'], unique=False)
|
||||||
|
op.create_index('idx_admin_menu_configs_frontend_type', 'admin_menu_configs', ['frontend_type'], unique=False)
|
||||||
|
op.alter_column('admin_menu_configs', 'updated_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('admin_menu_configs', 'created_at',
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
type_=postgresql.TIMESTAMP(timezone=True),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text('now()'))
|
||||||
|
op.alter_column('admin_menu_configs', 'user_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='User scope - applies to this specific super admin',
|
||||||
|
existing_comment='User scope - applies to this specific super admin (admin frontend only)',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.alter_column('admin_menu_configs', 'platform_id',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
comment='Platform scope - applies to all platform admins of this platform',
|
||||||
|
existing_comment='Platform scope - applies to users/vendors of this platform',
|
||||||
|
existing_nullable=True)
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_vendor_id'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_transaction_type'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_transaction_at'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_staff_pin_id'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_order_reference'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_id'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_loyalty_transactions_card_id'), table_name='loyalty_transactions')
|
||||||
|
op.drop_index('idx_loyalty_tx_vendor_date', table_name='loyalty_transactions')
|
||||||
|
op.drop_index('idx_loyalty_tx_type_date', table_name='loyalty_transactions')
|
||||||
|
op.drop_index('idx_loyalty_tx_card_type', table_name='loyalty_transactions')
|
||||||
|
op.drop_table('loyalty_transactions')
|
||||||
|
op.drop_index(op.f('ix_apple_device_registrations_id'), table_name='apple_device_registrations')
|
||||||
|
op.drop_index(op.f('ix_apple_device_registrations_device_library_identifier'), table_name='apple_device_registrations')
|
||||||
|
op.drop_index(op.f('ix_apple_device_registrations_card_id'), table_name='apple_device_registrations')
|
||||||
|
op.drop_index('idx_apple_device_card', table_name='apple_device_registrations')
|
||||||
|
op.drop_table('apple_device_registrations')
|
||||||
|
op.drop_index(op.f('ix_staff_pins_vendor_id'), table_name='staff_pins')
|
||||||
|
op.drop_index(op.f('ix_staff_pins_staff_id'), table_name='staff_pins')
|
||||||
|
op.drop_index(op.f('ix_staff_pins_program_id'), table_name='staff_pins')
|
||||||
|
op.drop_index(op.f('ix_staff_pins_is_active'), table_name='staff_pins')
|
||||||
|
op.drop_index(op.f('ix_staff_pins_id'), table_name='staff_pins')
|
||||||
|
op.drop_index('idx_staff_pin_vendor_active', table_name='staff_pins')
|
||||||
|
op.drop_index('idx_staff_pin_program_active', table_name='staff_pins')
|
||||||
|
op.drop_table('staff_pins')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_vendor_id'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_qr_code_data'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_program_id'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_is_active'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_id'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_google_object_id'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_customer_id'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_card_number'), table_name='loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_cards_apple_serial_number'), table_name='loyalty_cards')
|
||||||
|
op.drop_index('idx_loyalty_card_vendor_active', table_name='loyalty_cards')
|
||||||
|
op.drop_index('idx_loyalty_card_customer_program', table_name='loyalty_cards')
|
||||||
|
op.drop_table('loyalty_cards')
|
||||||
|
op.drop_index(op.f('ix_loyalty_programs_vendor_id'), table_name='loyalty_programs')
|
||||||
|
op.drop_index(op.f('ix_loyalty_programs_is_active'), table_name='loyalty_programs')
|
||||||
|
op.drop_index(op.f('ix_loyalty_programs_id'), table_name='loyalty_programs')
|
||||||
|
op.drop_index('idx_loyalty_program_vendor_active', table_name='loyalty_programs')
|
||||||
|
op.drop_table('loyalty_programs')
|
||||||
|
# ### end Alembic commands ###
|
||||||
48
app/modules/loyalty/__init__.py
Normal file
48
app/modules/loyalty/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# app/modules/loyalty/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty Module - Stamp and points-based loyalty programs.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- Stamp-based loyalty programs (collect N stamps, get reward)
|
||||||
|
- Points-based loyalty programs (earn points per euro spent)
|
||||||
|
- Customer loyalty cards with QR codes
|
||||||
|
- Staff PIN verification for fraud prevention
|
||||||
|
- Google Wallet and Apple Wallet integration
|
||||||
|
- Transaction history and analytics
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
- Admin: /api/v1/admin/loyalty/*
|
||||||
|
- Vendor: /api/v1/vendor/loyalty/*
|
||||||
|
- Public: /api/v1/loyalty/* (enrollment, wallet passes)
|
||||||
|
|
||||||
|
Menu Items:
|
||||||
|
- Admin: loyalty-programs, loyalty-analytics
|
||||||
|
- Vendor: loyalty, loyalty-cards, loyalty-stats
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.loyalty import loyalty_module
|
||||||
|
from app.modules.loyalty.services import program_service, card_service
|
||||||
|
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyCard
|
||||||
|
from app.modules.loyalty.exceptions import LoyaltyException
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Lazy imports to avoid circular dependencies
|
||||||
|
# Routers and module definition are imported on-demand
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"loyalty_module",
|
||||||
|
"get_loyalty_module_with_routers",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
"""Lazy import to avoid circular dependencies."""
|
||||||
|
if name == "loyalty_module":
|
||||||
|
from app.modules.loyalty.definition import loyalty_module
|
||||||
|
|
||||||
|
return loyalty_module
|
||||||
|
elif name == "get_loyalty_module_with_routers":
|
||||||
|
from app.modules.loyalty.definition import get_loyalty_module_with_routers
|
||||||
|
|
||||||
|
return get_loyalty_module_with_routers
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
51
app/modules/loyalty/config.py
Normal file
51
app/modules/loyalty/config.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# app/modules/loyalty/config.py
|
||||||
|
"""
|
||||||
|
Module configuration.
|
||||||
|
|
||||||
|
Environment-based configuration using Pydantic Settings.
|
||||||
|
Settings are loaded from environment variables with LOYALTY_ prefix.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
LOYALTY_DEFAULT_COOLDOWN_MINUTES=15
|
||||||
|
LOYALTY_MAX_DAILY_STAMPS=5
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.loyalty.config import config
|
||||||
|
cooldown = config.default_cooldown_minutes
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleConfig(BaseSettings):
|
||||||
|
"""Configuration for loyalty module."""
|
||||||
|
|
||||||
|
# Default anti-fraud settings
|
||||||
|
default_cooldown_minutes: int = 15
|
||||||
|
max_daily_stamps: int = 5
|
||||||
|
pin_max_failed_attempts: int = 5
|
||||||
|
pin_lockout_minutes: int = 30
|
||||||
|
|
||||||
|
# Points configuration
|
||||||
|
default_points_per_euro: int = 10 # 10 points per euro spent
|
||||||
|
|
||||||
|
# Google Wallet
|
||||||
|
google_issuer_id: str | None = None
|
||||||
|
google_service_account_json: str | None = None # Path to JSON file
|
||||||
|
|
||||||
|
# Apple Wallet
|
||||||
|
apple_pass_type_id: str | None = None
|
||||||
|
apple_team_id: str | None = None
|
||||||
|
apple_wwdr_cert_path: str | None = None # Apple WWDR certificate
|
||||||
|
apple_signer_cert_path: str | None = None # Pass signing certificate
|
||||||
|
apple_signer_key_path: str | None = None # Pass signing key
|
||||||
|
|
||||||
|
# QR code settings
|
||||||
|
qr_code_size: int = 300 # pixels
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "LOYALTY_"}
|
||||||
|
|
||||||
|
|
||||||
|
# Export for auto-discovery
|
||||||
|
config_class = ModuleConfig
|
||||||
|
config = ModuleConfig()
|
||||||
118
app/modules/loyalty/definition.py
Normal file
118
app/modules/loyalty/definition.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# app/modules/loyalty/definition.py
|
||||||
|
"""
|
||||||
|
Loyalty module definition.
|
||||||
|
|
||||||
|
Defines the loyalty module including its features, menu items,
|
||||||
|
route configurations, and scheduled tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.base import ModuleDefinition, ScheduledTask
|
||||||
|
from models.database.admin_menu_config import FrontendType
|
||||||
|
|
||||||
|
|
||||||
|
def _get_admin_router():
|
||||||
|
"""Lazy import of admin router to avoid circular imports."""
|
||||||
|
from app.modules.loyalty.routes.api.admin import admin_router
|
||||||
|
|
||||||
|
return admin_router
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vendor_router():
|
||||||
|
"""Lazy import of vendor router to avoid circular imports."""
|
||||||
|
from app.modules.loyalty.routes.api.vendor import vendor_router
|
||||||
|
|
||||||
|
return vendor_router
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_router():
|
||||||
|
"""Lazy import of public router to avoid circular imports."""
|
||||||
|
from app.modules.loyalty.routes.api.public import public_router
|
||||||
|
|
||||||
|
return public_router
|
||||||
|
|
||||||
|
|
||||||
|
# Loyalty module definition
|
||||||
|
loyalty_module = ModuleDefinition(
|
||||||
|
code="loyalty",
|
||||||
|
name="Loyalty Programs",
|
||||||
|
description=(
|
||||||
|
"Stamp-based and points-based loyalty programs with Google Wallet "
|
||||||
|
"and Apple Wallet integration. Includes anti-fraud features like "
|
||||||
|
"staff PINs, cooldown periods, and daily limits."
|
||||||
|
),
|
||||||
|
version="1.0.0",
|
||||||
|
requires=["customers"], # Depends on customers module for customer data
|
||||||
|
features=[
|
||||||
|
# Core features
|
||||||
|
"loyalty_stamps", # Stamp-based loyalty
|
||||||
|
"loyalty_points", # Points-based loyalty
|
||||||
|
"loyalty_hybrid", # Both stamps and points
|
||||||
|
# Card management
|
||||||
|
"loyalty_cards", # Customer card management
|
||||||
|
"loyalty_enrollment", # Customer enrollment
|
||||||
|
# Staff/fraud prevention
|
||||||
|
"loyalty_staff_pins", # Staff PIN management
|
||||||
|
"loyalty_anti_fraud", # Cooldown, daily limits
|
||||||
|
# Wallet integration
|
||||||
|
"loyalty_google_wallet", # Google Wallet passes
|
||||||
|
"loyalty_apple_wallet", # Apple Wallet passes
|
||||||
|
# Analytics
|
||||||
|
"loyalty_stats", # Dashboard statistics
|
||||||
|
"loyalty_reports", # Transaction reports
|
||||||
|
],
|
||||||
|
menu_items={
|
||||||
|
FrontendType.ADMIN: [
|
||||||
|
"loyalty-programs", # View all programs
|
||||||
|
"loyalty-analytics", # Platform-wide stats
|
||||||
|
],
|
||||||
|
FrontendType.VENDOR: [
|
||||||
|
"loyalty", # Loyalty dashboard
|
||||||
|
"loyalty-cards", # Customer cards
|
||||||
|
"loyalty-stats", # Vendor stats
|
||||||
|
],
|
||||||
|
},
|
||||||
|
is_core=False, # Loyalty can be disabled
|
||||||
|
# =========================================================================
|
||||||
|
# Self-Contained Module Configuration
|
||||||
|
# =========================================================================
|
||||||
|
is_self_contained=True,
|
||||||
|
services_path="app.modules.loyalty.services",
|
||||||
|
models_path="app.modules.loyalty.models",
|
||||||
|
schemas_path="app.modules.loyalty.schemas",
|
||||||
|
exceptions_path="app.modules.loyalty.exceptions",
|
||||||
|
tasks_path="app.modules.loyalty.tasks",
|
||||||
|
# =========================================================================
|
||||||
|
# Scheduled Tasks
|
||||||
|
# =========================================================================
|
||||||
|
scheduled_tasks=[
|
||||||
|
ScheduledTask(
|
||||||
|
name="loyalty.sync_wallet_passes",
|
||||||
|
task="app.modules.loyalty.tasks.wallet_sync.sync_wallet_passes",
|
||||||
|
schedule="0 * * * *", # Hourly
|
||||||
|
options={"queue": "scheduled"},
|
||||||
|
),
|
||||||
|
ScheduledTask(
|
||||||
|
name="loyalty.expire_points",
|
||||||
|
task="app.modules.loyalty.tasks.point_expiration.expire_points",
|
||||||
|
schedule="0 2 * * *", # Daily at 02:00
|
||||||
|
options={"queue": "scheduled"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_loyalty_module_with_routers() -> ModuleDefinition:
|
||||||
|
"""
|
||||||
|
Get loyalty module with routers attached.
|
||||||
|
|
||||||
|
This function attaches the routers lazily to avoid circular imports
|
||||||
|
during module initialization.
|
||||||
|
"""
|
||||||
|
loyalty_module.admin_router = _get_admin_router()
|
||||||
|
loyalty_module.vendor_router = _get_vendor_router()
|
||||||
|
# Note: public_router needs to be attached separately in main.py
|
||||||
|
# as it doesn't require authentication
|
||||||
|
return loyalty_module
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["loyalty_module", "get_loyalty_module_with_routers"]
|
||||||
288
app/modules/loyalty/exceptions.py
Normal file
288
app/modules/loyalty/exceptions.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# app/modules/loyalty/exceptions.py
|
||||||
|
"""
|
||||||
|
Loyalty module exceptions.
|
||||||
|
|
||||||
|
Custom exceptions for loyalty program operations including
|
||||||
|
stamp/points management, card operations, and wallet integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.exceptions.base import (
|
||||||
|
BusinessLogicException,
|
||||||
|
ConflictException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyException(BusinessLogicException):
|
||||||
|
"""Base exception for loyalty module errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
error_code: str = "LOYALTY_ERROR",
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message=message, error_code=error_code, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Program Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyProgramNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a loyalty program is not found."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str):
|
||||||
|
super().__init__("LoyaltyProgram", identifier)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyProgramAlreadyExistsException(ConflictException):
|
||||||
|
"""Raised when vendor already has a loyalty program."""
|
||||||
|
|
||||||
|
def __init__(self, vendor_id: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Vendor {vendor_id} already has a loyalty program",
|
||||||
|
error_code="LOYALTY_PROGRAM_ALREADY_EXISTS",
|
||||||
|
details={"vendor_id": vendor_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyProgramInactiveException(LoyaltyException):
|
||||||
|
"""Raised when trying to use an inactive loyalty program."""
|
||||||
|
|
||||||
|
def __init__(self, program_id: int):
|
||||||
|
super().__init__(
|
||||||
|
message="Loyalty program is not active",
|
||||||
|
error_code="LOYALTY_PROGRAM_INACTIVE",
|
||||||
|
details={"program_id": program_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Card Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyCardNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a loyalty card is not found."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str):
|
||||||
|
super().__init__("LoyaltyCard", identifier)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyCardAlreadyExistsException(ConflictException):
|
||||||
|
"""Raised when customer already has a card for this program."""
|
||||||
|
|
||||||
|
def __init__(self, customer_id: int, program_id: int):
|
||||||
|
super().__init__(
|
||||||
|
message="Customer already enrolled in this loyalty program",
|
||||||
|
error_code="LOYALTY_CARD_ALREADY_EXISTS",
|
||||||
|
details={"customer_id": customer_id, "program_id": program_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyCardInactiveException(LoyaltyException):
|
||||||
|
"""Raised when trying to use an inactive loyalty card."""
|
||||||
|
|
||||||
|
def __init__(self, card_id: int):
|
||||||
|
super().__init__(
|
||||||
|
message="Loyalty card is not active",
|
||||||
|
error_code="LOYALTY_CARD_INACTIVE",
|
||||||
|
details={"card_id": card_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Anti-Fraud Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPinNotFoundException(ResourceNotFoundException):
|
||||||
|
"""Raised when a staff PIN is not found."""
|
||||||
|
|
||||||
|
def __init__(self, identifier: str):
|
||||||
|
super().__init__("StaffPin", identifier)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPinRequiredException(LoyaltyException):
|
||||||
|
"""Raised when staff PIN is required but not provided."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="Staff PIN is required for this operation",
|
||||||
|
error_code="STAFF_PIN_REQUIRED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidStaffPinException(LoyaltyException):
|
||||||
|
"""Raised when staff PIN is invalid."""
|
||||||
|
|
||||||
|
def __init__(self, remaining_attempts: int | None = None):
|
||||||
|
details = {}
|
||||||
|
if remaining_attempts is not None:
|
||||||
|
details["remaining_attempts"] = remaining_attempts
|
||||||
|
super().__init__(
|
||||||
|
message="Invalid staff PIN",
|
||||||
|
error_code="INVALID_STAFF_PIN",
|
||||||
|
details=details if details else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPinLockedException(LoyaltyException):
|
||||||
|
"""Raised when staff PIN is locked due to too many failed attempts."""
|
||||||
|
|
||||||
|
def __init__(self, locked_until: str):
|
||||||
|
super().__init__(
|
||||||
|
message="Staff PIN is locked due to too many failed attempts",
|
||||||
|
error_code="STAFF_PIN_LOCKED",
|
||||||
|
details={"locked_until": locked_until},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StampCooldownException(LoyaltyException):
|
||||||
|
"""Raised when trying to stamp before cooldown period ends."""
|
||||||
|
|
||||||
|
def __init__(self, cooldown_ends: str, cooldown_minutes: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Please wait {cooldown_minutes} minutes between stamps",
|
||||||
|
error_code="STAMP_COOLDOWN",
|
||||||
|
details={"cooldown_ends": cooldown_ends, "cooldown_minutes": cooldown_minutes},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyStampLimitException(LoyaltyException):
|
||||||
|
"""Raised when daily stamp limit is exceeded."""
|
||||||
|
|
||||||
|
def __init__(self, max_daily_stamps: int, stamps_today: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Daily stamp limit of {max_daily_stamps} reached",
|
||||||
|
error_code="DAILY_STAMP_LIMIT",
|
||||||
|
details={"max_daily_stamps": max_daily_stamps, "stamps_today": stamps_today},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Redemption Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientStampsException(LoyaltyException):
|
||||||
|
"""Raised when card doesn't have enough stamps to redeem."""
|
||||||
|
|
||||||
|
def __init__(self, current_stamps: int, required_stamps: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Insufficient stamps: {current_stamps}/{required_stamps}",
|
||||||
|
error_code="INSUFFICIENT_STAMPS",
|
||||||
|
details={"current_stamps": current_stamps, "required_stamps": required_stamps},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientPointsException(LoyaltyException):
|
||||||
|
"""Raised when card doesn't have enough points to redeem."""
|
||||||
|
|
||||||
|
def __init__(self, current_points: int, required_points: int):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Insufficient points: {current_points}/{required_points}",
|
||||||
|
error_code="INSUFFICIENT_POINTS",
|
||||||
|
details={"current_points": current_points, "required_points": required_points},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRewardException(LoyaltyException):
|
||||||
|
"""Raised when trying to redeem an invalid or unavailable reward."""
|
||||||
|
|
||||||
|
def __init__(self, reward_id: str):
|
||||||
|
super().__init__(
|
||||||
|
message="Invalid or unavailable reward",
|
||||||
|
error_code="INVALID_REWARD",
|
||||||
|
details={"reward_id": reward_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Wallet Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class WalletIntegrationException(LoyaltyException):
|
||||||
|
"""Raised when wallet integration fails."""
|
||||||
|
|
||||||
|
def __init__(self, provider: str, message: str):
|
||||||
|
super().__init__(
|
||||||
|
message=f"Wallet integration error: {message}",
|
||||||
|
error_code="WALLET_INTEGRATION_ERROR",
|
||||||
|
details={"provider": provider},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleWalletNotConfiguredException(LoyaltyException):
|
||||||
|
"""Raised when Google Wallet is not configured."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="Google Wallet is not configured for this program",
|
||||||
|
error_code="GOOGLE_WALLET_NOT_CONFIGURED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppleWalletNotConfiguredException(LoyaltyException):
|
||||||
|
"""Raised when Apple Wallet is not configured."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="Apple Wallet is not configured for this program",
|
||||||
|
error_code="APPLE_WALLET_NOT_CONFIGURED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Validation Exceptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyValidationException(ValidationException):
|
||||||
|
"""Raised when loyalty data validation fails."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Loyalty validation failed",
|
||||||
|
field: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message=message, field=field, details=details)
|
||||||
|
self.error_code = "LOYALTY_VALIDATION_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Base
|
||||||
|
"LoyaltyException",
|
||||||
|
# Program
|
||||||
|
"LoyaltyProgramNotFoundException",
|
||||||
|
"LoyaltyProgramAlreadyExistsException",
|
||||||
|
"LoyaltyProgramInactiveException",
|
||||||
|
# Card
|
||||||
|
"LoyaltyCardNotFoundException",
|
||||||
|
"LoyaltyCardAlreadyExistsException",
|
||||||
|
"LoyaltyCardInactiveException",
|
||||||
|
# Anti-Fraud
|
||||||
|
"StaffPinNotFoundException",
|
||||||
|
"StaffPinRequiredException",
|
||||||
|
"InvalidStaffPinException",
|
||||||
|
"StaffPinLockedException",
|
||||||
|
"StampCooldownException",
|
||||||
|
"DailyStampLimitException",
|
||||||
|
# Redemption
|
||||||
|
"InsufficientStampsException",
|
||||||
|
"InsufficientPointsException",
|
||||||
|
"InvalidRewardException",
|
||||||
|
# Wallet
|
||||||
|
"WalletIntegrationException",
|
||||||
|
"GoogleWalletNotConfiguredException",
|
||||||
|
"AppleWalletNotConfiguredException",
|
||||||
|
# Validation
|
||||||
|
"LoyaltyValidationException",
|
||||||
|
]
|
||||||
72
app/modules/loyalty/locales/de.json
Normal file
72
app/modules/loyalty/locales/de.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"loyalty": {
|
||||||
|
"module": {
|
||||||
|
"name": "Treueprogramme",
|
||||||
|
"description": "Stempel- und punktebasierte Treueprogramme mit Wallet-Integration"
|
||||||
|
},
|
||||||
|
"program": {
|
||||||
|
"title": "Treueprogramm",
|
||||||
|
"create": "Programm erstellen",
|
||||||
|
"edit": "Programm bearbeiten",
|
||||||
|
"activate": "Aktivieren",
|
||||||
|
"deactivate": "Deaktivieren",
|
||||||
|
"type": {
|
||||||
|
"stamps": "Stempel",
|
||||||
|
"points": "Punkte",
|
||||||
|
"hybrid": "Hybrid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Treuekarte",
|
||||||
|
"number": "Kartennummer",
|
||||||
|
"qr_code": "QR-Code",
|
||||||
|
"enroll": "Kunde anmelden",
|
||||||
|
"deactivate": "Karte deaktivieren"
|
||||||
|
},
|
||||||
|
"stamp": {
|
||||||
|
"title": "Stempel",
|
||||||
|
"add": "Stempel hinzufügen",
|
||||||
|
"redeem": "Prämie einlösen",
|
||||||
|
"count": "{current} von {target}",
|
||||||
|
"until_reward": "Noch {count} bis zur Prämie"
|
||||||
|
},
|
||||||
|
"points": {
|
||||||
|
"title": "Punkte",
|
||||||
|
"earn": "Punkte sammeln",
|
||||||
|
"redeem": "Punkte einlösen",
|
||||||
|
"balance": "{count} Punkte",
|
||||||
|
"per_euro": "{points} Punkte pro Euro"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"title": "Mitarbeiter-PINs",
|
||||||
|
"create": "PIN erstellen",
|
||||||
|
"edit": "PIN bearbeiten",
|
||||||
|
"unlock": "PIN entsperren",
|
||||||
|
"locked": "PIN gesperrt bis {time}"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"google": "Zu Google Wallet hinzufügen",
|
||||||
|
"apple": "Zu Apple Wallet hinzufügen"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Statistiken",
|
||||||
|
"total_cards": "Karten insgesamt",
|
||||||
|
"active_cards": "Aktive Karten",
|
||||||
|
"stamps_issued": "Ausgegebene Stempel",
|
||||||
|
"rewards_redeemed": "Eingelöste Prämien"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"program_not_found": "Treueprogramm nicht gefunden",
|
||||||
|
"program_inactive": "Treueprogramm ist nicht aktiv",
|
||||||
|
"card_not_found": "Treuekarte nicht gefunden",
|
||||||
|
"card_inactive": "Treuekarte ist nicht aktiv",
|
||||||
|
"cooldown": "Bitte warten Sie {minutes} Minuten vor dem nächsten Stempel",
|
||||||
|
"daily_limit": "Tageslimit von {limit} Stempeln erreicht",
|
||||||
|
"insufficient_stamps": "Benötigt {required} Stempel, vorhanden {current}",
|
||||||
|
"insufficient_points": "Benötigt {required} Punkte, vorhanden {current}",
|
||||||
|
"pin_required": "Mitarbeiter-PIN erforderlich",
|
||||||
|
"pin_invalid": "Ungültiger PIN",
|
||||||
|
"pin_locked": "PIN wegen zu vieler Fehlversuche gesperrt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/modules/loyalty/locales/en.json
Normal file
72
app/modules/loyalty/locales/en.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"loyalty": {
|
||||||
|
"module": {
|
||||||
|
"name": "Loyalty Programs",
|
||||||
|
"description": "Stamp-based and points-based loyalty programs with wallet integration"
|
||||||
|
},
|
||||||
|
"program": {
|
||||||
|
"title": "Loyalty Program",
|
||||||
|
"create": "Create Program",
|
||||||
|
"edit": "Edit Program",
|
||||||
|
"activate": "Activate",
|
||||||
|
"deactivate": "Deactivate",
|
||||||
|
"type": {
|
||||||
|
"stamps": "Stamps",
|
||||||
|
"points": "Points",
|
||||||
|
"hybrid": "Hybrid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Loyalty Card",
|
||||||
|
"number": "Card Number",
|
||||||
|
"qr_code": "QR Code",
|
||||||
|
"enroll": "Enroll Customer",
|
||||||
|
"deactivate": "Deactivate Card"
|
||||||
|
},
|
||||||
|
"stamp": {
|
||||||
|
"title": "Stamps",
|
||||||
|
"add": "Add Stamp",
|
||||||
|
"redeem": "Redeem Reward",
|
||||||
|
"count": "{current} of {target}",
|
||||||
|
"until_reward": "{count} until reward"
|
||||||
|
},
|
||||||
|
"points": {
|
||||||
|
"title": "Points",
|
||||||
|
"earn": "Earn Points",
|
||||||
|
"redeem": "Redeem Points",
|
||||||
|
"balance": "{count} points",
|
||||||
|
"per_euro": "{points} points per euro"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"title": "Staff PINs",
|
||||||
|
"create": "Create PIN",
|
||||||
|
"edit": "Edit PIN",
|
||||||
|
"unlock": "Unlock PIN",
|
||||||
|
"locked": "PIN locked until {time}"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"google": "Add to Google Wallet",
|
||||||
|
"apple": "Add to Apple Wallet"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Statistics",
|
||||||
|
"total_cards": "Total Cards",
|
||||||
|
"active_cards": "Active Cards",
|
||||||
|
"stamps_issued": "Stamps Issued",
|
||||||
|
"rewards_redeemed": "Rewards Redeemed"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"program_not_found": "Loyalty program not found",
|
||||||
|
"program_inactive": "Loyalty program is not active",
|
||||||
|
"card_not_found": "Loyalty card not found",
|
||||||
|
"card_inactive": "Loyalty card is not active",
|
||||||
|
"cooldown": "Please wait {minutes} minutes before next stamp",
|
||||||
|
"daily_limit": "Daily stamp limit of {limit} reached",
|
||||||
|
"insufficient_stamps": "Need {required} stamps, have {current}",
|
||||||
|
"insufficient_points": "Need {required} points, have {current}",
|
||||||
|
"pin_required": "Staff PIN is required",
|
||||||
|
"pin_invalid": "Invalid staff PIN",
|
||||||
|
"pin_locked": "PIN locked due to too many failed attempts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/modules/loyalty/locales/fr.json
Normal file
72
app/modules/loyalty/locales/fr.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"loyalty": {
|
||||||
|
"module": {
|
||||||
|
"name": "Programmes de Fidélité",
|
||||||
|
"description": "Programmes de fidélité par tampons et points avec intégration wallet"
|
||||||
|
},
|
||||||
|
"program": {
|
||||||
|
"title": "Programme de Fidélité",
|
||||||
|
"create": "Créer un Programme",
|
||||||
|
"edit": "Modifier le Programme",
|
||||||
|
"activate": "Activer",
|
||||||
|
"deactivate": "Désactiver",
|
||||||
|
"type": {
|
||||||
|
"stamps": "Tampons",
|
||||||
|
"points": "Points",
|
||||||
|
"hybrid": "Hybride"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Carte de Fidélité",
|
||||||
|
"number": "Numéro de Carte",
|
||||||
|
"qr_code": "Code QR",
|
||||||
|
"enroll": "Inscrire un Client",
|
||||||
|
"deactivate": "Désactiver la Carte"
|
||||||
|
},
|
||||||
|
"stamp": {
|
||||||
|
"title": "Tampons",
|
||||||
|
"add": "Ajouter un Tampon",
|
||||||
|
"redeem": "Échanger la Récompense",
|
||||||
|
"count": "{current} sur {target}",
|
||||||
|
"until_reward": "Plus que {count} pour la récompense"
|
||||||
|
},
|
||||||
|
"points": {
|
||||||
|
"title": "Points",
|
||||||
|
"earn": "Gagner des Points",
|
||||||
|
"redeem": "Échanger des Points",
|
||||||
|
"balance": "{count} points",
|
||||||
|
"per_euro": "{points} points par euro"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"title": "Codes PIN du Personnel",
|
||||||
|
"create": "Créer un PIN",
|
||||||
|
"edit": "Modifier le PIN",
|
||||||
|
"unlock": "Débloquer le PIN",
|
||||||
|
"locked": "PIN bloqué jusqu'à {time}"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"google": "Ajouter à Google Wallet",
|
||||||
|
"apple": "Ajouter à Apple Wallet"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Statistiques",
|
||||||
|
"total_cards": "Total des Cartes",
|
||||||
|
"active_cards": "Cartes Actives",
|
||||||
|
"stamps_issued": "Tampons Émis",
|
||||||
|
"rewards_redeemed": "Récompenses Échangées"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"program_not_found": "Programme de fidélité introuvable",
|
||||||
|
"program_inactive": "Le programme de fidélité n'est pas actif",
|
||||||
|
"card_not_found": "Carte de fidélité introuvable",
|
||||||
|
"card_inactive": "La carte de fidélité n'est pas active",
|
||||||
|
"cooldown": "Veuillez attendre {minutes} minutes avant le prochain tampon",
|
||||||
|
"daily_limit": "Limite quotidienne de {limit} tampons atteinte",
|
||||||
|
"insufficient_stamps": "Il faut {required} tampons, vous en avez {current}",
|
||||||
|
"insufficient_points": "Il faut {required} points, vous en avez {current}",
|
||||||
|
"pin_required": "Le code PIN du personnel est requis",
|
||||||
|
"pin_invalid": "Code PIN invalide",
|
||||||
|
"pin_locked": "PIN bloqué suite à trop de tentatives échouées"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/modules/loyalty/locales/lu.json
Normal file
72
app/modules/loyalty/locales/lu.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"loyalty": {
|
||||||
|
"module": {
|
||||||
|
"name": "Treieprogrammer",
|
||||||
|
"description": "Stempel- a punktebaséiert Treieprogrammer mat Wallet-Integratioun"
|
||||||
|
},
|
||||||
|
"program": {
|
||||||
|
"title": "Treieprogramm",
|
||||||
|
"create": "Programm erstellen",
|
||||||
|
"edit": "Programm beaarbechten",
|
||||||
|
"activate": "Aktivéieren",
|
||||||
|
"deactivate": "Deaktivéieren",
|
||||||
|
"type": {
|
||||||
|
"stamps": "Stempelen",
|
||||||
|
"points": "Punkten",
|
||||||
|
"hybrid": "Hybrid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Treiekaart",
|
||||||
|
"number": "Kaartennummer",
|
||||||
|
"qr_code": "QR-Code",
|
||||||
|
"enroll": "Client umellen",
|
||||||
|
"deactivate": "Kaart deaktivéieren"
|
||||||
|
},
|
||||||
|
"stamp": {
|
||||||
|
"title": "Stempelen",
|
||||||
|
"add": "Stempel dobäisetzen",
|
||||||
|
"redeem": "Belounung aléisen",
|
||||||
|
"count": "{current} vun {target}",
|
||||||
|
"until_reward": "Nach {count} bis zur Belounung"
|
||||||
|
},
|
||||||
|
"points": {
|
||||||
|
"title": "Punkten",
|
||||||
|
"earn": "Punkten sammelen",
|
||||||
|
"redeem": "Punkten aléisen",
|
||||||
|
"balance": "{count} Punkten",
|
||||||
|
"per_euro": "{points} Punkten pro Euro"
|
||||||
|
},
|
||||||
|
"pin": {
|
||||||
|
"title": "Mataarbechter-PINen",
|
||||||
|
"create": "PIN erstellen",
|
||||||
|
"edit": "PIN beaarbechten",
|
||||||
|
"unlock": "PIN entspären",
|
||||||
|
"locked": "PIN gespaart bis {time}"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"google": "Bäi Google Wallet bäisetzen",
|
||||||
|
"apple": "Bäi Apple Wallet bäisetzen"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Statistiken",
|
||||||
|
"total_cards": "Total Kaarten",
|
||||||
|
"active_cards": "Aktiv Kaarten",
|
||||||
|
"stamps_issued": "Ausgestallte Stempelen",
|
||||||
|
"rewards_redeemed": "Agelëst Belounungen"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"program_not_found": "Treieprogramm net fonnt",
|
||||||
|
"program_inactive": "Treieprogramm ass net aktiv",
|
||||||
|
"card_not_found": "Treiekaart net fonnt",
|
||||||
|
"card_inactive": "Treiekaart ass net aktiv",
|
||||||
|
"cooldown": "Waart w.e.g. {minutes} Minutten virum nächste Stempel",
|
||||||
|
"daily_limit": "Dageslimit vun {limit} Stempelen erreecht",
|
||||||
|
"insufficient_stamps": "Brauch {required} Stempelen, hutt {current}",
|
||||||
|
"insufficient_points": "Brauch {required} Punkten, hutt {current}",
|
||||||
|
"pin_required": "Mataarbechter-PIN erfuerdert",
|
||||||
|
"pin_invalid": "Ongültege PIN",
|
||||||
|
"pin_locked": "PIN gespaart wéinst ze vill Feelverséich"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/modules/loyalty/migrations/__init__.py
Normal file
6
app/modules/loyalty/migrations/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# app/modules/loyalty/migrations/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module Alembic migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
6
app/modules/loyalty/migrations/versions/__init__.py
Normal file
6
app/modules/loyalty/migrations/versions/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# app/modules/loyalty/migrations/versions/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module migration versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
55
app/modules/loyalty/models/__init__.py
Normal file
55
app/modules/loyalty/models/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# app/modules/loyalty/models/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module database models.
|
||||||
|
|
||||||
|
This is the canonical location for loyalty models. Module models are automatically
|
||||||
|
discovered and registered with SQLAlchemy's Base.metadata at startup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.loyalty.models import (
|
||||||
|
LoyaltyProgram,
|
||||||
|
LoyaltyCard,
|
||||||
|
LoyaltyTransaction,
|
||||||
|
StaffPin,
|
||||||
|
AppleDeviceRegistration,
|
||||||
|
LoyaltyType,
|
||||||
|
TransactionType,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.loyalty.models.loyalty_program import (
|
||||||
|
# Enums
|
||||||
|
LoyaltyType,
|
||||||
|
# Model
|
||||||
|
LoyaltyProgram,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models.loyalty_card import (
|
||||||
|
# Model
|
||||||
|
LoyaltyCard,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models.loyalty_transaction import (
|
||||||
|
# Enums
|
||||||
|
TransactionType,
|
||||||
|
# Model
|
||||||
|
LoyaltyTransaction,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models.staff_pin import (
|
||||||
|
# Model
|
||||||
|
StaffPin,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models.apple_device import (
|
||||||
|
# Model
|
||||||
|
AppleDeviceRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Enums
|
||||||
|
"LoyaltyType",
|
||||||
|
"TransactionType",
|
||||||
|
# Models
|
||||||
|
"LoyaltyProgram",
|
||||||
|
"LoyaltyCard",
|
||||||
|
"LoyaltyTransaction",
|
||||||
|
"StaffPin",
|
||||||
|
"AppleDeviceRegistration",
|
||||||
|
]
|
||||||
79
app/modules/loyalty/models/apple_device.py
Normal file
79
app/modules/loyalty/models/apple_device.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# app/modules/loyalty/models/apple_device.py
|
||||||
|
"""
|
||||||
|
Apple device registration database model.
|
||||||
|
|
||||||
|
Tracks devices that have added an Apple Wallet pass for push
|
||||||
|
notification updates when the pass changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AppleDeviceRegistration(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Apple Wallet device registration.
|
||||||
|
|
||||||
|
When a user adds a pass to Apple Wallet, the device registers
|
||||||
|
with us to receive push notifications when the pass updates.
|
||||||
|
|
||||||
|
This implements the Apple Wallet Web Service for passbook updates:
|
||||||
|
https://developer.apple.com/documentation/walletpasses/
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "apple_device_registrations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Card relationship
|
||||||
|
card_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("loyalty_cards.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device identification
|
||||||
|
device_library_identifier = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Unique identifier for the device/library",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Push notification token
|
||||||
|
push_token = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="APNs push token for this device",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Relationships
|
||||||
|
# =========================================================================
|
||||||
|
card = relationship("LoyaltyCard", back_populates="apple_devices")
|
||||||
|
|
||||||
|
# Indexes - unique constraint on device + card combination
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"idx_apple_device_card",
|
||||||
|
"device_library_identifier",
|
||||||
|
"card_id",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<AppleDeviceRegistration(id={self.id}, "
|
||||||
|
f"device='{self.device_library_identifier[:8]}...', card_id={self.card_id})>"
|
||||||
|
)
|
||||||
317
app/modules/loyalty/models/loyalty_card.py
Normal file
317
app/modules/loyalty/models/loyalty_card.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# app/modules/loyalty/models/loyalty_card.py
|
||||||
|
"""
|
||||||
|
Loyalty card database model.
|
||||||
|
|
||||||
|
Represents a customer's loyalty card (PassObject) that tracks:
|
||||||
|
- Stamp count and history
|
||||||
|
- Points balance and history
|
||||||
|
- Wallet integration (Google/Apple pass IDs)
|
||||||
|
- QR code for scanning
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
def generate_card_number() -> str:
|
||||||
|
"""Generate a unique 12-digit card number."""
|
||||||
|
return "".join([str(secrets.randbelow(10)) for _ in range(12)])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_code_data() -> str:
|
||||||
|
"""Generate unique QR code data (URL-safe token)."""
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_apple_auth_token() -> str:
|
||||||
|
"""Generate Apple Wallet authentication token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyCard(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Customer's loyalty card (PassObject).
|
||||||
|
|
||||||
|
Links a customer to a vendor's loyalty program and tracks:
|
||||||
|
- Stamps and points balances
|
||||||
|
- Wallet pass integration
|
||||||
|
- Activity timestamps
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "loyalty_cards"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
customer_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("customers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
program_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Denormalized for query performance",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Card Identification
|
||||||
|
# =========================================================================
|
||||||
|
card_number = Column(
|
||||||
|
String(20),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
default=generate_card_number,
|
||||||
|
index=True,
|
||||||
|
comment="Human-readable card number",
|
||||||
|
)
|
||||||
|
qr_code_data = Column(
|
||||||
|
String(50),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
default=generate_qr_code_data,
|
||||||
|
index=True,
|
||||||
|
comment="Data encoded in QR code for scanning",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Stamps Tracking
|
||||||
|
# =========================================================================
|
||||||
|
stamp_count = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Current stamps toward next reward",
|
||||||
|
)
|
||||||
|
total_stamps_earned = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Lifetime stamps earned",
|
||||||
|
)
|
||||||
|
stamps_redeemed = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Total rewards redeemed (stamps reset on redemption)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Points Tracking
|
||||||
|
# =========================================================================
|
||||||
|
points_balance = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Current available points",
|
||||||
|
)
|
||||||
|
total_points_earned = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Lifetime points earned",
|
||||||
|
)
|
||||||
|
points_redeemed = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Lifetime points redeemed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Wallet Integration
|
||||||
|
# =========================================================================
|
||||||
|
# Google Wallet
|
||||||
|
google_object_id = Column(
|
||||||
|
String(200),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Google Wallet Loyalty Object ID",
|
||||||
|
)
|
||||||
|
google_object_jwt = Column(
|
||||||
|
String(2000),
|
||||||
|
nullable=True,
|
||||||
|
comment="JWT for Google Wallet 'Add to Wallet' button",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apple Wallet
|
||||||
|
apple_serial_number = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
comment="Apple Wallet pass serial number",
|
||||||
|
)
|
||||||
|
apple_auth_token = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
default=generate_apple_auth_token,
|
||||||
|
comment="Apple Wallet authentication token for updates",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Activity Timestamps
|
||||||
|
# =========================================================================
|
||||||
|
last_stamp_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last stamp added (for cooldown)",
|
||||||
|
)
|
||||||
|
last_points_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last points earned",
|
||||||
|
)
|
||||||
|
last_redemption_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last reward redemption",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Status
|
||||||
|
# =========================================================================
|
||||||
|
is_active = Column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Relationships
|
||||||
|
# =========================================================================
|
||||||
|
customer = relationship("Customer", backref="loyalty_cards")
|
||||||
|
program = relationship("LoyaltyProgram", back_populates="cards")
|
||||||
|
vendor = relationship("Vendor", backref="loyalty_cards")
|
||||||
|
transactions = relationship(
|
||||||
|
"LoyaltyTransaction",
|
||||||
|
back_populates="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="desc(LoyaltyTransaction.created_at)",
|
||||||
|
)
|
||||||
|
apple_devices = relationship(
|
||||||
|
"AppleDeviceRegistration",
|
||||||
|
back_populates="card",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
|
||||||
|
Index("idx_loyalty_card_vendor_active", "vendor_id", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<LoyaltyCard(id={self.id}, card_number='{self.card_number}', stamps={self.stamp_count})>"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Stamp Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def add_stamp(self) -> bool:
|
||||||
|
"""
|
||||||
|
Add a stamp to the card.
|
||||||
|
|
||||||
|
Returns True if this stamp completed a reward cycle.
|
||||||
|
"""
|
||||||
|
self.stamp_count += 1
|
||||||
|
self.total_stamps_earned += 1
|
||||||
|
self.last_stamp_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Check if reward cycle is complete (handled by caller with program.stamps_target)
|
||||||
|
return False # Caller checks against program.stamps_target
|
||||||
|
|
||||||
|
def redeem_stamps(self, stamps_target: int) -> bool:
|
||||||
|
"""
|
||||||
|
Redeem stamps for a reward.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stamps_target: Number of stamps required for reward
|
||||||
|
|
||||||
|
Returns True if redemption was successful.
|
||||||
|
"""
|
||||||
|
if self.stamp_count >= stamps_target:
|
||||||
|
self.stamp_count -= stamps_target
|
||||||
|
self.stamps_redeemed += 1
|
||||||
|
self.last_redemption_at = datetime.now(UTC)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Points Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def add_points(self, points: int) -> None:
|
||||||
|
"""Add points to the card."""
|
||||||
|
self.points_balance += points
|
||||||
|
self.total_points_earned += points
|
||||||
|
self.last_points_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
def redeem_points(self, points: int) -> bool:
|
||||||
|
"""
|
||||||
|
Redeem points for a reward.
|
||||||
|
|
||||||
|
Returns True if redemption was successful.
|
||||||
|
"""
|
||||||
|
if self.points_balance >= points:
|
||||||
|
self.points_balance -= points
|
||||||
|
self.points_redeemed += points
|
||||||
|
self.last_redemption_at = datetime.now(UTC)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Properties
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stamps_until_reward(self) -> int | None:
|
||||||
|
"""Get stamps remaining until next reward (needs program context)."""
|
||||||
|
# This should be calculated with program.stamps_target
|
||||||
|
return None
|
||||||
|
|
||||||
|
def can_stamp(self, cooldown_minutes: int) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Check if card can receive a stamp (cooldown check).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cooldown_minutes: Minutes required between stamps
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(can_stamp, error_message)
|
||||||
|
"""
|
||||||
|
if not self.last_stamp_at:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
elapsed = (now - self.last_stamp_at).total_seconds() / 60
|
||||||
|
|
||||||
|
if elapsed < cooldown_minutes:
|
||||||
|
remaining = int(cooldown_minutes - elapsed)
|
||||||
|
return False, f"Please wait {remaining} minutes before next stamp"
|
||||||
|
|
||||||
|
return True, None
|
||||||
268
app/modules/loyalty/models/loyalty_program.py
Normal file
268
app/modules/loyalty/models/loyalty_program.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# app/modules/loyalty/models/loyalty_program.py
|
||||||
|
"""
|
||||||
|
Loyalty program database model.
|
||||||
|
|
||||||
|
Defines the vendor's loyalty program configuration including:
|
||||||
|
- Program type (stamps, points, hybrid)
|
||||||
|
- Stamp configuration (target, reward description)
|
||||||
|
- Points configuration (per euro rate, rewards catalog)
|
||||||
|
- Anti-fraud settings (cooldown, daily limits, PIN requirement)
|
||||||
|
- Branding (card name, color, logo)
|
||||||
|
- Wallet integration IDs (Google, Apple)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyType(str, enum.Enum):
|
||||||
|
"""Type of loyalty program."""
|
||||||
|
|
||||||
|
STAMPS = "stamps" # Collect N stamps, get reward
|
||||||
|
POINTS = "points" # Earn points per euro, redeem for rewards
|
||||||
|
HYBRID = "hybrid" # Both stamps and points
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyProgram(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Vendor's loyalty program configuration.
|
||||||
|
|
||||||
|
Each vendor can have one loyalty program that defines:
|
||||||
|
- Program type and mechanics
|
||||||
|
- Stamp or points configuration
|
||||||
|
- Anti-fraud rules
|
||||||
|
- Branding and wallet integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "loyalty_programs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Vendor association (one program per vendor)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Program type
|
||||||
|
loyalty_type = Column(
|
||||||
|
String(20),
|
||||||
|
default=LoyaltyType.STAMPS.value,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Stamps Configuration
|
||||||
|
# =========================================================================
|
||||||
|
stamps_target = Column(
|
||||||
|
Integer,
|
||||||
|
default=10,
|
||||||
|
nullable=False,
|
||||||
|
comment="Number of stamps needed for reward",
|
||||||
|
)
|
||||||
|
stamps_reward_description = Column(
|
||||||
|
String(255),
|
||||||
|
default="Free item",
|
||||||
|
nullable=False,
|
||||||
|
comment="Description of stamp reward",
|
||||||
|
)
|
||||||
|
stamps_reward_value_cents = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Value of stamp reward in cents (for analytics)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Points Configuration
|
||||||
|
# =========================================================================
|
||||||
|
points_per_euro = Column(
|
||||||
|
Integer,
|
||||||
|
default=10,
|
||||||
|
nullable=False,
|
||||||
|
comment="Points earned per euro spent",
|
||||||
|
)
|
||||||
|
points_rewards = Column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
comment="List of point rewards: [{id, name, points_required, description}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Anti-Fraud Settings
|
||||||
|
# =========================================================================
|
||||||
|
cooldown_minutes = Column(
|
||||||
|
Integer,
|
||||||
|
default=15,
|
||||||
|
nullable=False,
|
||||||
|
comment="Minutes between stamps for same card",
|
||||||
|
)
|
||||||
|
max_daily_stamps = Column(
|
||||||
|
Integer,
|
||||||
|
default=5,
|
||||||
|
nullable=False,
|
||||||
|
comment="Maximum stamps per card per day",
|
||||||
|
)
|
||||||
|
require_staff_pin = Column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
comment="Require staff PIN for stamp/points operations",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Branding
|
||||||
|
# =========================================================================
|
||||||
|
card_name = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="Display name for loyalty card",
|
||||||
|
)
|
||||||
|
card_color = Column(
|
||||||
|
String(7),
|
||||||
|
default="#4F46E5",
|
||||||
|
nullable=False,
|
||||||
|
comment="Primary color for card (hex)",
|
||||||
|
)
|
||||||
|
card_secondary_color = Column(
|
||||||
|
String(7),
|
||||||
|
nullable=True,
|
||||||
|
comment="Secondary color for card (hex)",
|
||||||
|
)
|
||||||
|
logo_url = Column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="URL to vendor logo for card",
|
||||||
|
)
|
||||||
|
hero_image_url = Column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="URL to hero image for card",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Wallet Integration
|
||||||
|
# =========================================================================
|
||||||
|
google_issuer_id = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="Google Wallet Issuer ID",
|
||||||
|
)
|
||||||
|
google_class_id = Column(
|
||||||
|
String(200),
|
||||||
|
nullable=True,
|
||||||
|
comment="Google Wallet Loyalty Class ID",
|
||||||
|
)
|
||||||
|
apple_pass_type_id = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="Apple Wallet Pass Type ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Terms and Conditions
|
||||||
|
# =========================================================================
|
||||||
|
terms_text = Column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="Loyalty program terms and conditions",
|
||||||
|
)
|
||||||
|
privacy_url = Column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="URL to privacy policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Status
|
||||||
|
# =========================================================================
|
||||||
|
is_active = Column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
activated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="When program was first activated",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Relationships
|
||||||
|
# =========================================================================
|
||||||
|
vendor = relationship("Vendor", back_populates="loyalty_program")
|
||||||
|
cards = relationship(
|
||||||
|
"LoyaltyCard",
|
||||||
|
back_populates="program",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
staff_pins = relationship(
|
||||||
|
"StaffPin",
|
||||||
|
back_populates="program",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_loyalty_program_vendor_active", "vendor_id", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<LoyaltyProgram(id={self.id}, vendor_id={self.vendor_id}, type='{self.loyalty_type}')>"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Properties
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stamps_enabled(self) -> bool:
|
||||||
|
"""Check if stamps are enabled for this program."""
|
||||||
|
return self.loyalty_type in (LoyaltyType.STAMPS.value, LoyaltyType.HYBRID.value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_points_enabled(self) -> bool:
|
||||||
|
"""Check if points are enabled for this program."""
|
||||||
|
return self.loyalty_type in (LoyaltyType.POINTS.value, LoyaltyType.HYBRID.value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Get display name for the program."""
|
||||||
|
return self.card_name or "Loyalty Card"
|
||||||
|
|
||||||
|
def get_points_reward(self, reward_id: str) -> dict | None:
|
||||||
|
"""Get a specific points reward by ID."""
|
||||||
|
rewards = self.points_rewards or []
|
||||||
|
for reward in rewards:
|
||||||
|
if reward.get("id") == reward_id:
|
||||||
|
return reward
|
||||||
|
return None
|
||||||
|
|
||||||
|
def activate(self) -> None:
|
||||||
|
"""Activate the loyalty program."""
|
||||||
|
self.is_active = True
|
||||||
|
if not self.activated_at:
|
||||||
|
self.activated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
def deactivate(self) -> None:
|
||||||
|
"""Deactivate the loyalty program."""
|
||||||
|
self.is_active = False
|
||||||
238
app/modules/loyalty/models/loyalty_transaction.py
Normal file
238
app/modules/loyalty/models/loyalty_transaction.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# app/modules/loyalty/models/loyalty_transaction.py
|
||||||
|
"""
|
||||||
|
Loyalty transaction database model.
|
||||||
|
|
||||||
|
Records all loyalty events including:
|
||||||
|
- Stamps earned and redeemed
|
||||||
|
- Points earned and redeemed
|
||||||
|
- Associated metadata (staff PIN, purchase amount, IP, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionType(str, enum.Enum):
|
||||||
|
"""Type of loyalty transaction."""
|
||||||
|
|
||||||
|
# Stamps
|
||||||
|
STAMP_EARNED = "stamp_earned"
|
||||||
|
STAMP_REDEEMED = "stamp_redeemed"
|
||||||
|
|
||||||
|
# Points
|
||||||
|
POINTS_EARNED = "points_earned"
|
||||||
|
POINTS_REDEEMED = "points_redeemed"
|
||||||
|
|
||||||
|
# Adjustments (manual corrections by staff)
|
||||||
|
STAMP_ADJUSTMENT = "stamp_adjustment"
|
||||||
|
POINTS_ADJUSTMENT = "points_adjustment"
|
||||||
|
|
||||||
|
# Card lifecycle
|
||||||
|
CARD_CREATED = "card_created"
|
||||||
|
CARD_DEACTIVATED = "card_deactivated"
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyTransaction(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Loyalty transaction record.
|
||||||
|
|
||||||
|
Immutable audit log of all loyalty operations for fraud
|
||||||
|
detection, analytics, and customer support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "loyalty_transactions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Core relationships
|
||||||
|
card_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("loyalty_cards.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Denormalized for query performance",
|
||||||
|
)
|
||||||
|
staff_pin_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("staff_pins.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Staff PIN used for this operation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Transaction Details
|
||||||
|
# =========================================================================
|
||||||
|
transaction_type = Column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delta values (positive for earn, negative for redeem/adjustment)
|
||||||
|
stamps_delta = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Change in stamps (+1 for earn, -N for redeem)",
|
||||||
|
)
|
||||||
|
points_delta = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Change in points (+N for earn, -N for redeem)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Balance after transaction (for historical reference)
|
||||||
|
stamps_balance_after = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Stamp count after this transaction",
|
||||||
|
)
|
||||||
|
points_balance_after = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Points balance after this transaction",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Purchase Context (for points earned)
|
||||||
|
# =========================================================================
|
||||||
|
purchase_amount_cents = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Purchase amount in cents (for points calculation)",
|
||||||
|
)
|
||||||
|
order_reference = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Reference to order that triggered points",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Reward Context (for redemptions)
|
||||||
|
# =========================================================================
|
||||||
|
reward_id = Column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
comment="ID of redeemed reward (from program.points_rewards)",
|
||||||
|
)
|
||||||
|
reward_description = Column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="Description of redeemed reward",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Audit Fields
|
||||||
|
# =========================================================================
|
||||||
|
ip_address = Column(
|
||||||
|
String(45),
|
||||||
|
nullable=True,
|
||||||
|
comment="IP address of requester (IPv4 or IPv6)",
|
||||||
|
)
|
||||||
|
user_agent = Column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="User agent string",
|
||||||
|
)
|
||||||
|
notes = Column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="Additional notes (e.g., reason for adjustment)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Timestamps
|
||||||
|
# =========================================================================
|
||||||
|
transaction_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="When the transaction occurred (may differ from created_at)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Relationships
|
||||||
|
# =========================================================================
|
||||||
|
card = relationship("LoyaltyCard", back_populates="transactions")
|
||||||
|
vendor = relationship("Vendor", backref="loyalty_transactions")
|
||||||
|
staff_pin = relationship("StaffPin", backref="transactions")
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_loyalty_tx_card_type", "card_id", "transaction_type"),
|
||||||
|
Index("idx_loyalty_tx_vendor_date", "vendor_id", "transaction_at"),
|
||||||
|
Index("idx_loyalty_tx_type_date", "transaction_type", "transaction_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<LoyaltyTransaction(id={self.id}, type='{self.transaction_type}', "
|
||||||
|
f"stamps={self.stamps_delta:+d}, points={self.points_delta:+d})>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Properties
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stamp_transaction(self) -> bool:
|
||||||
|
"""Check if this is a stamp-related transaction."""
|
||||||
|
return self.transaction_type in (
|
||||||
|
TransactionType.STAMP_EARNED.value,
|
||||||
|
TransactionType.STAMP_REDEEMED.value,
|
||||||
|
TransactionType.STAMP_ADJUSTMENT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_points_transaction(self) -> bool:
|
||||||
|
"""Check if this is a points-related transaction."""
|
||||||
|
return self.transaction_type in (
|
||||||
|
TransactionType.POINTS_EARNED.value,
|
||||||
|
TransactionType.POINTS_REDEEMED.value,
|
||||||
|
TransactionType.POINTS_ADJUSTMENT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_earn_transaction(self) -> bool:
|
||||||
|
"""Check if this is an earning transaction (stamp or points)."""
|
||||||
|
return self.transaction_type in (
|
||||||
|
TransactionType.STAMP_EARNED.value,
|
||||||
|
TransactionType.POINTS_EARNED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_redemption_transaction(self) -> bool:
|
||||||
|
"""Check if this is a redemption transaction."""
|
||||||
|
return self.transaction_type in (
|
||||||
|
TransactionType.STAMP_REDEEMED.value,
|
||||||
|
TransactionType.POINTS_REDEEMED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def staff_name(self) -> str | None:
|
||||||
|
"""Get the name of the staff member who performed this transaction."""
|
||||||
|
if self.staff_pin:
|
||||||
|
return self.staff_pin.name
|
||||||
|
return None
|
||||||
205
app/modules/loyalty/models/staff_pin.py
Normal file
205
app/modules/loyalty/models/staff_pin.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# app/modules/loyalty/models/staff_pin.py
|
||||||
|
"""
|
||||||
|
Staff PIN database model.
|
||||||
|
|
||||||
|
Provides fraud prevention by requiring staff to authenticate
|
||||||
|
before performing stamp/points operations. Includes:
|
||||||
|
- Secure PIN hashing with bcrypt
|
||||||
|
- Failed attempt tracking
|
||||||
|
- Automatic lockout after too many failures
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPin(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Staff PIN for loyalty operations.
|
||||||
|
|
||||||
|
Each staff member can have their own PIN to authenticate
|
||||||
|
stamp/points operations. PINs are hashed with bcrypt and
|
||||||
|
include lockout protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "staff_pins"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
program_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vendor_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Denormalized for query performance",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Staff identity
|
||||||
|
name = Column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="Staff member name",
|
||||||
|
)
|
||||||
|
staff_id = Column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment="Optional staff ID/employee number",
|
||||||
|
)
|
||||||
|
|
||||||
|
# PIN authentication
|
||||||
|
pin_hash = Column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
comment="bcrypt hash of PIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security tracking
|
||||||
|
failed_attempts = Column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
comment="Consecutive failed PIN attempts",
|
||||||
|
)
|
||||||
|
locked_until = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Lockout expires at this time",
|
||||||
|
)
|
||||||
|
last_used_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Last successful use of PIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = Column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Relationships
|
||||||
|
# =========================================================================
|
||||||
|
program = relationship("LoyaltyProgram", back_populates="staff_pins")
|
||||||
|
vendor = relationship("Vendor", backref="staff_pins")
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_staff_pin_vendor_active", "vendor_id", "is_active"),
|
||||||
|
Index("idx_staff_pin_program_active", "program_id", "is_active"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<StaffPin(id={self.id}, name='{self.name}', active={self.is_active})>"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PIN Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def set_pin(self, plain_pin: str) -> None:
|
||||||
|
"""
|
||||||
|
Hash and store a PIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plain_pin: The plain text PIN (typically 4-6 digits)
|
||||||
|
"""
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
self.pin_hash = bcrypt.hashpw(plain_pin.encode("utf-8"), salt).decode("utf-8")
|
||||||
|
|
||||||
|
def verify_pin(self, plain_pin: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a PIN against the stored hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plain_pin: The plain text PIN to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if PIN matches, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.pin_hash:
|
||||||
|
return False
|
||||||
|
return bcrypt.checkpw(plain_pin.encode("utf-8"), self.pin_hash.encode("utf-8"))
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Lockout Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locked(self) -> bool:
|
||||||
|
"""Check if PIN is currently locked out."""
|
||||||
|
if not self.locked_until:
|
||||||
|
return False
|
||||||
|
return datetime.now(UTC) < self.locked_until
|
||||||
|
|
||||||
|
def record_failed_attempt(self, max_attempts: int = 5, lockout_minutes: int = 30) -> bool:
|
||||||
|
"""
|
||||||
|
Record a failed PIN attempt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: Maximum failed attempts before lockout
|
||||||
|
lockout_minutes: Duration of lockout in minutes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if account is now locked
|
||||||
|
"""
|
||||||
|
self.failed_attempts += 1
|
||||||
|
|
||||||
|
if self.failed_attempts >= max_attempts:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
self.locked_until = datetime.now(UTC) + timedelta(minutes=lockout_minutes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def record_success(self) -> None:
|
||||||
|
"""Record a successful PIN verification."""
|
||||||
|
self.failed_attempts = 0
|
||||||
|
self.locked_until = None
|
||||||
|
self.last_used_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
def unlock(self) -> None:
|
||||||
|
"""Manually unlock a PIN (admin action)."""
|
||||||
|
self.failed_attempts = 0
|
||||||
|
self.locked_until = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Properties
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_attempts(self) -> int:
|
||||||
|
"""Get remaining attempts before lockout (assuming max 5)."""
|
||||||
|
return max(0, 5 - self.failed_attempts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lockout_remaining_seconds(self) -> int | None:
|
||||||
|
"""Get seconds remaining in lockout, or None if not locked."""
|
||||||
|
if not self.locked_until:
|
||||||
|
return None
|
||||||
|
remaining = (self.locked_until - datetime.now(UTC)).total_seconds()
|
||||||
|
return max(0, int(remaining))
|
||||||
8
app/modules/loyalty/routes/__init__.py
Normal file
8
app/modules/loyalty/routes/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# app/modules/loyalty/routes/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module routes.
|
||||||
|
|
||||||
|
Provides API endpoints for loyalty program management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
11
app/modules/loyalty/routes/api/__init__.py
Normal file
11
app/modules/loyalty/routes/api/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# app/modules/loyalty/routes/api/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module API routes.
|
||||||
|
|
||||||
|
Provides REST API endpoints for:
|
||||||
|
- Admin: Platform-wide loyalty program management
|
||||||
|
- Vendor: Store loyalty operations (stamps, points, cards)
|
||||||
|
- Public: Customer enrollment and wallet passes
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
144
app/modules/loyalty/routes/api/admin.py
Normal file
144
app/modules/loyalty/routes/api/admin.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# app/modules/loyalty/routes/api/admin.py
|
||||||
|
"""
|
||||||
|
Loyalty module admin routes.
|
||||||
|
|
||||||
|
Platform admin endpoints for:
|
||||||
|
- Viewing all loyalty programs
|
||||||
|
- Platform-wide analytics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_admin_api, require_module_access
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.loyalty.schemas import (
|
||||||
|
ProgramListResponse,
|
||||||
|
ProgramResponse,
|
||||||
|
ProgramStatsResponse,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services import program_service
|
||||||
|
from models.database.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Admin router with module access control
|
||||||
|
admin_router = APIRouter(
|
||||||
|
prefix="/loyalty",
|
||||||
|
dependencies=[Depends(require_module_access("loyalty"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Program Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/programs", response_model=ProgramListResponse)
|
||||||
|
def list_programs(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all loyalty programs (platform admin)."""
|
||||||
|
programs, total = program_service.list_programs(
|
||||||
|
db,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
program_responses = []
|
||||||
|
for program in programs:
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
program_responses.append(response)
|
||||||
|
|
||||||
|
return ProgramListResponse(programs=program_responses, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/programs/{program_id}", response_model=ProgramResponse)
|
||||||
|
def get_program(
|
||||||
|
program_id: int,
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a specific loyalty program."""
|
||||||
|
program = program_service.require_program(db, program_id)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/programs/{program_id}/stats", response_model=ProgramStatsResponse)
|
||||||
|
def get_program_stats(
|
||||||
|
program_id: int,
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get statistics for a loyalty program."""
|
||||||
|
stats = program_service.get_program_stats(db, program_id)
|
||||||
|
return ProgramStatsResponse(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Platform Stats
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/stats")
|
||||||
|
def get_platform_stats(
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get platform-wide loyalty statistics."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||||
|
|
||||||
|
# Program counts
|
||||||
|
total_programs = db.query(func.count(LoyaltyProgram.id)).scalar() or 0
|
||||||
|
active_programs = (
|
||||||
|
db.query(func.count(LoyaltyProgram.id))
|
||||||
|
.filter(LoyaltyProgram.is_active == True)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Card counts
|
||||||
|
total_cards = db.query(func.count(LoyaltyCard.id)).scalar() or 0
|
||||||
|
active_cards = (
|
||||||
|
db.query(func.count(LoyaltyCard.id))
|
||||||
|
.filter(LoyaltyCard.is_active == True)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transaction counts (last 30 days)
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
|
||||||
|
transactions_30d = (
|
||||||
|
db.query(func.count(LoyaltyTransaction.id))
|
||||||
|
.filter(LoyaltyTransaction.transaction_at >= thirty_days_ago)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_programs": total_programs,
|
||||||
|
"active_programs": active_programs,
|
||||||
|
"total_cards": total_cards,
|
||||||
|
"active_cards": active_cards,
|
||||||
|
"transactions_30d": transactions_30d,
|
||||||
|
}
|
||||||
313
app/modules/loyalty/routes/api/public.py
Normal file
313
app/modules/loyalty/routes/api/public.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# app/modules/loyalty/routes/api/public.py
|
||||||
|
"""
|
||||||
|
Loyalty module public routes.
|
||||||
|
|
||||||
|
Public endpoints for:
|
||||||
|
- Customer enrollment (by vendor code)
|
||||||
|
- Apple Wallet pass download
|
||||||
|
- Apple Web Service endpoints for device registration/updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
LoyaltyCardNotFoundException,
|
||||||
|
LoyaltyException,
|
||||||
|
LoyaltyProgramNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
||||||
|
from app.modules.loyalty.services import (
|
||||||
|
apple_wallet_service,
|
||||||
|
card_service,
|
||||||
|
program_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Public router (no auth required for some endpoints)
|
||||||
|
public_router = APIRouter(prefix="/loyalty")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Enrollment
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/programs/{vendor_code}")
|
||||||
|
def get_program_by_vendor_code(
|
||||||
|
vendor_code: str = Path(..., min_length=1, max_length=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get loyalty program info by vendor code (for enrollment page)."""
|
||||||
|
from models.database.vendor import Vendor
|
||||||
|
|
||||||
|
# Find vendor by code (vendor_code or subdomain)
|
||||||
|
vendor = (
|
||||||
|
db.query(Vendor)
|
||||||
|
.filter(
|
||||||
|
(Vendor.vendor_code == vendor_code) | (Vendor.subdomain == vendor_code)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not vendor:
|
||||||
|
raise HTTPException(status_code=404, detail="Vendor not found")
|
||||||
|
|
||||||
|
# Get program
|
||||||
|
program = program_service.get_active_program_by_vendor(db, vendor.id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No active loyalty program")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"vendor_name": vendor.name,
|
||||||
|
"vendor_code": vendor.vendor_code,
|
||||||
|
"program": {
|
||||||
|
"id": program.id,
|
||||||
|
"type": program.loyalty_type,
|
||||||
|
"name": program.display_name,
|
||||||
|
"card_color": program.card_color,
|
||||||
|
"logo_url": program.logo_url,
|
||||||
|
"stamps_target": program.stamps_target if program.is_stamps_enabled else None,
|
||||||
|
"stamps_reward": program.stamps_reward_description if program.is_stamps_enabled else None,
|
||||||
|
"points_per_euro": program.points_per_euro if program.is_points_enabled else None,
|
||||||
|
"terms_text": program.terms_text,
|
||||||
|
"privacy_url": program.privacy_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Apple Wallet Pass Download
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/passes/apple/{serial_number}.pkpass")
|
||||||
|
def download_apple_pass(
|
||||||
|
serial_number: str = Path(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Download Apple Wallet pass for a card."""
|
||||||
|
# Find card by serial number
|
||||||
|
card = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(LoyaltyCard.apple_serial_number == serial_number)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404, detail="Pass not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pass_data = apple_wallet_service.generate_pass(db, card)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
logger.error(f"Failed to generate Apple pass for card {card.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to generate pass")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pass_data,
|
||||||
|
media_type="application/vnd.apple.pkpass",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{serial_number}.pkpass"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Apple Web Service Endpoints
|
||||||
|
# (Required for Apple Wallet to register devices and get updates)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
|
||||||
|
def register_device(
|
||||||
|
device_id: str = Path(...),
|
||||||
|
pass_type_id: str = Path(...),
|
||||||
|
serial_number: str = Path(...),
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Register a device for push notifications.
|
||||||
|
|
||||||
|
Called by Apple when user adds pass to wallet.
|
||||||
|
"""
|
||||||
|
# Validate authorization token
|
||||||
|
auth_token = None
|
||||||
|
if authorization and authorization.startswith("ApplePass "):
|
||||||
|
auth_token = authorization.split(" ", 1)[1]
|
||||||
|
|
||||||
|
# Find card
|
||||||
|
card = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(LoyaltyCard.apple_serial_number == serial_number)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
# Verify auth token
|
||||||
|
if not auth_token or auth_token != card.apple_auth_token:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
# Get push token from request body
|
||||||
|
# Note: In real implementation, parse the JSON body for pushToken
|
||||||
|
# For now, use device_id as a placeholder
|
||||||
|
try:
|
||||||
|
apple_wallet_service.register_device(db, card, device_id, device_id)
|
||||||
|
return Response(status_code=201)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to register device: {e}")
|
||||||
|
raise HTTPException(status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.delete("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
|
||||||
|
def unregister_device(
|
||||||
|
device_id: str = Path(...),
|
||||||
|
pass_type_id: str = Path(...),
|
||||||
|
serial_number: str = Path(...),
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Unregister a device.
|
||||||
|
|
||||||
|
Called by Apple when user removes pass from wallet.
|
||||||
|
"""
|
||||||
|
# Validate authorization token
|
||||||
|
auth_token = None
|
||||||
|
if authorization and authorization.startswith("ApplePass "):
|
||||||
|
auth_token = authorization.split(" ", 1)[1]
|
||||||
|
|
||||||
|
# Find card
|
||||||
|
card = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(LoyaltyCard.apple_serial_number == serial_number)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
# Verify auth token
|
||||||
|
if not auth_token or auth_token != card.apple_auth_token:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
apple_wallet_service.unregister_device(db, card, device_id)
|
||||||
|
return Response(status_code=200)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to unregister device: {e}")
|
||||||
|
raise HTTPException(status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/apple/v1/devices/{device_id}/registrations/{pass_type_id}")
|
||||||
|
def get_serial_numbers(
|
||||||
|
device_id: str = Path(...),
|
||||||
|
pass_type_id: str = Path(...),
|
||||||
|
passesUpdatedSince: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get list of pass serial numbers to update.
|
||||||
|
|
||||||
|
Called by Apple to check for updated passes.
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.models import AppleDeviceRegistration
|
||||||
|
|
||||||
|
# Find all cards registered to this device
|
||||||
|
registrations = (
|
||||||
|
db.query(AppleDeviceRegistration)
|
||||||
|
.filter(AppleDeviceRegistration.device_library_identifier == device_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not registrations:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
# Get cards that have been updated since the given timestamp
|
||||||
|
card_ids = [r.card_id for r in registrations]
|
||||||
|
|
||||||
|
query = db.query(LoyaltyCard).filter(LoyaltyCard.id.in_(card_ids))
|
||||||
|
|
||||||
|
if passesUpdatedSince:
|
||||||
|
try:
|
||||||
|
since = datetime.fromisoformat(passesUpdatedSince.replace("Z", "+00:00"))
|
||||||
|
query = query.filter(LoyaltyCard.updated_at > since)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cards = query.all()
|
||||||
|
|
||||||
|
if not cards:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
# Return serial numbers
|
||||||
|
serial_numbers = [card.apple_serial_number for card in cards if card.apple_serial_number]
|
||||||
|
last_updated = max(card.updated_at for card in cards)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"serialNumbers": serial_numbers,
|
||||||
|
"lastUpdated": last_updated.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.get("/apple/v1/passes/{pass_type_id}/{serial_number}")
|
||||||
|
def get_latest_pass(
|
||||||
|
pass_type_id: str = Path(...),
|
||||||
|
serial_number: str = Path(...),
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the latest version of a pass.
|
||||||
|
|
||||||
|
Called by Apple to fetch updated pass data.
|
||||||
|
"""
|
||||||
|
# Validate authorization token
|
||||||
|
auth_token = None
|
||||||
|
if authorization and authorization.startswith("ApplePass "):
|
||||||
|
auth_token = authorization.split(" ", 1)[1]
|
||||||
|
|
||||||
|
# Find card
|
||||||
|
card = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(LoyaltyCard.apple_serial_number == serial_number)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
# Verify auth token
|
||||||
|
if not auth_token or auth_token != card.apple_auth_token:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pass_data = apple_wallet_service.generate_pass(db, card)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
logger.error(f"Failed to generate Apple pass for card {card.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to generate pass")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pass_data,
|
||||||
|
media_type="application/vnd.apple.pkpass",
|
||||||
|
headers={
|
||||||
|
"Last-Modified": card.updated_at.strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@public_router.post("/apple/v1/log")
|
||||||
|
def log_errors():
|
||||||
|
"""
|
||||||
|
Receive error logs from Apple.
|
||||||
|
|
||||||
|
Apple sends error logs here when there are issues with passes.
|
||||||
|
"""
|
||||||
|
# Just acknowledge - in production you'd log these
|
||||||
|
return Response(status_code=200)
|
||||||
506
app/modules/loyalty/routes/api/vendor.py
Normal file
506
app/modules/loyalty/routes/api/vendor.py
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# app/modules/loyalty/routes/api/vendor.py
|
||||||
|
"""
|
||||||
|
Loyalty module vendor routes.
|
||||||
|
|
||||||
|
Store/vendor endpoints for:
|
||||||
|
- Program management
|
||||||
|
- Staff PINs
|
||||||
|
- Card operations (stamps, points, redemptions)
|
||||||
|
- Customer cards lookup
|
||||||
|
- Dashboard stats
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_vendor_api, require_module_access
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
LoyaltyCardNotFoundException,
|
||||||
|
LoyaltyException,
|
||||||
|
LoyaltyProgramNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.schemas import (
|
||||||
|
CardDetailResponse,
|
||||||
|
CardEnrollRequest,
|
||||||
|
CardListResponse,
|
||||||
|
CardLookupResponse,
|
||||||
|
CardResponse,
|
||||||
|
PinCreate,
|
||||||
|
PinListResponse,
|
||||||
|
PinResponse,
|
||||||
|
PinUpdate,
|
||||||
|
PointsEarnRequest,
|
||||||
|
PointsEarnResponse,
|
||||||
|
PointsRedeemRequest,
|
||||||
|
PointsRedeemResponse,
|
||||||
|
ProgramCreate,
|
||||||
|
ProgramResponse,
|
||||||
|
ProgramStatsResponse,
|
||||||
|
ProgramUpdate,
|
||||||
|
StampRedeemRequest,
|
||||||
|
StampRedeemResponse,
|
||||||
|
StampRequest,
|
||||||
|
StampResponse,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services import (
|
||||||
|
card_service,
|
||||||
|
pin_service,
|
||||||
|
points_service,
|
||||||
|
program_service,
|
||||||
|
stamp_service,
|
||||||
|
wallet_service,
|
||||||
|
)
|
||||||
|
from models.database.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Vendor router with module access control
|
||||||
|
vendor_router = APIRouter(
|
||||||
|
prefix="/loyalty",
|
||||||
|
dependencies=[Depends(require_module_access("loyalty"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_info(request: Request) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract client IP and user agent from request."""
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
return ip, user_agent
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Program Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.get("/program", response_model=ProgramResponse)
|
||||||
|
def get_program(
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get the vendor's loyalty program."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/program", response_model=ProgramResponse, status_code=201)
|
||||||
|
def create_program(
|
||||||
|
data: ProgramCreate,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a loyalty program for the vendor."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
program = program_service.create_program(db, vendor_id, data)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.patch("/program", response_model=ProgramResponse)
|
||||||
|
def update_program(
|
||||||
|
data: ProgramUpdate,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update the vendor's loyalty program."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
program = program_service.update_program(db, program.id, data)
|
||||||
|
|
||||||
|
response = ProgramResponse.model_validate(program)
|
||||||
|
response.is_stamps_enabled = program.is_stamps_enabled
|
||||||
|
response.is_points_enabled = program.is_points_enabled
|
||||||
|
response.display_name = program.display_name
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.get("/stats", response_model=ProgramStatsResponse)
|
||||||
|
def get_stats(
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get loyalty program statistics."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
stats = program_service.get_program_stats(db, program.id)
|
||||||
|
return ProgramStatsResponse(**stats)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Staff PINs
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.get("/pins", response_model=PinListResponse)
|
||||||
|
def list_pins(
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all staff PINs for the loyalty program."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
pins = pin_service.list_pins(db, program.id)
|
||||||
|
|
||||||
|
return PinListResponse(
|
||||||
|
pins=[PinResponse.model_validate(pin) for pin in pins],
|
||||||
|
total=len(pins),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/pins", response_model=PinResponse, status_code=201)
|
||||||
|
def create_pin(
|
||||||
|
data: PinCreate,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new staff PIN."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
pin = pin_service.create_pin(db, program.id, vendor_id, data)
|
||||||
|
return PinResponse.model_validate(pin)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.patch("/pins/{pin_id}", response_model=PinResponse)
|
||||||
|
def update_pin(
|
||||||
|
pin_id: int = Path(..., gt=0),
|
||||||
|
data: PinUpdate = None,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a staff PIN."""
|
||||||
|
pin = pin_service.update_pin(db, pin_id, data)
|
||||||
|
return PinResponse.model_validate(pin)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.delete("/pins/{pin_id}", status_code=204)
|
||||||
|
def delete_pin(
|
||||||
|
pin_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a staff PIN."""
|
||||||
|
pin_service.delete_pin(db, pin_id)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/pins/{pin_id}/unlock", response_model=PinResponse)
|
||||||
|
def unlock_pin(
|
||||||
|
pin_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Unlock a locked staff PIN."""
|
||||||
|
pin = pin_service.unlock_pin(db, pin_id)
|
||||||
|
return PinResponse.model_validate(pin)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Card Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.get("/cards", response_model=CardListResponse)
|
||||||
|
def list_cards(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
is_active: bool | None = Query(None),
|
||||||
|
search: str | None = Query(None, max_length=100),
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List loyalty cards for the vendor."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
program = program_service.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise HTTPException(status_code=404, detail="No loyalty program configured")
|
||||||
|
|
||||||
|
cards, total = card_service.list_cards(
|
||||||
|
db,
|
||||||
|
vendor_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
is_active=is_active,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
card_responses = []
|
||||||
|
for card in cards:
|
||||||
|
response = CardResponse(
|
||||||
|
id=card.id,
|
||||||
|
card_number=card.card_number,
|
||||||
|
customer_id=card.customer_id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
program_id=card.program_id,
|
||||||
|
stamp_count=card.stamp_count,
|
||||||
|
stamps_target=program.stamps_target,
|
||||||
|
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||||
|
total_stamps_earned=card.total_stamps_earned,
|
||||||
|
stamps_redeemed=card.stamps_redeemed,
|
||||||
|
points_balance=card.points_balance,
|
||||||
|
total_points_earned=card.total_points_earned,
|
||||||
|
points_redeemed=card.points_redeemed,
|
||||||
|
is_active=card.is_active,
|
||||||
|
created_at=card.created_at,
|
||||||
|
has_google_wallet=bool(card.google_object_id),
|
||||||
|
has_apple_wallet=bool(card.apple_serial_number),
|
||||||
|
)
|
||||||
|
card_responses.append(response)
|
||||||
|
|
||||||
|
return CardListResponse(cards=card_responses, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/cards/lookup", response_model=CardLookupResponse)
|
||||||
|
def lookup_card(
|
||||||
|
request: Request,
|
||||||
|
card_id: int | None = Query(None),
|
||||||
|
qr_code: str | None = Query(None),
|
||||||
|
card_number: str | None = Query(None),
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Look up a card by ID, QR code, or card number."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
card = card_service.lookup_card(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
qr_code=qr_code,
|
||||||
|
card_number=card_number,
|
||||||
|
)
|
||||||
|
except LoyaltyCardNotFoundException:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
|
||||||
|
# Verify card belongs to this vendor
|
||||||
|
if card.vendor_id != vendor_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Check cooldown
|
||||||
|
can_stamp, _ = card.can_stamp(program.cooldown_minutes)
|
||||||
|
cooldown_ends = None
|
||||||
|
if not can_stamp and card.last_stamp_at:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
|
||||||
|
|
||||||
|
# Get stamps today
|
||||||
|
stamps_today = card_service.get_stamps_today(db, card.id)
|
||||||
|
|
||||||
|
return CardLookupResponse(
|
||||||
|
card_id=card.id,
|
||||||
|
card_number=card.card_number,
|
||||||
|
customer_id=card.customer_id,
|
||||||
|
customer_name=card.customer.full_name if card.customer else None,
|
||||||
|
customer_email=card.customer.email if card.customer else "",
|
||||||
|
stamp_count=card.stamp_count,
|
||||||
|
stamps_target=program.stamps_target,
|
||||||
|
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||||
|
points_balance=card.points_balance,
|
||||||
|
can_redeem_stamps=card.stamp_count >= program.stamps_target,
|
||||||
|
stamp_reward_description=program.stamps_reward_description,
|
||||||
|
can_stamp=can_stamp,
|
||||||
|
cooldown_ends_at=cooldown_ends,
|
||||||
|
stamps_today=stamps_today,
|
||||||
|
max_daily_stamps=program.max_daily_stamps,
|
||||||
|
can_earn_more_stamps=stamps_today < program.max_daily_stamps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/cards/enroll", response_model=CardResponse, status_code=201)
|
||||||
|
def enroll_customer(
|
||||||
|
data: CardEnrollRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Enroll a customer in the loyalty program."""
|
||||||
|
vendor_id = current_user.token_vendor_id
|
||||||
|
|
||||||
|
if not data.customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
card = card_service.enroll_customer(db, data.customer_id, vendor_id)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
return CardResponse(
|
||||||
|
id=card.id,
|
||||||
|
card_number=card.card_number,
|
||||||
|
customer_id=card.customer_id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
program_id=card.program_id,
|
||||||
|
stamp_count=card.stamp_count,
|
||||||
|
stamps_target=program.stamps_target,
|
||||||
|
stamps_until_reward=program.stamps_target,
|
||||||
|
total_stamps_earned=card.total_stamps_earned,
|
||||||
|
stamps_redeemed=card.stamps_redeemed,
|
||||||
|
points_balance=card.points_balance,
|
||||||
|
total_points_earned=card.total_points_earned,
|
||||||
|
points_redeemed=card.points_redeemed,
|
||||||
|
is_active=card.is_active,
|
||||||
|
created_at=card.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stamp Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/stamp", response_model=StampResponse)
|
||||||
|
def add_stamp(
|
||||||
|
request: Request,
|
||||||
|
data: StampRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a stamp to a loyalty card."""
|
||||||
|
ip, user_agent = get_client_info(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = stamp_service.add_stamp(
|
||||||
|
db,
|
||||||
|
card_id=data.card_id,
|
||||||
|
qr_code=data.qr_code,
|
||||||
|
card_number=data.card_number,
|
||||||
|
staff_pin=data.staff_pin,
|
||||||
|
ip_address=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
return StampResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/stamp/redeem", response_model=StampRedeemResponse)
|
||||||
|
def redeem_stamps(
|
||||||
|
request: Request,
|
||||||
|
data: StampRedeemRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Redeem stamps for a reward."""
|
||||||
|
ip, user_agent = get_client_info(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = stamp_service.redeem_stamps(
|
||||||
|
db,
|
||||||
|
card_id=data.card_id,
|
||||||
|
qr_code=data.qr_code,
|
||||||
|
card_number=data.card_number,
|
||||||
|
staff_pin=data.staff_pin,
|
||||||
|
ip_address=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
return StampRedeemResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Points Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/points", response_model=PointsEarnResponse)
|
||||||
|
def earn_points(
|
||||||
|
request: Request,
|
||||||
|
data: PointsEarnRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Earn points from a purchase."""
|
||||||
|
ip, user_agent = get_client_info(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = points_service.earn_points(
|
||||||
|
db,
|
||||||
|
card_id=data.card_id,
|
||||||
|
qr_code=data.qr_code,
|
||||||
|
card_number=data.card_number,
|
||||||
|
purchase_amount_cents=data.purchase_amount_cents,
|
||||||
|
order_reference=data.order_reference,
|
||||||
|
staff_pin=data.staff_pin,
|
||||||
|
ip_address=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
return PointsEarnResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@vendor_router.post("/points/redeem", response_model=PointsRedeemResponse)
|
||||||
|
def redeem_points(
|
||||||
|
request: Request,
|
||||||
|
data: PointsRedeemRequest,
|
||||||
|
current_user: User = Depends(get_current_vendor_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Redeem points for a reward."""
|
||||||
|
ip, user_agent = get_client_info(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = points_service.redeem_points(
|
||||||
|
db,
|
||||||
|
card_id=data.card_id,
|
||||||
|
qr_code=data.qr_code,
|
||||||
|
card_number=data.card_number,
|
||||||
|
reward_id=data.reward_id,
|
||||||
|
staff_pin=data.staff_pin,
|
||||||
|
ip_address=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
except LoyaltyException as e:
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||||
|
|
||||||
|
return PointsRedeemResponse(**result)
|
||||||
8
app/modules/loyalty/routes/pages/__init__.py
Normal file
8
app/modules/loyalty/routes/pages/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# app/modules/loyalty/routes/pages/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module page routes.
|
||||||
|
|
||||||
|
Reserved for future HTML page endpoints (enrollment pages, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
106
app/modules/loyalty/schemas/__init__.py
Normal file
106
app/modules/loyalty/schemas/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# app/modules/loyalty/schemas/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module Pydantic schemas.
|
||||||
|
|
||||||
|
Request and response models for the loyalty API endpoints.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.loyalty.schemas import (
|
||||||
|
# Program
|
||||||
|
ProgramCreate,
|
||||||
|
ProgramUpdate,
|
||||||
|
ProgramResponse,
|
||||||
|
# Card
|
||||||
|
CardEnrollRequest,
|
||||||
|
CardResponse,
|
||||||
|
# Stamp
|
||||||
|
StampRequest,
|
||||||
|
StampResponse,
|
||||||
|
# Points
|
||||||
|
PointsEarnRequest,
|
||||||
|
PointsRedeemRequest,
|
||||||
|
# PIN
|
||||||
|
PinCreate,
|
||||||
|
PinVerifyRequest,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.loyalty.schemas.program import (
|
||||||
|
# Program CRUD
|
||||||
|
ProgramCreate,
|
||||||
|
ProgramUpdate,
|
||||||
|
ProgramResponse,
|
||||||
|
ProgramListResponse,
|
||||||
|
# Points rewards
|
||||||
|
PointsRewardConfig,
|
||||||
|
# Stats
|
||||||
|
ProgramStatsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.loyalty.schemas.card import (
|
||||||
|
# Card operations
|
||||||
|
CardEnrollRequest,
|
||||||
|
CardResponse,
|
||||||
|
CardDetailResponse,
|
||||||
|
CardListResponse,
|
||||||
|
CardLookupResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.loyalty.schemas.stamp import (
|
||||||
|
# Stamp operations
|
||||||
|
StampRequest,
|
||||||
|
StampResponse,
|
||||||
|
StampRedeemRequest,
|
||||||
|
StampRedeemResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.loyalty.schemas.points import (
|
||||||
|
# Points operations
|
||||||
|
PointsEarnRequest,
|
||||||
|
PointsEarnResponse,
|
||||||
|
PointsRedeemRequest,
|
||||||
|
PointsRedeemResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.loyalty.schemas.pin import (
|
||||||
|
# Staff PIN
|
||||||
|
PinCreate,
|
||||||
|
PinUpdate,
|
||||||
|
PinResponse,
|
||||||
|
PinListResponse,
|
||||||
|
PinVerifyRequest,
|
||||||
|
PinVerifyResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Program
|
||||||
|
"ProgramCreate",
|
||||||
|
"ProgramUpdate",
|
||||||
|
"ProgramResponse",
|
||||||
|
"ProgramListResponse",
|
||||||
|
"PointsRewardConfig",
|
||||||
|
"ProgramStatsResponse",
|
||||||
|
# Card
|
||||||
|
"CardEnrollRequest",
|
||||||
|
"CardResponse",
|
||||||
|
"CardDetailResponse",
|
||||||
|
"CardListResponse",
|
||||||
|
"CardLookupResponse",
|
||||||
|
# Stamp
|
||||||
|
"StampRequest",
|
||||||
|
"StampResponse",
|
||||||
|
"StampRedeemRequest",
|
||||||
|
"StampRedeemResponse",
|
||||||
|
# Points
|
||||||
|
"PointsEarnRequest",
|
||||||
|
"PointsEarnResponse",
|
||||||
|
"PointsRedeemRequest",
|
||||||
|
"PointsRedeemResponse",
|
||||||
|
# PIN
|
||||||
|
"PinCreate",
|
||||||
|
"PinUpdate",
|
||||||
|
"PinResponse",
|
||||||
|
"PinListResponse",
|
||||||
|
"PinVerifyRequest",
|
||||||
|
"PinVerifyResponse",
|
||||||
|
]
|
||||||
118
app/modules/loyalty/schemas/card.py
Normal file
118
app/modules/loyalty/schemas/card.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# app/modules/loyalty/schemas/card.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for loyalty card operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CardEnrollRequest(BaseModel):
|
||||||
|
"""Schema for enrolling a customer in a loyalty program."""
|
||||||
|
|
||||||
|
customer_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Customer ID (required for vendor API, optional for public enrollment)",
|
||||||
|
)
|
||||||
|
email: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Customer email (for public enrollment without customer_id)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CardResponse(BaseModel):
|
||||||
|
"""Schema for loyalty card response (summary)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
card_number: str
|
||||||
|
customer_id: int
|
||||||
|
vendor_id: int
|
||||||
|
program_id: int
|
||||||
|
|
||||||
|
# Stamps
|
||||||
|
stamp_count: int
|
||||||
|
stamps_target: int # From program
|
||||||
|
stamps_until_reward: int
|
||||||
|
total_stamps_earned: int
|
||||||
|
stamps_redeemed: int
|
||||||
|
|
||||||
|
# Points
|
||||||
|
points_balance: int
|
||||||
|
total_points_earned: int
|
||||||
|
points_redeemed: int
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
# Wallet
|
||||||
|
has_google_wallet: bool = False
|
||||||
|
has_apple_wallet: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CardDetailResponse(CardResponse):
|
||||||
|
"""Schema for detailed loyalty card response."""
|
||||||
|
|
||||||
|
# QR code
|
||||||
|
qr_code_data: str
|
||||||
|
qr_code_url: str | None = None # Generated QR code image URL
|
||||||
|
|
||||||
|
# Customer info
|
||||||
|
customer_name: str | None = None
|
||||||
|
customer_email: str | None = None
|
||||||
|
|
||||||
|
# Program info
|
||||||
|
program_name: str
|
||||||
|
program_type: str
|
||||||
|
reward_description: str | None = None
|
||||||
|
|
||||||
|
# Activity
|
||||||
|
last_stamp_at: datetime | None = None
|
||||||
|
last_points_at: datetime | None = None
|
||||||
|
last_redemption_at: datetime | None = None
|
||||||
|
|
||||||
|
# Wallet URLs
|
||||||
|
google_wallet_url: str | None = None
|
||||||
|
apple_wallet_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CardListResponse(BaseModel):
|
||||||
|
"""Schema for listing loyalty cards."""
|
||||||
|
|
||||||
|
cards: list[CardResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class CardLookupResponse(BaseModel):
|
||||||
|
"""Schema for card lookup by QR code or card number."""
|
||||||
|
|
||||||
|
# Card info
|
||||||
|
card_id: int
|
||||||
|
card_number: str
|
||||||
|
|
||||||
|
# Customer
|
||||||
|
customer_id: int
|
||||||
|
customer_name: str | None = None
|
||||||
|
customer_email: str
|
||||||
|
|
||||||
|
# Current balances
|
||||||
|
stamp_count: int
|
||||||
|
stamps_target: int
|
||||||
|
stamps_until_reward: int
|
||||||
|
points_balance: int
|
||||||
|
|
||||||
|
# Can redeem?
|
||||||
|
can_redeem_stamps: bool = False
|
||||||
|
stamp_reward_description: str | None = None
|
||||||
|
|
||||||
|
# Cooldown status
|
||||||
|
can_stamp: bool = True
|
||||||
|
cooldown_ends_at: datetime | None = None
|
||||||
|
|
||||||
|
# Today's activity
|
||||||
|
stamps_today: int = 0
|
||||||
|
max_daily_stamps: int = 5
|
||||||
|
can_earn_more_stamps: bool = True
|
||||||
98
app/modules/loyalty/schemas/pin.py
Normal file
98
app/modules/loyalty/schemas/pin.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# app/modules/loyalty/schemas/pin.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for staff PIN operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PinCreate(BaseModel):
|
||||||
|
"""Schema for creating a staff PIN."""
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
description="Staff member name",
|
||||||
|
)
|
||||||
|
staff_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=50,
|
||||||
|
description="Optional employee ID",
|
||||||
|
)
|
||||||
|
pin: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
pattern="^[0-9]+$",
|
||||||
|
description="4-6 digit PIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PinUpdate(BaseModel):
|
||||||
|
"""Schema for updating a staff PIN."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
name: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
staff_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=50,
|
||||||
|
)
|
||||||
|
pin: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
pattern="^[0-9]+$",
|
||||||
|
description="New PIN (if changing)",
|
||||||
|
)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PinResponse(BaseModel):
|
||||||
|
"""Schema for staff PIN response (never includes actual PIN)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
staff_id: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
is_locked: bool = False
|
||||||
|
locked_until: datetime | None = None
|
||||||
|
last_used_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PinListResponse(BaseModel):
|
||||||
|
"""Schema for listing staff PINs."""
|
||||||
|
|
||||||
|
pins: list[PinResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PinVerifyRequest(BaseModel):
|
||||||
|
"""Schema for verifying a staff PIN."""
|
||||||
|
|
||||||
|
pin: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
pattern="^[0-9]+$",
|
||||||
|
description="PIN to verify",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PinVerifyResponse(BaseModel):
|
||||||
|
"""Schema for PIN verification response."""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
staff_name: str | None = None
|
||||||
|
remaining_attempts: int | None = None
|
||||||
|
locked_until: datetime | None = None
|
||||||
124
app/modules/loyalty/schemas/points.py
Normal file
124
app/modules/loyalty/schemas/points.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# app/modules/loyalty/schemas/points.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for points operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PointsEarnRequest(BaseModel):
|
||||||
|
"""Schema for earning points from a purchase."""
|
||||||
|
|
||||||
|
card_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card ID (use this or qr_code)",
|
||||||
|
)
|
||||||
|
qr_code: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="QR code data from card scan",
|
||||||
|
)
|
||||||
|
card_number: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card number (manual entry)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Purchase info
|
||||||
|
purchase_amount_cents: int = Field(
|
||||||
|
...,
|
||||||
|
gt=0,
|
||||||
|
description="Purchase amount in cents",
|
||||||
|
)
|
||||||
|
order_reference: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=100,
|
||||||
|
description="Order reference for tracking",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
staff_pin: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
description="Staff PIN for verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
notes: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Optional note",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsEarnResponse(BaseModel):
|
||||||
|
"""Schema for points earning response."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str = "Points earned successfully"
|
||||||
|
|
||||||
|
# Points info
|
||||||
|
points_earned: int
|
||||||
|
points_per_euro: int
|
||||||
|
purchase_amount_cents: int
|
||||||
|
|
||||||
|
# Card state after earning
|
||||||
|
card_id: int
|
||||||
|
card_number: str
|
||||||
|
points_balance: int
|
||||||
|
total_points_earned: int
|
||||||
|
|
||||||
|
|
||||||
|
class PointsRedeemRequest(BaseModel):
|
||||||
|
"""Schema for redeeming points for a reward."""
|
||||||
|
|
||||||
|
card_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card ID (use this or qr_code)",
|
||||||
|
)
|
||||||
|
qr_code: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="QR code data from card scan",
|
||||||
|
)
|
||||||
|
card_number: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card number (manual entry)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reward selection
|
||||||
|
reward_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="ID of the reward to redeem",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
staff_pin: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
description="Staff PIN for verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
notes: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Optional note",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsRedeemResponse(BaseModel):
|
||||||
|
"""Schema for points redemption response."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str = "Reward redeemed successfully"
|
||||||
|
|
||||||
|
# Reward info
|
||||||
|
reward_id: str
|
||||||
|
reward_name: str
|
||||||
|
points_spent: int
|
||||||
|
|
||||||
|
# Card state after redemption
|
||||||
|
card_id: int
|
||||||
|
card_number: str
|
||||||
|
points_balance: int
|
||||||
|
total_points_redeemed: int
|
||||||
203
app/modules/loyalty/schemas/program.py
Normal file
203
app/modules/loyalty/schemas/program.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# app/modules/loyalty/schemas/program.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for loyalty program operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PointsRewardConfig(BaseModel):
|
||||||
|
"""Configuration for a points-based reward."""
|
||||||
|
|
||||||
|
id: str = Field(..., description="Unique reward identifier")
|
||||||
|
name: str = Field(..., max_length=100, description="Reward name")
|
||||||
|
points_required: int = Field(..., gt=0, description="Points needed to redeem")
|
||||||
|
description: str | None = Field(None, max_length=255, description="Reward description")
|
||||||
|
is_active: bool = Field(True, description="Whether reward is currently available")
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramCreate(BaseModel):
|
||||||
|
"""Schema for creating a loyalty program."""
|
||||||
|
|
||||||
|
# Program type
|
||||||
|
loyalty_type: str = Field(
|
||||||
|
"stamps",
|
||||||
|
pattern="^(stamps|points|hybrid)$",
|
||||||
|
description="Program type: stamps, points, or hybrid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamps configuration
|
||||||
|
stamps_target: int = Field(10, ge=1, le=50, description="Stamps needed for reward")
|
||||||
|
stamps_reward_description: str = Field(
|
||||||
|
"Free item",
|
||||||
|
max_length=255,
|
||||||
|
description="Description of stamp reward",
|
||||||
|
)
|
||||||
|
stamps_reward_value_cents: int | None = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Value of reward in cents (for analytics)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Points configuration
|
||||||
|
points_per_euro: int = Field(10, ge=1, le=1000, description="Points per euro spent")
|
||||||
|
points_rewards: list[PointsRewardConfig] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Available point rewards",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anti-fraud
|
||||||
|
cooldown_minutes: int = Field(15, ge=0, le=1440, description="Minutes between stamps")
|
||||||
|
max_daily_stamps: int = Field(5, ge=1, le=50, description="Max stamps per card per day")
|
||||||
|
require_staff_pin: bool = Field(True, description="Require staff PIN for operations")
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
card_name: str | None = Field(None, max_length=100, description="Display name for card")
|
||||||
|
card_color: str = Field(
|
||||||
|
"#4F46E5",
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$",
|
||||||
|
description="Primary color (hex)",
|
||||||
|
)
|
||||||
|
card_secondary_color: str | None = Field(
|
||||||
|
None,
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$",
|
||||||
|
description="Secondary color (hex)",
|
||||||
|
)
|
||||||
|
logo_url: str | None = Field(None, max_length=500, description="Logo URL")
|
||||||
|
hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL")
|
||||||
|
|
||||||
|
# Terms
|
||||||
|
terms_text: str | None = Field(None, description="Terms and conditions")
|
||||||
|
privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL")
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramUpdate(BaseModel):
|
||||||
|
"""Schema for updating a loyalty program."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
# Program type (cannot change from stamps to points after cards exist)
|
||||||
|
loyalty_type: str | None = Field(
|
||||||
|
None,
|
||||||
|
pattern="^(stamps|points|hybrid)$",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamps configuration
|
||||||
|
stamps_target: int | None = Field(None, ge=1, le=50)
|
||||||
|
stamps_reward_description: str | None = Field(None, max_length=255)
|
||||||
|
stamps_reward_value_cents: int | None = Field(None, ge=0)
|
||||||
|
|
||||||
|
# Points configuration
|
||||||
|
points_per_euro: int | None = Field(None, ge=1, le=1000)
|
||||||
|
points_rewards: list[PointsRewardConfig] | None = None
|
||||||
|
|
||||||
|
# Anti-fraud
|
||||||
|
cooldown_minutes: int | None = Field(None, ge=0, le=1440)
|
||||||
|
max_daily_stamps: int | None = Field(None, ge=1, le=50)
|
||||||
|
require_staff_pin: bool | None = None
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
card_name: str | None = Field(None, max_length=100)
|
||||||
|
card_color: str | None = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
|
||||||
|
card_secondary_color: str | None = Field(None, pattern="^#[0-9A-Fa-f]{6}$")
|
||||||
|
logo_url: str | None = Field(None, max_length=500)
|
||||||
|
hero_image_url: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
# Terms
|
||||||
|
terms_text: str | None = None
|
||||||
|
privacy_url: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
# Wallet integration
|
||||||
|
google_issuer_id: str | None = Field(None, max_length=100)
|
||||||
|
apple_pass_type_id: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramResponse(BaseModel):
|
||||||
|
"""Schema for loyalty program response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
vendor_id: int
|
||||||
|
loyalty_type: str
|
||||||
|
|
||||||
|
# Stamps
|
||||||
|
stamps_target: int
|
||||||
|
stamps_reward_description: str
|
||||||
|
stamps_reward_value_cents: int | None = None
|
||||||
|
|
||||||
|
# Points
|
||||||
|
points_per_euro: int
|
||||||
|
points_rewards: list[PointsRewardConfig] = []
|
||||||
|
|
||||||
|
# Anti-fraud
|
||||||
|
cooldown_minutes: int
|
||||||
|
max_daily_stamps: int
|
||||||
|
require_staff_pin: bool
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
card_name: str | None = None
|
||||||
|
card_color: str
|
||||||
|
card_secondary_color: str | None = None
|
||||||
|
logo_url: str | None = None
|
||||||
|
hero_image_url: str | None = None
|
||||||
|
|
||||||
|
# Terms
|
||||||
|
terms_text: str | None = None
|
||||||
|
privacy_url: str | None = None
|
||||||
|
|
||||||
|
# Wallet
|
||||||
|
google_issuer_id: str | None = None
|
||||||
|
google_class_id: str | None = None
|
||||||
|
apple_pass_type_id: str | None = None
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active: bool
|
||||||
|
activated_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
# Computed
|
||||||
|
is_stamps_enabled: bool = False
|
||||||
|
is_points_enabled: bool = False
|
||||||
|
display_name: str = "Loyalty Card"
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramListResponse(BaseModel):
|
||||||
|
"""Schema for listing loyalty programs (admin)."""
|
||||||
|
|
||||||
|
programs: list[ProgramResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramStatsResponse(BaseModel):
|
||||||
|
"""Schema for program statistics."""
|
||||||
|
|
||||||
|
# Cards
|
||||||
|
total_cards: int = 0
|
||||||
|
active_cards: int = 0
|
||||||
|
|
||||||
|
# Stamps (if enabled)
|
||||||
|
total_stamps_issued: int = 0
|
||||||
|
total_stamps_redeemed: int = 0
|
||||||
|
stamps_this_month: int = 0
|
||||||
|
redemptions_this_month: int = 0
|
||||||
|
|
||||||
|
# Points (if enabled)
|
||||||
|
total_points_issued: int = 0
|
||||||
|
total_points_redeemed: int = 0
|
||||||
|
points_this_month: int = 0
|
||||||
|
points_redeemed_this_month: int = 0
|
||||||
|
|
||||||
|
# Engagement
|
||||||
|
cards_with_activity_30d: int = 0
|
||||||
|
average_stamps_per_card: float = 0.0
|
||||||
|
average_points_per_card: float = 0.0
|
||||||
|
|
||||||
|
# Value
|
||||||
|
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
|
||||||
114
app/modules/loyalty/schemas/stamp.py
Normal file
114
app/modules/loyalty/schemas/stamp.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# app/modules/loyalty/schemas/stamp.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for stamp operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class StampRequest(BaseModel):
|
||||||
|
"""Schema for adding a stamp to a card."""
|
||||||
|
|
||||||
|
card_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card ID (use this or qr_code)",
|
||||||
|
)
|
||||||
|
qr_code: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="QR code data from card scan",
|
||||||
|
)
|
||||||
|
card_number: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card number (manual entry)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
staff_pin: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
description="Staff PIN for verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
notes: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Optional note about this stamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StampResponse(BaseModel):
|
||||||
|
"""Schema for stamp operation response."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str = "Stamp added successfully"
|
||||||
|
|
||||||
|
# Card state after stamp
|
||||||
|
card_id: int
|
||||||
|
card_number: str
|
||||||
|
stamp_count: int
|
||||||
|
stamps_target: int
|
||||||
|
stamps_until_reward: int
|
||||||
|
|
||||||
|
# Did this trigger a reward?
|
||||||
|
reward_earned: bool = False
|
||||||
|
reward_description: str | None = None
|
||||||
|
|
||||||
|
# Cooldown info
|
||||||
|
next_stamp_available_at: datetime | None = None
|
||||||
|
|
||||||
|
# Today's activity
|
||||||
|
stamps_today: int
|
||||||
|
stamps_remaining_today: int
|
||||||
|
|
||||||
|
|
||||||
|
class StampRedeemRequest(BaseModel):
|
||||||
|
"""Schema for redeeming stamps for a reward."""
|
||||||
|
|
||||||
|
card_id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card ID (use this or qr_code)",
|
||||||
|
)
|
||||||
|
qr_code: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="QR code data from card scan",
|
||||||
|
)
|
||||||
|
card_number: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Card number (manual entry)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
staff_pin: str | None = Field(
|
||||||
|
None,
|
||||||
|
min_length=4,
|
||||||
|
max_length=6,
|
||||||
|
description="Staff PIN for verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
notes: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="Optional note about this redemption",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StampRedeemResponse(BaseModel):
|
||||||
|
"""Schema for stamp redemption response."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str = "Reward redeemed successfully"
|
||||||
|
|
||||||
|
# Card state after redemption
|
||||||
|
card_id: int
|
||||||
|
card_number: str
|
||||||
|
stamp_count: int # Should be 0 after redemption
|
||||||
|
stamps_target: int
|
||||||
|
|
||||||
|
# Reward info
|
||||||
|
reward_description: str
|
||||||
|
total_redemptions: int # Lifetime redemptions for this card
|
||||||
59
app/modules/loyalty/services/__init__.py
Normal file
59
app/modules/loyalty/services/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# app/modules/loyalty/services/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module services.
|
||||||
|
|
||||||
|
Provides loyalty program management, card operations, stamp/points
|
||||||
|
handling, and wallet integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.modules.loyalty.services.program_service import (
|
||||||
|
ProgramService,
|
||||||
|
program_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.card_service import (
|
||||||
|
CardService,
|
||||||
|
card_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.stamp_service import (
|
||||||
|
StampService,
|
||||||
|
stamp_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.points_service import (
|
||||||
|
PointsService,
|
||||||
|
points_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.pin_service import (
|
||||||
|
PinService,
|
||||||
|
pin_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.wallet_service import (
|
||||||
|
WalletService,
|
||||||
|
wallet_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
|
GoogleWalletService,
|
||||||
|
google_wallet_service,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.services.apple_wallet_service import (
|
||||||
|
AppleWalletService,
|
||||||
|
apple_wallet_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProgramService",
|
||||||
|
"program_service",
|
||||||
|
"CardService",
|
||||||
|
"card_service",
|
||||||
|
"StampService",
|
||||||
|
"stamp_service",
|
||||||
|
"PointsService",
|
||||||
|
"points_service",
|
||||||
|
"PinService",
|
||||||
|
"pin_service",
|
||||||
|
"WalletService",
|
||||||
|
"wallet_service",
|
||||||
|
"GoogleWalletService",
|
||||||
|
"google_wallet_service",
|
||||||
|
"AppleWalletService",
|
||||||
|
"apple_wallet_service",
|
||||||
|
]
|
||||||
388
app/modules/loyalty/services/apple_wallet_service.py
Normal file
388
app/modules/loyalty/services/apple_wallet_service.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# app/modules/loyalty/services/apple_wallet_service.py
|
||||||
|
"""
|
||||||
|
Apple Wallet service.
|
||||||
|
|
||||||
|
Handles Apple Wallet integration including:
|
||||||
|
- Generating .pkpass files
|
||||||
|
- Apple Web Service for device registration
|
||||||
|
- Push notifications for pass updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import zipfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.config import config
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
AppleWalletNotConfiguredException,
|
||||||
|
WalletIntegrationException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import AppleDeviceRegistration, LoyaltyCard, LoyaltyProgram
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AppleWalletService:
|
||||||
|
"""Service for Apple Wallet integration."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""Check if Apple Wallet is configured."""
|
||||||
|
return bool(
|
||||||
|
config.apple_pass_type_id
|
||||||
|
and config.apple_team_id
|
||||||
|
and config.apple_wwdr_cert_path
|
||||||
|
and config.apple_signer_cert_path
|
||||||
|
and config.apple_signer_key_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Pass Generation
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def generate_pass(self, db: Session, card: LoyaltyCard) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a .pkpass file for a loyalty card.
|
||||||
|
|
||||||
|
The .pkpass is a ZIP file containing:
|
||||||
|
- pass.json: Pass configuration
|
||||||
|
- icon.png, icon@2x.png: App icon
|
||||||
|
- logo.png, logo@2x.png: Logo on pass
|
||||||
|
- manifest.json: SHA-1 hashes of all files
|
||||||
|
- signature: PKCS#7 signature
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Loyalty card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bytes of the .pkpass file
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise AppleWalletNotConfiguredException()
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Ensure serial number is set
|
||||||
|
if not card.apple_serial_number:
|
||||||
|
card.apple_serial_number = f"card_{card.id}_{card.qr_code_data[:8]}"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Build pass.json
|
||||||
|
pass_data = self._build_pass_json(card, program)
|
||||||
|
|
||||||
|
# Create the pass package
|
||||||
|
pass_files = {
|
||||||
|
"pass.json": json.dumps(pass_data).encode("utf-8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add placeholder images (in production, these would be actual images)
|
||||||
|
# For now, we'll skip images and use the pass.json only
|
||||||
|
# pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||||
|
# pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||||
|
# pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||||
|
# pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||||
|
|
||||||
|
# Create manifest
|
||||||
|
manifest = {}
|
||||||
|
for filename, content in pass_files.items():
|
||||||
|
manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||||
|
pass_files["manifest.json"] = json.dumps(manifest).encode("utf-8")
|
||||||
|
|
||||||
|
# Sign the manifest
|
||||||
|
try:
|
||||||
|
signature = self._sign_manifest(pass_files["manifest.json"])
|
||||||
|
pass_files["signature"] = signature
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sign pass: {e}")
|
||||||
|
raise WalletIntegrationException("apple", f"Failed to sign pass: {e}")
|
||||||
|
|
||||||
|
# Create ZIP file
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for filename, content in pass_files.items():
|
||||||
|
zf.writestr(filename, content)
|
||||||
|
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def _build_pass_json(self, card: LoyaltyCard, program: LoyaltyProgram) -> dict[str, Any]:
|
||||||
|
"""Build the pass.json structure for a loyalty card."""
|
||||||
|
pass_data = {
|
||||||
|
"formatVersion": 1,
|
||||||
|
"passTypeIdentifier": config.apple_pass_type_id,
|
||||||
|
"serialNumber": card.apple_serial_number,
|
||||||
|
"teamIdentifier": config.apple_team_id,
|
||||||
|
"organizationName": program.display_name,
|
||||||
|
"description": f"{program.display_name} Loyalty Card",
|
||||||
|
"backgroundColor": self._hex_to_rgb(program.card_color),
|
||||||
|
"foregroundColor": "rgb(255, 255, 255)",
|
||||||
|
"labelColor": "rgb(255, 255, 255)",
|
||||||
|
"authenticationToken": card.apple_auth_token,
|
||||||
|
"webServiceURL": self._get_web_service_url(),
|
||||||
|
"barcode": {
|
||||||
|
"message": card.qr_code_data,
|
||||||
|
"format": "PKBarcodeFormatQR",
|
||||||
|
"messageEncoding": "iso-8859-1",
|
||||||
|
},
|
||||||
|
"barcodes": [
|
||||||
|
{
|
||||||
|
"message": card.qr_code_data,
|
||||||
|
"format": "PKBarcodeFormatQR",
|
||||||
|
"messageEncoding": "iso-8859-1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add loyalty-specific fields
|
||||||
|
if program.is_stamps_enabled:
|
||||||
|
pass_data["storeCard"] = {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "stamps",
|
||||||
|
"label": "STAMPS",
|
||||||
|
"value": f"{card.stamp_count}/{program.stamps_target}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "reward",
|
||||||
|
"label": "NEXT REWARD",
|
||||||
|
"value": program.stamps_reward_description,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "progress",
|
||||||
|
"label": "PROGRESS",
|
||||||
|
"value": f"{card.stamp_count} stamps collected",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "cardNumber",
|
||||||
|
"label": "Card Number",
|
||||||
|
"value": card.card_number,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalStamps",
|
||||||
|
"label": "Total Stamps Earned",
|
||||||
|
"value": str(card.total_stamps_earned),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "redemptions",
|
||||||
|
"label": "Total Rewards",
|
||||||
|
"value": str(card.stamps_redeemed),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
elif program.is_points_enabled:
|
||||||
|
pass_data["storeCard"] = {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "points",
|
||||||
|
"label": "POINTS",
|
||||||
|
"value": str(card.points_balance),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "balance",
|
||||||
|
"label": "BALANCE",
|
||||||
|
"value": f"{card.points_balance} points",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "cardNumber",
|
||||||
|
"label": "Card Number",
|
||||||
|
"value": card.card_number,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalPoints",
|
||||||
|
"label": "Total Points Earned",
|
||||||
|
"value": str(card.total_points_earned),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "redeemed",
|
||||||
|
"label": "Points Redeemed",
|
||||||
|
"value": str(card.points_redeemed),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass_data
|
||||||
|
|
||||||
|
def _hex_to_rgb(self, hex_color: str) -> str:
|
||||||
|
"""Convert hex color to RGB format for Apple Wallet."""
|
||||||
|
hex_color = hex_color.lstrip("#")
|
||||||
|
r = int(hex_color[0:2], 16)
|
||||||
|
g = int(hex_color[2:4], 16)
|
||||||
|
b = int(hex_color[4:6], 16)
|
||||||
|
return f"rgb({r}, {g}, {b})"
|
||||||
|
|
||||||
|
def _get_web_service_url(self) -> str:
|
||||||
|
"""Get the base URL for Apple Web Service endpoints."""
|
||||||
|
# This should be configured based on your deployment
|
||||||
|
# For now, return a placeholder
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
base_url = getattr(settings, "BASE_URL", "https://api.example.com")
|
||||||
|
return f"{base_url}/api/v1/loyalty/apple"
|
||||||
|
|
||||||
|
def _sign_manifest(self, manifest_data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Sign the manifest using PKCS#7.
|
||||||
|
|
||||||
|
This requires the Apple WWDR certificate and your
|
||||||
|
pass signing certificate and key.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs7
|
||||||
|
|
||||||
|
# Load certificates
|
||||||
|
with open(config.apple_wwdr_cert_path, "rb") as f:
|
||||||
|
wwdr_cert = x509.load_pem_x509_certificate(f.read())
|
||||||
|
|
||||||
|
with open(config.apple_signer_cert_path, "rb") as f:
|
||||||
|
signer_cert = x509.load_pem_x509_certificate(f.read())
|
||||||
|
|
||||||
|
with open(config.apple_signer_key_path, "rb") as f:
|
||||||
|
signer_key = serialization.load_pem_private_key(f.read(), password=None)
|
||||||
|
|
||||||
|
# Create PKCS#7 signature
|
||||||
|
signature = (
|
||||||
|
pkcs7.PKCS7SignatureBuilder()
|
||||||
|
.set_data(manifest_data)
|
||||||
|
.add_signer(signer_cert, signer_key, hashes.SHA256())
|
||||||
|
.add_certificate(wwdr_cert)
|
||||||
|
.sign(serialization.Encoding.DER, [pkcs7.PKCS7Options.DetachedSignature])
|
||||||
|
)
|
||||||
|
|
||||||
|
return signature
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise WalletIntegrationException("apple", f"Certificate file not found: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise WalletIntegrationException("apple", f"Failed to sign manifest: {e}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Pass URLs
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_pass_url(self, card: LoyaltyCard) -> str:
|
||||||
|
"""Get the URL to download the .pkpass file."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
base_url = getattr(settings, "BASE_URL", "https://api.example.com")
|
||||||
|
return f"{base_url}/api/v1/loyalty/passes/apple/{card.apple_serial_number}.pkpass"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Device Registration (Apple Web Service)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card: LoyaltyCard,
|
||||||
|
device_library_id: str,
|
||||||
|
push_token: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a device for push notifications.
|
||||||
|
|
||||||
|
Called by Apple when user adds pass to their wallet.
|
||||||
|
"""
|
||||||
|
# Check if already registered
|
||||||
|
existing = (
|
||||||
|
db.query(AppleDeviceRegistration)
|
||||||
|
.filter(
|
||||||
|
AppleDeviceRegistration.card_id == card.id,
|
||||||
|
AppleDeviceRegistration.device_library_identifier == device_library_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update push token
|
||||||
|
existing.push_token = push_token
|
||||||
|
else:
|
||||||
|
# Create new registration
|
||||||
|
registration = AppleDeviceRegistration(
|
||||||
|
card_id=card.id,
|
||||||
|
device_library_identifier=device_library_id,
|
||||||
|
push_token=push_token,
|
||||||
|
)
|
||||||
|
db.add(registration)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Registered device {device_library_id[:8]}... for card {card.id}")
|
||||||
|
|
||||||
|
def unregister_device(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card: LoyaltyCard,
|
||||||
|
device_library_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Unregister a device.
|
||||||
|
|
||||||
|
Called by Apple when user removes pass from their wallet.
|
||||||
|
"""
|
||||||
|
db.query(AppleDeviceRegistration).filter(
|
||||||
|
AppleDeviceRegistration.card_id == card.id,
|
||||||
|
AppleDeviceRegistration.device_library_identifier == device_library_id,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Unregistered device {device_library_id[:8]}... for card {card.id}")
|
||||||
|
|
||||||
|
def send_push_updates(self, db: Session, card: LoyaltyCard) -> None:
|
||||||
|
"""
|
||||||
|
Send push notifications to all registered devices for a card.
|
||||||
|
|
||||||
|
This tells Apple Wallet to fetch the updated pass.
|
||||||
|
"""
|
||||||
|
registrations = (
|
||||||
|
db.query(AppleDeviceRegistration)
|
||||||
|
.filter(AppleDeviceRegistration.card_id == card.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not registrations:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send push notification to each device
|
||||||
|
for registration in registrations:
|
||||||
|
try:
|
||||||
|
self._send_push(registration.push_token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to send push to device {registration.device_library_identifier[:8]}...: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_push(self, push_token: str) -> None:
|
||||||
|
"""
|
||||||
|
Send an empty push notification to trigger pass update.
|
||||||
|
|
||||||
|
Apple Wallet will then call our web service to fetch the updated pass.
|
||||||
|
"""
|
||||||
|
# This would use APNs to send the push notification
|
||||||
|
# For now, we'll log and skip the actual push
|
||||||
|
logger.debug(f"Would send push to token {push_token[:8]}...")
|
||||||
|
|
||||||
|
# In production, you would use something like:
|
||||||
|
# from apns2.client import APNsClient
|
||||||
|
# from apns2.payload import Payload
|
||||||
|
# client = APNsClient(config.apple_signer_cert_path, use_sandbox=True)
|
||||||
|
# payload = Payload()
|
||||||
|
# client.send_notification(push_token, payload, "pass.com.example.loyalty")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
apple_wallet_service = AppleWalletService()
|
||||||
348
app/modules/loyalty/services/card_service.py
Normal file
348
app/modules/loyalty/services/card_service.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# app/modules/loyalty/services/card_service.py
|
||||||
|
"""
|
||||||
|
Loyalty card service.
|
||||||
|
|
||||||
|
Handles card operations including:
|
||||||
|
- Customer enrollment
|
||||||
|
- Card lookup (by ID, QR code, card number)
|
||||||
|
- Card management (activation, deactivation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
LoyaltyCardAlreadyExistsException,
|
||||||
|
LoyaltyCardNotFoundException,
|
||||||
|
LoyaltyProgramInactiveException,
|
||||||
|
LoyaltyProgramNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CardService:
|
||||||
|
"""Service for loyalty card operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Read Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_card(self, db: Session, card_id: int) -> LoyaltyCard | None:
|
||||||
|
"""Get a loyalty card by ID."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(LoyaltyCard.id == card_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_card_by_qr_code(self, db: Session, qr_code: str) -> LoyaltyCard | None:
|
||||||
|
"""Get a loyalty card by QR code data."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(LoyaltyCard.qr_code_data == qr_code)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None:
|
||||||
|
"""Get a loyalty card by card number."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(LoyaltyCard.card_number == card_number)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_card_by_customer_and_program(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
customer_id: int,
|
||||||
|
program_id: int,
|
||||||
|
) -> LoyaltyCard | None:
|
||||||
|
"""Get a customer's card for a specific program."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.customer_id == customer_id,
|
||||||
|
LoyaltyCard.program_id == program_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||||
|
"""Get a card or raise exception if not found."""
|
||||||
|
card = self.get_card(db, card_id)
|
||||||
|
if not card:
|
||||||
|
raise LoyaltyCardNotFoundException(str(card_id))
|
||||||
|
return card
|
||||||
|
|
||||||
|
def lookup_card(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
card_id: int | None = None,
|
||||||
|
qr_code: str | None = None,
|
||||||
|
card_number: str | None = None,
|
||||||
|
) -> LoyaltyCard:
|
||||||
|
"""
|
||||||
|
Look up a card by any identifier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
qr_code: QR code data
|
||||||
|
card_number: Card number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Found card
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LoyaltyCardNotFoundException: If no card found
|
||||||
|
"""
|
||||||
|
card = None
|
||||||
|
|
||||||
|
if card_id:
|
||||||
|
card = self.get_card(db, card_id)
|
||||||
|
elif qr_code:
|
||||||
|
card = self.get_card_by_qr_code(db, qr_code)
|
||||||
|
elif card_number:
|
||||||
|
card = self.get_card_by_number(db, card_number)
|
||||||
|
|
||||||
|
if not card:
|
||||||
|
identifier = card_id or qr_code or card_number or "unknown"
|
||||||
|
raise LoyaltyCardNotFoundException(str(identifier))
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
def list_cards(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
*,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[LoyaltyCard], int]:
|
||||||
|
"""
|
||||||
|
List loyalty cards for a vendor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
skip: Pagination offset
|
||||||
|
limit: Pagination limit
|
||||||
|
is_active: Filter by active status
|
||||||
|
search: Search by card number or customer email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(cards, total_count)
|
||||||
|
"""
|
||||||
|
from models.database.customer import Customer
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.customer))
|
||||||
|
.filter(LoyaltyCard.vendor_id == vendor_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(LoyaltyCard.is_active == is_active)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.join(Customer).filter(
|
||||||
|
(LoyaltyCard.card_number.ilike(f"%{search}%"))
|
||||||
|
| (Customer.email.ilike(f"%{search}%"))
|
||||||
|
| (Customer.first_name.ilike(f"%{search}%"))
|
||||||
|
| (Customer.last_name.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
cards = (
|
||||||
|
query.order_by(LoyaltyCard.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return cards, total
|
||||||
|
|
||||||
|
def list_customer_cards(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
customer_id: int,
|
||||||
|
) -> list[LoyaltyCard]:
|
||||||
|
"""List all loyalty cards for a customer."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(LoyaltyCard.customer_id == customer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Write Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def enroll_customer(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
customer_id: int,
|
||||||
|
vendor_id: int,
|
||||||
|
*,
|
||||||
|
program_id: int | None = None,
|
||||||
|
) -> LoyaltyCard:
|
||||||
|
"""
|
||||||
|
Enroll a customer in a loyalty program.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
customer_id: Customer ID
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
program_id: Optional program ID (defaults to vendor's program)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created loyalty card
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LoyaltyProgramNotFoundException: If no program exists
|
||||||
|
LoyaltyProgramInactiveException: If program is inactive
|
||||||
|
LoyaltyCardAlreadyExistsException: If customer already enrolled
|
||||||
|
"""
|
||||||
|
# Get the program
|
||||||
|
if program_id:
|
||||||
|
program = (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(LoyaltyProgram.id == program_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
program = (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not program:
|
||||||
|
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||||
|
|
||||||
|
if not program.is_active:
|
||||||
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
|
# Check if customer already has a card
|
||||||
|
existing = self.get_card_by_customer_and_program(db, customer_id, program.id)
|
||||||
|
if existing:
|
||||||
|
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||||
|
|
||||||
|
# Create the card
|
||||||
|
card = LoyaltyCard(
|
||||||
|
customer_id=customer_id,
|
||||||
|
program_id=program.id,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(card)
|
||||||
|
db.flush() # Get the card ID
|
||||||
|
|
||||||
|
# Create enrollment transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
transaction_type=TransactionType.CARD_CREATED.value,
|
||||||
|
transaction_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Enrolled customer {customer_id} in loyalty program {program.id} "
|
||||||
|
f"(card: {card.card_number})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||||
|
"""Deactivate a loyalty card."""
|
||||||
|
card = self.require_card(db, card_id)
|
||||||
|
card.is_active = False
|
||||||
|
|
||||||
|
# Create deactivation transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
transaction_type=TransactionType.CARD_DEACTIVATED.value,
|
||||||
|
transaction_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(f"Deactivated loyalty card {card_id}")
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
def reactivate_card(self, db: Session, card_id: int) -> LoyaltyCard:
|
||||||
|
"""Reactivate a deactivated loyalty card."""
|
||||||
|
card = self.require_card(db, card_id)
|
||||||
|
card.is_active = True
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(f"Reactivated loyalty card {card_id}")
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_stamps_today(self, db: Session, card_id: int) -> int:
|
||||||
|
"""Get number of stamps earned today for a card."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
count = (
|
||||||
|
db.query(func.count(LoyaltyTransaction.id))
|
||||||
|
.filter(
|
||||||
|
LoyaltyTransaction.card_id == card_id,
|
||||||
|
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
|
||||||
|
LoyaltyTransaction.transaction_at >= today_start,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
return count or 0
|
||||||
|
|
||||||
|
def get_card_transactions(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card_id: int,
|
||||||
|
*,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[list[LoyaltyTransaction], int]:
|
||||||
|
"""Get transaction history for a card."""
|
||||||
|
query = (
|
||||||
|
db.query(LoyaltyTransaction)
|
||||||
|
.filter(LoyaltyTransaction.card_id == card_id)
|
||||||
|
.order_by(LoyaltyTransaction.transaction_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
transactions = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return transactions, total
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
card_service = CardService()
|
||||||
366
app/modules/loyalty/services/google_wallet_service.py
Normal file
366
app/modules/loyalty/services/google_wallet_service.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
# app/modules/loyalty/services/google_wallet_service.py
|
||||||
|
"""
|
||||||
|
Google Wallet service.
|
||||||
|
|
||||||
|
Handles Google Wallet integration including:
|
||||||
|
- Creating LoyaltyClass for programs
|
||||||
|
- Creating LoyaltyObject for cards
|
||||||
|
- Updating objects on balance changes
|
||||||
|
- Generating "Add to Wallet" URLs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.config import config
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
GoogleWalletNotConfiguredException,
|
||||||
|
WalletIntegrationException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleWalletService:
|
||||||
|
"""Service for Google Wallet integration."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Google Wallet service."""
|
||||||
|
self._credentials = None
|
||||||
|
self._http_client = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""Check if Google Wallet is configured."""
|
||||||
|
return bool(config.google_issuer_id and config.google_service_account_json)
|
||||||
|
|
||||||
|
def _get_credentials(self):
|
||||||
|
"""Get Google service account credentials."""
|
||||||
|
if self._credentials:
|
||||||
|
return self._credentials
|
||||||
|
|
||||||
|
if not config.google_service_account_json:
|
||||||
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
|
||||||
|
scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"]
|
||||||
|
|
||||||
|
self._credentials = service_account.Credentials.from_service_account_file(
|
||||||
|
config.google_service_account_json,
|
||||||
|
scopes=scopes,
|
||||||
|
)
|
||||||
|
return self._credentials
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load Google credentials: {e}")
|
||||||
|
raise WalletIntegrationException("google", str(e))
|
||||||
|
|
||||||
|
def _get_http_client(self):
|
||||||
|
"""Get authenticated HTTP client."""
|
||||||
|
if self._http_client:
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google.auth.transport.requests import AuthorizedSession
|
||||||
|
|
||||||
|
credentials = self._get_credentials()
|
||||||
|
self._http_client = AuthorizedSession(credentials)
|
||||||
|
return self._http_client
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create Google HTTP client: {e}")
|
||||||
|
raise WalletIntegrationException("google", str(e))
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# LoyaltyClass Operations (Program-level)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_class(self, db: Session, program: LoyaltyProgram) -> str:
|
||||||
|
"""
|
||||||
|
Create a LoyaltyClass for a loyalty program.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
program: Loyalty program
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Google Wallet class ID
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
|
issuer_id = config.google_issuer_id
|
||||||
|
class_id = f"{issuer_id}.loyalty_program_{program.id}"
|
||||||
|
|
||||||
|
class_data = {
|
||||||
|
"id": class_id,
|
||||||
|
"issuerId": issuer_id,
|
||||||
|
"reviewStatus": "UNDER_REVIEW",
|
||||||
|
"programName": program.display_name,
|
||||||
|
"programLogo": {
|
||||||
|
"sourceUri": {
|
||||||
|
"uri": program.logo_url or "https://via.placeholder.com/100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"hexBackgroundColor": program.card_color,
|
||||||
|
"localizedProgramName": {
|
||||||
|
"defaultValue": {
|
||||||
|
"language": "en",
|
||||||
|
"value": program.display_name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add hero image if configured
|
||||||
|
if program.hero_image_url:
|
||||||
|
class_data["heroImage"] = {
|
||||||
|
"sourceUri": {"uri": program.hero_image_url},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
http = self._get_http_client()
|
||||||
|
response = http.post(
|
||||||
|
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass",
|
||||||
|
json=class_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
# Update program with class ID
|
||||||
|
program.google_class_id = class_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Created Google Wallet class {class_id} for program {program.id}")
|
||||||
|
return class_id
|
||||||
|
elif response.status_code == 409:
|
||||||
|
# Class already exists
|
||||||
|
program.google_class_id = class_id
|
||||||
|
db.commit()
|
||||||
|
return class_id
|
||||||
|
else:
|
||||||
|
error = response.json() if response.text else {}
|
||||||
|
raise WalletIntegrationException(
|
||||||
|
"google",
|
||||||
|
f"Failed to create class: {response.status_code} - {error}",
|
||||||
|
)
|
||||||
|
except WalletIntegrationException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create Google Wallet class: {e}")
|
||||||
|
raise WalletIntegrationException("google", str(e))
|
||||||
|
|
||||||
|
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
||||||
|
"""Update a LoyaltyClass when program settings change."""
|
||||||
|
if not program.google_class_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
class_data = {
|
||||||
|
"programName": program.display_name,
|
||||||
|
"hexBackgroundColor": program.card_color,
|
||||||
|
}
|
||||||
|
|
||||||
|
if program.logo_url:
|
||||||
|
class_data["programLogo"] = {
|
||||||
|
"sourceUri": {"uri": program.logo_url},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
http = self._get_http_client()
|
||||||
|
response = http.patch(
|
||||||
|
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{program.google_class_id}",
|
||||||
|
json=class_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to update Google Wallet class {program.google_class_id}: "
|
||||||
|
f"{response.status_code}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Google Wallet class: {e}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# LoyaltyObject Operations (Card-level)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_object(self, db: Session, card: LoyaltyCard) -> str:
|
||||||
|
"""
|
||||||
|
Create a LoyaltyObject for a loyalty card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Loyalty card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Google Wallet object ID
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
if not program.google_class_id:
|
||||||
|
# Create class first
|
||||||
|
self.create_class(db, program)
|
||||||
|
|
||||||
|
issuer_id = config.google_issuer_id
|
||||||
|
object_id = f"{issuer_id}.loyalty_card_{card.id}"
|
||||||
|
|
||||||
|
object_data = self._build_object_data(card, object_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
http = self._get_http_client()
|
||||||
|
response = http.post(
|
||||||
|
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject",
|
||||||
|
json=object_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
card.google_object_id = object_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Created Google Wallet object {object_id} for card {card.id}")
|
||||||
|
return object_id
|
||||||
|
elif response.status_code == 409:
|
||||||
|
# Object already exists
|
||||||
|
card.google_object_id = object_id
|
||||||
|
db.commit()
|
||||||
|
return object_id
|
||||||
|
else:
|
||||||
|
error = response.json() if response.text else {}
|
||||||
|
raise WalletIntegrationException(
|
||||||
|
"google",
|
||||||
|
f"Failed to create object: {response.status_code} - {error}",
|
||||||
|
)
|
||||||
|
except WalletIntegrationException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create Google Wallet object: {e}")
|
||||||
|
raise WalletIntegrationException("google", str(e))
|
||||||
|
|
||||||
|
def update_object(self, db: Session, card: LoyaltyCard) -> None:
|
||||||
|
"""Update a LoyaltyObject when card balance changes."""
|
||||||
|
if not card.google_object_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
object_data = self._build_object_data(card, card.google_object_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
http = self._get_http_client()
|
||||||
|
response = http.patch(
|
||||||
|
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}",
|
||||||
|
json=object_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
logger.debug(f"Updated Google Wallet object for card {card.id}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to update Google Wallet object {card.google_object_id}: "
|
||||||
|
f"{response.status_code}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update Google Wallet object: {e}")
|
||||||
|
|
||||||
|
def _build_object_data(self, card: LoyaltyCard, object_id: str) -> dict[str, Any]:
|
||||||
|
"""Build the LoyaltyObject data structure."""
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
object_data = {
|
||||||
|
"id": object_id,
|
||||||
|
"classId": program.google_class_id,
|
||||||
|
"state": "ACTIVE" if card.is_active else "INACTIVE",
|
||||||
|
"accountId": card.card_number,
|
||||||
|
"accountName": card.card_number,
|
||||||
|
"barcode": {
|
||||||
|
"type": "QR_CODE",
|
||||||
|
"value": card.qr_code_data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add loyalty points (stamps as points for display)
|
||||||
|
if program.is_stamps_enabled:
|
||||||
|
object_data["loyaltyPoints"] = {
|
||||||
|
"label": "Stamps",
|
||||||
|
"balance": {
|
||||||
|
"int": card.stamp_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Add secondary points showing target
|
||||||
|
object_data["secondaryLoyaltyPoints"] = {
|
||||||
|
"label": f"of {program.stamps_target}",
|
||||||
|
"balance": {
|
||||||
|
"int": program.stamps_target,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif program.is_points_enabled:
|
||||||
|
object_data["loyaltyPoints"] = {
|
||||||
|
"label": "Points",
|
||||||
|
"balance": {
|
||||||
|
"int": card.points_balance,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return object_data
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Save URL Generation
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_save_url(self, db: Session, card: LoyaltyCard) -> str:
|
||||||
|
"""
|
||||||
|
Get the "Add to Google Wallet" URL for a card.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Loyalty card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL for adding pass to Google Wallet
|
||||||
|
"""
|
||||||
|
if not self.is_configured:
|
||||||
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
|
# Ensure object exists
|
||||||
|
if not card.google_object_id:
|
||||||
|
self.create_object(db, card)
|
||||||
|
|
||||||
|
# Generate JWT for save link
|
||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
credentials = self._get_credentials()
|
||||||
|
|
||||||
|
claims = {
|
||||||
|
"iss": credentials.service_account_email,
|
||||||
|
"aud": "google",
|
||||||
|
"origins": [],
|
||||||
|
"typ": "savetowallet",
|
||||||
|
"payload": {
|
||||||
|
"loyaltyObjects": [{"id": card.google_object_id}],
|
||||||
|
},
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sign with service account private key
|
||||||
|
token = jwt.encode(
|
||||||
|
claims,
|
||||||
|
credentials._signer._key,
|
||||||
|
algorithm="RS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
card.google_object_jwt = token
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"https://pay.google.com/gp/v/save/{token}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate Google Wallet save URL: {e}")
|
||||||
|
raise WalletIntegrationException("google", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
google_wallet_service = GoogleWalletService()
|
||||||
281
app/modules/loyalty/services/pin_service.py
Normal file
281
app/modules/loyalty/services/pin_service.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# app/modules/loyalty/services/pin_service.py
|
||||||
|
"""
|
||||||
|
Staff PIN service.
|
||||||
|
|
||||||
|
Handles PIN operations including:
|
||||||
|
- PIN creation and management
|
||||||
|
- PIN verification with lockout
|
||||||
|
- PIN security (failed attempts, lockout)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.config import config
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
InvalidStaffPinException,
|
||||||
|
StaffPinLockedException,
|
||||||
|
StaffPinNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import StaffPin
|
||||||
|
from app.modules.loyalty.schemas.pin import PinCreate, PinUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PinService:
|
||||||
|
"""Service for staff PIN operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Read Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_pin(self, db: Session, pin_id: int) -> StaffPin | None:
|
||||||
|
"""Get a staff PIN by ID."""
|
||||||
|
return db.query(StaffPin).filter(StaffPin.id == pin_id).first()
|
||||||
|
|
||||||
|
def get_pin_by_staff_id(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
staff_id: str,
|
||||||
|
) -> StaffPin | None:
|
||||||
|
"""Get a staff PIN by employee ID."""
|
||||||
|
return (
|
||||||
|
db.query(StaffPin)
|
||||||
|
.filter(
|
||||||
|
StaffPin.program_id == program_id,
|
||||||
|
StaffPin.staff_id == staff_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_pin(self, db: Session, pin_id: int) -> StaffPin:
|
||||||
|
"""Get a PIN or raise exception if not found."""
|
||||||
|
pin = self.get_pin(db, pin_id)
|
||||||
|
if not pin:
|
||||||
|
raise StaffPinNotFoundException(str(pin_id))
|
||||||
|
return pin
|
||||||
|
|
||||||
|
def list_pins(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
*,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> list[StaffPin]:
|
||||||
|
"""List all staff PINs for a program."""
|
||||||
|
query = db.query(StaffPin).filter(StaffPin.program_id == program_id)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(StaffPin.is_active == is_active)
|
||||||
|
|
||||||
|
return query.order_by(StaffPin.name).all()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Write Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_pin(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
vendor_id: int,
|
||||||
|
data: PinCreate,
|
||||||
|
) -> StaffPin:
|
||||||
|
"""
|
||||||
|
Create a new staff PIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
program_id: Program ID
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
data: PIN creation data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created PIN
|
||||||
|
"""
|
||||||
|
pin = StaffPin(
|
||||||
|
program_id=program_id,
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
name=data.name,
|
||||||
|
staff_id=data.staff_id,
|
||||||
|
)
|
||||||
|
pin.set_pin(data.pin)
|
||||||
|
|
||||||
|
db.add(pin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pin)
|
||||||
|
|
||||||
|
logger.info(f"Created staff PIN {pin.id} for '{pin.name}' in program {program_id}")
|
||||||
|
|
||||||
|
return pin
|
||||||
|
|
||||||
|
def update_pin(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
pin_id: int,
|
||||||
|
data: PinUpdate,
|
||||||
|
) -> StaffPin:
|
||||||
|
"""
|
||||||
|
Update a staff PIN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
pin_id: PIN ID
|
||||||
|
data: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated PIN
|
||||||
|
"""
|
||||||
|
pin = self.require_pin(db, pin_id)
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
pin.name = data.name
|
||||||
|
|
||||||
|
if data.staff_id is not None:
|
||||||
|
pin.staff_id = data.staff_id
|
||||||
|
|
||||||
|
if data.pin is not None:
|
||||||
|
pin.set_pin(data.pin)
|
||||||
|
# Reset lockout when PIN is changed
|
||||||
|
pin.failed_attempts = 0
|
||||||
|
pin.locked_until = None
|
||||||
|
|
||||||
|
if data.is_active is not None:
|
||||||
|
pin.is_active = data.is_active
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pin)
|
||||||
|
|
||||||
|
logger.info(f"Updated staff PIN {pin_id}")
|
||||||
|
|
||||||
|
return pin
|
||||||
|
|
||||||
|
def delete_pin(self, db: Session, pin_id: int) -> None:
|
||||||
|
"""Delete a staff PIN."""
|
||||||
|
pin = self.require_pin(db, pin_id)
|
||||||
|
program_id = pin.program_id
|
||||||
|
|
||||||
|
db.delete(pin)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted staff PIN {pin_id} from program {program_id}")
|
||||||
|
|
||||||
|
def unlock_pin(self, db: Session, pin_id: int) -> StaffPin:
|
||||||
|
"""Unlock a locked staff PIN."""
|
||||||
|
pin = self.require_pin(db, pin_id)
|
||||||
|
pin.unlock()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pin)
|
||||||
|
|
||||||
|
logger.info(f"Unlocked staff PIN {pin_id}")
|
||||||
|
|
||||||
|
return pin
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Verification
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def verify_pin(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
plain_pin: str,
|
||||||
|
) -> StaffPin:
|
||||||
|
"""
|
||||||
|
Verify a staff PIN.
|
||||||
|
|
||||||
|
Checks all active PINs for the program and returns the matching one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
program_id: Program ID
|
||||||
|
plain_pin: Plain text PIN to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Verified StaffPin object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidStaffPinException: PIN is invalid
|
||||||
|
StaffPinLockedException: PIN is locked
|
||||||
|
"""
|
||||||
|
# Get all active PINs for the program
|
||||||
|
pins = self.list_pins(db, program_id, is_active=True)
|
||||||
|
|
||||||
|
if not pins:
|
||||||
|
raise InvalidStaffPinException()
|
||||||
|
|
||||||
|
# Try each PIN
|
||||||
|
for pin in pins:
|
||||||
|
# Check if locked
|
||||||
|
if pin.is_locked:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Verify PIN
|
||||||
|
if pin.verify_pin(plain_pin):
|
||||||
|
# Success - record it
|
||||||
|
pin.record_success()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.debug(f"PIN verified for '{pin.name}' in program {program_id}")
|
||||||
|
|
||||||
|
return pin
|
||||||
|
|
||||||
|
# No match found - record failed attempt on all unlocked PINs
|
||||||
|
# This is a simplified approach; in production you might want to
|
||||||
|
# track which PIN was attempted based on additional context
|
||||||
|
locked_pin = None
|
||||||
|
remaining = None
|
||||||
|
|
||||||
|
for pin in pins:
|
||||||
|
if not pin.is_locked:
|
||||||
|
is_now_locked = pin.record_failed_attempt(
|
||||||
|
max_attempts=config.pin_max_failed_attempts,
|
||||||
|
lockout_minutes=config.pin_lockout_minutes,
|
||||||
|
)
|
||||||
|
if is_now_locked:
|
||||||
|
locked_pin = pin
|
||||||
|
else:
|
||||||
|
remaining = pin.remaining_attempts
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If a PIN just got locked, raise that specific error
|
||||||
|
if locked_pin:
|
||||||
|
raise StaffPinLockedException(locked_pin.locked_until.isoformat())
|
||||||
|
|
||||||
|
raise InvalidStaffPinException(remaining)
|
||||||
|
|
||||||
|
def find_matching_pin(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
plain_pin: str,
|
||||||
|
) -> StaffPin | None:
|
||||||
|
"""
|
||||||
|
Find a matching PIN without recording attempts.
|
||||||
|
|
||||||
|
Useful for checking PIN validity without side effects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
program_id: Program ID
|
||||||
|
plain_pin: Plain text PIN to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching StaffPin or None
|
||||||
|
"""
|
||||||
|
pins = self.list_pins(db, program_id, is_active=True)
|
||||||
|
|
||||||
|
for pin in pins:
|
||||||
|
if not pin.is_locked and pin.verify_pin(plain_pin):
|
||||||
|
return pin
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
pin_service = PinService()
|
||||||
356
app/modules/loyalty/services/points_service.py
Normal file
356
app/modules/loyalty/services/points_service.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# app/modules/loyalty/services/points_service.py
|
||||||
|
"""
|
||||||
|
Points service.
|
||||||
|
|
||||||
|
Handles points operations including:
|
||||||
|
- Earning points from purchases
|
||||||
|
- Redeeming points for rewards
|
||||||
|
- Points balance management
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
InsufficientPointsException,
|
||||||
|
InvalidRewardException,
|
||||||
|
LoyaltyCardInactiveException,
|
||||||
|
LoyaltyProgramInactiveException,
|
||||||
|
StaffPinRequiredException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction, TransactionType
|
||||||
|
from app.modules.loyalty.services.card_service import card_service
|
||||||
|
from app.modules.loyalty.services.pin_service import pin_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsService:
|
||||||
|
"""Service for points operations."""
|
||||||
|
|
||||||
|
def earn_points(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
card_id: int | None = None,
|
||||||
|
qr_code: str | None = None,
|
||||||
|
card_number: str | None = None,
|
||||||
|
purchase_amount_cents: int,
|
||||||
|
order_reference: str | None = None,
|
||||||
|
staff_pin: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Earn points from a purchase.
|
||||||
|
|
||||||
|
Points are calculated based on the program's points_per_euro rate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
qr_code: QR code data
|
||||||
|
card_number: Card number
|
||||||
|
purchase_amount_cents: Purchase amount in cents
|
||||||
|
order_reference: Order reference for tracking
|
||||||
|
staff_pin: Staff PIN for verification
|
||||||
|
ip_address: Request IP for audit
|
||||||
|
user_agent: Request user agent for audit
|
||||||
|
notes: Optional notes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operation result
|
||||||
|
"""
|
||||||
|
# Look up the card
|
||||||
|
card = card_service.lookup_card(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
qr_code=qr_code,
|
||||||
|
card_number=card_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate card and program
|
||||||
|
if not card.is_active:
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
if not program.is_active:
|
||||||
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
|
# Check if points are enabled
|
||||||
|
if not program.is_points_enabled:
|
||||||
|
logger.warning(f"Points attempted on stamps-only program {program.id}")
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
# Verify staff PIN if required
|
||||||
|
verified_pin = None
|
||||||
|
if program.require_staff_pin:
|
||||||
|
if not staff_pin:
|
||||||
|
raise StaffPinRequiredException()
|
||||||
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||||
|
|
||||||
|
# Calculate points
|
||||||
|
# points_per_euro is per full euro, so divide cents by 100
|
||||||
|
purchase_euros = purchase_amount_cents / 100
|
||||||
|
points_earned = int(purchase_euros * program.points_per_euro)
|
||||||
|
|
||||||
|
if points_earned <= 0:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Purchase too small to earn points",
|
||||||
|
"points_earned": 0,
|
||||||
|
"points_per_euro": program.points_per_euro,
|
||||||
|
"purchase_amount_cents": purchase_amount_cents,
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"points_balance": card.points_balance,
|
||||||
|
"total_points_earned": card.total_points_earned,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add points
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
card.points_balance += points_earned
|
||||||
|
card.total_points_earned += points_earned
|
||||||
|
card.last_points_at = now
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
transaction_type=TransactionType.POINTS_EARNED.value,
|
||||||
|
points_delta=points_earned,
|
||||||
|
stamps_balance_after=card.stamp_count,
|
||||||
|
points_balance_after=card.points_balance,
|
||||||
|
purchase_amount_cents=purchase_amount_cents,
|
||||||
|
order_reference=order_reference,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=notes,
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Added {points_earned} points to card {card.id} "
|
||||||
|
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Points earned successfully",
|
||||||
|
"points_earned": points_earned,
|
||||||
|
"points_per_euro": program.points_per_euro,
|
||||||
|
"purchase_amount_cents": purchase_amount_cents,
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"points_balance": card.points_balance,
|
||||||
|
"total_points_earned": card.total_points_earned,
|
||||||
|
}
|
||||||
|
|
||||||
|
def redeem_points(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
card_id: int | None = None,
|
||||||
|
qr_code: str | None = None,
|
||||||
|
card_number: str | None = None,
|
||||||
|
reward_id: str,
|
||||||
|
staff_pin: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Redeem points for a reward.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
qr_code: QR code data
|
||||||
|
card_number: Card number
|
||||||
|
reward_id: ID of the reward to redeem
|
||||||
|
staff_pin: Staff PIN for verification
|
||||||
|
ip_address: Request IP for audit
|
||||||
|
user_agent: Request user agent for audit
|
||||||
|
notes: Optional notes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidRewardException: Reward not found or inactive
|
||||||
|
InsufficientPointsException: Not enough points
|
||||||
|
"""
|
||||||
|
# Look up the card
|
||||||
|
card = card_service.lookup_card(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
qr_code=qr_code,
|
||||||
|
card_number=card_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate card and program
|
||||||
|
if not card.is_active:
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
if not program.is_active:
|
||||||
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
|
# Find the reward
|
||||||
|
reward = program.get_points_reward(reward_id)
|
||||||
|
if not reward:
|
||||||
|
raise InvalidRewardException(reward_id)
|
||||||
|
|
||||||
|
if not reward.get("is_active", True):
|
||||||
|
raise InvalidRewardException(reward_id)
|
||||||
|
|
||||||
|
points_required = reward["points_required"]
|
||||||
|
reward_name = reward["name"]
|
||||||
|
|
||||||
|
# Check if enough points
|
||||||
|
if card.points_balance < points_required:
|
||||||
|
raise InsufficientPointsException(card.points_balance, points_required)
|
||||||
|
|
||||||
|
# Verify staff PIN if required
|
||||||
|
verified_pin = None
|
||||||
|
if program.require_staff_pin:
|
||||||
|
if not staff_pin:
|
||||||
|
raise StaffPinRequiredException()
|
||||||
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||||
|
|
||||||
|
# Redeem points
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
card.points_balance -= points_required
|
||||||
|
card.points_redeemed += points_required
|
||||||
|
card.last_redemption_at = now
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
transaction_type=TransactionType.POINTS_REDEEMED.value,
|
||||||
|
points_delta=-points_required,
|
||||||
|
stamps_balance_after=card.stamp_count,
|
||||||
|
points_balance_after=card.points_balance,
|
||||||
|
reward_id=reward_id,
|
||||||
|
reward_description=reward_name,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=notes,
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Redeemed {points_required} points from card {card.id} "
|
||||||
|
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Reward redeemed successfully",
|
||||||
|
"reward_id": reward_id,
|
||||||
|
"reward_name": reward_name,
|
||||||
|
"points_spent": points_required,
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"points_balance": card.points_balance,
|
||||||
|
"total_points_redeemed": card.points_redeemed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def adjust_points(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card_id: int,
|
||||||
|
points_delta: int,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
staff_pin: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Manually adjust points (admin operation).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
points_delta: Points to add (positive) or remove (negative)
|
||||||
|
reason: Reason for adjustment
|
||||||
|
staff_pin: Staff PIN for verification
|
||||||
|
ip_address: Request IP for audit
|
||||||
|
user_agent: Request user agent for audit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operation result
|
||||||
|
"""
|
||||||
|
card = card_service.require_card(db, card_id)
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Verify staff PIN if required
|
||||||
|
verified_pin = None
|
||||||
|
if program.require_staff_pin and staff_pin:
|
||||||
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||||
|
|
||||||
|
# Apply adjustment
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
card.points_balance += points_delta
|
||||||
|
|
||||||
|
if points_delta > 0:
|
||||||
|
card.total_points_earned += points_delta
|
||||||
|
else:
|
||||||
|
# Negative adjustment - don't add to redeemed, just reduce balance
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure balance doesn't go negative
|
||||||
|
if card.points_balance < 0:
|
||||||
|
card.points_balance = 0
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
transaction_type=TransactionType.POINTS_ADJUSTMENT.value,
|
||||||
|
points_delta=points_delta,
|
||||||
|
stamps_balance_after=card.stamp_count,
|
||||||
|
points_balance_after=card.points_balance,
|
||||||
|
notes=reason,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
||||||
|
f"(reason: {reason}, balance: {card.points_balance})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Points adjusted successfully",
|
||||||
|
"points_delta": points_delta,
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"points_balance": card.points_balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
points_service = PointsService()
|
||||||
379
app/modules/loyalty/services/program_service.py
Normal file
379
app/modules/loyalty/services/program_service.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# app/modules/loyalty/services/program_service.py
|
||||||
|
"""
|
||||||
|
Loyalty program service.
|
||||||
|
|
||||||
|
Handles CRUD operations for loyalty programs including:
|
||||||
|
- Program creation and configuration
|
||||||
|
- Program updates
|
||||||
|
- Program activation/deactivation
|
||||||
|
- Statistics retrieval
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
LoyaltyProgramAlreadyExistsException,
|
||||||
|
LoyaltyProgramNotFoundException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType
|
||||||
|
from app.modules.loyalty.schemas.program import (
|
||||||
|
ProgramCreate,
|
||||||
|
ProgramUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramService:
|
||||||
|
"""Service for loyalty program operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Read Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_program(self, db: Session, program_id: int) -> LoyaltyProgram | None:
|
||||||
|
"""Get a loyalty program by ID."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(LoyaltyProgram.id == program_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||||
|
"""Get a vendor's loyalty program."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(LoyaltyProgram.vendor_id == vendor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None:
|
||||||
|
"""Get a vendor's active loyalty program."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(
|
||||||
|
LoyaltyProgram.vendor_id == vendor_id,
|
||||||
|
LoyaltyProgram.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||||
|
"""Get a program or raise exception if not found."""
|
||||||
|
program = self.get_program(db, program_id)
|
||||||
|
if not program:
|
||||||
|
raise LoyaltyProgramNotFoundException(str(program_id))
|
||||||
|
return program
|
||||||
|
|
||||||
|
def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram:
|
||||||
|
"""Get a vendor's program or raise exception if not found."""
|
||||||
|
program = self.get_program_by_vendor(db, vendor_id)
|
||||||
|
if not program:
|
||||||
|
raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}")
|
||||||
|
return program
|
||||||
|
|
||||||
|
def list_programs(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> tuple[list[LoyaltyProgram], int]:
|
||||||
|
"""List all loyalty programs (admin)."""
|
||||||
|
query = db.query(LoyaltyProgram)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(LoyaltyProgram.is_active == is_active)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
programs = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return programs, total
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Write Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_program(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
data: ProgramCreate,
|
||||||
|
) -> LoyaltyProgram:
|
||||||
|
"""
|
||||||
|
Create a new loyalty program for a vendor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID
|
||||||
|
data: Program configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created program
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LoyaltyProgramAlreadyExistsException: If vendor already has a program
|
||||||
|
"""
|
||||||
|
# Check if vendor already has a program
|
||||||
|
existing = self.get_program_by_vendor(db, vendor_id)
|
||||||
|
if existing:
|
||||||
|
raise LoyaltyProgramAlreadyExistsException(vendor_id)
|
||||||
|
|
||||||
|
# Convert points_rewards to dict list for JSON storage
|
||||||
|
points_rewards_data = [r.model_dump() for r in data.points_rewards]
|
||||||
|
|
||||||
|
program = LoyaltyProgram(
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
loyalty_type=data.loyalty_type,
|
||||||
|
# Stamps
|
||||||
|
stamps_target=data.stamps_target,
|
||||||
|
stamps_reward_description=data.stamps_reward_description,
|
||||||
|
stamps_reward_value_cents=data.stamps_reward_value_cents,
|
||||||
|
# Points
|
||||||
|
points_per_euro=data.points_per_euro,
|
||||||
|
points_rewards=points_rewards_data,
|
||||||
|
# Anti-fraud
|
||||||
|
cooldown_minutes=data.cooldown_minutes,
|
||||||
|
max_daily_stamps=data.max_daily_stamps,
|
||||||
|
require_staff_pin=data.require_staff_pin,
|
||||||
|
# Branding
|
||||||
|
card_name=data.card_name,
|
||||||
|
card_color=data.card_color,
|
||||||
|
card_secondary_color=data.card_secondary_color,
|
||||||
|
logo_url=data.logo_url,
|
||||||
|
hero_image_url=data.hero_image_url,
|
||||||
|
# Terms
|
||||||
|
terms_text=data.terms_text,
|
||||||
|
privacy_url=data.privacy_url,
|
||||||
|
# Status
|
||||||
|
is_active=True,
|
||||||
|
activated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(program)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created loyalty program {program.id} for vendor {vendor_id} "
|
||||||
|
f"(type: {program.loyalty_type})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return program
|
||||||
|
|
||||||
|
def update_program(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
program_id: int,
|
||||||
|
data: ProgramUpdate,
|
||||||
|
) -> LoyaltyProgram:
|
||||||
|
"""
|
||||||
|
Update a loyalty program.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
program_id: Program ID
|
||||||
|
data: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated program
|
||||||
|
"""
|
||||||
|
program = self.require_program(db, program_id)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle points_rewards specially (convert to dict list)
|
||||||
|
if "points_rewards" in update_data and update_data["points_rewards"] is not None:
|
||||||
|
update_data["points_rewards"] = [
|
||||||
|
r.model_dump() if hasattr(r, "model_dump") else r
|
||||||
|
for r in update_data["points_rewards"]
|
||||||
|
]
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(program, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
|
||||||
|
logger.info(f"Updated loyalty program {program_id}")
|
||||||
|
|
||||||
|
return program
|
||||||
|
|
||||||
|
def activate_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||||
|
"""Activate a loyalty program."""
|
||||||
|
program = self.require_program(db, program_id)
|
||||||
|
program.activate()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
logger.info(f"Activated loyalty program {program_id}")
|
||||||
|
return program
|
||||||
|
|
||||||
|
def deactivate_program(self, db: Session, program_id: int) -> LoyaltyProgram:
|
||||||
|
"""Deactivate a loyalty program."""
|
||||||
|
program = self.require_program(db, program_id)
|
||||||
|
program.deactivate()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(program)
|
||||||
|
logger.info(f"Deactivated loyalty program {program_id}")
|
||||||
|
return program
|
||||||
|
|
||||||
|
def delete_program(self, db: Session, program_id: int) -> None:
|
||||||
|
"""Delete a loyalty program and all associated data."""
|
||||||
|
program = self.require_program(db, program_id)
|
||||||
|
vendor_id = program.vendor_id
|
||||||
|
|
||||||
|
db.delete(program)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Statistics
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_program_stats(self, db: Session, program_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get statistics for a loyalty program.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
- total_cards, active_cards
|
||||||
|
- total_stamps_issued, total_stamps_redeemed
|
||||||
|
- total_points_issued, total_points_redeemed
|
||||||
|
- etc.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||||
|
|
||||||
|
program = self.require_program(db, program_id)
|
||||||
|
|
||||||
|
# Card counts
|
||||||
|
total_cards = (
|
||||||
|
db.query(func.count(LoyaltyCard.id))
|
||||||
|
.filter(LoyaltyCard.program_id == program_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
active_cards = (
|
||||||
|
db.query(func.count(LoyaltyCard.id))
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.program_id == program_id,
|
||||||
|
LoyaltyCard.is_active == True,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamp totals from cards
|
||||||
|
stamp_stats = (
|
||||||
|
db.query(
|
||||||
|
func.sum(LoyaltyCard.total_stamps_earned),
|
||||||
|
func.sum(LoyaltyCard.stamps_redeemed),
|
||||||
|
)
|
||||||
|
.filter(LoyaltyCard.program_id == program_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
total_stamps_issued = stamp_stats[0] or 0
|
||||||
|
total_stamps_redeemed = stamp_stats[1] or 0
|
||||||
|
|
||||||
|
# Points totals from cards
|
||||||
|
points_stats = (
|
||||||
|
db.query(
|
||||||
|
func.sum(LoyaltyCard.total_points_earned),
|
||||||
|
func.sum(LoyaltyCard.points_redeemed),
|
||||||
|
)
|
||||||
|
.filter(LoyaltyCard.program_id == program_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
total_points_issued = points_stats[0] or 0
|
||||||
|
total_points_redeemed = points_stats[1] or 0
|
||||||
|
|
||||||
|
# This month's activity
|
||||||
|
month_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
stamps_this_month = (
|
||||||
|
db.query(func.count(LoyaltyTransaction.id))
|
||||||
|
.join(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.program_id == program_id,
|
||||||
|
LoyaltyTransaction.transaction_type == "stamp_earned",
|
||||||
|
LoyaltyTransaction.transaction_at >= month_start,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
redemptions_this_month = (
|
||||||
|
db.query(func.count(LoyaltyTransaction.id))
|
||||||
|
.join(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.program_id == program_id,
|
||||||
|
LoyaltyTransaction.transaction_type == "stamp_redeemed",
|
||||||
|
LoyaltyTransaction.transaction_at >= month_start,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 30-day active cards
|
||||||
|
thirty_days_ago = datetime.now(UTC) - timedelta(days=30)
|
||||||
|
cards_with_activity_30d = (
|
||||||
|
db.query(func.count(func.distinct(LoyaltyTransaction.card_id)))
|
||||||
|
.join(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.program_id == program_id,
|
||||||
|
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Averages
|
||||||
|
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
|
||||||
|
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
|
||||||
|
|
||||||
|
# Estimated liability (unredeemed value)
|
||||||
|
current_stamps = (
|
||||||
|
db.query(func.sum(LoyaltyCard.stamp_count))
|
||||||
|
.filter(LoyaltyCard.program_id == program_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
stamp_value = program.stamps_reward_value_cents or 0
|
||||||
|
current_points = (
|
||||||
|
db.query(func.sum(LoyaltyCard.points_balance))
|
||||||
|
.filter(LoyaltyCard.program_id == program_id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
# Rough estimate: assume 100 points = €1
|
||||||
|
points_value_cents = current_points // 100 * 100
|
||||||
|
|
||||||
|
estimated_liability = (
|
||||||
|
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_cards": total_cards,
|
||||||
|
"active_cards": active_cards,
|
||||||
|
"total_stamps_issued": total_stamps_issued,
|
||||||
|
"total_stamps_redeemed": total_stamps_redeemed,
|
||||||
|
"stamps_this_month": stamps_this_month,
|
||||||
|
"redemptions_this_month": redemptions_this_month,
|
||||||
|
"total_points_issued": total_points_issued,
|
||||||
|
"total_points_redeemed": total_points_redeemed,
|
||||||
|
"cards_with_activity_30d": cards_with_activity_30d,
|
||||||
|
"average_stamps_per_card": round(avg_stamps, 2),
|
||||||
|
"average_points_per_card": round(avg_points, 2),
|
||||||
|
"estimated_liability_cents": estimated_liability,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
program_service = ProgramService()
|
||||||
279
app/modules/loyalty/services/stamp_service.py
Normal file
279
app/modules/loyalty/services/stamp_service.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# app/modules/loyalty/services/stamp_service.py
|
||||||
|
"""
|
||||||
|
Stamp service.
|
||||||
|
|
||||||
|
Handles stamp operations including:
|
||||||
|
- Adding stamps with anti-fraud checks
|
||||||
|
- Redeeming stamps for rewards
|
||||||
|
- Daily limit tracking
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.config import config
|
||||||
|
from app.modules.loyalty.exceptions import (
|
||||||
|
DailyStampLimitException,
|
||||||
|
InsufficientStampsException,
|
||||||
|
LoyaltyCardInactiveException,
|
||||||
|
LoyaltyProgramInactiveException,
|
||||||
|
StampCooldownException,
|
||||||
|
StaffPinRequiredException,
|
||||||
|
)
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction, TransactionType
|
||||||
|
from app.modules.loyalty.services.card_service import card_service
|
||||||
|
from app.modules.loyalty.services.pin_service import pin_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StampService:
|
||||||
|
"""Service for stamp operations."""
|
||||||
|
|
||||||
|
def add_stamp(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
card_id: int | None = None,
|
||||||
|
qr_code: str | None = None,
|
||||||
|
card_number: str | None = None,
|
||||||
|
staff_pin: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Add a stamp to a loyalty card.
|
||||||
|
|
||||||
|
Performs all anti-fraud checks:
|
||||||
|
- Staff PIN verification (if required)
|
||||||
|
- Cooldown period check
|
||||||
|
- Daily limit check
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
qr_code: QR code data
|
||||||
|
card_number: Card number
|
||||||
|
staff_pin: Staff PIN for verification
|
||||||
|
ip_address: Request IP for audit
|
||||||
|
user_agent: Request user agent for audit
|
||||||
|
notes: Optional notes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LoyaltyCardNotFoundException: Card not found
|
||||||
|
LoyaltyCardInactiveException: Card is inactive
|
||||||
|
LoyaltyProgramInactiveException: Program is inactive
|
||||||
|
StaffPinRequiredException: PIN required but not provided
|
||||||
|
InvalidStaffPinException: PIN is invalid
|
||||||
|
StampCooldownException: Cooldown period not elapsed
|
||||||
|
DailyStampLimitException: Daily limit reached
|
||||||
|
"""
|
||||||
|
# Look up the card
|
||||||
|
card = card_service.lookup_card(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
qr_code=qr_code,
|
||||||
|
card_number=card_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate card and program
|
||||||
|
if not card.is_active:
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
if not program.is_active:
|
||||||
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
|
# Check if stamps are enabled
|
||||||
|
if not program.is_stamps_enabled:
|
||||||
|
logger.warning(f"Stamp attempted on points-only program {program.id}")
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
# Verify staff PIN if required
|
||||||
|
verified_pin = None
|
||||||
|
if program.require_staff_pin:
|
||||||
|
if not staff_pin:
|
||||||
|
raise StaffPinRequiredException()
|
||||||
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||||
|
|
||||||
|
# Check cooldown
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if card.last_stamp_at:
|
||||||
|
cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes)
|
||||||
|
if now < cooldown_ends:
|
||||||
|
raise StampCooldownException(
|
||||||
|
cooldown_ends.isoformat(),
|
||||||
|
program.cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check daily limit
|
||||||
|
stamps_today = card_service.get_stamps_today(db, card.id)
|
||||||
|
if stamps_today >= program.max_daily_stamps:
|
||||||
|
raise DailyStampLimitException(program.max_daily_stamps, stamps_today)
|
||||||
|
|
||||||
|
# Add the stamp
|
||||||
|
card.stamp_count += 1
|
||||||
|
card.total_stamps_earned += 1
|
||||||
|
card.last_stamp_at = now
|
||||||
|
|
||||||
|
# Check if reward earned
|
||||||
|
reward_earned = card.stamp_count >= program.stamps_target
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||||
|
stamps_delta=1,
|
||||||
|
stamps_balance_after=card.stamp_count,
|
||||||
|
points_balance_after=card.points_balance,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=notes,
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
stamps_today += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Added stamp to card {card.id} "
|
||||||
|
f"(stamps: {card.stamp_count}/{program.stamps_target}, "
|
||||||
|
f"today: {stamps_today}/{program.max_daily_stamps})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next stamp availability
|
||||||
|
next_stamp_at = now + timedelta(minutes=program.cooldown_minutes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Stamp added successfully",
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"stamp_count": card.stamp_count,
|
||||||
|
"stamps_target": program.stamps_target,
|
||||||
|
"stamps_until_reward": max(0, program.stamps_target - card.stamp_count),
|
||||||
|
"reward_earned": reward_earned,
|
||||||
|
"reward_description": program.stamps_reward_description if reward_earned else None,
|
||||||
|
"next_stamp_available_at": next_stamp_at,
|
||||||
|
"stamps_today": stamps_today,
|
||||||
|
"stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today),
|
||||||
|
}
|
||||||
|
|
||||||
|
def redeem_stamps(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
card_id: int | None = None,
|
||||||
|
qr_code: str | None = None,
|
||||||
|
card_number: str | None = None,
|
||||||
|
staff_pin: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Redeem stamps for a reward.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card_id: Card ID
|
||||||
|
qr_code: QR code data
|
||||||
|
card_number: Card number
|
||||||
|
staff_pin: Staff PIN for verification
|
||||||
|
ip_address: Request IP for audit
|
||||||
|
user_agent: Request user agent for audit
|
||||||
|
notes: Optional notes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with operation result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LoyaltyCardNotFoundException: Card not found
|
||||||
|
InsufficientStampsException: Not enough stamps
|
||||||
|
StaffPinRequiredException: PIN required but not provided
|
||||||
|
"""
|
||||||
|
# Look up the card
|
||||||
|
card = card_service.lookup_card(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
qr_code=qr_code,
|
||||||
|
card_number=card_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate card and program
|
||||||
|
if not card.is_active:
|
||||||
|
raise LoyaltyCardInactiveException(card.id)
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
if not program.is_active:
|
||||||
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
|
# Check if enough stamps
|
||||||
|
if card.stamp_count < program.stamps_target:
|
||||||
|
raise InsufficientStampsException(card.stamp_count, program.stamps_target)
|
||||||
|
|
||||||
|
# Verify staff PIN if required
|
||||||
|
verified_pin = None
|
||||||
|
if program.require_staff_pin:
|
||||||
|
if not staff_pin:
|
||||||
|
raise StaffPinRequiredException()
|
||||||
|
verified_pin = pin_service.verify_pin(db, program.id, staff_pin)
|
||||||
|
|
||||||
|
# Redeem stamps
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
stamps_redeemed = program.stamps_target
|
||||||
|
card.stamp_count -= stamps_redeemed
|
||||||
|
card.stamps_redeemed += 1
|
||||||
|
card.last_redemption_at = now
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
vendor_id=card.vendor_id,
|
||||||
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
transaction_type=TransactionType.STAMP_REDEEMED.value,
|
||||||
|
stamps_delta=-stamps_redeemed,
|
||||||
|
stamps_balance_after=card.stamp_count,
|
||||||
|
points_balance_after=card.points_balance,
|
||||||
|
reward_description=program.stamps_reward_description,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
notes=notes,
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(card)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Redeemed stamps from card {card.id} "
|
||||||
|
f"(reward: {program.stamps_reward_description}, "
|
||||||
|
f"total redemptions: {card.stamps_redeemed})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Reward redeemed successfully",
|
||||||
|
"card_id": card.id,
|
||||||
|
"card_number": card.card_number,
|
||||||
|
"stamp_count": card.stamp_count,
|
||||||
|
"stamps_target": program.stamps_target,
|
||||||
|
"reward_description": program.stamps_reward_description,
|
||||||
|
"total_redemptions": card.stamps_redeemed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
stamp_service = StampService()
|
||||||
144
app/modules/loyalty/services/wallet_service.py
Normal file
144
app/modules/loyalty/services/wallet_service.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# app/modules/loyalty/services/wallet_service.py
|
||||||
|
"""
|
||||||
|
Unified wallet service.
|
||||||
|
|
||||||
|
Provides a unified interface for wallet operations across
|
||||||
|
Google Wallet and Apple Wallet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletService:
|
||||||
|
"""Unified service for wallet operations."""
|
||||||
|
|
||||||
|
def get_add_to_wallet_urls(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card: LoyaltyCard,
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
"""
|
||||||
|
Get URLs for adding card to wallets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Loyalty card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with google_wallet_url and apple_wallet_url
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.services.apple_wallet_service import apple_wallet_service
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
|
||||||
|
|
||||||
|
urls = {
|
||||||
|
"google_wallet_url": None,
|
||||||
|
"apple_wallet_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Google Wallet
|
||||||
|
if program.google_issuer_id or program.google_class_id:
|
||||||
|
try:
|
||||||
|
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get Google Wallet URL for card {card.id}: {e}")
|
||||||
|
|
||||||
|
# Apple Wallet
|
||||||
|
if program.apple_pass_type_id:
|
||||||
|
try:
|
||||||
|
urls["apple_wallet_url"] = apple_wallet_service.get_pass_url(card)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get Apple Wallet URL for card {card.id}: {e}")
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def sync_card_to_wallets(self, db: Session, card: LoyaltyCard) -> dict[str, bool]:
|
||||||
|
"""
|
||||||
|
Sync card data to all configured wallets.
|
||||||
|
|
||||||
|
Called after stamp/points operations to update wallet passes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Loyalty card to sync
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status for each wallet
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.services.apple_wallet_service import apple_wallet_service
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"google_wallet": False,
|
||||||
|
"apple_wallet": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Sync to Google Wallet
|
||||||
|
if card.google_object_id:
|
||||||
|
try:
|
||||||
|
google_wallet_service.update_object(db, card)
|
||||||
|
results["google_wallet"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sync card {card.id} to Google Wallet: {e}")
|
||||||
|
|
||||||
|
# Sync to Apple Wallet (via push notification)
|
||||||
|
if card.apple_serial_number:
|
||||||
|
try:
|
||||||
|
apple_wallet_service.send_push_updates(db, card)
|
||||||
|
results["apple_wallet"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Apple Wallet push for card {card.id}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def create_wallet_objects(self, db: Session, card: LoyaltyCard) -> dict[str, bool]:
|
||||||
|
"""
|
||||||
|
Create wallet objects for a new card.
|
||||||
|
|
||||||
|
Called during enrollment to set up wallet passes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
card: Newly created loyalty card
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status for each wallet
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"google_wallet": False,
|
||||||
|
"apple_wallet": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
# Create Google Wallet object
|
||||||
|
if program.google_issuer_id:
|
||||||
|
try:
|
||||||
|
google_wallet_service.create_object(db, card)
|
||||||
|
results["google_wallet"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create Google Wallet object for card {card.id}: {e}")
|
||||||
|
|
||||||
|
# Apple Wallet objects are created on-demand when user downloads pass
|
||||||
|
# No pre-creation needed, but we set up the serial number
|
||||||
|
if program.apple_pass_type_id:
|
||||||
|
card.apple_serial_number = f"card_{card.id}_{card.qr_code_data[:8]}"
|
||||||
|
db.commit()
|
||||||
|
results["apple_wallet"] = True
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
wallet_service = WalletService()
|
||||||
10
app/modules/loyalty/tasks/__init__.py
Normal file
10
app/modules/loyalty/tasks/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# app/modules/loyalty/tasks/__init__.py
|
||||||
|
"""
|
||||||
|
Loyalty module Celery tasks.
|
||||||
|
|
||||||
|
Background tasks for:
|
||||||
|
- Point expiration
|
||||||
|
- Wallet synchronization
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
41
app/modules/loyalty/tasks/point_expiration.py
Normal file
41
app/modules/loyalty/tasks/point_expiration.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# app/modules/loyalty/tasks/point_expiration.py
|
||||||
|
"""
|
||||||
|
Point expiration task.
|
||||||
|
|
||||||
|
Handles expiring points that are older than the configured
|
||||||
|
expiration period (future enhancement).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="loyalty.expire_points")
|
||||||
|
def expire_points() -> dict:
|
||||||
|
"""
|
||||||
|
Expire points that are past their expiration date.
|
||||||
|
|
||||||
|
This is a placeholder for future functionality where points
|
||||||
|
can be configured to expire after a certain period.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary of expired points
|
||||||
|
"""
|
||||||
|
# Future implementation:
|
||||||
|
# 1. Find programs with point expiration enabled
|
||||||
|
# 2. Find cards with points earned before expiration threshold
|
||||||
|
# 3. Calculate points to expire
|
||||||
|
# 4. Create adjustment transactions
|
||||||
|
# 5. Update card balances
|
||||||
|
# 6. Notify customers (optional)
|
||||||
|
|
||||||
|
logger.info("Point expiration task running (no-op for now)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"cards_processed": 0,
|
||||||
|
"points_expired": 0,
|
||||||
|
}
|
||||||
99
app/modules/loyalty/tasks/wallet_sync.py
Normal file
99
app/modules/loyalty/tasks/wallet_sync.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# app/modules/loyalty/tasks/wallet_sync.py
|
||||||
|
"""
|
||||||
|
Wallet synchronization task.
|
||||||
|
|
||||||
|
Handles syncing loyalty card data to Google Wallet and Apple Wallet
|
||||||
|
for cards that may have missed real-time updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="loyalty.sync_wallet_passes")
|
||||||
|
def sync_wallet_passes() -> dict:
|
||||||
|
"""
|
||||||
|
Sync wallet passes for cards that may be out of sync.
|
||||||
|
|
||||||
|
This catches any cards that missed real-time updates due to
|
||||||
|
errors or network issues.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary of synced passes
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||||
|
from app.modules.loyalty.services import wallet_service
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Find cards with transactions in the last hour that have wallet IDs
|
||||||
|
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
||||||
|
|
||||||
|
# Get card IDs with recent transactions
|
||||||
|
recent_tx_card_ids = (
|
||||||
|
db.query(LoyaltyTransaction.card_id)
|
||||||
|
.filter(LoyaltyTransaction.transaction_at >= one_hour_ago)
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
card_ids = [row[0] for row in recent_tx_card_ids]
|
||||||
|
|
||||||
|
if not card_ids:
|
||||||
|
logger.info("No cards with recent transactions to sync")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"cards_checked": 0,
|
||||||
|
"google_synced": 0,
|
||||||
|
"apple_synced": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get cards with wallet integrations
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.id.in_(card_ids),
|
||||||
|
(LoyaltyCard.google_object_id.isnot(None))
|
||||||
|
| (LoyaltyCard.apple_serial_number.isnot(None)),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
google_synced = 0
|
||||||
|
apple_synced = 0
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
try:
|
||||||
|
results = wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
if results.get("google_wallet"):
|
||||||
|
google_synced += 1
|
||||||
|
if results.get("apple_wallet"):
|
||||||
|
apple_synced += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync card {card.id} to wallets: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Wallet sync complete: {len(cards)} cards checked, "
|
||||||
|
f"{google_synced} Google, {apple_synced} Apple"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"cards_checked": len(cards),
|
||||||
|
"google_synced": google_synced,
|
||||||
|
"apple_synced": apple_synced,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Wallet sync task failed: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
275
docs/modules/loyalty.md
Normal file
275
docs/modules/loyalty.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Loyalty Module
|
||||||
|
|
||||||
|
The Loyalty Module provides stamp-based and points-based loyalty programs for Wizamart vendors with Google Wallet and Apple Wallet integration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Aspect | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Module Code | `loyalty` |
|
||||||
|
| Dependencies | `customers` |
|
||||||
|
| Status | Phase 1 MVP Complete |
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Stamp-based loyalty**: Collect N stamps, get a reward (e.g., "Buy 10 coffees, get 1 free")
|
||||||
|
- **Points-based loyalty**: Earn points per euro spent, redeem for rewards
|
||||||
|
- **Hybrid programs**: Support both stamps and points simultaneously
|
||||||
|
- **Anti-fraud system**: Staff PINs, cooldown periods, daily limits, lockout protection
|
||||||
|
- **Wallet integration**: Google Wallet and Apple Wallet pass generation
|
||||||
|
- **Full audit trail**: Transaction logging with IP, user agent, and staff attribution
|
||||||
|
|
||||||
|
## Entity Model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Vendor │───────│ LoyaltyProgram │
|
||||||
|
└─────────────────┘ 1:1 └─────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┼───────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ StaffPin │ │LoyaltyCard│ │ (config)│
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌──────────────┐
|
||||||
|
└───▶│ Transaction │
|
||||||
|
└──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│AppleDeviceRegistration│
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Tables
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `loyalty_programs` | Vendor's program configuration (type, targets, branding) |
|
||||||
|
| `loyalty_cards` | Customer cards with stamp/point balances |
|
||||||
|
| `loyalty_transactions` | Immutable audit log of all operations |
|
||||||
|
| `staff_pins` | Hashed PINs for fraud prevention |
|
||||||
|
| `apple_device_registrations` | Apple Wallet push notification tokens |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables (prefix: `LOYALTY_`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Anti-fraud defaults
|
||||||
|
LOYALTY_DEFAULT_COOLDOWN_MINUTES=15
|
||||||
|
LOYALTY_MAX_DAILY_STAMPS=5
|
||||||
|
LOYALTY_PIN_MAX_FAILED_ATTEMPTS=5
|
||||||
|
LOYALTY_PIN_LOCKOUT_MINUTES=30
|
||||||
|
|
||||||
|
# Points
|
||||||
|
LOYALTY_DEFAULT_POINTS_PER_EURO=10
|
||||||
|
|
||||||
|
# Google Wallet
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
||||||
|
|
||||||
|
# Apple Wallet
|
||||||
|
LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||||
|
LOYALTY_APPLE_TEAM_ID=ABCD1234
|
||||||
|
LOYALTY_APPLE_WWDR_CERT_PATH=/path/to/wwdr.pem
|
||||||
|
LOYALTY_APPLE_SIGNER_CERT_PATH=/path/to/signer.pem
|
||||||
|
LOYALTY_APPLE_SIGNER_KEY_PATH=/path/to/signer.key
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Vendor Endpoints (`/api/v1/vendor/loyalty/`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/program` | Get vendor's loyalty program |
|
||||||
|
| `POST` | `/program` | Create loyalty program |
|
||||||
|
| `PATCH` | `/program` | Update loyalty program |
|
||||||
|
| `GET` | `/stats` | Get program statistics |
|
||||||
|
| `GET` | `/cards` | List customer cards |
|
||||||
|
| `POST` | `/cards/enroll` | Enroll customer in program |
|
||||||
|
| `POST` | `/cards/lookup` | Look up card by QR/number |
|
||||||
|
| `POST` | `/stamp` | Add stamp to card |
|
||||||
|
| `POST` | `/stamp/redeem` | Redeem stamps for reward |
|
||||||
|
| `POST` | `/points` | Earn points from purchase |
|
||||||
|
| `POST` | `/points/redeem` | Redeem points for reward |
|
||||||
|
| `GET` | `/pins` | List staff PINs |
|
||||||
|
| `POST` | `/pins` | Create staff PIN |
|
||||||
|
| `PATCH` | `/pins/{id}` | Update staff PIN |
|
||||||
|
| `DELETE` | `/pins/{id}` | Delete staff PIN |
|
||||||
|
| `POST` | `/pins/{id}/unlock` | Unlock locked PIN |
|
||||||
|
|
||||||
|
### Admin Endpoints (`/api/v1/admin/loyalty/`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/programs` | List all loyalty programs |
|
||||||
|
| `GET` | `/programs/{id}` | Get specific program |
|
||||||
|
| `GET` | `/programs/{id}/stats` | Get program statistics |
|
||||||
|
| `GET` | `/stats` | Platform-wide statistics |
|
||||||
|
|
||||||
|
### Public Endpoints (`/api/v1/loyalty/`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/programs/{vendor_code}` | Get program info for enrollment |
|
||||||
|
| `GET` | `/passes/apple/{serial}.pkpass` | Download Apple Wallet pass |
|
||||||
|
| `POST` | `/apple/v1/devices/...` | Apple Web Service: register device |
|
||||||
|
| `DELETE` | `/apple/v1/devices/...` | Apple Web Service: unregister |
|
||||||
|
| `GET` | `/apple/v1/devices/...` | Apple Web Service: get updates |
|
||||||
|
| `GET` | `/apple/v1/passes/...` | Apple Web Service: get pass |
|
||||||
|
|
||||||
|
## Anti-Fraud System
|
||||||
|
|
||||||
|
### Staff PIN Verification
|
||||||
|
|
||||||
|
All stamp and points operations require staff PIN verification (configurable per program).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PIN is hashed with bcrypt
|
||||||
|
pin.set_pin("1234") # Stores bcrypt hash
|
||||||
|
pin.verify_pin("1234") # Returns True/False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lockout Protection
|
||||||
|
|
||||||
|
- **Max failed attempts**: 5 (configurable)
|
||||||
|
- **Lockout duration**: 30 minutes (configurable)
|
||||||
|
- After lockout expires, PIN can be used again
|
||||||
|
- Admin can manually unlock via API
|
||||||
|
|
||||||
|
### Cooldown Period
|
||||||
|
|
||||||
|
Prevents rapid stamp collection (fraud prevention):
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer scans card → Gets stamp → Must wait 15 minutes → Can get next stamp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Daily Limits
|
||||||
|
|
||||||
|
Maximum stamps per card per day (default: 5).
|
||||||
|
|
||||||
|
## Wallet Integration
|
||||||
|
|
||||||
|
### Google Wallet
|
||||||
|
|
||||||
|
Architecture: **Server-side storage with API updates**
|
||||||
|
|
||||||
|
1. Program created → Create `LoyaltyClass` via Google API
|
||||||
|
2. Customer enrolls → Create `LoyaltyObject` via Google API
|
||||||
|
3. Stamp/points change → `PATCH` the object
|
||||||
|
4. Generate JWT for "Add to Wallet" button
|
||||||
|
|
||||||
|
No device registration needed - Google syncs automatically.
|
||||||
|
|
||||||
|
### Apple Wallet
|
||||||
|
|
||||||
|
Architecture: **Push notification model**
|
||||||
|
|
||||||
|
1. Customer adds pass → Device registers with our server
|
||||||
|
2. Stamp/points change → Send push notification to APNs
|
||||||
|
3. Device receives push → Fetches updated pass from our server
|
||||||
|
|
||||||
|
Requires `apple_device_registrations` table for push tokens.
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Create a Loyalty Program
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.modules.loyalty.services import program_service
|
||||||
|
from app.modules.loyalty.schemas import ProgramCreate
|
||||||
|
|
||||||
|
data = ProgramCreate(
|
||||||
|
loyalty_type="stamps",
|
||||||
|
stamps_target=10,
|
||||||
|
stamps_reward_description="Free coffee",
|
||||||
|
cooldown_minutes=15,
|
||||||
|
max_daily_stamps=5,
|
||||||
|
require_staff_pin=True,
|
||||||
|
card_color="#4F46E5",
|
||||||
|
)
|
||||||
|
|
||||||
|
program = program_service.create_program(db, vendor_id=1, data=data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enroll a Customer
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.modules.loyalty.services import card_service
|
||||||
|
|
||||||
|
card = card_service.enroll_customer(db, customer_id=123, vendor_id=1)
|
||||||
|
# Returns LoyaltyCard with unique card_number and qr_code_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add a Stamp
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.modules.loyalty.services import stamp_service
|
||||||
|
|
||||||
|
result = stamp_service.add_stamp(
|
||||||
|
db,
|
||||||
|
qr_code="abc123xyz",
|
||||||
|
staff_pin="1234",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
)
|
||||||
|
# Returns dict with stamp_count, reward_earned, next_stamp_available_at, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Earn Points from Purchase
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.modules.loyalty.services import points_service
|
||||||
|
|
||||||
|
result = points_service.earn_points(
|
||||||
|
db,
|
||||||
|
card_number="123456789012",
|
||||||
|
purchase_amount_cents=2500, # €25.00
|
||||||
|
order_reference="ORD-12345",
|
||||||
|
staff_pin="1234",
|
||||||
|
)
|
||||||
|
# Returns dict with points_earned (250 at 10pts/€), points_balance, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `program_service` | Program CRUD and statistics |
|
||||||
|
| `card_service` | Card enrollment, lookup, management |
|
||||||
|
| `stamp_service` | Stamp operations with anti-fraud |
|
||||||
|
| `points_service` | Points operations and redemption |
|
||||||
|
| `pin_service` | Staff PIN CRUD and verification |
|
||||||
|
| `wallet_service` | Unified wallet abstraction |
|
||||||
|
| `google_wallet_service` | Google Wallet API integration |
|
||||||
|
| `apple_wallet_service` | Apple Wallet pass generation |
|
||||||
|
|
||||||
|
## Scheduled Tasks
|
||||||
|
|
||||||
|
| Task | Schedule | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates |
|
||||||
|
| `loyalty.expire_points` | Daily 02:00 | Expire old points (future enhancement) |
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Available in 4 languages:
|
||||||
|
- English (`en.json`)
|
||||||
|
- French (`fr.json`)
|
||||||
|
- German (`de.json`)
|
||||||
|
- Luxembourgish (`lu.json`)
|
||||||
|
|
||||||
|
## Future Enhancements (Phase 2)
|
||||||
|
|
||||||
|
- Rewards catalog with configurable tiers
|
||||||
|
- Customer tiers (Bronze/Silver/Gold)
|
||||||
|
- Referral program
|
||||||
|
- Gamification (spin wheel, scratch cards)
|
||||||
|
- POS integration
|
||||||
|
- Points expiration rules
|
||||||
|
- Batch import of existing loyalty cards
|
||||||
670
docs/proposals/loyalty-phase2-interfaces-plan.md
Normal file
670
docs/proposals/loyalty-phase2-interfaces-plan.md
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
# Loyalty Module Phase 2: Admin & Vendor Interfaces
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines the plan for building admin and vendor interfaces for the Loyalty Module, along with detailed user journeys for stamp-based and points-based loyalty programs. The design follows market best practices from leading loyalty platforms (Square Loyalty, Toast, Fivestars, Belly, Punchh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Interface Design
|
||||||
|
|
||||||
|
### 1.1 Vendor Dashboard (Retail Store)
|
||||||
|
|
||||||
|
#### Main Loyalty Dashboard (`/vendor/loyalty`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🎯 Loyalty Program [Setup] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
|
||||||
|
│ │ 1,247 │ │ 892 │ │ 156 │ │ €2.3k ││
|
||||||
|
│ │ Members │ │ Active │ │ Redeemed │ │ Saved ││
|
||||||
|
│ │ Total │ │ 30 days │ │ This Month │ │ Value ││
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ 📊 Activity Chart (Last 30 Days) ││
|
||||||
|
│ │ [Stamps Issued] [Rewards Redeemed] [New Members] ││
|
||||||
|
│ │ ═══════════════════════════════════════════════ ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │
|
||||||
|
│ │ 🔥 Quick Actions │ │ 📋 Recent Activity │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ [➕ Add Stamp] │ │ • John D. earned stamp #8 │ │
|
||||||
|
│ │ [🎁 Redeem Reward] │ │ • Marie L. redeemed reward │ │
|
||||||
|
│ │ [👤 Enroll Customer] │ │ • Alex K. joined program │ │
|
||||||
|
│ │ [🔍 Look Up Card] │ │ • Sarah M. earned 50 pts │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └─────────────────────────┘ └─────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stamp/Points Terminal (`/vendor/loyalty/terminal`)
|
||||||
|
|
||||||
|
**Primary interface for daily operations - optimized for tablet/touchscreen:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🎯 Loyalty Terminal │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 📷 SCAN QR CODE │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [Camera Viewfinder Area] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ or enter card number │ │
|
||||||
|
│ │ ┌─────────────────────────┐ │ │
|
||||||
|
│ │ │ Card Number... │ │ │
|
||||||
|
│ │ └─────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Use Camera] [Enter Manually] [Recent Cards ▼] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**After scanning - Customer Card View:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ← Back Customer Card │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 👤 Marie Laurent │ │
|
||||||
|
│ │ marie.laurent@email.com │ │
|
||||||
|
│ │ Member since: Jan 2024 │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ○ ○ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 8 / 10 stamps │ │
|
||||||
|
│ │ 2 more until FREE COFFEE │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [ ➕ ADD STAMP ] [ 🎁 REDEEM ] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ Next stamp available in 12 minutes │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**PIN Entry Modal (appears when adding stamp):**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Enter Staff PIN │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ │
|
||||||
|
│ │ ● ● ● ● │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │ 1 │ │ 2 │ │ 3 │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │ 4 │ │ 5 │ │ 6 │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │ 7 │ │ 8 │ │ 9 │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ │
|
||||||
|
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
|
│ │ ⌫ │ │ 0 │ │ ✓ │ │
|
||||||
|
│ └─────┘ └─────┘ └─────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Program Setup (`/vendor/loyalty/settings`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚙️ Loyalty Program Settings │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Program Type │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ ☑️ Stamps │ │ ☐ Points │ │ ☐ Hybrid │ │
|
||||||
|
│ │ Buy 10 Get 1 │ │ Earn per € │ │ Both systems │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ Stamp Configuration │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Stamps needed for reward: [ 10 ▼ ] │ │
|
||||||
|
│ │ Reward description: [ Free coffee of choice ] │ │
|
||||||
|
│ │ Reward value (optional): [ €4.50 ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 🛡️ Fraud Prevention │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ☑️ Require staff PIN for operations │ │
|
||||||
|
│ │ Cooldown between stamps: [ 15 ] minutes │ │
|
||||||
|
│ │ Max stamps per day: [ 5 ] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 🎨 Card Branding │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Card name: [ Café Loyalty Card ] │ │
|
||||||
|
│ │ Primary color: [████] #4F46E5 │ │
|
||||||
|
│ │ Logo: [Upload] cafe-logo.png ✓ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Preview: ┌────────────────────┐ │ │
|
||||||
|
│ │ │ ☕ Café Loyalty │ │ │
|
||||||
|
│ │ │ ████████░░ │ │ │
|
||||||
|
│ │ │ 8/10 stamps │ │ │
|
||||||
|
│ │ └────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Save Changes] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Staff PIN Management (`/vendor/loyalty/pins`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🔐 Staff PINs [+ Add PIN] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 👤 Marie (Manager) [Edit] [🗑️] │ │
|
||||||
|
│ │ Last used: Today, 14:32 │ │
|
||||||
|
│ │ Status: ✅ Active │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ 👤 Thomas (Staff) [Edit] [🗑️] │ │
|
||||||
|
│ │ Last used: Today, 11:15 │ │
|
||||||
|
│ │ Status: ✅ Active │ │
|
||||||
|
│ ├─────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ 👤 Julie (Staff) [Edit] [🗑️] │ │
|
||||||
|
│ │ Last used: Yesterday │ │
|
||||||
|
│ │ Status: 🔒 Locked (3 failed attempts) [Unlock] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ℹ️ Staff PINs prevent unauthorized stamp/point operations. │
|
||||||
|
│ PINs are locked after 5 failed attempts for 30 minutes. │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Customer Cards List (`/vendor/loyalty/cards`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 👥 Loyalty Members 🔍 [Search...] [Export]│
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Filter: [All ▼] [Active ▼] [Has Reward Ready ▼] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ Customer │ Card # │ Stamps │ Last Visit │ ⋮ ││
|
||||||
|
│ ├───────────────────┼──────────────┼────────┼────────────┼────┤│
|
||||||
|
│ │ Marie Laurent │ 4821-7493 │ 8/10 ⭐│ Today │ ⋮ ││
|
||||||
|
│ │ Jean Dupont │ 4821-2847 │ 10/10 🎁│ Yesterday │ ⋮ ││
|
||||||
|
│ │ Sophie Martin │ 4821-9382 │ 3/10 │ 3 days ago │ ⋮ ││
|
||||||
|
│ │ Pierre Bernard │ 4821-1029 │ 6/10 │ 1 week ago │ ⋮ ││
|
||||||
|
│ │ ... │ ... │ ... │ ... │ ⋮ ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
│ Showing 1-20 of 1,247 members [← Prev] [1] [2] [Next →]│
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Admin Dashboard (Platform)
|
||||||
|
|
||||||
|
#### Platform Loyalty Overview (`/admin/loyalty`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 🎯 Loyalty Programs Platform │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
|
||||||
|
│ │ 47 │ │ 38 │ │ 12,847 │ │ €47k ││
|
||||||
|
│ │ Programs │ │ Active │ │ Members │ │ Saved ││
|
||||||
|
│ │ Total │ │ Programs │ │ Total │ │ Value ││
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│
|
||||||
|
│ │
|
||||||
|
│ Programs by Type: │
|
||||||
|
│ ═══════════════════════════════════════ │
|
||||||
|
│ Stamps: ████████████████████ 32 (68%) │
|
||||||
|
│ Points: ███████ 11 (23%) │
|
||||||
|
│ Hybrid: ████ 4 (9%) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ Vendor │ Type │ Members │ Activity │ Status ││
|
||||||
|
│ ├───────────────────┼─────────┼─────────┼──────────┼──────────┤│
|
||||||
|
│ │ Café du Coin │ Stamps │ 1,247 │ High │ ✅ Active││
|
||||||
|
│ │ Boulangerie Paul │ Points │ 892 │ Medium │ ✅ Active││
|
||||||
|
│ │ Pizza Roma │ Stamps │ 456 │ Low │ ⚠️ Setup ││
|
||||||
|
│ │ ... │ ... │ ... │ ... │ ... ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: User Journeys
|
||||||
|
|
||||||
|
### 2.1 Stamp-Based Loyalty Journey
|
||||||
|
|
||||||
|
#### Customer Journey: Enrollment
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STAMP LOYALTY - ENROLLMENT │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ DISCOVER│────▶│ JOIN │────▶│ SAVE │────▶│ USE │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘ └─────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Customer sees │ 2. Scans QR at │ 3. Card added │ 4. Ready to │
|
||||||
|
│ sign at counter│ register or │ to Google/ │ collect │
|
||||||
|
│ "Join our │ gives email │ Apple Wallet│ stamps! │
|
||||||
|
│ loyalty!" │ to cashier │ │ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Detailed Steps:**
|
||||||
|
|
||||||
|
1. **Discovery** (In-Store)
|
||||||
|
- Customer sees loyalty program signage/tent card
|
||||||
|
- QR code displayed at counter
|
||||||
|
- Staff mentions program during checkout
|
||||||
|
|
||||||
|
2. **Sign Up** (30 seconds)
|
||||||
|
- Customer scans QR code with phone
|
||||||
|
- Lands on mobile enrollment page
|
||||||
|
- Enters: Email (required), Name (optional)
|
||||||
|
- Accepts terms with checkbox
|
||||||
|
- Submits
|
||||||
|
|
||||||
|
3. **Card Creation** (Instant)
|
||||||
|
- System creates loyalty card
|
||||||
|
- Generates unique card number & QR code
|
||||||
|
- Shows "Add to Wallet" buttons
|
||||||
|
- Sends welcome email with card link
|
||||||
|
|
||||||
|
4. **Wallet Save** (Optional but encouraged)
|
||||||
|
- Customer taps "Add to Google Wallet" or "Add to Apple Wallet"
|
||||||
|
- Pass appears in their wallet app
|
||||||
|
- Always accessible, works offline
|
||||||
|
|
||||||
|
#### Customer Journey: Earning Stamps
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STAMP LOYALTY - EARNING │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Customer Staff System Wallet
|
||||||
|
│ │ │ │
|
||||||
|
│ 1. Makes │ │ │
|
||||||
|
│ purchase │ │ │
|
||||||
|
│───────────────▶│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 2. Shows │ │ │
|
||||||
|
│ loyalty card │ │ │
|
||||||
|
│───────────────▶│ │ │
|
||||||
|
│ │ 3. Scans QR │ │
|
||||||
|
│ │─────────────────▶│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 4. Enters PIN │ │
|
||||||
|
│ │─────────────────▶│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 5. Confirms │ │
|
||||||
|
│ │◀─────────────────│ │
|
||||||
|
│ │ "Stamp added!" │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 6. Verbal │ │ 7. Push │
|
||||||
|
│ confirmation │ │ notification │
|
||||||
|
│◀───────────────│ │────────────────▶│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 8. Pass updates│
|
||||||
|
│◀───────────────────────────────────│────────────────▶│
|
||||||
|
│ "8/10 stamps" │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anti-Fraud Checks (Automatic):**
|
||||||
|
|
||||||
|
1. ✅ Card is active
|
||||||
|
2. ✅ Program is active
|
||||||
|
3. ✅ Staff PIN is valid
|
||||||
|
4. ✅ Cooldown period elapsed (15 min since last stamp)
|
||||||
|
5. ✅ Daily limit not reached (max 5/day)
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"stamp_count": 8,
|
||||||
|
"stamps_target": 10,
|
||||||
|
"stamps_until_reward": 2,
|
||||||
|
"message": "2 more stamps until your free coffee!",
|
||||||
|
"next_stamp_available": "2024-01-28T15:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Customer Journey: Redeeming Reward
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ STAMP LOYALTY - REDEMPTION │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Customer Staff System
|
||||||
|
│ │ │
|
||||||
|
│ 1. "I'd like │ │
|
||||||
|
│ to redeem my │ │
|
||||||
|
│ free coffee" │ │
|
||||||
|
│───────────────▶│ │
|
||||||
|
│ │ │
|
||||||
|
│ 2. Shows card │ │
|
||||||
|
│ (10/10 stamps)│ │
|
||||||
|
│───────────────▶│ │
|
||||||
|
│ │ 3. Scans + sees │
|
||||||
|
│ │ "REWARD READY" │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 4. Clicks │
|
||||||
|
│ │ [REDEEM REWARD] │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 5. Enters PIN │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 6. Confirms │
|
||||||
|
│ │◀─────────────────│
|
||||||
|
│ │ "Reward redeemed"│
|
||||||
|
│ │ Stamps reset: 0 │
|
||||||
|
│ │ │
|
||||||
|
│ 7. Gives free │ │
|
||||||
|
│ coffee │ │
|
||||||
|
│◀───────────────│ │
|
||||||
|
│ │ │
|
||||||
|
│ 🎉 HAPPY │ │
|
||||||
|
│ CUSTOMER! │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Points-Based Loyalty Journey
|
||||||
|
|
||||||
|
#### Customer Journey: Earning Points
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ POINTS LOYALTY - EARNING │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Customer Staff System
|
||||||
|
│ │ │
|
||||||
|
│ 1. Purchases │ │
|
||||||
|
│ €25.00 order │ │
|
||||||
|
│───────────────▶│ │
|
||||||
|
│ │ │
|
||||||
|
│ 2. Shows │ │
|
||||||
|
│ loyalty card │ │
|
||||||
|
│───────────────▶│ │
|
||||||
|
│ │ 3. Scans card │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 4. Enters amount │
|
||||||
|
│ │ €25.00 │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 5. Enters PIN │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │ ┌──────────┐
|
||||||
|
│ │ │ │Calculate:│
|
||||||
|
│ │ │ │€25 × 10 │
|
||||||
|
│ │ │ │= 250 pts │
|
||||||
|
│ │ │ └──────────┘
|
||||||
|
│ │ 6. Confirms │
|
||||||
|
│ │◀─────────────────│
|
||||||
|
│ │ "+250 points!" │
|
||||||
|
│ │ │
|
||||||
|
│ 7. Receipt │ │
|
||||||
|
│ shows points │ │
|
||||||
|
│◀───────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points Calculation:**
|
||||||
|
```
|
||||||
|
Purchase: €25.00
|
||||||
|
Rate: 10 points per euro
|
||||||
|
Points Earned: 250 points
|
||||||
|
New Balance: 750 points
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Customer Journey: Redeeming Points
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ POINTS LOYALTY - REDEMPTION │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Customer Staff System
|
||||||
|
│ │ │
|
||||||
|
│ 1. Views │ │
|
||||||
|
│ rewards in │ │
|
||||||
|
│ wallet app │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ ┌──────────┐ │ │
|
||||||
|
│ │ REWARDS │ │ │
|
||||||
|
│ │──────────│ │ │
|
||||||
|
│ │ 500 pts │ │ │
|
||||||
|
│ │ Free │ │ │
|
||||||
|
│ │ Drink │ │ │
|
||||||
|
│ │──────────│ │ │
|
||||||
|
│ │ 1000 pts │ │ │
|
||||||
|
│ │ Free │ │ │
|
||||||
|
│ │ Meal │ │ │
|
||||||
|
│ └──────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2. "I want to │ │
|
||||||
|
│ redeem for │ │
|
||||||
|
│ free drink" │ │
|
||||||
|
│───────────────▶│ │
|
||||||
|
│ │ 3. Scans card │
|
||||||
|
│ │ Selects reward │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 4. Enters PIN │
|
||||||
|
│ │─────────────────▶│
|
||||||
|
│ │ │
|
||||||
|
│ │ 5. Confirms │
|
||||||
|
│ │◀─────────────────│
|
||||||
|
│ │ "-500 points" │
|
||||||
|
│ │ Balance: 250 pts │
|
||||||
|
│ │ │
|
||||||
|
│ 6. Gets free │ │
|
||||||
|
│ drink │ │
|
||||||
|
│◀───────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Market Best Practices
|
||||||
|
|
||||||
|
### 3.1 Competitive Analysis
|
||||||
|
|
||||||
|
| Feature | Square Loyalty | Toast | Fivestars | **Wizamart** |
|
||||||
|
|---------|---------------|-------|-----------|--------------|
|
||||||
|
| Stamp cards | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Points system | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Google Wallet | ✅ | ❌ | ✅ | ✅ |
|
||||||
|
| Apple Wallet | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Staff PIN | ❌ | ✅ | ✅ | ✅ |
|
||||||
|
| Cooldown fraud protection | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| Daily limits | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| Tablet terminal | ✅ | ✅ | ✅ | ✅ (planned) |
|
||||||
|
| Customer app | ✅ | ✅ | ✅ | Via Wallet |
|
||||||
|
| Analytics dashboard | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
### 3.2 Best Practices to Implement
|
||||||
|
|
||||||
|
#### UX Best Practices
|
||||||
|
|
||||||
|
1. **Instant gratification** - Show stamp/points immediately after transaction
|
||||||
|
2. **Progress visualization** - Clear progress bars/stamp grids
|
||||||
|
3. **Reward proximity** - "Only 2 more until your free coffee!"
|
||||||
|
4. **Wallet-first** - Push customers to save to wallet
|
||||||
|
5. **Offline support** - Card works even without internet (via wallet)
|
||||||
|
|
||||||
|
#### Fraud Prevention Best Practices
|
||||||
|
|
||||||
|
1. **Multi-layer security** - PIN + cooldown + daily limits
|
||||||
|
2. **Staff accountability** - Every transaction tied to a staff PIN
|
||||||
|
3. **Audit trail** - Complete history with IP/device info
|
||||||
|
4. **Lockout protection** - Automatic PIN lockout after failures
|
||||||
|
5. **Admin oversight** - Unlock and PIN management in dashboard
|
||||||
|
|
||||||
|
#### Engagement Best Practices
|
||||||
|
|
||||||
|
1. **Welcome bonus** - Give 1 stamp on enrollment (configurable)
|
||||||
|
2. **Birthday rewards** - Extra stamps/points on customer birthday
|
||||||
|
3. **Milestone notifications** - "Congrats! 50 stamps earned lifetime!"
|
||||||
|
4. **Re-engagement** - Remind inactive customers via email
|
||||||
|
5. **Double points days** - Promotional multipliers (future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 2A: Vendor Interface (Priority)
|
||||||
|
|
||||||
|
| Task | Effort | Priority |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Loyalty terminal (scan/stamp/redeem) | 3 days | P0 |
|
||||||
|
| Program setup wizard | 2 days | P0 |
|
||||||
|
| Staff PIN management | 1 day | P0 |
|
||||||
|
| Customer cards list | 1 day | P1 |
|
||||||
|
| Dashboard with stats | 2 days | P1 |
|
||||||
|
| Export functionality | 1 day | P2 |
|
||||||
|
|
||||||
|
### Phase 2B: Admin Interface
|
||||||
|
|
||||||
|
| Task | Effort | Priority |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Programs list view | 1 day | P1 |
|
||||||
|
| Platform-wide stats | 1 day | P1 |
|
||||||
|
| Program detail view | 0.5 day | P2 |
|
||||||
|
|
||||||
|
### Phase 2C: Customer Experience
|
||||||
|
|
||||||
|
| Task | Effort | Priority |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Enrollment page (mobile) | 1 day | P0 |
|
||||||
|
| Card detail page | 0.5 day | P1 |
|
||||||
|
| Wallet pass polish | 1 day | P1 |
|
||||||
|
| Email templates | 1 day | P2 |
|
||||||
|
|
||||||
|
### Phase 2D: Polish & Advanced
|
||||||
|
|
||||||
|
| Task | Effort | Priority |
|
||||||
|
|------|--------|----------|
|
||||||
|
| QR code scanner (JS) | 2 days | P0 |
|
||||||
|
| Real-time updates (WebSocket) | 1 day | P2 |
|
||||||
|
| Receipt printing | 1 day | P3 |
|
||||||
|
| POS integration hooks | 2 days | P3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5: Technical Specifications
|
||||||
|
|
||||||
|
### Vendor Terminal Requirements
|
||||||
|
|
||||||
|
- **Responsive**: Works on tablet (primary), desktop, mobile
|
||||||
|
- **Touch-friendly**: Large buttons, numpad for PIN
|
||||||
|
- **Camera access**: For QR code scanning (WebRTC)
|
||||||
|
- **Offline-capable**: Queue operations if network down (future)
|
||||||
|
- **Real-time**: WebSocket for instant updates
|
||||||
|
|
||||||
|
### Frontend Stack
|
||||||
|
|
||||||
|
- **Framework**: React/Vue components (match existing stack)
|
||||||
|
- **QR Scanner**: `html5-qrcode` or `@aspect-sdk/barcode-reader`
|
||||||
|
- **Charts**: Existing charting library (Chart.js or similar)
|
||||||
|
- **Animations**: CSS transitions for stamp animations
|
||||||
|
|
||||||
|
### API Considerations
|
||||||
|
|
||||||
|
- All vendor endpoints require `vendor_id` from auth token
|
||||||
|
- Staff PIN passed in request body, not headers
|
||||||
|
- Rate limiting on lookup/scan endpoints
|
||||||
|
- Pagination on card list (default 50)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Mockup Reference Images
|
||||||
|
|
||||||
|
### Stamp Card Visual (Wallet Pass)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ ☕ Café du Coin │
|
||||||
|
│ │
|
||||||
|
│ ████ ████ ████ ████ ████ │
|
||||||
|
│ ████ ████ ████ ░░░░ ░░░░ │
|
||||||
|
│ │
|
||||||
|
│ 8/10 STAMPS │
|
||||||
|
│ 2 more until FREE COFFEE │
|
||||||
|
│ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||||
|
│ │
|
||||||
|
│ Card #4821-7493-2841 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Points Card Visual (Wallet Pass)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ 🍕 Pizza Roma Rewards │
|
||||||
|
│ │
|
||||||
|
│ ★ 750 ★ │
|
||||||
|
│ POINTS │
|
||||||
|
│ │
|
||||||
|
│ ────────────────────── │
|
||||||
|
│ Next reward: 500 pts │
|
||||||
|
│ Free drink │
|
||||||
|
│ ────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||||
|
│ │
|
||||||
|
│ Card #4821-2847-9283 │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 1.0*
|
||||||
|
*Created: 2025-01-28*
|
||||||
|
*Author: Wizamart Engineering*
|
||||||
@@ -252,6 +252,14 @@ class Vendor(Base, TimestampMixin):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Loyalty program (one-to-one)
|
||||||
|
loyalty_program = relationship(
|
||||||
|
"LoyaltyProgram",
|
||||||
|
back_populates="vendor",
|
||||||
|
uselist=False,
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""String representation of the Vendor object."""
|
"""String representation of the Vendor object."""
|
||||||
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
|
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"
|
||||||
|
|||||||
Reference in New Issue
Block a user