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

@@ -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,
)