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:
2026-01-28 23:04:00 +01:00
parent fbcf07914e
commit b5a803cde8
44 changed files with 8073 additions and 0 deletions

View 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 ###

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

View 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()

View 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"]

View 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",
]

View 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"
}
}
}

View 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"
}
}
}

View 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"
}
}
}

View 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"
}
}
}

View File

@@ -0,0 +1,6 @@
# app/modules/loyalty/migrations/__init__.py
"""
Loyalty module Alembic migrations.
"""
__all__: list[str] = []

View File

@@ -0,0 +1,6 @@
# app/modules/loyalty/migrations/versions/__init__.py
"""
Loyalty module migration versions.
"""
__all__: list[str] = []

View 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",
]

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

View 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

View 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

View 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

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

View File

@@ -0,0 +1,8 @@
# app/modules/loyalty/routes/__init__.py
"""
Loyalty module routes.
Provides API endpoints for loyalty program management.
"""
__all__: list[str] = []

View 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] = []

View 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,
}

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

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

View 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] = []

View 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",
]

View 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

View 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

View 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

View 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

View 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

View 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",
]

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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] = []

View 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,
}

View 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
View 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

View 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*

View File

@@ -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}')>"