feat(loyalty): implement Phase 2 - company-wide points system

Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 22:10:27 +01:00
parent 3bdf1695fd
commit d8f3338bc8
54 changed files with 7252 additions and 186 deletions

View File

@@ -2,6 +2,11 @@
"""
Staff PIN database model.
Company-based staff PINs:
- PINs belong to a company's loyalty program
- Each vendor (location) has its own set of staff PINs
- Staff can only use PINs at their assigned location
Provides fraud prevention by requiring staff to authenticate
before performing stamp/points operations. Includes:
- Secure PIN hashing with bcrypt
@@ -34,13 +39,25 @@ class StaffPin(Base, TimestampMixin):
Each staff member can have their own PIN to authenticate
stamp/points operations. PINs are hashed with bcrypt and
include lockout protection.
PINs are scoped to a specific vendor (location) within the
company's loyalty program.
"""
__tablename__ = "staff_pins"
id = Column(Integer, primary_key=True, index=True)
# Relationships
# Company association
company_id = Column(
Integer,
ForeignKey("companies.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Company that owns the loyalty program",
)
# Program and vendor relationships
program_id = Column(
Integer,
ForeignKey("loyalty_programs.id", ondelete="CASCADE"),
@@ -52,7 +69,7 @@ class StaffPin(Base, TimestampMixin):
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="Denormalized for query performance",
comment="Vendor (location) where this staff member works",
)
# Staff identity
@@ -104,17 +121,19 @@ class StaffPin(Base, TimestampMixin):
# =========================================================================
# Relationships
# =========================================================================
company = relationship("Company", backref="staff_pins")
program = relationship("LoyaltyProgram", back_populates="staff_pins")
vendor = relationship("Vendor", backref="staff_pins")
# Indexes
__table_args__ = (
Index("idx_staff_pin_company_active", "company_id", "is_active"),
Index("idx_staff_pin_vendor_active", "vendor_id", "is_active"),
Index("idx_staff_pin_program_active", "program_id", "is_active"),
)
def __repr__(self) -> str:
return f"<StaffPin(id={self.id}, name='{self.name}', active={self.is_active})>"
return f"<StaffPin(id={self.id}, name='{self.name}', vendor_id={self.vendor_id}, active={self.is_active})>"
# =========================================================================
# PIN Operations