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