POS BFF — Phase 3

Что сделано

NEW lib/approvalToken.ts

HS256 JWT через node:crypto (без зависимости jsonwebtoken).

issueApprovalToken(scope, sub) → { token, payload }
verifyApprovalToken(token) → ApprovalPayload | null  // null если подпись/exp невалидны

Payload: { approval_for, max_amount?, max_pct?, sub, exp, jti }. Подпись через config.jwtSecret.

NEW routes/manager-auth.ts

POST /api/v1/pos/manager-auth/verify-pin
Body: { pin: string, scope: { type: "refund_amount" | "discount_pct", max_amount?/max_pct? } }

Flow:

  1. requireCashier — токен валиден, pos.access есть
  2. POST /internal/users/validate-pin (user-service) с { pin, store_id }
  3. Проверяем что в возвращённых permissions[] есть pos.settings.edit — иначе 403 NOT_A_MANAGER
  4. issueApprovalToken(scope, manager.id) → возвращаем { approval_token, expires_in: 300, scope }

NEW routes/customers.ts

  • GET /api/v1/pos/customers/search?phone=…/internal/customers/search-by-phone?franchise_id=…&phone=…
  • POST /api/v1/pos/customers/quick-create/internal/customers/quick-create?franchise_id=…&employee_id=…

middleware/auth.ts — NEW requireApprovalToken

Универсальная: requireCashier → достаёт X-Approval-Token, валидирует через verifyApprovalToken. Кладёт payload в request.approvalPayload. Сама проверка scope (max_amount/max_pct) — в route handler’е (там видно конкретный amount/pct).

routes/orders.ts — расширения

  • SubmitOrderBody: новые поля customer_id?, discount_pct?
  • В /submit: если discount_pct > config.approval.discountMaxPct (default 20) — проверяем X-Approval-Token (scope=discount_pct AND max_pct ≥ запрошенному). Без него → 403 APPROVAL_REQUIRED. Логируем factой что approved.
  • OpenOrderBody.customer_id, OpenDineInBody.customer_id — прокидываются в order-service
  • NEW PATCH /:id/customer — proxy на /internal/orders/:id/customer
  • NEW DELETE /:id/customer — proxy

routes/refunds.ts — Manager PIN flow

  • amount > config.approval.refundMaxAmountRub (default 1000 ₽) — проверка X-Approval-Token (scope=refund_amount AND max_amount ≥ запрошенному). Без него — 403 APPROVAL_REQUIRED.

config.ts — новые поля

customerServiceUrl: string,
jwtSecret: string,
approval: {
  refundMaxAmountRub: 1000,
  discountMaxPct: 20,
  tokenTtlSec: 300,
}

server.ts

app.register(customerRoutes, { prefix: "/api/v1/pos/customers" });
app.register(managerAuthRoutes, { prefix: "/api/v1/pos/manager-auth" });

Файлы

  • NEW bff/src/lib/approvalToken.ts
  • NEW bff/src/routes/customers.ts
  • NEW bff/src/routes/manager-auth.ts
  • bff/src/middleware/auth.ts (requireApprovalToken)
  • bff/src/routes/orders.ts (extend)
  • bff/src/routes/refunds.ts (extend)
  • bff/src/config.ts (extend)
  • bff/src/server.ts (register)

Smoke

# Manager PIN issue (требует валидный JWT кассира — нет в этом тесте)
curl -X POST http://localhost:3022/api/v1/pos/manager-auth/verify-pin \
  -H 'Authorization: Bearer <cashier-jwt>' -H 'Content-Type: application/json' \
  -d '{"pin":"<manager-pin>","scope":{"type":"refund_amount","max_amount":1500}}'
# → { "data": { "approval_token":"<jwt>", "expires_in":300, "scope":{...} }}
 
# Refund > 1000 без token
curl -X POST http://localhost:3022/api/v1/pos/refunds/ \
  -H 'Authorization: Bearer <cashier-jwt>' -H 'Content-Type: application/json' \
  -d '{...amount=150000,...}'  # 1500 ₽ в копейках
# → 403 APPROVAL_REQUIRED
 
# Refund > 1000 с token
curl ... -H "X-Approval-Token: <jwt>" ...
# → 200/201 + refund record