Add Checkbox ETTN receipts for Nova Poshta COD waybills

Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment
control; Checkbox fiscalizes it itself when the parcel is paid for.

- cash_registers (Fernet-encrypted license key / PIN) and receipts tables
- Checkbox HTTP client + stub (ETTN does not work on test registers)
- two-phase create via ARQ job, timeout reconciliation, cron status polling
- /receipts and /cash-registers API, audit records
- dashboard: per-order and bulk create, prepayment, cancel; cash registers page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 23:39:00 +03:00
co-authored by Claude Opus 5.5
parent d13e7ce2b3
commit 518a99197f
44 changed files with 2931 additions and 20 deletions
+27
View File
@@ -0,0 +1,27 @@
/** Деньги на фронте приходят строками "1200.00"; в API чеков уходят integer-копейками. */
export function toKopecks(value: string): number | null {
const normalized = value.trim().replace(/\s/g, '').replace(',', '.')
if (normalized === '') return 0
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null
return Math.round(Number(normalized) * 100)
}
export function formatKopecks(kopecks: number): string {
return (kopecks / 100).toFixed(2)
}
/** Предоплата по умолчанию: сумма заказа − наложка НП (как считает бэкенд). */
export function defaultPrepayment(total: string, cod: string | null): string {
if (!cod) return ''
const diff = (toKopecks(total) ?? 0) - (toKopecks(cod) ?? 0)
return diff > 0 ? formatKopecks(diff) : '0.00'
}
/** Проверка инварианта ЕТТН-чека: сумма − предоплата = наложка. */
export function prepaymentMatches(total: string, cod: string | null, prepayment: string): boolean {
if (!cod) return false
const prep = toKopecks(prepayment)
if (prep === null) return false
return (toKopecks(total) ?? 0) - prep === toKopecks(cod)
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Типы, зеркалящие backend/app/schemas/receipts.py и `ReceiptStatus`
* (backend/app/db/models/receipt.py). Меняются синхронно с ними вручную.
*/
export type ReceiptStatus =
| 'pending'
| 'created'
| 'done'
| 'returned'
| 'receipt_error'
| 'cancelled'
| 'failed'
export interface Receipt {
id: string
order_id: string
waybill_number: string
status: ReceiptStatus
total_amount: string
prepayment_amount: string
cod_amount: string
checkbox_ettn_id: string | null
checkbox_status: string | null
checkbox_receipt_id: string | null
error: string | null
created_at: string
last_checked_at: string | null
}
export interface ReceiptRequestItem {
order_id: string
/** null — предоплата = сумма заказа − наложка (считает бэкенд). */
prepayment_kopecks: number | null
}
export interface ReceiptCreateResponse {
created: Receipt[]
/** order_id → причина, по которой чек не создан. */
errors: Record<string, string>
}
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
pending: { label: 'Отправляется', tone: 'new' },
created: { label: 'Ждёт оплаты', tone: 'processing' },
done: { label: 'Фискализирован', tone: 'delivered' },
returned: { label: 'Возврат посылки', tone: 'danger' },
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' },
cancelled: { label: 'Отменён', tone: 'new' },
failed: { label: 'Не создан', tone: 'danger' },
}
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
export const CANCELLABLE: ReadonlySet<ReceiptStatus> = new Set(['created', 'receipt_error'])