feat(loyalty): pair POS terminal devices with one-time setup QR
Some checks failed
Some checks failed
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:
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user