diff --git a/alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py b/alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py new file mode 100644 index 00000000..5ab3ca09 --- /dev/null +++ b/alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py @@ -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 ### diff --git a/app/modules/loyalty/__init__.py b/app/modules/loyalty/__init__.py new file mode 100644 index 00000000..c9f98117 --- /dev/null +++ b/app/modules/loyalty/__init__.py @@ -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}") diff --git a/app/modules/loyalty/config.py b/app/modules/loyalty/config.py new file mode 100644 index 00000000..d8297eb3 --- /dev/null +++ b/app/modules/loyalty/config.py @@ -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() diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py new file mode 100644 index 00000000..835d5c97 --- /dev/null +++ b/app/modules/loyalty/definition.py @@ -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"] diff --git a/app/modules/loyalty/exceptions.py b/app/modules/loyalty/exceptions.py new file mode 100644 index 00000000..8c8d890f --- /dev/null +++ b/app/modules/loyalty/exceptions.py @@ -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", +] diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json new file mode 100644 index 00000000..76795d2c --- /dev/null +++ b/app/modules/loyalty/locales/de.json @@ -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" + } + } +} diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json new file mode 100644 index 00000000..b9570cc7 --- /dev/null +++ b/app/modules/loyalty/locales/en.json @@ -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" + } + } +} diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json new file mode 100644 index 00000000..f9092e62 --- /dev/null +++ b/app/modules/loyalty/locales/fr.json @@ -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" + } + } +} diff --git a/app/modules/loyalty/locales/lu.json b/app/modules/loyalty/locales/lu.json new file mode 100644 index 00000000..33c81f50 --- /dev/null +++ b/app/modules/loyalty/locales/lu.json @@ -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" + } + } +} diff --git a/app/modules/loyalty/migrations/__init__.py b/app/modules/loyalty/migrations/__init__.py new file mode 100644 index 00000000..d23496a1 --- /dev/null +++ b/app/modules/loyalty/migrations/__init__.py @@ -0,0 +1,6 @@ +# app/modules/loyalty/migrations/__init__.py +""" +Loyalty module Alembic migrations. +""" + +__all__: list[str] = [] diff --git a/app/modules/loyalty/migrations/versions/__init__.py b/app/modules/loyalty/migrations/versions/__init__.py new file mode 100644 index 00000000..e87c81e6 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/__init__.py @@ -0,0 +1,6 @@ +# app/modules/loyalty/migrations/versions/__init__.py +""" +Loyalty module migration versions. +""" + +__all__: list[str] = [] diff --git a/app/modules/loyalty/models/__init__.py b/app/modules/loyalty/models/__init__.py new file mode 100644 index 00000000..c5cd3c14 --- /dev/null +++ b/app/modules/loyalty/models/__init__.py @@ -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", +] diff --git a/app/modules/loyalty/models/apple_device.py b/app/modules/loyalty/models/apple_device.py new file mode 100644 index 00000000..31a056d6 --- /dev/null +++ b/app/modules/loyalty/models/apple_device.py @@ -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"" + ) diff --git a/app/modules/loyalty/models/loyalty_card.py b/app/modules/loyalty/models/loyalty_card.py new file mode 100644 index 00000000..01a77fa8 --- /dev/null +++ b/app/modules/loyalty/models/loyalty_card.py @@ -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"" + + # ========================================================================= + # 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 diff --git a/app/modules/loyalty/models/loyalty_program.py b/app/modules/loyalty/models/loyalty_program.py new file mode 100644 index 00000000..dd3f06fb --- /dev/null +++ b/app/modules/loyalty/models/loyalty_program.py @@ -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"" + + # ========================================================================= + # 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 diff --git a/app/modules/loyalty/models/loyalty_transaction.py b/app/modules/loyalty/models/loyalty_transaction.py new file mode 100644 index 00000000..3d74bedf --- /dev/null +++ b/app/modules/loyalty/models/loyalty_transaction.py @@ -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"" + ) + + # ========================================================================= + # 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 diff --git a/app/modules/loyalty/models/staff_pin.py b/app/modules/loyalty/models/staff_pin.py new file mode 100644 index 00000000..68cf9381 --- /dev/null +++ b/app/modules/loyalty/models/staff_pin.py @@ -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"" + + # ========================================================================= + # 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)) diff --git a/app/modules/loyalty/routes/__init__.py b/app/modules/loyalty/routes/__init__.py new file mode 100644 index 00000000..31f37b85 --- /dev/null +++ b/app/modules/loyalty/routes/__init__.py @@ -0,0 +1,8 @@ +# app/modules/loyalty/routes/__init__.py +""" +Loyalty module routes. + +Provides API endpoints for loyalty program management. +""" + +__all__: list[str] = [] diff --git a/app/modules/loyalty/routes/api/__init__.py b/app/modules/loyalty/routes/api/__init__.py new file mode 100644 index 00000000..62a0be27 --- /dev/null +++ b/app/modules/loyalty/routes/api/__init__.py @@ -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] = [] diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py new file mode 100644 index 00000000..757a0f80 --- /dev/null +++ b/app/modules/loyalty/routes/api/admin.py @@ -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, + } diff --git a/app/modules/loyalty/routes/api/public.py b/app/modules/loyalty/routes/api/public.py new file mode 100644 index 00000000..4ca61f0b --- /dev/null +++ b/app/modules/loyalty/routes/api/public.py @@ -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) diff --git a/app/modules/loyalty/routes/api/vendor.py b/app/modules/loyalty/routes/api/vendor.py new file mode 100644 index 00000000..4d32968f --- /dev/null +++ b/app/modules/loyalty/routes/api/vendor.py @@ -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) diff --git a/app/modules/loyalty/routes/pages/__init__.py b/app/modules/loyalty/routes/pages/__init__.py new file mode 100644 index 00000000..8dd7186f --- /dev/null +++ b/app/modules/loyalty/routes/pages/__init__.py @@ -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] = [] diff --git a/app/modules/loyalty/schemas/__init__.py b/app/modules/loyalty/schemas/__init__.py new file mode 100644 index 00000000..d6634630 --- /dev/null +++ b/app/modules/loyalty/schemas/__init__.py @@ -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", +] diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py new file mode 100644 index 00000000..baa31aed --- /dev/null +++ b/app/modules/loyalty/schemas/card.py @@ -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 diff --git a/app/modules/loyalty/schemas/pin.py b/app/modules/loyalty/schemas/pin.py new file mode 100644 index 00000000..6b612e09 --- /dev/null +++ b/app/modules/loyalty/schemas/pin.py @@ -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 diff --git a/app/modules/loyalty/schemas/points.py b/app/modules/loyalty/schemas/points.py new file mode 100644 index 00000000..ff153b74 --- /dev/null +++ b/app/modules/loyalty/schemas/points.py @@ -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 diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py new file mode 100644 index 00000000..c621772a --- /dev/null +++ b/app/modules/loyalty/schemas/program.py @@ -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 diff --git a/app/modules/loyalty/schemas/stamp.py b/app/modules/loyalty/schemas/stamp.py new file mode 100644 index 00000000..c7733393 --- /dev/null +++ b/app/modules/loyalty/schemas/stamp.py @@ -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 diff --git a/app/modules/loyalty/services/__init__.py b/app/modules/loyalty/services/__init__.py new file mode 100644 index 00000000..2af5c3f2 --- /dev/null +++ b/app/modules/loyalty/services/__init__.py @@ -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", +] diff --git a/app/modules/loyalty/services/apple_wallet_service.py b/app/modules/loyalty/services/apple_wallet_service.py new file mode 100644 index 00000000..bdd47f50 --- /dev/null +++ b/app/modules/loyalty/services/apple_wallet_service.py @@ -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() diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py new file mode 100644 index 00000000..d3301f13 --- /dev/null +++ b/app/modules/loyalty/services/card_service.py @@ -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() diff --git a/app/modules/loyalty/services/google_wallet_service.py b/app/modules/loyalty/services/google_wallet_service.py new file mode 100644 index 00000000..e2c0df59 --- /dev/null +++ b/app/modules/loyalty/services/google_wallet_service.py @@ -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() diff --git a/app/modules/loyalty/services/pin_service.py b/app/modules/loyalty/services/pin_service.py new file mode 100644 index 00000000..8c984938 --- /dev/null +++ b/app/modules/loyalty/services/pin_service.py @@ -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() diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py new file mode 100644 index 00000000..837d73f6 --- /dev/null +++ b/app/modules/loyalty/services/points_service.py @@ -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() diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py new file mode 100644 index 00000000..f2ee3d64 --- /dev/null +++ b/app/modules/loyalty/services/program_service.py @@ -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() diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py new file mode 100644 index 00000000..9251d821 --- /dev/null +++ b/app/modules/loyalty/services/stamp_service.py @@ -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() diff --git a/app/modules/loyalty/services/wallet_service.py b/app/modules/loyalty/services/wallet_service.py new file mode 100644 index 00000000..e9093bad --- /dev/null +++ b/app/modules/loyalty/services/wallet_service.py @@ -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() diff --git a/app/modules/loyalty/tasks/__init__.py b/app/modules/loyalty/tasks/__init__.py new file mode 100644 index 00000000..cd4980dc --- /dev/null +++ b/app/modules/loyalty/tasks/__init__.py @@ -0,0 +1,10 @@ +# app/modules/loyalty/tasks/__init__.py +""" +Loyalty module Celery tasks. + +Background tasks for: +- Point expiration +- Wallet synchronization +""" + +__all__: list[str] = [] diff --git a/app/modules/loyalty/tasks/point_expiration.py b/app/modules/loyalty/tasks/point_expiration.py new file mode 100644 index 00000000..fba3bb52 --- /dev/null +++ b/app/modules/loyalty/tasks/point_expiration.py @@ -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, + } diff --git a/app/modules/loyalty/tasks/wallet_sync.py b/app/modules/loyalty/tasks/wallet_sync.py new file mode 100644 index 00000000..9f4086c9 --- /dev/null +++ b/app/modules/loyalty/tasks/wallet_sync.py @@ -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() diff --git a/docs/modules/loyalty.md b/docs/modules/loyalty.md new file mode 100644 index 00000000..1d8ba34b --- /dev/null +++ b/docs/modules/loyalty.md @@ -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 diff --git a/docs/proposals/loyalty-phase2-interfaces-plan.md b/docs/proposals/loyalty-phase2-interfaces-plan.md new file mode 100644 index 00000000..6c3dd0a9 --- /dev/null +++ b/docs/proposals/loyalty-phase2-interfaces-plan.md @@ -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* diff --git a/models/database/vendor.py b/models/database/vendor.py index 03a7937e..a4c1ab43 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -252,6 +252,14 @@ class Vendor(Base, TimestampMixin): 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): """String representation of the Vendor object.""" return f""