# 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_id": store.id, "store_code": store.store_code, "store_name": store.name, "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()