feat(loyalty): pair POS terminal devices with one-time setup QR
Some checks failed
CI / ruff (push) Successful in 47s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Adds the backend half of the Android tablet rollout. Merchants can
pair tablets to specific stores from /merchants/loyalty/devices (or
admins can pair on behalf from the merchant detail page). Each
pairing issues a long-lived JWT shown ONCE in the response with a
server-rendered QR PNG containing {api_url, store_code, auth_token} —
the tablet scans it on first boot and persists the three fields.

The store API (/api/v1/store/loyalty/*) now accepts these device JWTs
alongside user JWTs. Revoking a device row immediately rejects its
token (401 TERMINAL_DEVICE_REVOKED). Tokens expire after 1 year;
re-pair to renew.

- Migration loyalty_010 + TerminalDevice model
- create_device_token / verify_device_token JWT helpers
- 5 endpoints x 2 portals (merchant + admin on-behalf)
- Bearer-auth wiring in app/api/deps.py
- Pages, shared list partial with one-time pairing-QR modal,
  Alpine.js factories
- Locale strings (en authoritative; fr/de/lb seeded with EN copy
  for translation)
- 6 integration tests covering pair, list, revoke, idempotency,
  cross-merchant rejection, store-API auth via device JWT

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:18:57 +02:00
parent c7ab5eb900
commit 6276e9e3ac
28 changed files with 1971 additions and 7 deletions

View File

@@ -750,7 +750,68 @@ def get_current_store_from_cookie_or_header(
return UserContext.from_user(user)
def _try_authenticate_terminal_device(
token: str, db: Session, request: Request | None
) -> UserContext | None:
"""Validate a paired-terminal device JWT and return a synthetic store context.
Returns ``None`` for any token that is not a device token (caller falls
through to the user-token path). Raises if the token *is* a device token
but the underlying device is unknown, revoked, or expired.
The principal of record is the user who paired the device (``created_by_id``).
Audit logs see that user's id; ``UserContext.terminal_device_id`` carries
the device id so callers that care can distinguish.
"""
from app.modules.loyalty.exceptions import (
TerminalDeviceExpiredException,
TerminalDeviceNotFoundException,
TerminalDeviceRevokedException,
)
from app.modules.loyalty.services import terminal_device_service
from middleware.auth import AuthManager
auth = AuthManager()
try:
payload = auth.verify_device_token(token)
except InvalidTokenException as e:
# Not a device token — fall through to the user-token path.
if "Not a device token" in str(e):
return None
raise
jti = payload["jti"]
device = terminal_device_service.get_device_by_jti(db, jti)
if not device:
raise TerminalDeviceNotFoundException(jti)
if device.is_revoked:
raise TerminalDeviceRevokedException(device.id)
if device.is_expired:
raise TerminalDeviceExpiredException(device.id)
if not device.created_by_id:
raise TerminalDeviceRevokedException(device.id)
user = (
db.query(UserModel).filter(UserModel.id == device.created_by_id).first()
)
if not user or not user.is_active:
raise TerminalDeviceRevokedException(device.id)
client_ip = (
request.client.host if request is not None and request.client else None
)
terminal_device_service.touch_last_seen(db, device, ip=client_ip)
user.token_store_id = device.store_id
user.token_store_code = payload.get("store_code")
user.token_store_role = "store_member"
user.terminal_device_id = device.id
return UserContext.from_user(user)
def get_current_store_api(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> UserContext:
@@ -762,6 +823,10 @@ def get_current_store_api(
1. Token contains store context (token_store_id)
2. User still has access to the store specified in the token
Also accepts paired terminal-device tokens (``device_setup`` claim): the
device row is the source of truth for revocation, and the principal of
record is the user who paired it.
Args:
credentials: Bearer token from Authorization header
db: Database session
@@ -776,6 +841,12 @@ def get_current_store_api(
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
device_ctx = _try_authenticate_terminal_device(
credentials.credentials, db, request
)
if device_ctx is not None:
return device_ctx
user = _validate_user_token(credentials.credentials, db)
# Block admins from store API

View File

@@ -149,6 +149,7 @@ loyalty_module = ModuleDefinition(
"analytics", # Merchant loyalty analytics
"transactions", # Transaction feed
"pins", # Staff PINs
"devices", # Terminal devices (POS tablets)
"settings", # Settings (read-only)
],
},
@@ -278,6 +279,13 @@ loyalty_module = ModuleDefinition(
route="/merchants/loyalty/pins",
order=30,
),
MenuItemDefinition(
id="devices",
label_key="loyalty.menu.terminal_devices",
icon="device-tablet",
route="/merchants/loyalty/devices",
order=32,
),
MenuItemDefinition(
id="settings",
label_key="loyalty.menu.settings",

View File

@@ -331,6 +331,42 @@ class OrderReferenceRequiredException(LoyaltyException):
)
# =============================================================================
# Terminal Device Exceptions
# =============================================================================
class TerminalDeviceNotFoundException(ResourceNotFoundException):
"""Raised when a terminal device is not found."""
def __init__(self, identifier: str):
super().__init__("TerminalDevice", identifier)
class TerminalDeviceRevokedException(LoyaltyException):
"""Raised when a request presents a revoked terminal device token."""
def __init__(self, device_id: int):
super().__init__(
message="Terminal device has been revoked",
error_code="TERMINAL_DEVICE_REVOKED",
details={"device_id": device_id},
)
self.status_code = 401
class TerminalDeviceExpiredException(LoyaltyException):
"""Raised when a request presents an expired terminal device token."""
def __init__(self, device_id: int):
super().__init__(
message="Terminal device token has expired — re-pair the tablet",
error_code="TERMINAL_DEVICE_EXPIRED",
details={"device_id": device_id},
)
self.status_code = 401
# =============================================================================
# Validation Exceptions
# =============================================================================
@@ -385,6 +421,10 @@ __all__ = [
"CustomerIdentifierRequiredException",
# Order
"OrderReferenceRequiredException",
# Terminal Device
"TerminalDeviceNotFoundException",
"TerminalDeviceRevokedException",
"TerminalDeviceExpiredException",
# Validation
"LoyaltyValidationException",
]

View File

@@ -83,7 +83,8 @@
"settings": "Einstellungen",
"wallet_debug": "Wallet Debug",
"staff_pins": "Mitarbeiter-PINs",
"transactions": "Transaktionen"
"transactions": "Transaktionen",
"terminal_devices": "Terminal Devices"
},
"permissions": {
"view_programs": "Programme anzeigen",
@@ -910,6 +911,51 @@
"pin_delete_error": "PIN konnte nicht gelöscht werden",
"pin_unlock_error": "PIN konnte nicht entsperrt werden"
},
"terminal_devices": {
"title": "Terminal Devices",
"subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.",
"admin_subtitle": "Pair POS tablets on behalf of this merchant.",
"loading": "Loading devices...",
"error_loading": "Failed to load devices",
"no_devices": "No devices paired yet",
"show_revoked": "Include revoked",
"pair_device": "Pair Tablet",
"pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.",
"pairing_qr": "Tablet Pairing QR",
"qr_warning_title": "Save this now —",
"qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.",
"qr_done": "I've paired the tablet",
"show_payload": "Show raw payload",
"label_field": "Device label",
"label_placeholder": "e.g. Counter 1",
"store_field": "Store",
"select_store": "Select store...",
"revoke": "Revoke",
"revoke_title": "Revoke device?",
"confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.",
"delete_title": "Delete device?",
"confirm_delete": "Permanently delete device '{label}'? This cannot be undone.",
"col_label": "Label",
"col_store": "Store",
"col_status": "Status",
"col_last_seen": "Last seen",
"col_expires": "Expires",
"col_actions": "Actions",
"status_active": "Active",
"status_revoked": "Revoked",
"status_expired": "Expired",
"toasts": {
"paired": "Tablet paired — show the QR to the device",
"revoked": "Device revoked",
"deleted": "Device deleted"
},
"errors": {
"store_required": "Please select a store",
"pair_failed": "Failed to pair device",
"revoke_failed": "Failed to revoke device",
"delete_failed": "Failed to delete device"
}
},
"errors": {
"card_not_found": "Karte nicht gefunden"
}

View File

@@ -93,7 +93,8 @@
"settings": "Settings",
"wallet_debug": "Wallet Debug",
"staff_pins": "Staff PINs",
"transactions": "Transactions"
"transactions": "Transactions",
"terminal_devices": "Terminal Devices"
},
"onboarding": {
"create_program": {
@@ -928,6 +929,51 @@
"pin_delete_error": "Failed to delete PIN",
"pin_unlock_error": "Failed to unlock PIN"
},
"terminal_devices": {
"title": "Terminal Devices",
"subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.",
"admin_subtitle": "Pair POS tablets on behalf of this merchant.",
"loading": "Loading devices...",
"error_loading": "Failed to load devices",
"no_devices": "No devices paired yet",
"show_revoked": "Include revoked",
"pair_device": "Pair Tablet",
"pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.",
"pairing_qr": "Tablet Pairing QR",
"qr_warning_title": "Save this now —",
"qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.",
"qr_done": "I've paired the tablet",
"show_payload": "Show raw payload",
"label_field": "Device label",
"label_placeholder": "e.g. Counter 1",
"store_field": "Store",
"select_store": "Select store...",
"revoke": "Revoke",
"revoke_title": "Revoke device?",
"confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.",
"delete_title": "Delete device?",
"confirm_delete": "Permanently delete device '{label}'? This cannot be undone.",
"col_label": "Label",
"col_store": "Store",
"col_status": "Status",
"col_last_seen": "Last seen",
"col_expires": "Expires",
"col_actions": "Actions",
"status_active": "Active",
"status_revoked": "Revoked",
"status_expired": "Expired",
"toasts": {
"paired": "Tablet paired — show the QR to the device",
"revoked": "Device revoked",
"deleted": "Device deleted"
},
"errors": {
"store_required": "Please select a store",
"pair_failed": "Failed to pair device",
"revoke_failed": "Failed to revoke device",
"delete_failed": "Failed to delete device"
}
},
"errors": {
"card_not_found": "Card not found"
}

View File

@@ -83,7 +83,8 @@
"settings": "Paramètres",
"wallet_debug": "Wallet Debug",
"staff_pins": "PINs du personnel",
"transactions": "Transactions"
"transactions": "Transactions",
"terminal_devices": "Terminal Devices"
},
"permissions": {
"view_programs": "Voir les programmes",
@@ -910,6 +911,51 @@
"pin_delete_error": "Erreur lors de la suppression du PIN",
"pin_unlock_error": "Erreur lors du déverrouillage du PIN"
},
"terminal_devices": {
"title": "Terminal Devices",
"subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.",
"admin_subtitle": "Pair POS tablets on behalf of this merchant.",
"loading": "Loading devices...",
"error_loading": "Failed to load devices",
"no_devices": "No devices paired yet",
"show_revoked": "Include revoked",
"pair_device": "Pair Tablet",
"pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.",
"pairing_qr": "Tablet Pairing QR",
"qr_warning_title": "Save this now —",
"qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.",
"qr_done": "I've paired the tablet",
"show_payload": "Show raw payload",
"label_field": "Device label",
"label_placeholder": "e.g. Counter 1",
"store_field": "Store",
"select_store": "Select store...",
"revoke": "Revoke",
"revoke_title": "Revoke device?",
"confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.",
"delete_title": "Delete device?",
"confirm_delete": "Permanently delete device '{label}'? This cannot be undone.",
"col_label": "Label",
"col_store": "Store",
"col_status": "Status",
"col_last_seen": "Last seen",
"col_expires": "Expires",
"col_actions": "Actions",
"status_active": "Active",
"status_revoked": "Revoked",
"status_expired": "Expired",
"toasts": {
"paired": "Tablet paired — show the QR to the device",
"revoked": "Device revoked",
"deleted": "Device deleted"
},
"errors": {
"store_required": "Please select a store",
"pair_failed": "Failed to pair device",
"revoke_failed": "Failed to revoke device",
"delete_failed": "Failed to delete device"
}
},
"errors": {
"card_not_found": "Carte non trouvée"
}

View File

@@ -83,7 +83,8 @@
"settings": "Astellungen",
"wallet_debug": "Wallet Debug",
"staff_pins": "Mataarbechter-PINen",
"transactions": "Transaktionen"
"transactions": "Transaktionen",
"terminal_devices": "Terminal Devices"
},
"permissions": {
"view_programs": "Programmer kucken",
@@ -910,6 +911,51 @@
"pin_delete_error": "PIN konnt net geläscht ginn",
"pin_unlock_error": "PIN konnt net entspäert ginn"
},
"terminal_devices": {
"title": "Terminal Devices",
"subtitle": "Pair POS tablets to each store. The setup QR is shown only once at pairing time.",
"admin_subtitle": "Pair POS tablets on behalf of this merchant.",
"loading": "Loading devices...",
"error_loading": "Failed to load devices",
"no_devices": "No devices paired yet",
"show_revoked": "Include revoked",
"pair_device": "Pair Tablet",
"pair_hint": "A long-lived setup QR is generated. The tablet scans it once and connects from then on.",
"pairing_qr": "Tablet Pairing QR",
"qr_warning_title": "Save this now —",
"qr_warning_body": "this QR is shown only this time. If you close it before pairing, you will need to revoke and pair again.",
"qr_done": "I've paired the tablet",
"show_payload": "Show raw payload",
"label_field": "Device label",
"label_placeholder": "e.g. Counter 1",
"store_field": "Store",
"select_store": "Select store...",
"revoke": "Revoke",
"revoke_title": "Revoke device?",
"confirm_revoke": "Revoke device '{label}'? Its tablet will stop working immediately.",
"delete_title": "Delete device?",
"confirm_delete": "Permanently delete device '{label}'? This cannot be undone.",
"col_label": "Label",
"col_store": "Store",
"col_status": "Status",
"col_last_seen": "Last seen",
"col_expires": "Expires",
"col_actions": "Actions",
"status_active": "Active",
"status_revoked": "Revoked",
"status_expired": "Expired",
"toasts": {
"paired": "Tablet paired — show the QR to the device",
"revoked": "Device revoked",
"deleted": "Device deleted"
},
"errors": {
"store_required": "Please select a store",
"pair_failed": "Failed to pair device",
"revoke_failed": "Failed to revoke device",
"delete_failed": "Failed to delete device"
}
},
"errors": {
"card_not_found": "Kaart net fonnt"
}

View File

@@ -0,0 +1,123 @@
"""loyalty 010 - add terminal_devices table
Pairs Android (or other) POS tablets to a single store. Stores only the
JWT id (`jti`); the token itself is shown once at pairing and never
persisted. Multiple devices per store are allowed.
Revision ID: loyalty_010
Revises: loyalty_009
Create Date: 2026-05-04
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_010"
down_revision = "loyalty_009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"loyalty_terminal_devices",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"merchant_id",
sa.Integer(),
sa.ForeignKey("merchants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"store_id",
sa.Integer(),
sa.ForeignKey("stores.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("label", sa.String(length=100), nullable=False),
sa.Column("jti", sa.String(length=36), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_seen_ip", sa.String(length=45), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"revoked_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("revoked_reason", sa.String(length=255), nullable=True),
sa.Column(
"created_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint(
"expires_at > created_at",
name="ck_loyalty_terminal_devices_expiry_after_creation",
),
)
op.create_index(
"ix_loyalty_terminal_devices_merchant_id",
"loyalty_terminal_devices",
["merchant_id"],
)
op.create_index(
"ix_loyalty_terminal_devices_store_id",
"loyalty_terminal_devices",
["store_id"],
)
op.create_index(
"ix_loyalty_terminal_devices_revoked_at",
"loyalty_terminal_devices",
["revoked_at"],
)
op.create_index(
"ix_loyalty_terminal_devices_jti",
"loyalty_terminal_devices",
["jti"],
unique=True,
)
op.create_index(
"idx_loyalty_terminal_devices_store_active",
"loyalty_terminal_devices",
["store_id", "revoked_at"],
)
def downgrade() -> None:
op.drop_index(
"idx_loyalty_terminal_devices_store_active",
table_name="loyalty_terminal_devices",
)
op.drop_index(
"ix_loyalty_terminal_devices_jti",
table_name="loyalty_terminal_devices",
)
op.drop_index(
"ix_loyalty_terminal_devices_revoked_at",
table_name="loyalty_terminal_devices",
)
op.drop_index(
"ix_loyalty_terminal_devices_store_id",
table_name="loyalty_terminal_devices",
)
op.drop_index(
"ix_loyalty_terminal_devices_merchant_id",
table_name="loyalty_terminal_devices",
)
op.drop_table("loyalty_terminal_devices")

View File

@@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import (
# Model
StaffPin,
)
from app.modules.loyalty.models.terminal_device import (
# Model
TerminalDevice,
)
from app.modules.loyalty.models.transaction_category import (
# Model
StoreTransactionCategory,
@@ -67,4 +71,5 @@ __all__ = [
"AppleDeviceRegistration",
"MerchantLoyaltySettings",
"StoreTransactionCategory",
"TerminalDevice",
]

View File

@@ -0,0 +1,127 @@
# app/modules/loyalty/models/terminal_device.py
"""
Terminal device model.
A TerminalDevice represents a paired Android (or other) POS tablet bound
to a single store. Each row owns the unique `jti` that we encode in the
device's long-lived JWT — that's the only handle we keep on the issued
token, since the JWT itself is shown once at pairing time and then never
stored. Two states render a device's token unusable: explicit `revoked_at`
(merchant action, row stays for audit) or `expires_at < now` (1y default).
Multiple devices per store are allowed — a store can have several cashiers
each with their own tablet — so there is no unique constraint across
(store_id), only an index for lookups.
"""
from datetime import UTC, datetime
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class TerminalDevice(Base, TimestampMixin):
"""A paired POS terminal device (typically an Android tablet)."""
__tablename__ = "loyalty_terminal_devices"
id = Column(Integer, primary_key=True, index=True)
merchant_id = Column(
Integer,
ForeignKey("merchants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
store_id = Column(
Integer,
ForeignKey("stores.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
label = Column(
String(100),
nullable=False,
comment="Merchant-facing device name e.g. 'Counter 1'",
)
# The only handle we keep on the issued JWT. The token itself is shown
# once at creation and never stored.
jti = Column(String(36), nullable=False, unique=True, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
last_seen_ip = Column(String(45), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True, index=True)
revoked_by_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
revoked_reason = Column(String(255), nullable=True)
# The user who paired the device. Used as the principal of record when
# the device performs operations on the store API.
created_by_id = Column(
Integer,
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
merchant = relationship("Merchant", backref="terminal_devices")
store = relationship("Store", backref="terminal_devices")
revoked_by = relationship("User", foreign_keys=[revoked_by_id])
created_by = relationship("User", foreign_keys=[created_by_id])
__table_args__ = (
CheckConstraint(
"expires_at > created_at",
name="ck_loyalty_terminal_devices_expiry_after_creation",
),
Index(
"idx_loyalty_terminal_devices_store_active",
"store_id",
"revoked_at",
),
)
def __repr__(self) -> str:
return (
f"<TerminalDevice(id={self.id}, label='{self.label}', "
f"store_id={self.store_id}, revoked={self.revoked_at is not None})>"
)
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
@property
def is_expired(self) -> bool:
if not self.expires_at:
return False
expiry = self.expires_at
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=UTC)
return datetime.now(UTC) >= expiry
@property
def status(self) -> str:
"""One of: active, revoked, expired."""
if self.is_revoked:
return "revoked"
if self.is_expired:
return "expired"
return "active"

View File

@@ -35,10 +35,21 @@ from app.modules.loyalty.schemas import (
ProgramResponse,
ProgramStatsResponse,
ProgramUpdate,
TerminalDeviceCreate,
TerminalDeviceListResponse,
TerminalDevicePairingResponse,
TerminalDeviceResponse,
TerminalDeviceRevoke,
TerminalDeviceUpdate,
TransactionListResponse,
TransactionResponse,
)
from app.modules.loyalty.services import card_service, pin_service, program_service
from app.modules.loyalty.services import (
card_service,
pin_service,
program_service,
terminal_device_service,
)
from app.modules.loyalty.services.analytics_service import analytics_service
from app.modules.tenancy.models import User # API-007
@@ -1035,3 +1046,130 @@ def restore_deleted_merchant_data(
"cards_restored": cards_restored,
"message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)",
}
# =============================================================================
# Terminal Devices (admin acts on behalf of merchant)
# =============================================================================
@router.get(
"/merchants/{merchant_id}/devices",
response_model=TerminalDeviceListResponse,
)
def admin_list_terminal_devices(
merchant_id: int = Path(..., gt=0),
store_id: int | None = Query(None, gt=0),
include_revoked: bool = Query(False),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List paired POS terminal devices for a merchant."""
devices = terminal_device_service.list_devices(
db, merchant_id, store_id=store_id, include_revoked=include_revoked
)
return TerminalDeviceListResponse(
devices=[
TerminalDeviceResponse(**terminal_device_service.build_response(d))
for d in devices
],
total=len(devices),
)
@router.post(
"/merchants/{merchant_id}/devices",
response_model=TerminalDevicePairingResponse,
status_code=201,
)
def admin_pair_terminal_device(
data: TerminalDeviceCreate,
merchant_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Pair a terminal device on behalf of a merchant. Returns a one-time QR."""
device, token, payload, qr_png = terminal_device_service.create_device(
db,
merchant_id=merchant_id,
data=data,
created_by_user_id=current_user.id,
)
logger.info(
f"Admin {current_user.id} paired terminal device {device.id} "
f"for merchant {merchant_id}"
)
return TerminalDevicePairingResponse(
**terminal_device_service.build_response(device),
setup_token=token,
setup_payload=payload,
qr_png_base64=qr_png,
)
@router.patch(
"/merchants/{merchant_id}/devices/{device_id}",
response_model=TerminalDeviceResponse,
)
def admin_update_terminal_device(
data: TerminalDeviceUpdate,
merchant_id: int = Path(..., gt=0),
device_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Rename a paired device on behalf of a merchant."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant_id
)
device = terminal_device_service.update_device(db, device, data)
return TerminalDeviceResponse(**terminal_device_service.build_response(device))
@router.post(
"/merchants/{merchant_id}/devices/{device_id}/revoke",
response_model=TerminalDeviceResponse,
)
def admin_revoke_terminal_device(
data: TerminalDeviceRevoke,
merchant_id: int = Path(..., gt=0),
device_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Revoke a paired device's token. The row stays for audit."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant_id
)
device = terminal_device_service.revoke_device(
db,
device,
revoked_by_user_id=current_user.id,
reason=data.reason,
)
logger.info(
f"Admin {current_user.id} revoked terminal device {device_id} "
f"for merchant {merchant_id}"
)
return TerminalDeviceResponse(**terminal_device_service.build_response(device))
@router.delete(
"/merchants/{merchant_id}/devices/{device_id}",
status_code=204,
)
def admin_delete_terminal_device(
merchant_id: int = Path(..., gt=0),
device_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Permanently delete a paired device row."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant_id
)
terminal_device_service.delete_device(db, device)
logger.info(
f"Admin {current_user.id} deleted terminal device {device_id} "
f"for merchant {merchant_id}"
)

View File

@@ -26,7 +26,7 @@ import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_merchant_for_current_user
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
from app.core.database import get_db
from app.modules.loyalty.exceptions import (
LoyaltyCardNotFoundException,
@@ -45,12 +45,24 @@ from app.modules.loyalty.schemas import (
ProgramCreate,
ProgramResponse,
ProgramUpdate,
TerminalDeviceCreate,
TerminalDeviceListResponse,
TerminalDevicePairingResponse,
TerminalDeviceResponse,
TerminalDeviceRevoke,
TerminalDeviceUpdate,
TransactionListResponse,
TransactionResponse,
)
from app.modules.loyalty.schemas.program import MerchantStatsResponse
from app.modules.loyalty.services import card_service, pin_service, program_service
from app.modules.loyalty.services import (
card_service,
pin_service,
program_service,
terminal_device_service,
)
from app.modules.tenancy.models import Merchant
from app.modules.tenancy.schemas.auth import UserContext
logger = logging.getLogger(__name__)
@@ -397,6 +409,117 @@ def get_settings(
return MerchantSettingsResponse.model_validate(settings)
# =============================================================================
# Terminal Devices
# =============================================================================
@router.get("/devices", response_model=TerminalDeviceListResponse)
def list_terminal_devices(
store_id: int | None = Query(None, gt=0),
include_revoked: bool = Query(False),
merchant: Merchant = Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""List paired POS terminal devices for the merchant."""
devices = terminal_device_service.list_devices(
db,
merchant.id,
store_id=store_id,
include_revoked=include_revoked,
)
return TerminalDeviceListResponse(
devices=[
TerminalDeviceResponse(**terminal_device_service.build_response(d))
for d in devices
],
total=len(devices),
)
@router.post(
"/devices",
response_model=TerminalDevicePairingResponse,
status_code=201,
)
def pair_terminal_device(
data: TerminalDeviceCreate,
merchant: Merchant = Depends(get_merchant_for_current_user),
current_user: UserContext = Depends(get_current_merchant_api),
db: Session = Depends(get_db),
):
"""Pair a new POS tablet. Returns a one-time setup token + QR PNG.
The setup token is shown only in this response — the server keeps only
the device's `jti` for revocation. If the merchant loses the QR they
must revoke the device and pair again.
"""
device, token, payload, qr_png = terminal_device_service.create_device(
db,
merchant_id=merchant.id,
data=data,
created_by_user_id=current_user.id,
)
return TerminalDevicePairingResponse(
**terminal_device_service.build_response(device),
setup_token=token,
setup_payload=payload,
qr_png_base64=qr_png,
)
@router.patch("/devices/{device_id}", response_model=TerminalDeviceResponse)
def update_terminal_device(
data: TerminalDeviceUpdate,
device_id: int = Path(..., gt=0),
merchant: Merchant = Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Rename a paired device (label only — the token is unaffected)."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant.id
)
device = terminal_device_service.update_device(db, device, data)
return TerminalDeviceResponse(**terminal_device_service.build_response(device))
@router.post(
"/devices/{device_id}/revoke",
response_model=TerminalDeviceResponse,
)
def revoke_terminal_device(
data: TerminalDeviceRevoke,
device_id: int = Path(..., gt=0),
merchant: Merchant = Depends(get_merchant_for_current_user),
current_user: UserContext = Depends(get_current_merchant_api),
db: Session = Depends(get_db),
):
"""Revoke a paired device's token. The row stays for audit."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant.id
)
device = terminal_device_service.revoke_device(
db,
device,
revoked_by_user_id=current_user.id,
reason=data.reason,
)
return TerminalDeviceResponse(**terminal_device_service.build_response(device))
@router.delete("/devices/{device_id}", status_code=204)
def delete_terminal_device(
device_id: int = Path(..., gt=0),
merchant: Merchant = Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""Permanently delete a paired device row (revokes implicitly)."""
device = terminal_device_service.require_device_for_merchant(
db, device_id, merchant.id
)
terminal_device_service.delete_device(db, device)
# =============================================================================
# Locations
# =============================================================================

View File

@@ -238,3 +238,28 @@ async def admin_loyalty_merchant_settings(
"loyalty/admin/merchant-settings.html",
get_admin_context(request, db, current_user, merchant_id=merchant_id),
)
@router.get(
"/merchants/{merchant_id}/devices",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_loyalty_merchant_devices(
request: Request,
merchant_id: int = Path(..., description="Merchant ID"),
current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""List + pair POS terminal devices on behalf of a merchant."""
merchant_name = _get_merchant_name(db, merchant_id)
return templates.TemplateResponse(
"loyalty/admin/merchant-devices.html",
get_admin_context(
request,
db,
current_user,
merchant_id=merchant_id,
merchant_name=merchant_name,
),
)

View File

@@ -279,3 +279,28 @@ async def merchant_loyalty_settings(
"loyalty/merchant/settings.html",
context,
)
# ============================================================================
# TERMINAL DEVICES (POS tablet pairing)
# ============================================================================
@router.get("/devices", response_class=HTMLResponse, include_in_schema=False)
async def merchant_loyalty_devices(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
merchant: Merchant = Depends(get_merchant_for_current_user_page),
db: Session = Depends(get_db),
):
"""List + pair POS terminal devices for the merchant."""
context = _get_merchant_context(
request,
db,
current_user,
merchant_id=merchant.id,
)
return templates.TemplateResponse(
"loyalty/merchant/devices.html",
context,
)

View File

@@ -83,6 +83,15 @@ from app.modules.loyalty.schemas.stamp import (
StampVoidRequest,
StampVoidResponse,
)
from app.modules.loyalty.schemas.terminal_device import (
# Terminal device pairing & management
TerminalDeviceCreate,
TerminalDeviceListResponse,
TerminalDevicePairingResponse,
TerminalDeviceResponse,
TerminalDeviceRevoke,
TerminalDeviceUpdate,
)
__all__ = [
# Program
@@ -129,4 +138,11 @@ __all__ = [
"PinDetailListResponse",
"PinVerifyRequest",
"PinVerifyResponse",
# Terminal device
"TerminalDeviceCreate",
"TerminalDeviceUpdate",
"TerminalDeviceRevoke",
"TerminalDeviceResponse",
"TerminalDevicePairingResponse",
"TerminalDeviceListResponse",
]

View File

@@ -0,0 +1,72 @@
# app/modules/loyalty/schemas/terminal_device.py
"""Pydantic schemas for terminal device pairing & management."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class TerminalDeviceCreate(BaseModel):
"""Pair a new terminal device. Issues a long-lived JWT shown once."""
store_id: int = Field(..., gt=0, description="Store the device is bound to")
label: str = Field(
...,
min_length=1,
max_length=100,
description="Merchant-facing name e.g. 'Counter 1'",
)
class TerminalDeviceUpdate(BaseModel):
"""Update a paired device's label. Token is not affected."""
model_config = ConfigDict(from_attributes=True)
label: str | None = Field(None, min_length=1, max_length=100)
class TerminalDeviceRevoke(BaseModel):
"""Revoke a device's token, optionally with a reason for the audit log."""
reason: str | None = Field(None, max_length=255)
class TerminalDeviceResponse(BaseModel):
"""Read view of a paired device. Never includes the JWT."""
model_config = ConfigDict(from_attributes=True)
id: int
merchant_id: int
store_id: int
store_name: str | None = None
label: str
status: str # active | revoked | expired
expires_at: datetime
last_seen_at: datetime | None = None
last_seen_ip: str | None = None
revoked_at: datetime | None = None
revoked_by_id: int | None = None
revoked_reason: str | None = None
created_by_id: int | None = None
created_at: datetime
class TerminalDevicePairingResponse(TerminalDeviceResponse):
"""Returned by POST only. Carries the one-time setup token + QR PNG."""
setup_token: str = Field(..., description="The signed JWT — shown once")
setup_payload: dict = Field(
...,
description="Decoded {api_url, store_code, auth_token} payload encoded in the QR",
)
qr_png_base64: str = Field(
...,
description="Base64-encoded PNG of the setup QR (data:image/png;base64,...)",
)
class TerminalDeviceListResponse(BaseModel):
devices: list[TerminalDeviceResponse]
total: int

View File

@@ -34,6 +34,10 @@ from app.modules.loyalty.services.stamp_service import (
StampService,
stamp_service,
)
from app.modules.loyalty.services.terminal_device_service import (
TerminalDeviceService,
terminal_device_service,
)
from app.modules.loyalty.services.wallet_service import (
WalletService,
wallet_service,
@@ -56,4 +60,6 @@ __all__ = [
"google_wallet_service",
"AppleWalletService",
"apple_wallet_service",
"TerminalDeviceService",
"terminal_device_service",
]

View File

@@ -0,0 +1,255 @@
# app/modules/loyalty/services/terminal_device_service.py
"""Terminal device service.
Pairs Android (or other) POS tablets to a single store. Issues a long-lived
JWT at pair time — the token itself is shown once and never persisted; only
the `jti` lives on the device row, which remains the source of truth for
revocation.
Two failure modes render a token unusable:
- Explicit `revoked_at` (merchant action, row stays for audit)
- `expires_at < now` (1y default, no auto-renewal — re-pair the tablet)
"""
import base64
import io
import json
import logging
import uuid
from datetime import UTC, datetime, timedelta
import qrcode
from sqlalchemy.orm import Session
from app.core.config import settings
from app.modules.loyalty.exceptions import TerminalDeviceNotFoundException
from app.modules.loyalty.models import TerminalDevice
from app.modules.loyalty.schemas.terminal_device import (
TerminalDeviceCreate,
TerminalDeviceUpdate,
)
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
DEFAULT_TOKEN_LIFETIME = timedelta(days=365)
class TerminalDeviceService:
"""Service for pairing and managing terminal devices."""
def __init__(self):
self._auth = AuthManager()
# =========================================================================
# Reads
# =========================================================================
def get_device(self, db: Session, device_id: int) -> TerminalDevice | None:
return (
db.query(TerminalDevice)
.filter(TerminalDevice.id == device_id)
.first()
)
def require_device(self, db: Session, device_id: int) -> TerminalDevice:
device = self.get_device(db, device_id)
if not device:
raise TerminalDeviceNotFoundException(str(device_id))
return device
def require_device_for_merchant(
self, db: Session, device_id: int, merchant_id: int
) -> TerminalDevice:
"""Fetch a device but enforce it belongs to the given merchant."""
device = self.require_device(db, device_id)
if device.merchant_id != merchant_id:
raise TerminalDeviceNotFoundException(str(device_id))
return device
def get_device_by_jti(self, db: Session, jti: str) -> TerminalDevice | None:
return db.query(TerminalDevice).filter(TerminalDevice.jti == jti).first()
def list_devices(
self,
db: Session,
merchant_id: int,
*,
store_id: int | None = None,
include_revoked: bool = False,
) -> list[TerminalDevice]:
query = db.query(TerminalDevice).filter(
TerminalDevice.merchant_id == merchant_id
)
if store_id is not None:
query = query.filter(TerminalDevice.store_id == store_id)
if not include_revoked:
query = query.filter(TerminalDevice.revoked_at.is_(None))
return query.order_by(
TerminalDevice.store_id, TerminalDevice.created_at.desc()
).all()
# =========================================================================
# Writes
# =========================================================================
def create_device(
self,
db: Session,
*,
merchant_id: int,
data: TerminalDeviceCreate,
created_by_user_id: int | None,
) -> tuple[TerminalDevice, str, dict, str]:
"""Pair a new device and issue its setup token.
Returns ``(device, setup_token, setup_payload, qr_png_base64)``.
The caller must NOT persist ``setup_token`` — only the device row's
``jti`` is kept.
"""
from app.modules.tenancy.models import Store
store = (
db.query(Store)
.filter(Store.id == data.store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise TerminalDeviceNotFoundException(f"store:{data.store_id}")
jti = str(uuid.uuid4())
expires_at = datetime.now(UTC) + DEFAULT_TOKEN_LIFETIME
device = TerminalDevice(
merchant_id=merchant_id,
store_id=store.id,
label=data.label,
jti=jti,
expires_at=expires_at,
created_by_id=created_by_user_id,
)
db.add(device)
db.commit()
db.refresh(device)
token = self._auth.create_device_token(
device_id=device.id,
jti=jti,
merchant_id=merchant_id,
store_id=store.id,
store_code=store.store_code,
expires_at=expires_at,
)
payload = {
"api_url": settings.app_base_url,
"store_code": store.store_code,
"auth_token": token,
}
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))
logger.info(
f"Paired terminal device {device.id} ('{device.label}') for "
f"merchant {merchant_id}, store {store.id} ({store.store_code})"
)
return device, token, payload, qr_png_base64
def update_device(
self,
db: Session,
device: TerminalDevice,
data: TerminalDeviceUpdate,
) -> TerminalDevice:
if data.label is not None:
device.label = data.label
db.commit()
db.refresh(device)
return device
def revoke_device(
self,
db: Session,
device: TerminalDevice,
*,
revoked_by_user_id: int | None,
reason: str | None = None,
) -> TerminalDevice:
if device.revoked_at is not None:
return device
device.revoked_at = datetime.now(UTC)
device.revoked_by_id = revoked_by_user_id
device.revoked_reason = reason
db.commit()
db.refresh(device)
logger.info(
f"Revoked terminal device {device.id} by user {revoked_by_user_id}"
)
return device
def delete_device(self, db: Session, device: TerminalDevice) -> None:
device_id = device.id
db.delete(device)
db.commit()
logger.info(f"Deleted terminal device {device_id}")
def touch_last_seen(
self,
db: Session,
device: TerminalDevice,
ip: str | None = None,
) -> None:
"""Bump last_seen_at/ip on each authenticated request. Best-effort."""
device.last_seen_at = datetime.now(UTC)
if ip is not None:
device.last_seen_ip = ip
try:
db.commit()
except Exception: # noqa: EXC003 - best-effort heartbeat, must not break the request
db.rollback()
logger.warning(
f"Failed to update last_seen for device {device.id}", exc_info=True
)
# =========================================================================
# Helpers
# =========================================================================
@staticmethod
def _render_qr_png_base64(data: str) -> str:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=8,
border=2,
)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
@staticmethod
def build_response(device: TerminalDevice) -> dict:
"""Hydrate a response dict including derived status + store name."""
return {
"id": device.id,
"merchant_id": device.merchant_id,
"store_id": device.store_id,
"store_name": device.store.name if device.store else None,
"label": device.label,
"status": device.status,
"expires_at": device.expires_at,
"last_seen_at": device.last_seen_at,
"last_seen_ip": device.last_seen_ip,
"revoked_at": device.revoked_at,
"revoked_by_id": device.revoked_by_id,
"revoked_reason": device.revoked_reason,
"created_by_id": device.created_by_id,
"created_at": device.created_at,
}
terminal_device_service = TerminalDeviceService()

View File

@@ -0,0 +1,23 @@
// app/modules/loyalty/static/admin/js/loyalty-merchant-devices.js
const adminMerchantDevicesLog = window.LogConfig.loggers.adminMerchantDevices || window.LogConfig.createLogger('adminMerchantDevices');
function adminMerchantDevices() {
const merchantId = document.body.dataset.merchantId
|| (window.location.pathname.match(/\/merchants\/(\d+)/) || [])[1];
if (!merchantId) {
adminMerchantDevicesLog.error('Could not determine merchant_id from URL');
}
return loyaltyDevicesList({
apiPrefix: '/admin/loyalty/merchants/' + merchantId,
// Admin reuses the merchant locations endpoint for the store dropdown.
locationsApiPrefix: '/admin/loyalty/merchants/' + merchantId,
showStoreFilter: true,
showCrud: true,
currentPage: 'admin-merchant-devices',
});
}
if (!window.LogConfig.loggers.adminMerchantDevices) {
window.LogConfig.loggers.adminMerchantDevices = window.LogConfig.createLogger('adminMerchantDevices');
}
adminMerchantDevicesLog.info('Admin merchant devices module loaded');

View File

@@ -0,0 +1,16 @@
// app/modules/loyalty/static/merchant/js/loyalty-devices.js
const merchantDevicesLog = window.LogConfig.loggers.merchantDevices || window.LogConfig.createLogger('merchantDevices');
function merchantLoyaltyDevices() {
return loyaltyDevicesList({
apiPrefix: '/merchants/loyalty',
showStoreFilter: true,
showCrud: true,
currentPage: 'devices',
});
}
if (!window.LogConfig.loggers.merchantDevices) {
window.LogConfig.loggers.merchantDevices = window.LogConfig.createLogger('merchantDevices');
}
merchantDevicesLog.info('Merchant loyalty devices module loaded');

View File

@@ -0,0 +1,205 @@
// app/modules/loyalty/static/shared/js/loyalty-devices-list.js
// Shared Alpine.js data factory for terminal device pairing pages.
// Used by both merchant and admin views.
const loyaltyDevicesListLog = window.LogConfig.loggers.loyaltyDevicesList || window.LogConfig.createLogger('loyaltyDevicesList');
/**
* Factory that returns an Alpine.js data object for terminal device management.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix (devices live under `${apiPrefix}/devices`)
* @param {string} [config.locationsApiPrefix] - Where to load store locations (defaults to apiPrefix)
* @param {boolean} config.showStoreFilter - Whether to show the store dropdown filter
* @param {boolean} config.showCrud - Whether to show pair/revoke/delete UI
* @param {string} config.currentPage - Alpine currentPage identifier
*/
function loyaltyDevicesList(config) {
const guardKey = '_loyaltyDevicesList_' + config.currentPage + '_initialized';
const locationsPrefix = config.locationsApiPrefix || config.apiPrefix;
return {
...data(),
currentPage: config.currentPage,
devices: [],
locations: [],
filters: {
store_id: '',
include_revoked: false,
},
loading: false,
error: null,
// Pair modal
showPairModal: false,
pairing: false,
pairForm: { label: '', store_id: '' },
// QR reveal modal (one-time)
showQrModal: false,
pairingResult: null,
// Revoke / delete confirms
showRevokeModal: false,
showDeleteModal: false,
targetDevice: null,
revokeConfirmMessage: '',
deleteConfirmMessage: '',
_config: config,
async init() {
loyaltyDevicesListLog.info('=== LOYALTY DEVICES LIST INITIALIZING ===', config.currentPage);
if (window[guardKey]) return;
window[guardKey] = true;
const parentInit = data().init;
if (parentInit) await parentInit.call(this);
await this.loadData();
},
async loadData() {
this.loading = true;
this.error = null;
try {
const parallel = [this.loadDevices()];
if (config.showStoreFilter) parallel.push(this.loadLocations());
await Promise.all(parallel);
} catch (error) {
loyaltyDevicesListLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadDevices() {
try {
const params = new URLSearchParams();
if (this.filters.store_id) params.append('store_id', this.filters.store_id);
if (this.filters.include_revoked) params.append('include_revoked', 'true');
const qs = params.toString();
const url = config.apiPrefix + '/devices' + (qs ? '?' + qs : '');
const response = await apiClient.get(url);
if (response) this.devices = response.devices || [];
} catch (error) {
loyaltyDevicesListLog.error('Failed to load devices:', error);
throw error;
}
},
async loadLocations() {
try {
const response = await apiClient.get(locationsPrefix + '/locations');
if (response) {
this.locations = Array.isArray(response) ? response : (response.locations || []);
}
} catch (error) {
loyaltyDevicesListLog.warn('Failed to load locations:', error.message);
}
},
applyFilter() {
this.loadDevices();
},
formatDate(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString();
} catch (e) {
return value;
}
},
// ---- Pair flow ----
openPairModal() {
this.pairForm = { label: '', store_id: '' };
this.showPairModal = true;
},
async pairDevice() {
this.pairing = true;
try {
const payload = {
label: this.pairForm.label,
store_id: parseInt(this.pairForm.store_id, 10),
};
if (!payload.store_id) {
Utils.showToast(I18n.t('loyalty.terminal_devices.errors.store_required'), 'error');
this.pairing = false;
return;
}
const response = await apiClient.post(config.apiPrefix + '/devices', payload);
this.pairingResult = response;
this.showPairModal = false;
this.showQrModal = true;
await this.loadDevices();
} catch (error) {
loyaltyDevicesListLog.error('Failed to pair device:', error);
Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.pair_failed'), 'error');
} finally {
this.pairing = false;
}
},
closeQrModal() {
this.showQrModal = false;
this.pairingResult = null;
},
// ---- Revoke flow ----
confirmRevoke(device) {
this.targetDevice = device;
this.revokeConfirmMessage = I18n.t('loyalty.terminal_devices.confirm_revoke', { label: device.label });
this.showRevokeModal = true;
},
async revokeDevice() {
if (!this.targetDevice) return;
try {
await apiClient.post(config.apiPrefix + '/devices/' + this.targetDevice.id + '/revoke', {});
Utils.showToast(I18n.t('loyalty.terminal_devices.toasts.revoked'), 'success');
this.showRevokeModal = false;
this.targetDevice = null;
await this.loadDevices();
} catch (error) {
loyaltyDevicesListLog.error('Failed to revoke device:', error);
Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.revoke_failed'), 'error');
}
},
// ---- Delete flow ----
confirmDelete(device) {
this.targetDevice = device;
this.deleteConfirmMessage = I18n.t('loyalty.terminal_devices.confirm_delete', { label: device.label });
this.showDeleteModal = true;
},
async deleteDevice() {
if (!this.targetDevice) return;
try {
await apiClient.delete(config.apiPrefix + '/devices/' + this.targetDevice.id);
Utils.showToast(I18n.t('loyalty.terminal_devices.toasts.deleted'), 'success');
this.showDeleteModal = false;
this.targetDevice = null;
await this.loadDevices();
} catch (error) {
loyaltyDevicesListLog.error('Failed to delete device:', error);
Utils.showToast(error.message || I18n.t('loyalty.terminal_devices.errors.delete_failed'), 'error');
}
},
};
}
if (!window.LogConfig.loggers.loyaltyDevicesList) {
window.LogConfig.loggers.loyaltyDevicesList = window.LogConfig.createLogger('loyaltyDevicesList');
}
loyaltyDevicesListLog.info('Loyalty devices list factory loaded');

View File

@@ -0,0 +1,27 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-devices.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.terminal_devices.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}adminMerchantDevices(){% endblock %}
{% block content %}
{% call detail_page_header("'" + _('loyalty.terminal_devices.title') + ": " + (merchant_name or '') + "'", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='program') %}
{{ _('loyalty.terminal_devices.admin_subtitle') }}
{% endcall %}
{{ loading_state(_('loyalty.terminal_devices.loading')) }}
{{ error_state(_('loyalty.terminal_devices.error_loading')) }}
{% set devices_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set show_store_filter = true %}
{% set show_crud = true %}
{% include 'loyalty/shared/devices-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-devices-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='admin/js/loyalty-merchant-devices.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{# app/modules/loyalty/templates/loyalty/merchant/devices.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}{{ _('loyalty.terminal_devices.title') }}{% endblock %}
{% block i18n_modules %}['loyalty']{% endblock %}
{% block alpine_data %}merchantLoyaltyDevices(){% endblock %}
{% block content %}
{% call page_header_flex(title=_('loyalty.terminal_devices.title'), subtitle=_('loyalty.terminal_devices.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadDevices()', variant='secondary') }}
<button @click="openPairModal()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
{{ _('loyalty.terminal_devices.pair_device') }}
</button>
</div>
{% endcall %}
{{ loading_state(_('loyalty.terminal_devices.loading')) }}
{{ error_state(_('loyalty.terminal_devices.error_loading')) }}
{% set devices_api_prefix = '/merchants/loyalty' %}
{% set show_store_filter = true %}
{% set show_crud = true %}
{% include 'loyalty/shared/devices-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='shared/js/loyalty-devices-list.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-devices.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,192 @@
{# app/modules/loyalty/templates/loyalty/shared/devices-list.html #}
{#
Shared terminal-devices list partial. Set these variables before including:
- devices_api_prefix (str): API base URL for device data
- show_store_filter (bool): Show store dropdown filter (true for merchant view)
- show_crud (bool): Show pair/revoke/delete actions (false for admin read-only)
#}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal, confirm_modal_dynamic %}
<!-- Filter Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
{% if show_store_filter %}
<select x-model="filters.store_id" @change="applyFilter()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.common.all_stores') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
{% endif %}
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" x-model="filters.include_revoked" @change="applyFilter()"
class="mr-2 rounded border-gray-300 text-purple-600 focus:ring-purple-500">
{{ _('loyalty.terminal_devices.show_revoked') }}
</label>
</div>
</div>
<!-- Devices Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{% if show_store_filter %}
{{ table_header([
_('loyalty.terminal_devices.col_label'),
_('loyalty.terminal_devices.col_store'),
_('loyalty.terminal_devices.col_status'),
_('loyalty.terminal_devices.col_last_seen'),
_('loyalty.terminal_devices.col_expires'),
_('loyalty.terminal_devices.col_actions'),
]) }}
{% else %}
{{ table_header([
_('loyalty.terminal_devices.col_label'),
_('loyalty.terminal_devices.col_status'),
_('loyalty.terminal_devices.col_last_seen'),
_('loyalty.terminal_devices.col_expires'),
_('loyalty.terminal_devices.col_actions'),
]) }}
{% endif %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="devices.length === 0">
<tr>
<td :colspan="'{{ '6' if show_store_filter else '5' }}'" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('device-tablet', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.terminal_devices.no_devices') }}</p>
</div>
</td>
</tr>
</template>
<template x-for="device in devices" :key="device.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 text-sm font-semibold" x-text="device.label"></td>
{% if show_store_filter %}
<td class="px-4 py-3 text-sm" x-text="device.store_name || '-'"></td>
{% endif %}
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': device.status === 'active',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': device.status === 'revoked',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': device.status === 'expired'
}"
x-text="$t('loyalty.terminal_devices.status_' + device.status)"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="device.last_seen_at ? formatDate(device.last_seen_at) : '-'"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(device.expires_at)"></td>
<td class="px-4 py-3">
{% if show_crud %}
<div class="flex items-center gap-2">
<button x-show="device.status === 'active'" @click="confirmRevoke(device)" type="button"
aria-label="{{ _('loyalty.terminal_devices.revoke') }}"
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm">
<span x-html="$icon('lock-closed', 'w-4 h-4')"></span>
</button>
<button @click="confirmDelete(device)" type="button"
aria-label="{{ _('loyalty.common.delete') }}"
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</div>
{% else %}
<span class="text-sm text-gray-400">-</span>
{% endif %}
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
{% if show_crud %}
<!-- Pair Device Modal -->
{% call modal('pairDeviceModal', _('loyalty.terminal_devices.pair_device'), 'showPairModal', size='md', show_footer=false) %}
<form @submit.prevent="pairDevice()">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.terminal_devices.label_field') }}</label>
<input type="text" x-model="pairForm.label" required minlength="1" maxlength="100"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="{{ _('loyalty.terminal_devices.label_placeholder') }}">
</div>
{% if show_store_filter %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('loyalty.terminal_devices.store_field') }}</label>
<select x-model="pairForm.store_id" required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.terminal_devices.select_store') }}</option>
<template x-for="loc in locations" :key="loc.store_id">
<option :value="loc.store_id" x-text="loc.store_name"></option>
</template>
</select>
</div>
{% endif %}
<p class="text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.terminal_devices.pair_hint') }}</p>
</div>
<div class="flex items-center justify-end gap-3 mt-6">
<button type="button" @click="showPairModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{{ _('loyalty.common.cancel') }}
</button>
<button type="submit" :disabled="pairing"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="pairing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="pairing ? $t('loyalty.common.saving') : $t('loyalty.terminal_devices.pair_device')"></span>
</button>
</div>
</form>
{% endcall %}
<!-- QR Code Modal (one-time pairing reveal) -->
{% call modal('pairingQrModal', _('loyalty.terminal_devices.pairing_qr'), 'showQrModal', size='md', show_footer=false, close_on_backdrop=false) %}
<div class="space-y-4">
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 p-3">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<strong>{{ _('loyalty.terminal_devices.qr_warning_title') }}</strong>
{{ _('loyalty.terminal_devices.qr_warning_body') }}
</p>
</div>
<div class="flex items-center justify-center bg-white p-4 rounded-lg" x-show="pairingResult">
<img :src="pairingResult.qr_png_base64" alt="Pairing QR code" class="w-64 h-64">
</div>
<details class="text-sm" x-show="pairingResult">
<summary class="cursor-pointer text-gray-600 dark:text-gray-400">{{ _('loyalty.terminal_devices.show_payload') }}</summary>
<pre class="mt-2 p-3 text-xs bg-gray-50 dark:bg-gray-900 rounded overflow-x-auto" x-text="JSON.stringify(pairingResult.setup_payload, null, 2)"></pre>
</details>
<div class="flex items-center justify-end">
<button @click="closeQrModal()" type="button"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
{{ _('loyalty.terminal_devices.qr_done') }}
</button>
</div>
</div>
{% endcall %}
<!-- Revoke Confirm -->
{{ confirm_modal_dynamic(
'revokeDeviceModal',
_('loyalty.terminal_devices.revoke_title'),
'revokeConfirmMessage',
'revokeDevice()',
'showRevokeModal',
confirm_text=_('loyalty.terminal_devices.revoke'),
cancel_text=_('loyalty.common.cancel'),
variant='warning'
) }}
<!-- Delete Confirm -->
{{ confirm_modal_dynamic(
'deleteDeviceModal',
_('loyalty.terminal_devices.delete_title'),
'deleteConfirmMessage',
'deleteDevice()',
'showDeleteModal',
confirm_text=_('loyalty.common.delete'),
cancel_text=_('loyalty.common.cancel'),
variant='danger'
) }}
{% endif %}

View File

@@ -0,0 +1,186 @@
# app/modules/loyalty/tests/integration/test_terminal_devices.py
"""Integration tests for terminal device pairing & revocation.
Covers both the merchant API (`/api/v1/merchants/loyalty/devices/...`) and
the admin-on-behalf API (`/api/v1/admin/loyalty/merchants/{id}/devices/...`),
plus the bearer-auth path on the store API that accepts a paired-device JWT.
"""
import pytest
from jose import jwt
from app.modules.loyalty.models import TerminalDevice
from middleware.auth import AuthManager
MERCHANT_BASE = "/api/v1/merchants/loyalty"
STORE_BASE = "/api/v1/store/loyalty"
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantPairing:
"""POST /merchants/loyalty/devices — creates row and returns one-time QR."""
def test_pair_device_returns_setup_token_and_qr(
self, client, loyalty_merchant_headers, loyalty_store_setup, db
):
store = loyalty_store_setup["store"]
response = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": store.id, "label": "Counter 1"},
headers=loyalty_merchant_headers,
)
assert response.status_code == 201, response.text
data = response.json()
assert data["label"] == "Counter 1"
assert data["store_id"] == store.id
assert data["status"] == "active"
assert data["setup_token"]
assert data["qr_png_base64"].startswith("data:image/png;base64,")
assert data["setup_payload"]["store_code"] == store.store_code
assert data["setup_payload"]["auth_token"] == data["setup_token"]
# Token is signed by AuthManager and carries the device_setup claim
# plus the same jti as the row.
auth = AuthManager()
decoded = jwt.decode(
data["setup_token"], auth.secret_key, algorithms=[auth.algorithm]
)
assert decoded["device_setup"] is True
assert decoded["store_id"] == store.id
device = (
db.query(TerminalDevice)
.filter(TerminalDevice.id == data["id"])
.first()
)
assert device is not None
assert device.jti == decoded["jti"]
def test_pair_device_rejects_foreign_store(
self, client, loyalty_merchant_headers, db
):
"""A merchant cannot pair against a store they don't own."""
# store_id=99999 doesn't belong to this merchant
response = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": 99999, "label": "Hijack"},
headers=loyalty_merchant_headers,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestMerchantListAndRevoke:
"""GET / POST revoke — listing and lifecycle."""
def test_list_excludes_revoked_by_default(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
store = loyalty_store_setup["store"]
# Pair two devices
for label in ("A", "B"):
r = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": store.id, "label": label},
headers=loyalty_merchant_headers,
)
assert r.status_code == 201
# Revoke first one
listing = client.get(
f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers
).json()
first_id = listing["devices"][-1]["id"] # deterministic order: created_at desc
r = client.post(
f"{MERCHANT_BASE}/devices/{first_id}/revoke",
json={"reason": "lost"},
headers=loyalty_merchant_headers,
)
assert r.status_code == 200
assert r.json()["status"] == "revoked"
# Default list excludes the revoked one
active = client.get(
f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers
).json()
assert active["total"] == 1
# include_revoked=true brings it back
full = client.get(
f"{MERCHANT_BASE}/devices?include_revoked=true",
headers=loyalty_merchant_headers,
).json()
assert full["total"] == 2
def test_revoke_is_idempotent(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
store = loyalty_store_setup["store"]
r = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": store.id, "label": "Idem"},
headers=loyalty_merchant_headers,
).json()
first = client.post(
f"{MERCHANT_BASE}/devices/{r['id']}/revoke",
json={},
headers=loyalty_merchant_headers,
).json()
second = client.post(
f"{MERCHANT_BASE}/devices/{r['id']}/revoke",
json={},
headers=loyalty_merchant_headers,
).json()
assert first["revoked_at"] == second["revoked_at"]
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.loyalty
class TestStoreAPIBearerAuth:
"""Paired-device JWT is accepted on the store API."""
def test_device_token_authenticates_store_api(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
store = loyalty_store_setup["store"]
paired = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": store.id, "label": "Counter 1"},
headers=loyalty_merchant_headers,
).json()
token = paired["setup_token"]
response = client.get(
f"{STORE_BASE}/program",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["merchant_id"] == loyalty_store_setup["merchant"].id
def test_revoked_device_token_is_rejected(
self, client, loyalty_merchant_headers, loyalty_store_setup
):
store = loyalty_store_setup["store"]
paired = client.post(
f"{MERCHANT_BASE}/devices",
json={"store_id": store.id, "label": "Burn"},
headers=loyalty_merchant_headers,
).json()
token = paired["setup_token"]
client.post(
f"{MERCHANT_BASE}/devices/{paired['id']}/revoke",
json={"reason": "test"},
headers=loyalty_merchant_headers,
)
response = client.get(
f"{STORE_BASE}/program",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code in (400, 401)

View File

@@ -249,6 +249,11 @@ class UserContext(BaseModel):
token_store_code: str | None = None
token_store_role: str | None = None
# Set when the request was authenticated by a paired POS terminal device
# rather than a human user logging in. The device's row carries the actual
# principal for audit; this surfaces it for endpoints that care.
terminal_device_id: int | None = None
# Optional profile fields
first_name: str | None = None
last_name: str | None = None
@@ -344,4 +349,8 @@ class UserContext(BaseModel):
data["token_store_code"] = getattr(user, "token_store_code", None)
data["token_store_role"] = getattr(user, "token_store_role", None)
# Surface terminal-device principal if the user object came from a
# device-token authentication path.
data["terminal_device_id"] = getattr(user, "terminal_device_id", None)
return cls(**data)

View File

@@ -206,6 +206,57 @@ class AuthManager:
"expires_in": self.token_expire_minutes * 60, # Convert minutes to seconds
}
def create_device_token(
self,
*,
device_id: int,
jti: str,
merchant_id: int,
store_id: int,
store_code: str,
expires_at: datetime,
) -> str:
"""Create a long-lived JWT for a paired terminal device.
Carries `device_setup: True` to distinguish from user tokens, plus
`jti` so the device row remains the source of truth for revocation.
Returns the encoded token only — caller stores `jti`, never the token.
"""
payload = {
"sub": str(device_id),
"jti": jti,
"device_setup": True,
"merchant_id": merchant_id,
"store_id": store_id,
"store_code": store_code,
"exp": expires_at,
"iat": datetime.now(UTC),
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def verify_device_token(self, token: str) -> dict[str, Any]:
"""Verify a device JWT and return its payload.
Validates signature, expiration, the `device_setup` claim, and the
presence of jti / store_id. Raises InvalidTokenException for any
non-device or malformed token, TokenExpiredException for expired.
"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
except jwt.ExpiredSignatureError:
raise TokenExpiredException()
except jwt.JWTError as e:
logger.error(f"Device JWT decode error: {e}")
raise InvalidTokenException("Could not validate device token")
if not payload.get("device_setup"):
raise InvalidTokenException("Not a device token")
if not payload.get("jti"):
raise InvalidTokenException("Device token missing jti")
if payload.get("store_id") is None:
raise InvalidTokenException("Device token missing store context")
return payload
def verify_token(self, token: str) -> dict[str, Any]:
"""Verify and decode a JWT token, returning the user data.

View File

@@ -25,6 +25,7 @@ beautifulsoup4==4.14.3
# Image processing
Pillow>=10.0.0
qrcode>=7.4.0
# System monitoring
psutil>=5.9.0