From d4808b3e46f2e726158e7e7d123ddbf4d050fb30 Mon Sep 17 00:00:00 2001 From: denisireri Date: Sun, 11 Oct 2026 18:34:35 +0300 Subject: [PATCH 1/4] Enforce roles and tenant status on the server, and use the Kenyan business day The admin pages redirected cashiers away, but the API itself let them read profit, expenses and purchases and record expenses and purchases. Branch managers could also change product prices for every branch. Every gated route now goes through authorize(), which answers 401 when signed out and 403 for the wrong role. - Owners and managers only: profit report, expenses, purchases, users, morning dispatch and evening count. Owner only: create and edit products, rename branches. - PATCH /api/users/:id deactivates or reactivates staff (managers: their own cashiers only) and revokes the user's device sessions. PATCH /api/branches/:id renames a branch. - Login and token refresh refuse inactive or SUSPENDED businesses. - Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered operator email can no longer be claimed. - M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the pilot): its routes return 404 and the Daraja variables are optional. Turning it on in production requires the callback IP allowlist. - "Today" and the default month now follow Africa/Nairobi instead of the server's UTC clock, so the business day no longer starts at 03:00 EAT. - Malformed ids in query strings and bodies return 400 instead of a 500, the inventory routes check the product belongs to the tenant, and the reconciliation lookups are tenant-scoped. - Branch-scoped staff see their branch's override price from GET /api/products. - Adds a vitest suite against an in-memory MongoDB (npm test). Co-Authored-By: Claude Opus 5.5 --- .env.example | 13 +- package-lock.json | 1779 ++++++++++++++++- package.json | 8 +- src/app/api/auth/login/route.ts | 6 + src/app/api/auth/me/route.ts | 2 + src/app/api/auth/refresh/route.ts | 6 + src/app/api/auth/signup/route.ts | 9 + src/app/api/branches/[id]/route.ts | 37 + src/app/api/branches/route.ts | 11 +- src/app/api/dashboard/reconciliation/route.ts | 39 +- src/app/api/dashboard/summary/route.ts | 22 +- src/app/api/expenses/route.ts | 27 +- src/app/api/inventory/dispatch/route.ts | 34 +- src/app/api/inventory/evening-count/route.ts | 34 +- src/app/api/mpesa/callback/route.ts | 2 + .../api/mpesa/status/[transactionId]/route.ts | 2 + src/app/api/mpesa/stkpush/route.ts | 2 + src/app/api/products/[id]/route.ts | 15 +- src/app/api/products/route.ts | 32 +- src/app/api/purchases/route.ts | 21 +- src/app/api/reports/profit/route.ts | 19 +- src/app/api/transactions/route.ts | 16 +- src/app/api/users/[id]/route.ts | 86 + src/app/api/users/route.ts | 42 +- src/lib/server/auth.ts | 46 +- src/lib/server/businessDay.ts | 40 + src/lib/server/env.ts | 91 +- src/lib/server/models/Product.ts | 2 + src/lib/server/mpesa.ts | 2 +- src/lib/server/subscription.ts | 19 +- src/lib/server/validation.ts | 8 + tests/access.test.ts | 166 ++ tests/businessDay.test.ts | 29 + tests/globalSetup.ts | 12 + tests/helpers.ts | 133 ++ tests/setup.ts | 26 + tsconfig.tsbuildinfo | 1 - vitest.config.ts | 19 + 38 files changed, 2700 insertions(+), 158 deletions(-) create mode 100644 src/app/api/branches/[id]/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/lib/server/businessDay.ts create mode 100644 src/lib/server/validation.ts create mode 100644 tests/access.test.ts create mode 100644 tests/businessDay.test.ts create mode 100644 tests/globalSetup.ts create mode 100644 tests/helpers.ts create mode 100644 tests/setup.ts delete mode 100644 tsconfig.tsbuildinfo create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index 8caa795..d3e130a 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,18 @@ JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=30d DELIVERY_MAX_DEVICES=2 -# ---- Safaricom Daraja ---- +# ---- Platform operator ---- +# Comma-separated emails with cross-tenant access to /platform. Create these +# accounts with `npm run seed`; public signup refuses these addresses. +PLATFORM_ADMIN_EMAILS= + +# ---- M-Pesa (optional, off for the pilot) ---- +# M-Pesa STK Push is out of scope for the pilot. While this is false every +# /api/mpesa route returns 404 and the Daraja variables below are ignored. +# If you turn it on in production, DARAJA_CALLBACK_IP_ALLOWLIST is required. +FEATURE_MPESA=false + +# ---- Safaricom Daraja (only read when FEATURE_MPESA=true) ---- DARAJA_ENV=sandbox DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx diff --git a/package-lock.json b/package-lock.json index 62bd3ca..8f8c6bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,10 +27,12 @@ "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.19", "dotenv": "^16.4.5", + "mongodb-memory-server": "^10.1.4", "postcss": "^8.4.39", "tailwindcss": "^3.4.4", "tsx": "^4.19.0", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -536,6 +538,26 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@next/env": { "version": "14.2.35", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", @@ -724,6 +746,395 @@ "node": ">= 8" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz", + "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -747,6 +1158,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -818,6 +1236,119 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -858,6 +1389,26 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -913,6 +1464,106 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.26", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", @@ -1018,6 +1669,16 @@ "node": ">=10.16.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1031,6 +1692,19 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1061,6 +1735,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1127,6 +1828,13 @@ "node": ">= 6" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1174,6 +1882,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1258,6 +1976,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1337,6 +2062,43 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1390,6 +2152,38 @@ "node": ">=8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1740,6 +2534,19 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1794,6 +2601,49 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1910,6 +2760,69 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/mongodb-memory-server": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.1.4.tgz", + "integrity": "sha512-+oKQ/kc3CX+816oPFRtaF0CN4vNcGKNjpOQe4bHo/21A3pMD+lC7Xz1EX5HP7siCX4iCpVchDMmCOFXVQSGkUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.1.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.1.4.tgz", + "integrity": "sha512-o8fgY7ZalEd8pGps43fFPr/hkQu1L8i6HFEGbsTfA2zDOW0TopgpswaBCqDr0qD7ptibyPfB5DmC+UlIxbThzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.3.7", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.9", + "https-proxy-agent": "^7.0.5", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.6.3", + "tar-stream": "^3.1.7", + "tslib": "^2.7.0", + "yauzl": "^3.1.3" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/mongoose": { "version": "8.24.4", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.4.tgz", @@ -1989,6 +2902,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/next": { "version": "14.2.35", "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", @@ -2107,6 +3033,55 @@ "node": ">= 6" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2114,6 +3089,30 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2143,6 +3142,19 @@ "node": ">= 6" } }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -2423,6 +3435,52 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz", + "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2494,6 +3552,13 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -2512,6 +3577,20 @@ "memory-pager": "^1.0.2" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -2520,6 +3599,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -2617,6 +3708,39 @@ "node": ">=14.0.0" } }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2640,6 +3764,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2688,6 +3826,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2804,6 +3972,585 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -2826,6 +4573,36 @@ "node": ">=18" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index bac9b8b..bf58592 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "start": "next start", "lint": "next lint", "seed": "tsx scripts/seed.ts", - "migrate:multitenant": "tsx scripts/migrate-multitenant.ts" + "migrate:multitenant": "tsx scripts/migrate-multitenant.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "axios": "^1.7.7", @@ -29,10 +31,12 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.19", + "dotenv": "^16.4.5", + "mongodb-memory-server": "^10.1.4", "postcss": "^8.4.39", "tailwindcss": "^3.4.4", "tsx": "^4.19.0", "typescript": "^5.5.3", - "dotenv": "^16.4.5" + "vitest": "^2.1.9" } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index a9ff1cb..bc57232 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { isPlatformAdminEmail } from "@/lib/server/platform"; import { getEnv } from "@/lib/server/env"; +import { tenantAccessError } from "@/lib/server/subscription"; export const runtime = "nodejs"; @@ -70,6 +71,11 @@ export async function POST(req: NextRequest) { user.lockedUntil = null; await user.save(); + const tenantError = await tenantAccessError(user.businessId); + if (tenantError) { + return NextResponse.json({ error: tenantError }, { status: 403 }); + } + if (user.role === "DELIVERY") { const binding = await enforceDeviceBinding(user._id.toString(), deviceId); if (!binding.allowed) { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 09ce640..26ccf4d 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -5,6 +5,7 @@ import { Branch } from "@/lib/server/models/Branch"; import { Business } from "@/lib/server/models/Business"; import { getAuth } from "@/lib/server/auth"; import { isPlatformAdminEmail } from "@/lib/server/platform"; +import { isMpesaEnabled } from "@/lib/server/env"; import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers"; export const runtime = "nodejs"; @@ -49,5 +50,6 @@ export async function GET(req: NextRequest) { maxBranches: tierDef.maxBranches, maxUsers: tierDef.maxUsers, }, + features: { mpesa: isMpesaEnabled() }, }); } \ No newline at end of file diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 5e78149..56c2d15 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db"; import { User } from "@/lib/server/models/User"; import { UserSession } from "@/lib/server/models/UserSession"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; +import { tenantAccessError } from "@/lib/server/subscription"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; export const runtime = "nodejs"; @@ -29,6 +30,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Account no longer active" }, { status: 401 }); } + const tenantError = await tenantAccessError(user.businessId); + if (tenantError) { + return NextResponse.json({ error: tenantError }, { status: 403 }); + } + const newRefreshToken = generateRefreshToken(); session.refreshTokenHash = hashRefreshToken(newRefreshToken); session.lastUsedAt = new Date(); diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index df9f2b2..bbdc1e6 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -11,6 +11,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { normalizeKenyanPhone } from "@/lib/server/phone"; import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers"; +import { isPlatformAdminEmail } from "@/lib/server/platform"; export const runtime = "nodejs"; @@ -52,6 +53,14 @@ export async function POST(req: NextRequest) { const lowerEmail = email.toLowerCase(); + // Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS) + // and signup does not verify email ownership, so an operator address + // must never be claimable through public signup. Operator accounts are + // created with `npm run seed` instead. + if (isPlatformAdminEmail(lowerEmail)) { + return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 }); + } + const emailTaken = await User.findOne({ email: lowerEmail }).lean(); if (emailTaken) { return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); diff --git a/src/app/api/branches/[id]/route.ts b/src/app/api/branches/[id]/route.ts new file mode 100644 index 0000000..1730732 --- /dev/null +++ b/src/app/api/branches/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isValidObjectId } from "mongoose"; +import { connectDb } from "@/lib/server/db"; +import { Branch } from "@/lib/server/models/Branch"; +import { authorize } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +const updateSchema = z + .object({ + branchName: z.string().trim().min(1).max(120).optional(), + locationCity: z.string().trim().min(1).max(120).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: "No fields to update" }); + +// PATCH /api/branches/:id — owner renames a branch or changes its city. +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + await connectDb(); + const gate = authorize(req, ["SUPER_ADMIN"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Branch not found" }, { status: 404 }); + + const json = await req.json().catch(() => null); + const parsed = updateSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); + } + + const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, { + new: true, + }).lean(); + if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 }); + return NextResponse.json({ branch }); +} diff --git a/src/app/api/branches/route.ts b/src/app/api/branches/route.ts index 6d2fc07..a439696 100644 --- a/src/app/api/branches/route.ts +++ b/src/app/api/branches/route.ts @@ -4,7 +4,7 @@ import bcrypt from "bcryptjs"; import { connectDb } from "@/lib/server/db"; import { Branch } from "@/lib/server/models/Branch"; import { User } from "@/lib/server/models/User"; -import { getAuth, requireRole } from "@/lib/server/auth"; +import { authorize, getAuth } from "@/lib/server/auth"; import { normalizeKenyanPhone } from "@/lib/server/phone"; import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription"; @@ -40,10 +40,9 @@ const createSchema = z.object({ export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, ["SUPER_ADMIN"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); @@ -51,7 +50,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } const { branchName, locationCity, manager } = parsed.data; - const businessId = auth!.businessId; + const businessId = auth.businessId; const tier = await loadTier(businessId); const branchErr = await branchLimitError(businessId, tier); diff --git a/src/app/api/dashboard/reconciliation/route.ts b/src/app/api/dashboard/reconciliation/route.ts index a676824..fb9e487 100644 --- a/src/app/api/dashboard/reconciliation/route.ts +++ b/src/app/api/dashboard/reconciliation/route.ts @@ -3,27 +3,48 @@ import mongoose from "mongoose"; import { connectDb } from "@/lib/server/db"; import { Transaction } from "@/lib/server/models/Transaction"; import { DailyInventory } from "@/lib/server/models/DailyInventory"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { authorize, badRequest, branchParam } from "@/lib/server/auth"; +import { dayRangeNairobi, isBusinessDate, todayNairobi } from "@/lib/server/businessDay"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req); + if (!gate.ok) return gate.response; + const auth = gate.auth; - const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); - const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10); + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const branchId = scope.branchId; + const date = req.nextUrl.searchParams.get("date") || todayNairobi(); + if (!isBusinessDate(date)) return badRequest("date must be YYYY-MM-DD"); const invMatch: Record = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) }; if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId); const inventoryRows = await DailyInventory.aggregate([ { $match: invMatch }, - { $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } }, + // Lookups are tenant-scoped too, so a row can never surface another + // tenant's product or branch even if one were referenced by mistake. + { + $lookup: { + from: "products", + let: { pid: "$productId", biz: "$businessId" }, + pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$pid"] }, { $eq: ["$businessId", "$$biz"] }] } } }], + as: "product", + }, + }, { $unwind: "$product" }, - { $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } }, + { + $lookup: { + from: "branches", + let: { bid: "$branchId", biz: "$businessId" }, + pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$bid"] }, { $eq: ["$businessId", "$$biz"] }] } } }], + as: "branch", + }, + }, { $unwind: "$branch" }, { $project: { @@ -39,9 +60,7 @@ export async function GET(req: NextRequest) { }, ]); - const dayStart = new Date(date + "T00:00:00.000Z"); - const dayEnd = new Date(dayStart); - dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); + const { start: dayStart, end: dayEnd } = dayRangeNairobi(date); const soldMatch: Record = { businessId: new mongoose.Types.ObjectId(auth.businessId), diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4e6fb46..8052fc4 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -2,26 +2,22 @@ import { NextRequest, NextResponse } from "next/server"; import mongoose from "mongoose"; import { connectDb } from "@/lib/server/db"; import { Transaction } from "@/lib/server/models/Transaction"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { authorize, branchParam } from "@/lib/server/auth"; +import { dayRangeNairobi, todayNairobi } from "@/lib/server/businessDay"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -function todayRange() { - const start = new Date(); - start.setHours(0, 0, 0, 0); - const end = new Date(start); - end.setDate(end.getDate() + 1); - return { start, end }; -} - export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req); + if (!gate.ok) return gate.response; + const auth = gate.auth; - const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); - const { start, end } = todayRange(); + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const branchId = scope.branchId; + const { start, end } = dayRangeNairobi(todayNairobi()); const matchStage: Record = { businessId: new mongoose.Types.ObjectId(auth.businessId), diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index 17cdc08..dffbefb 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -3,21 +3,25 @@ import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense"; import { Branch } from "@/lib/server/models/Branch"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; // GET /api/expenses?branchId=&from=&to= -// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is -// hard-scoped to their own branch regardless of query params. +// Managers and owners only. SUPER_ADMIN sees all (optionally filtered by +// branch); a BRANCH_MANAGER is hard-scoped to their own branch. export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const url = new URL(req.url); - const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); + const scope = branchParam(auth, url.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const scopeBranch = scope.branchId; const filter: Record = { businessId: auth.businessId }; if (scopeBranch) filter.branchId = scopeBranch; @@ -42,7 +46,7 @@ export async function GET(req: NextRequest) { } const createSchema = z.object({ - branchId: z.string().trim().min(1).optional(), + branchId: objectId.optional(), category: z.enum(EXPENSE_CATEGORIES), amount: z.number().positive().max(100_000_000), description: z.string().trim().min(1).max(300), @@ -50,12 +54,13 @@ const createSchema = z.object({ notes: z.string().trim().max(500).optional(), }); -// POST /api/expenses — any authenticated staff can record an expense, but -// only against a branch they are allowed to touch. +// POST /api/expenses — managers and owners only, and only against a branch +// they are allowed to touch. export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); diff --git a/src/app/api/inventory/dispatch/route.ts b/src/app/api/inventory/dispatch/route.ts index 1f22f9b..843b0c0 100644 --- a/src/app/api/inventory/dispatch/route.ts +++ b/src/app/api/inventory/dispatch/route.ts @@ -3,27 +3,25 @@ import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { Branch } from "@/lib/server/models/Branch"; -import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; +import { Product } from "@/lib/server/models/Product"; +import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth"; +import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay"; +import { objectId } from "@/lib/server/validation"; export const runtime = "nodejs"; -function todayLocal(): string { - return new Date().toISOString().slice(0, 10); -} - const dispatchSchema = z.object({ - productId: z.string().min(1), + productId: objectId, morningDispatched: z.number().int().min(0), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - branchId: z.string().optional(), + date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(), + branchId: objectId.optional(), }); export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = dispatchSchema.safeParse(json); @@ -31,18 +29,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - const branchId = resolveBranchScope(auth!, parsed.data.branchId); + const branchId = resolveBranchScope(auth, parsed.data.branchId); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); // The branch MUST belong to this tenant — blocks a crafted branchId from // writing inventory into another business. - const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); + const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean(); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); - const date = parsed.data.date ?? todayLocal(); + // The product must belong to this tenant too. + const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId }); + if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 }); + + const date = parsed.data.date ?? todayNairobi(); const row = await DailyInventory.findOneAndUpdate( { branchId, productId: parsed.data.productId, date }, - { morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId }, + { morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId }, { upsert: true, new: true, setDefaultsOnInsert: true } ); return NextResponse.json({ inventory: row }); diff --git a/src/app/api/inventory/evening-count/route.ts b/src/app/api/inventory/evening-count/route.ts index bc9de05..7c80057 100644 --- a/src/app/api/inventory/evening-count/route.ts +++ b/src/app/api/inventory/evening-count/route.ts @@ -3,27 +3,25 @@ import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { Branch } from "@/lib/server/models/Branch"; -import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; +import { Product } from "@/lib/server/models/Product"; +import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth"; +import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay"; +import { objectId } from "@/lib/server/validation"; export const runtime = "nodejs"; -function todayLocal(): string { - return new Date().toISOString().slice(0, 10); -} - const countSchema = z.object({ - productId: z.string().min(1), + productId: objectId, eveningPhysicalCount: z.number().int().min(0), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - branchId: z.string().optional(), + date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(), + branchId: objectId.optional(), }); export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = countSchema.safeParse(json); @@ -31,17 +29,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - const branchId = resolveBranchScope(auth!, parsed.data.branchId); + const branchId = resolveBranchScope(auth, parsed.data.branchId); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); // The branch MUST belong to this tenant. - const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); + const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean(); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); - const date = parsed.data.date ?? todayLocal(); + // The product must belong to this tenant too. + const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId }); + if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 }); + + const date = parsed.data.date ?? todayNairobi(); const row = await DailyInventory.findOneAndUpdate( { branchId, productId: parsed.data.productId, date }, - { eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId }, + { eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId }, { upsert: true, new: true, setDefaultsOnInsert: true } ); return NextResponse.json({ inventory: row }); diff --git a/src/app/api/mpesa/callback/route.ts b/src/app/api/mpesa/callback/route.ts index 4b20887..653bbc6 100644 --- a/src/app/api/mpesa/callback/route.ts +++ b/src/app/api/mpesa/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { connectDb } from "@/lib/server/db"; +import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; @@ -18,6 +19,7 @@ function isIpAllowed(ip: string): boolean { } export async function POST(req: NextRequest) { + if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 }); await connectDb(); const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" }); const sourceIp = getSourceIp(req); diff --git a/src/app/api/mpesa/status/[transactionId]/route.ts b/src/app/api/mpesa/status/[transactionId]/route.ts index 48faefc..3e2d8d9 100644 --- a/src/app/api/mpesa/status/[transactionId]/route.ts +++ b/src/app/api/mpesa/status/[transactionId]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { connectDb } from "@/lib/server/db"; +import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; import { getAuth } from "@/lib/server/auth"; import { queryStkStatus } from "@/lib/server/mpesa"; @@ -8,6 +9,7 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) { + if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 }); await connectDb(); const auth = getAuth(req); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); diff --git a/src/app/api/mpesa/stkpush/route.ts b/src/app/api/mpesa/stkpush/route.ts index 4b8808a..aa4a8a0 100644 --- a/src/app/api/mpesa/stkpush/route.ts +++ b/src/app/api/mpesa/stkpush/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; +import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; import { Product, BranchPricing } from "@/lib/server/models/Product"; import { Branch } from "@/lib/server/models/Branch"; @@ -31,6 +32,7 @@ function rateLimited(key: string): boolean { } export async function POST(req: NextRequest) { + if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 }); await connectDb(); const auth = getAuth(req); if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { diff --git a/src/app/api/products/[id]/route.ts b/src/app/api/products/[id]/route.ts index a2fc57d..1b5a095 100644 --- a/src/app/api/products/[id]/route.ts +++ b/src/app/api/products/[id]/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { Product } from "@/lib/server/models/Product"; -import { getAuth, requireRole } from "@/lib/server/auth"; +import { authorize } from "@/lib/server/auth"; +import { isValidObjectId } from "mongoose"; export const runtime = "nodejs"; @@ -12,14 +13,16 @@ const updateSchema = z.object({ unitPrice: z.number().nonnegative().optional(), sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), + lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(), }); export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + // Owner only — product prices apply to every branch. + const gate = authorize(req, ["SUPER_ADMIN"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; + if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Product not found" }, { status: 404 }); const json = await req.json().catch(() => null); const parsed = updateSchema.safeParse(json); @@ -31,7 +34,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string } const product = await Product.findOneAndUpdate( - { _id: params.id, businessId: auth!.businessId }, + { _id: params.id, businessId: auth.businessId }, parsed.data, { new: true } ); diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts index 4faf49d..7ad7635 100644 --- a/src/app/api/products/route.ts +++ b/src/app/api/products/route.ts @@ -1,18 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; -import { Product } from "@/lib/server/models/Product"; -import { getAuth, requireRole } from "@/lib/server/auth"; +import { Product, BranchPricing } from "@/lib/server/models/Product"; +import { authorize } from "@/lib/server/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +// GET /api/products — the tenant's active catalogue. For branch-scoped +// staff the price returned is the price THEIR branch charges (the branch +// override when one exists), so the terminal shows exactly what the +// server will record. export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req); + if (!gate.ok) return gate.response; + const auth = gate.auth; const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean(); + if (auth.branchId) { + const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean(); + const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); + for (const p of products) { + p.unitPrice = priceByProduct.get(p._id.toString()) ?? p.unitPrice; + } + } return NextResponse.json({ products }); } @@ -23,20 +35,22 @@ const createSchema = z.object({ category: z.string().trim().min(1).max(40).optional(), unitPrice: z.number().nonnegative(), sortOrder: z.number().int().optional(), + lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(), }); +// POST /api/products — owner only: the catalogue and its prices apply to +// every branch, so a branch manager must not be able to change them. export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, ["SUPER_ADMIN"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - const product = await Product.create({ ...parsed.data, businessId: auth!.businessId }); + const product = await Product.create({ ...parsed.data, businessId: auth.businessId }); return NextResponse.json({ product }, { status: 201 }); } diff --git a/src/app/api/purchases/route.ts b/src/app/api/purchases/route.ts index 3b5d35f..00f6fc1 100644 --- a/src/app/api/purchases/route.ts +++ b/src/app/api/purchases/route.ts @@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db"; import { Purchase } from "@/lib/server/models/Purchase"; import { Product } from "@/lib/server/models/Product"; import { Branch } from "@/lib/server/models/Branch"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -12,11 +13,14 @@ export const dynamic = "force-dynamic"; // GET /api/purchases?branchId=&from=&to= export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const url = new URL(req.url); - const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); + const scope = branchParam(auth, url.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const scopeBranch = scope.branchId; const filter: Record = { businessId: auth.businessId }; if (scopeBranch) filter.branchId = scopeBranch; @@ -41,9 +45,9 @@ export async function GET(req: NextRequest) { } const createSchema = z.object({ - branchId: z.string().trim().min(1).optional(), + branchId: objectId.optional(), supplierName: z.string().trim().min(1).max(120), - productId: z.string().trim().min(1).optional(), + productId: objectId.optional(), productLabel: z.string().trim().min(1).max(120).optional(), quantity: z.number().int().positive().max(1_000_000), unitCost: z.number().nonnegative().max(100_000_000), @@ -57,8 +61,9 @@ const createSchema = z.object({ // branch stock. export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); diff --git a/src/app/api/reports/profit/route.ts b/src/app/api/reports/profit/route.ts index 89133d0..706fbf0 100644 --- a/src/app/api/reports/profit/route.ts +++ b/src/app/api/reports/profit/route.ts @@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db"; import { Transaction } from "@/lib/server/models/Transaction"; import { Purchase } from "@/lib/server/models/Purchase"; import { Expense } from "@/lib/server/models/Expense"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth"; +import { startOfMonthNairobi } from "@/lib/server/businessDay"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -17,17 +18,21 @@ export const dynamic = "force-dynamic"; // Net Profit = Gross Profit - Expenses export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + // Revenue, costs and profit are owner/manager information, never a cashier's. + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const url = new URL(req.url); - const branchId = resolveBranchScope(auth, url.searchParams.get("branchId")); + const scope = branchParam(auth, url.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const branchId = scope.branchId; - // Default range: start of the current month -> now. + // Default range: start of the current business month (EAT) -> now. const now = new Date(); - const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0); - const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom; + const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now); const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now; + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date"); const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null; const businessObjId = new mongoose.Types.ObjectId(auth.businessId); diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 3de7b63..7b8348f 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -1,25 +1,27 @@ import { NextRequest, NextResponse } from "next/server"; import { connectDb } from "@/lib/server/db"; import { Transaction } from "@/lib/server/models/Transaction"; -import { getAuth, resolveBranchScope } from "@/lib/server/auth"; +import { authorize, branchParam } from "@/lib/server/auth"; +import { startOfTodayNairobi } from "@/lib/server/businessDay"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + const gate = authorize(req); + if (!gate.ok) return gate.response; + const auth = gate.auth; - const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const branchId = scope.branchId; const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200); const filter: Record = { businessId: auth.businessId }; if (branchId) filter.branchId = branchId; - const startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - filter.createdAt = { $gte: startOfDay }; + filter.createdAt = { $gte: startOfTodayNairobi() }; const transactions = await Transaction.find(filter) .sort({ createdAt: -1 }) diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..0572791 --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isValidObjectId } from "mongoose"; +import { connectDb } from "@/lib/server/db"; +import { User } from "@/lib/server/models/User"; +import { Branch } from "@/lib/server/models/Branch"; +import { Business } from "@/lib/server/models/Business"; +import { UserSession } from "@/lib/server/models/UserSession"; +import { authorize, MANAGERS } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; + +export const runtime = "nodejs"; + +const updateSchema = z + .object({ + isActive: z.boolean().optional(), + branchId: objectId.optional(), // owner only: move staff to another branch + }) + .refine((v) => v.isActive !== undefined || v.branchId !== undefined, { message: "No fields to update" }); + +/** + * PATCH /api/users/:id — deactivate/reactivate a staff account, or (owner + * only) move it to another branch. This is how an owner "removes" a user: + * the account and its sales history stay, but it can no longer sign in. + * + * SUPER_ADMIN any user in the business except themselves and the owner of record + * BRANCH_MANAGER only DELIVERY staff in their own branch, and only isActive + */ +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + await connectDb(); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 }); + + const json = await req.json().catch(() => null); + const parsed = updateSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); + } + const { isActive, branchId } = parsed.data; + + const target = await User.findOne({ _id: params.id, businessId: auth.businessId }); + if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 }); + + if (target._id.toString() === auth.sub) { + return NextResponse.json({ error: "You cannot change your own account here" }, { status: 403 }); + } + + if (auth.role === "BRANCH_MANAGER") { + const ownStaff = target.role === "DELIVERY" && target.branchId?.toString() === auth.branchId; + if (!ownStaff) { + return NextResponse.json({ error: "Branch managers may only manage delivery staff in their own branch" }, { status: 403 }); + } + if (branchId !== undefined) { + return NextResponse.json({ error: "Only the owner can move staff between branches" }, { status: 403 }); + } + } + + const business = await Business.findById(auth.businessId).select("ownerId").lean(); + if (business && business.ownerId.toString() === target._id.toString()) { + return NextResponse.json({ error: "The business owner's account cannot be changed" }, { status: 403 }); + } + + if (branchId !== undefined) { + if (target.role === "SUPER_ADMIN") { + return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 }); + } + const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean(); + if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); + target.branchId = branch._id; + } + if (isActive !== undefined) target.isActive = isActive; + await target.save(); + + // Revoke every device session so a deactivated (or moved) user cannot + // mint new access tokens; any current access token expires within 15m. + if (isActive === false || branchId !== undefined) { + await UserSession.deleteMany({ userId: target._id }); + } + + const obj = target.toObject() as unknown as Record; + delete obj.passwordHash; + return NextResponse.json({ user: obj }); +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 596a816..c821cbd 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { User } from "@/lib/server/models/User"; import { Branch } from "@/lib/server/models/Branch"; -import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; import { normalizeKenyanPhone } from "@/lib/server/phone"; import { userLimitError, loadTier } from "@/lib/server/subscription"; @@ -13,14 +14,14 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; - const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId")); - const filter: Record = { businessId: auth!.businessId }; - if (branchId) filter.branchId = branchId; + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const filter: Record = { businessId: auth.businessId }; + if (scope.branchId) filter.branchId = scope.branchId; const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean(); return NextResponse.json({ users }); } @@ -28,7 +29,7 @@ export async function GET(req: NextRequest) { const createSchema = z.object({ name: z.string().trim().min(1).max(120), role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]), - branchId: z.string().min(1).optional(), + branchId: objectId.optional(), phoneNumber: z.string().min(9).max(15), email: z.string().email(), password: z.string().min(8), @@ -36,10 +37,9 @@ const createSchema = z.object({ export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const parsed = createSchema.safeParse(json); @@ -48,22 +48,22 @@ export async function POST(req: NextRequest) { } const data = parsed.data; - if (auth!.role === "BRANCH_MANAGER") { + if (auth.role === "BRANCH_MANAGER") { if (data.role !== "DELIVERY") { return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 }); } - if (data.branchId && data.branchId !== auth!.branchId) { + if (data.branchId && data.branchId !== auth.branchId) { return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 }); } } - const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined; + const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined; if (data.role !== "SUPER_ADMIN" && !branchId) { return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 }); } if (branchId) { - const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); + const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean(); if (!branch) { return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); } @@ -77,15 +77,15 @@ export async function POST(req: NextRequest) { } // Enforce the plan's user cap (counts owner + managers + staff). - const tier = await loadTier(auth!.businessId); - const userErr = await userLimitError(auth!.businessId, tier); + const tier = await loadTier(auth.businessId); + const userErr = await userLimitError(auth.businessId, tier); if (userErr) return NextResponse.json({ error: userErr }, { status: 403 }); const passwordHash = await bcrypt.hash(data.password, 12); try { const user = await User.create({ - businessId: auth!.businessId, + businessId: auth.businessId, name: data.name, role: data.role, branchId: branchId ?? null, @@ -96,7 +96,7 @@ export async function POST(req: NextRequest) { if (data.role === "BRANCH_MANAGER" && branchId) { await Branch.findOneAndUpdate( - { _id: branchId, businessId: auth!.businessId }, + { _id: branchId, businessId: auth.businessId }, { managerId: user._id } ); } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 66bfe82..0ce35dc 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,4 +1,5 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { isValidObjectId } from "mongoose"; import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt"; import { UserRole } from "@/lib/server/models/User"; @@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?: return auth.branchId; } +export const MANAGERS: UserRole[] = ["BRANCH_MANAGER", "SUPER_ADMIN"]; + +type Gate = { ok: true; auth: AccessTokenPayload } | { ok: false; response: NextResponse }; + +/** + * Authenticate the request and, when `roles` is given, authorize it. + * 401 = not signed in; 403 = signed in with the wrong role. Every route + * that limits roles must use this rather than relying on the admin pages + * redirecting — the PRD requires enforcement on the backend too. + */ +export function authorize(req: NextRequest, roles?: UserRole[]): Gate { + const auth = getAuth(req); + if (!auth) { + return { ok: false, response: NextResponse.json({ error: "Not authenticated" }, { status: 401 }) }; + } + if (roles && !roles.includes(auth.role)) { + return { + ok: false, + response: NextResponse.json({ error: "You do not have permission to do this" }, { status: 403 }), + }; + } + return { ok: true, auth }; +} + +type BranchParam = { ok: true; branchId: string | null } | { ok: false; response: NextResponse }; + +/** + * Resolve the `branchId` query parameter for a read route: owners may pick + * a branch or ALL, everyone else is pinned to their own branch. Rejects a + * malformed id with 400 instead of letting ObjectId() throw a 500. + */ +export function branchParam(auth: AccessTokenPayload, raw: string | null | undefined): BranchParam { + const branchId = resolveBranchScope(auth, raw); + if (branchId && !isValidObjectId(branchId)) { + return { ok: false, response: NextResponse.json({ error: "Invalid branchId" }, { status: 400 }) }; + } + return { ok: true, branchId }; +} + +export function badRequest(message: string): NextResponse { + return NextResponse.json({ error: message }, { status: 400 }); +} + export function isProd(): boolean { return process.env.NODE_ENV === "production"; } diff --git a/src/lib/server/businessDay.ts b/src/lib/server/businessDay.ts new file mode 100644 index 0000000..035b5e8 --- /dev/null +++ b/src/lib/server/businessDay.ts @@ -0,0 +1,40 @@ +// The business day for every tenant is the Kenyan calendar day +// (Africa/Nairobi, EAT). EAT is a fixed UTC+3 with no daylight saving, so a +// constant offset is exact — no timezone database needed. +// +// Never use `new Date().setHours(0)` or `toISOString().slice(0, 10)` for +// "today" on the server: on Vercel the server clock is UTC, which would +// start the business day at 03:00 EAT and push early-morning sales onto +// the previous day. + +const EAT_OFFSET_MS = 3 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Today's business day as YYYY-MM-DD. */ +export function todayNairobi(now: Date = new Date()): string { + return new Date(now.getTime() + EAT_OFFSET_MS).toISOString().slice(0, 10); +} + +export function isBusinessDate(value: string): boolean { + if (!DATE_RE.test(value)) return false; + const d = new Date(value + "T00:00:00.000Z"); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; +} + +/** The UTC instants [start, end) covering one business day. */ +export function dayRangeNairobi(date: string): { start: Date; end: Date } { + if (!isBusinessDate(date)) throw new Error(`Invalid business date: ${date}`); + const start = new Date(new Date(date + "T00:00:00.000Z").getTime() - EAT_OFFSET_MS); + return { start, end: new Date(start.getTime() + DAY_MS) }; +} + +/** Start of the business day containing `now`. */ +export function startOfTodayNairobi(now: Date = new Date()): Date { + return dayRangeNairobi(todayNairobi(now)).start; +} + +/** Start of the current business month (1st, 00:00 EAT). */ +export function startOfMonthNairobi(now: Date = new Date()): Date { + return dayRangeNairobi(todayNairobi(now).slice(0, 8) + "01").start; +} diff --git a/src/lib/server/env.ts b/src/lib/server/env.ts index 85cb7b9..393222a 100644 --- a/src/lib/server/env.ts +++ b/src/lib/server/env.ts @@ -1,26 +1,60 @@ import { z } from "zod"; -const schema = z.object({ - NODE_ENV: z.enum(["development", "production", "test"]).default("development"), - MONGODB_URI: z.string().min(1), +const booleanFlag = z + .enum(["true", "false", "1", "0", ""]) + .optional() + .transform((v) => v === "true" || v === "1"); - JWT_ACCESS_SECRET: z.string().min(16), - JWT_REFRESH_SECRET: z.string().min(16), - JWT_ACCESS_TTL: z.string().default("15m"), - JWT_REFRESH_TTL: z.string().default("30d"), - DELIVERY_MAX_DEVICES: z.coerce.number().default(2), +const schema = z + .object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + MONGODB_URI: z.string().min(1), - DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"), - DARAJA_CONSUMER_KEY: z.string().min(1), - DARAJA_CONSUMER_SECRET: z.string().min(1), - DARAJA_SHORTCODE: z.string().min(1), - DARAJA_PASSKEY: z.string().min(1), - DARAJA_TRANSACTION_TYPE: z - .enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]) - .default("CustomerPayBillOnline"), - DARAJA_CALLBACK_URL: z.string().url(), - DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(), -}); + JWT_ACCESS_SECRET: z.string().min(16), + JWT_REFRESH_SECRET: z.string().min(16), + JWT_ACCESS_TTL: z.string().default("15m"), + JWT_REFRESH_TTL: z.string().default("30d"), + DELIVERY_MAX_DEVICES: z.coerce.number().default(2), + + // M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it + // is OFF unless explicitly enabled. When off, the Daraja variables are + // not required and every /api/mpesa route answers 404. + FEATURE_MPESA: booleanFlag, + + DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"), + DARAJA_CONSUMER_KEY: z.string().optional(), + DARAJA_CONSUMER_SECRET: z.string().optional(), + DARAJA_SHORTCODE: z.string().optional(), + DARAJA_PASSKEY: z.string().optional(), + DARAJA_TRANSACTION_TYPE: z + .enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]) + .default("CustomerPayBillOnline"), + DARAJA_CALLBACK_URL: z.string().url().optional(), + DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(), + }) + .superRefine((env, ctx) => { + if (!env.FEATURE_MPESA) return; + const required = [ + "DARAJA_CONSUMER_KEY", + "DARAJA_CONSUMER_SECRET", + "DARAJA_SHORTCODE", + "DARAJA_PASSKEY", + "DARAJA_CALLBACK_URL", + ] as const; + for (const key of required) { + if (!env[key]) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "required when FEATURE_MPESA=true" }); + } + } + // An open callback endpoint lets anyone mark a pending sale as paid. + if (env.NODE_ENV === "production" && !env.DARAJA_CALLBACK_IP_ALLOWLIST) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DARAJA_CALLBACK_IP_ALLOWLIST"], + message: "required in production when FEATURE_MPESA=true", + }); + } + }); type ServerEnv = z.infer; let cached: ServerEnv | null = null; @@ -35,3 +69,22 @@ export function getEnv(): ServerEnv { cached = parsed.data; return cached; } + +export function isMpesaEnabled(): boolean { + return getEnv().FEATURE_MPESA; +} + +/** Daraja settings, only valid when FEATURE_MPESA is on (validated above). */ +export function getDarajaEnv() { + const env = getEnv(); + if (!env.FEATURE_MPESA) throw new Error("M-Pesa is disabled (FEATURE_MPESA is not true)"); + return { + DARAJA_ENV: env.DARAJA_ENV, + DARAJA_CONSUMER_KEY: env.DARAJA_CONSUMER_KEY!, + DARAJA_CONSUMER_SECRET: env.DARAJA_CONSUMER_SECRET!, + DARAJA_SHORTCODE: env.DARAJA_SHORTCODE!, + DARAJA_PASSKEY: env.DARAJA_PASSKEY!, + DARAJA_TRANSACTION_TYPE: env.DARAJA_TRANSACTION_TYPE, + DARAJA_CALLBACK_URL: env.DARAJA_CALLBACK_URL!, + }; +} diff --git a/src/lib/server/models/Product.ts b/src/lib/server/models/Product.ts index fb656fe..b14f4bc 100644 --- a/src/lib/server/models/Product.ts +++ b/src/lib/server/models/Product.ts @@ -10,6 +10,7 @@ export interface IProduct extends Document { unitPrice: number; sortOrder: number; isActive: boolean; + lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning createdAt: Date; updatedAt: Date; } @@ -24,6 +25,7 @@ const productSchema = new Schema( unitPrice: { type: Number, required: true, min: 0 }, sortOrder: { type: Number, default: 0 }, isActive: { type: Boolean, default: true, index: true }, + lowStockThreshold: { type: Number, default: 10, min: 0 }, }, { timestamps: true } ); diff --git a/src/lib/server/mpesa.ts b/src/lib/server/mpesa.ts index 1948f78..b0d9ba0 100644 --- a/src/lib/server/mpesa.ts +++ b/src/lib/server/mpesa.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { getEnv } from "@/lib/server/env"; +import { getDarajaEnv as getEnv } from "@/lib/server/env"; function baseUrl(): string { return getEnv().DARAJA_ENV === "production" diff --git a/src/lib/server/subscription.ts b/src/lib/server/subscription.ts index befbc76..002ba24 100644 --- a/src/lib/server/subscription.ts +++ b/src/lib/server/subscription.ts @@ -8,7 +8,7 @@ import { Types } from "mongoose"; import { Branch } from "@/lib/server/models/Branch"; import { User } from "@/lib/server/models/User"; import { Business, IBusiness } from "@/lib/server/models/Business"; -import { TIERS, type TierId } from "@/lib/tiers"; +import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers"; export function tierOf(business: Pick): TierId { return (business.tier as TierId) ?? "STARTER"; @@ -52,4 +52,19 @@ export async function userLimitError( export async function loadTier(businessId: Types.ObjectId | string): Promise { const biz = await Business.findById(businessId).select("tier").lean(); return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness); -} \ No newline at end of file +} + +/** + * Returns a user-facing reason this tenant may not use the app right now, + * or null when access is allowed. Checked at login and on every token + * refresh, so a suspension takes effect within one access-token lifetime + * (15 minutes) even for devices that are already signed in. + */ +export async function tenantAccessError(businessId: Types.ObjectId | string): Promise { + const biz = await Business.findById(businessId).select("isActive subscriptionStatus").lean(); + if (!biz || !biz.isActive) return "This business account has been deactivated."; + if (!isUsable((biz.subscriptionStatus as SubscriptionStatus) ?? "TRIALING")) { + return "This business account is suspended. Contact support to restore access."; + } + return null; +} diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts new file mode 100644 index 0000000..401bf49 --- /dev/null +++ b/src/lib/server/validation.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import { isValidObjectId } from "mongoose"; + +/** A MongoDB ObjectId string. Validating up front turns a would-be CastError 500 into a 400. */ +export const objectId = z + .string() + .trim() + .refine((v) => isValidObjectId(v) && /^[0-9a-f]{24}$/i.test(v), { message: "Invalid id" }); diff --git a/tests/access.test.ts b/tests/access.test.ts new file mode 100644 index 0000000..a1e7f82 --- /dev/null +++ b/tests/access.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import bcrypt from "bcryptjs"; +import { Business } from "@/lib/server/models/Business"; +import { User } from "@/lib/server/models/User"; +import { UserSession } from "@/lib/server/models/UserSession"; +import * as profit from "@/app/api/reports/profit/route"; +import * as expenses from "@/app/api/expenses/route"; +import * as purchases from "@/app/api/purchases/route"; +import * as products from "@/app/api/products/route"; +import * as productById from "@/app/api/products/[id]/route"; +import * as userById from "@/app/api/users/[id]/route"; +import * as branchById from "@/app/api/branches/[id]/route"; +import * as summary from "@/app/api/dashboard/summary/route"; +import * as login from "@/app/api/auth/login/route"; +import * as signup from "@/app/api/auth/signup/route"; +import * as stkpush from "@/app/api/mpesa/stkpush/route"; +import * as callback from "@/app/api/mpesa/callback/route"; +import { json, makeTenant, params, req, uuid } from "./helpers"; + +describe("role-based access on the backend", () => { + it("blocks cashiers from owner-level money endpoints with 403", async () => { + const t = await makeTenant(); + const as = t.cashierA; + const responses = await Promise.all([ + profit.GET(req("GET", "/api/reports/profit", { as })), + expenses.GET(req("GET", "/api/expenses", { as })), + purchases.GET(req("GET", "/api/purchases", { as })), + expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })), + purchases.POST( + req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } }) + ), + ]); + expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]); + }); + + it("still lets a branch manager use them for their own branch", async () => { + const t = await makeTenant(); + const res = await expenses.POST( + req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } }) + ); + expect(res.status).toBe(201); + expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200); + }); + + it("returns 401, not 403, when signed out", async () => { + expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401); + }); + + it("only lets the owner create or reprice products", async () => { + const t = await makeTenant(); + const body = { label: "1L", sizeLiters: 1, unitPrice: 20 }; + expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403); + expect( + (await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 }))) + .status + ).toBe(403); + expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201); + }); + + it("rejects a malformed branchId with 400 instead of crashing", async () => { + const t = await makeTenant(); + const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner })); + expect(res.status).toBe(400); + }); +}); + +describe("user deactivation", () => { + it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => { + const t = await makeTenant(); + await UserSession.create({ + userId: t.cashierA.id, + deviceId: uuid(), + refreshTokenHash: "h", + expiresAt: new Date(Date.now() + 86_400_000), + }); + const res = await userById.PATCH( + req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }), + params({ id: t.cashierA.id }) + ); + expect(res.status).toBe(200); + expect((await User.findById(t.cashierA.id))!.isActive).toBe(false); + expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0); + }); + + it("does not let a manager touch another branch's staff", async () => { + const t = await makeTenant(); + const res = await userById.PATCH( + req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }), + params({ id: t.cashierB.id }) + ); + expect(res.status).toBe(403); + }); + + it("does not let the owner deactivate themselves", async () => { + const t = await makeTenant(); + const res = await userById.PATCH( + req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), + params({ id: t.owner.id }) + ); + expect(res.status).toBe(403); + }); +}); + +describe("tenant status", () => { + it("refuses login for a suspended business", async () => { + const t = await makeTenant(); + const passwordHash = await bcrypt.hash("correct-horse", 4); + await User.updateOne({ _id: t.cashierA.id }, { passwordHash }); + await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" }); + + const res = await login.POST( + req("POST", "/api/auth/login", { + body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() }, + }) + ); + expect(res.status).toBe(403); + }); + + it("still allows login for an active business", async () => { + const t = await makeTenant(); + const passwordHash = await bcrypt.hash("correct-horse", 4); + await User.updateOne({ _id: t.managerA.id }, { passwordHash }); + const res = await login.POST( + req("POST", "/api/auth/login", { + body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() }, + }) + ); + expect(res.status).toBe(200); + }); +}); + +describe("platform and M-Pesa hardening", () => { + it("refuses public signup with a platform-operator email", async () => { + const res = await signup.POST( + req("POST", "/api/auth/signup", { + body: { + businessName: "Takeover Ltd", + ownerName: "Mallory", + email: "operator@aquaflow.test", + phoneNumber: "0712345678", + password: "password123", + deviceId: uuid(), + }, + }) + ); + expect(res.status).toBe(403); + }); + + it("hides every M-Pesa route when FEATURE_MPESA is off", async () => { + const t = await makeTenant(); + const push = await stkpush.POST( + req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } }) + ); + const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } })); + expect([push.status, cb.status]).toEqual([404, 404]); + }); + + it("lets only the owner rename a branch", async () => { + const t = await makeTenant(); + const body = { branchName: "Renamed" }; + expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403); + const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA }))); + expect(ok.status).toBe(200); + expect(ok.body.branch.branchName).toBe("Renamed"); + }); +}); diff --git a/tests/businessDay.test.ts b/tests/businessDay.test.ts new file mode 100644 index 0000000..f208b89 --- /dev/null +++ b/tests/businessDay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay"; + +describe("Nairobi business day", () => { + it("rolls over at midnight EAT, not midnight UTC", () => { + // 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct. + expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12"); + // 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct. + expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11"); + }); + + it("maps a business date to the UTC range 21:00 -> 21:00", () => { + const { start, end } = dayRangeNairobi("2026-10-12"); + expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z"); + expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z"); + }); + + it("starts the month on the 1st at 00:00 EAT", () => { + expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z"); + expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z"); + }); + + it("rejects malformed dates", () => { + expect(isBusinessDate("2026-02-30")).toBe(false); + expect(isBusinessDate("2026-2-3")).toBe(false); + expect(isBusinessDate("2026-02-28")).toBe(true); + expect(() => dayRangeNairobi("nope")).toThrow(); + }); +}); diff --git a/tests/globalSetup.ts b/tests/globalSetup.ts new file mode 100644 index 0000000..7716e8e --- /dev/null +++ b/tests/globalSetup.ts @@ -0,0 +1,12 @@ +import { MongoMemoryServer } from "mongodb-memory-server"; + +let server: MongoMemoryServer | null = null; + +export async function setup({ provide }: { provide: (key: string, value: string) => void }) { + server = await MongoMemoryServer.create(); + provide("mongoUri", server.getUri("aquaflow-test")); +} + +export async function teardown() { + await server?.stop(); +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..b52c9cf --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "crypto"; +import mongoose from "mongoose"; +import { NextRequest } from "next/server"; +import { Business } from "@/lib/server/models/Business"; +import { Branch } from "@/lib/server/models/Branch"; +import { User, UserRole } from "@/lib/server/models/User"; +import { Product } from "@/lib/server/models/Product"; +import { signAccessToken } from "@/lib/server/jwt"; +import { ACCESS_COOKIE } from "@/lib/server/auth"; + +let phoneSeq = 0; +function nextPhone(): string { + phoneSeq += 1; + return "2547" + String(10_000_000 + phoneSeq).slice(-8); +} + +export interface Actor { + id: string; + businessId: string; + role: UserRole; + branchId: string | null; + email: string; +} + +export interface Tenant { + businessId: string; + owner: Actor; + branchA: string; + branchB: string; + managerA: Actor; + cashierA: Actor; + cashierB: Actor; + water20: string; + water5: string; +} + +async function makeUser( + businessId: string, + role: UserRole, + branchId: string | null, + label: string +): Promise { + const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`; + const user = await User.create({ + businessId, + name: label, + role, + branchId, + phoneNumber: nextPhone(), + email, + passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in + }); + return { id: user._id.toString(), businessId, role, branchId, email }; +} + +/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */ +export async function makeTenant(name = "Tenant"): Promise { + const business = await Business.create({ + name, + ownerId: new mongoose.Types.ObjectId(), + tier: "ENTERPRISE", + subscriptionStatus: "ACTIVE", + }); + const businessId = business._id.toString(); + const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner"); + business.ownerId = new mongoose.Types.ObjectId(owner.id); + await business.save(); + + const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" }); + const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" }); + const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA"); + const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA"); + const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB"); + + const [p20, p5] = await Product.insertMany([ + { businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 }, + { businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 }, + ]); + + return { + businessId, + owner, + branchA: a._id.toString(), + branchB: b._id.toString(), + managerA, + cashierA, + cashierB, + water20: p20._id.toString(), + water5: p5._id.toString(), + }; +} + +export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string { + return signAccessToken({ + sub: actor.id, + businessId: actor.businessId, + role: actor.role, + branchId: actor.branchId, + deviceId, + }); +} + +export function req( + method: string, + path: string, + opts: { as?: Actor; body?: unknown; cookies?: Record } = {} +): NextRequest { + const cookies: Record = { ...(opts.cookies ?? {}) }; + if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as); + const cookieHeader = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + return new NextRequest(new URL(path, "http://localhost"), { + method, + headers: { + "content-type": "application/json", + ...(cookieHeader ? { cookie: cookieHeader } : {}), + }, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); +} + +export function params>(p: T) { + return { params: p }; +} + +export async function json(res: Response): Promise<{ status: number; body: T }> { + return { status: res.status, body: (await res.json()) as T }; +} + +export function uuid(): string { + return randomUUID(); +} diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..6caf82b --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,26 @@ +import { afterAll, beforeEach, inject } from "vitest"; +import mongoose from "mongoose"; +import { connectDb } from "@/lib/server/db"; + +declare module "vitest" { + export interface ProvidedContext { + mongoUri: string; + } +} + +process.env.MONGODB_URI = inject("mongoUri"); +process.env.JWT_ACCESS_SECRET = "test-access-secret-0123456789"; +process.env.JWT_REFRESH_SECRET = "test-refresh-secret-0123456789"; +process.env.PLATFORM_ADMIN_EMAILS = "operator@aquaflow.test"; + +beforeEach(async () => { + await connectDb(); + const collections = await mongoose.connection.db!.collections(); + await Promise.all(collections.map((c) => c.deleteMany({}))); + await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes())); +}); + +afterAll(async () => { + await mongoose.disconnect(); + global.__mongooseCache = { conn: null, promise: null }; +}); diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index d66aeb6..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/future/route-kind.d.ts","./node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/font-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/client/components/request-async-storage-instance.d.ts","./node_modules/next/dist/client/components/request-async-storage.external.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","./node_modules/next/dist/client/components/app-router.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/action-async-storage-instance.d.ts","./node_modules/next/dist/client/components/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/search-params.d.ts","./node_modules/next/dist/client/components/not-found-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/lib/builtin-request-context.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/future/normalizers/normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","./node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","./node_modules/next/dist/server/future/normalizers/request/action.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/types/index.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/client/components/draft-mode.d.ts","./node_modules/next/dist/client/components/headers.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corepluginlist.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./node_modules/dotenv/lib/main.d.ts","./node_modules/bson/bson.d.ts","./node_modules/mongodb/mongodb.d.ts","./node_modules/mongoose/types/aggregate.d.ts","./node_modules/mongoose/types/callback.d.ts","./node_modules/mongoose/types/collection.d.ts","./node_modules/mongoose/types/connection.d.ts","./node_modules/mongoose/types/cursor.d.ts","./node_modules/mongoose/types/document.d.ts","./node_modules/mongoose/types/error.d.ts","./node_modules/mongoose/types/expressions.d.ts","./node_modules/mongoose/types/helpers.d.ts","./node_modules/kareem/index.d.ts","./node_modules/mongoose/types/middlewares.d.ts","./node_modules/mongoose/types/indexes.d.ts","./node_modules/mongoose/types/models.d.ts","./node_modules/mongoose/types/mongooseoptions.d.ts","./node_modules/mongoose/types/pipelinestage.d.ts","./node_modules/mongoose/types/populate.d.ts","./node_modules/mongoose/types/query.d.ts","./node_modules/mongoose/types/schemaoptions.d.ts","./node_modules/mongoose/types/schematypes.d.ts","./node_modules/mongoose/types/session.d.ts","./node_modules/mongoose/types/types.d.ts","./node_modules/mongoose/types/utility.d.ts","./node_modules/mongoose/types/validation.d.ts","./node_modules/mongoose/types/inferschematype.d.ts","./node_modules/mongoose/types/inferrawdoctype.d.ts","./node_modules/mongoose/types/virtuals.d.ts","./node_modules/mongoose/types/augmentations.d.ts","./node_modules/mongoose/types/index.d.ts","./src/lib/server/db.ts","./node_modules/@types/bcryptjs/index.d.ts","./src/lib/server/models/user.ts","./src/lib/server/models/branch.ts","./src/lib/server/models/product.ts","./src/lib/server/models/transaction.ts","./src/lib/server/models/dailyinventory.ts","./src/lib/server/models/expense.ts","./src/lib/server/models/purchase.ts","./src/lib/tiers.ts","./src/lib/server/models/business.ts","./scripts/migrate-multitenant.ts","./scripts/seed.ts","./node_modules/zod/v3/helpers/typealiases.d.cts","./node_modules/zod/v3/helpers/util.d.cts","./node_modules/zod/v3/index.d.cts","./node_modules/zod/v3/zoderror.d.cts","./node_modules/zod/v3/locales/en.d.cts","./node_modules/zod/v3/errors.d.cts","./node_modules/zod/v3/helpers/parseutil.d.cts","./node_modules/zod/v3/helpers/enumutil.d.cts","./node_modules/zod/v3/helpers/errorutil.d.cts","./node_modules/zod/v3/helpers/partialutil.d.cts","./node_modules/zod/v3/standard-schema.d.cts","./node_modules/zod/v3/types.d.cts","./node_modules/zod/v3/external.d.cts","./node_modules/zod/index.d.cts","./src/lib/server/models/usersession.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/jsonwebtoken/index.d.ts","./src/lib/server/env.ts","./src/lib/server/jwt.ts","./src/lib/server/auth.ts","./src/lib/server/platform.ts","./src/app/api/auth/login/route.ts","./src/app/api/auth/logout/route.ts","./src/app/api/auth/me/route.ts","./src/app/api/auth/refresh/route.ts","./src/app/api/auth/sessions/route.ts","./src/app/api/auth/sessions/[deviceid]/route.ts","./src/lib/server/phone.ts","./src/app/api/auth/signup/route.ts","./src/lib/server/subscription.ts","./src/app/api/branches/route.ts","./src/app/api/dashboard/reconciliation/route.ts","./src/app/api/dashboard/summary/route.ts","./src/app/api/expenses/route.ts","./src/app/api/health/route.ts","./src/app/api/inventory/dispatch/route.ts","./src/app/api/inventory/evening-count/route.ts","./src/lib/server/models/mpesacallbacklog.ts","./src/app/api/mpesa/callback/route.ts","./node_modules/axios/index.d.ts","./src/lib/server/mpesa.ts","./src/app/api/mpesa/status/[transactionid]/route.ts","./src/app/api/mpesa/stkpush/route.ts","./src/app/api/platform/businesses/route.ts","./src/app/api/platform/businesses/[id]/route.ts","./src/app/api/products/route.ts","./src/app/api/products/[id]/route.ts","./src/app/api/purchases/route.ts","./src/app/api/reports/profit/route.ts","./src/app/api/transactions/route.ts","./src/app/api/transactions/cash/route.ts","./src/app/api/users/route.ts","./src/lib/deviceid.ts","./src/lib/api.ts","./node_modules/idb/build/wrap-idb-value.d.ts","./node_modules/idb/build/entry.d.ts","./node_modules/idb/build/database-extras.d.ts","./node_modules/idb/build/async-iterators.d.ts","./node_modules/idb/build/index.d.ts","./src/lib/offlinequeue.ts","./src/lib/phone.ts","./src/lib/types.ts","./src/lib/pollingfeed.ts","./src/components/ui/toast.tsx","./src/app/layout.tsx","./src/app/page.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/emptystate.tsx","./src/app/admin/branches/page.tsx","./src/components/admin/globalswitcher.tsx","./src/components/admin/financialoverviewcards.tsx","./src/components/admin/paymentsplitdonut.tsx","./src/components/admin/radialgauge.tsx","./src/components/admin/livestatsrow.tsx","./src/components/admin/branchrankinglist.tsx","./node_modules/date-fns/locale/types.d.ts","./node_modules/date-fns/fp/types.d.ts","./node_modules/date-fns/types.d.ts","./node_modules/date-fns/add.d.ts","./node_modules/date-fns/addbusinessdays.d.ts","./node_modules/date-fns/adddays.d.ts","./node_modules/date-fns/addhours.d.ts","./node_modules/date-fns/addisoweekyears.d.ts","./node_modules/date-fns/addmilliseconds.d.ts","./node_modules/date-fns/addminutes.d.ts","./node_modules/date-fns/addmonths.d.ts","./node_modules/date-fns/addquarters.d.ts","./node_modules/date-fns/addseconds.d.ts","./node_modules/date-fns/addweeks.d.ts","./node_modules/date-fns/addyears.d.ts","./node_modules/date-fns/areintervalsoverlapping.d.ts","./node_modules/date-fns/clamp.d.ts","./node_modules/date-fns/closestindexto.d.ts","./node_modules/date-fns/closestto.d.ts","./node_modules/date-fns/compareasc.d.ts","./node_modules/date-fns/comparedesc.d.ts","./node_modules/date-fns/constructfrom.d.ts","./node_modules/date-fns/constructnow.d.ts","./node_modules/date-fns/daystoweeks.d.ts","./node_modules/date-fns/differenceinbusinessdays.d.ts","./node_modules/date-fns/differenceincalendardays.d.ts","./node_modules/date-fns/differenceincalendarisoweekyears.d.ts","./node_modules/date-fns/differenceincalendarisoweeks.d.ts","./node_modules/date-fns/differenceincalendarmonths.d.ts","./node_modules/date-fns/differenceincalendarquarters.d.ts","./node_modules/date-fns/differenceincalendarweeks.d.ts","./node_modules/date-fns/differenceincalendaryears.d.ts","./node_modules/date-fns/differenceindays.d.ts","./node_modules/date-fns/differenceinhours.d.ts","./node_modules/date-fns/differenceinisoweekyears.d.ts","./node_modules/date-fns/differenceinmilliseconds.d.ts","./node_modules/date-fns/differenceinminutes.d.ts","./node_modules/date-fns/differenceinmonths.d.ts","./node_modules/date-fns/differenceinquarters.d.ts","./node_modules/date-fns/differenceinseconds.d.ts","./node_modules/date-fns/differenceinweeks.d.ts","./node_modules/date-fns/differenceinyears.d.ts","./node_modules/date-fns/eachdayofinterval.d.ts","./node_modules/date-fns/eachhourofinterval.d.ts","./node_modules/date-fns/eachminuteofinterval.d.ts","./node_modules/date-fns/eachmonthofinterval.d.ts","./node_modules/date-fns/eachquarterofinterval.d.ts","./node_modules/date-fns/eachweekofinterval.d.ts","./node_modules/date-fns/eachweekendofinterval.d.ts","./node_modules/date-fns/eachweekendofmonth.d.ts","./node_modules/date-fns/eachweekendofyear.d.ts","./node_modules/date-fns/eachyearofinterval.d.ts","./node_modules/date-fns/endofday.d.ts","./node_modules/date-fns/endofdecade.d.ts","./node_modules/date-fns/endofhour.d.ts","./node_modules/date-fns/endofisoweek.d.ts","./node_modules/date-fns/endofisoweekyear.d.ts","./node_modules/date-fns/endofminute.d.ts","./node_modules/date-fns/endofmonth.d.ts","./node_modules/date-fns/endofquarter.d.ts","./node_modules/date-fns/endofsecond.d.ts","./node_modules/date-fns/endoftoday.d.ts","./node_modules/date-fns/endoftomorrow.d.ts","./node_modules/date-fns/endofweek.d.ts","./node_modules/date-fns/endofyear.d.ts","./node_modules/date-fns/endofyesterday.d.ts","./node_modules/date-fns/_lib/format/formatters.d.ts","./node_modules/date-fns/_lib/format/longformatters.d.ts","./node_modules/date-fns/format.d.ts","./node_modules/date-fns/formatdistance.d.ts","./node_modules/date-fns/formatdistancestrict.d.ts","./node_modules/date-fns/formatdistancetonow.d.ts","./node_modules/date-fns/formatdistancetonowstrict.d.ts","./node_modules/date-fns/formatduration.d.ts","./node_modules/date-fns/formatiso.d.ts","./node_modules/date-fns/formatiso9075.d.ts","./node_modules/date-fns/formatisoduration.d.ts","./node_modules/date-fns/formatrfc3339.d.ts","./node_modules/date-fns/formatrfc7231.d.ts","./node_modules/date-fns/formatrelative.d.ts","./node_modules/date-fns/fromunixtime.d.ts","./node_modules/date-fns/getdate.d.ts","./node_modules/date-fns/getday.d.ts","./node_modules/date-fns/getdayofyear.d.ts","./node_modules/date-fns/getdaysinmonth.d.ts","./node_modules/date-fns/getdaysinyear.d.ts","./node_modules/date-fns/getdecade.d.ts","./node_modules/date-fns/_lib/defaultoptions.d.ts","./node_modules/date-fns/getdefaultoptions.d.ts","./node_modules/date-fns/gethours.d.ts","./node_modules/date-fns/getisoday.d.ts","./node_modules/date-fns/getisoweek.d.ts","./node_modules/date-fns/getisoweekyear.d.ts","./node_modules/date-fns/getisoweeksinyear.d.ts","./node_modules/date-fns/getmilliseconds.d.ts","./node_modules/date-fns/getminutes.d.ts","./node_modules/date-fns/getmonth.d.ts","./node_modules/date-fns/getoverlappingdaysinintervals.d.ts","./node_modules/date-fns/getquarter.d.ts","./node_modules/date-fns/getseconds.d.ts","./node_modules/date-fns/gettime.d.ts","./node_modules/date-fns/getunixtime.d.ts","./node_modules/date-fns/getweek.d.ts","./node_modules/date-fns/getweekofmonth.d.ts","./node_modules/date-fns/getweekyear.d.ts","./node_modules/date-fns/getweeksinmonth.d.ts","./node_modules/date-fns/getyear.d.ts","./node_modules/date-fns/hourstomilliseconds.d.ts","./node_modules/date-fns/hourstominutes.d.ts","./node_modules/date-fns/hourstoseconds.d.ts","./node_modules/date-fns/interval.d.ts","./node_modules/date-fns/intervaltoduration.d.ts","./node_modules/date-fns/intlformat.d.ts","./node_modules/date-fns/intlformatdistance.d.ts","./node_modules/date-fns/isafter.d.ts","./node_modules/date-fns/isbefore.d.ts","./node_modules/date-fns/isdate.d.ts","./node_modules/date-fns/isequal.d.ts","./node_modules/date-fns/isexists.d.ts","./node_modules/date-fns/isfirstdayofmonth.d.ts","./node_modules/date-fns/isfriday.d.ts","./node_modules/date-fns/isfuture.d.ts","./node_modules/date-fns/islastdayofmonth.d.ts","./node_modules/date-fns/isleapyear.d.ts","./node_modules/date-fns/ismatch.d.ts","./node_modules/date-fns/ismonday.d.ts","./node_modules/date-fns/ispast.d.ts","./node_modules/date-fns/issameday.d.ts","./node_modules/date-fns/issamehour.d.ts","./node_modules/date-fns/issameisoweek.d.ts","./node_modules/date-fns/issameisoweekyear.d.ts","./node_modules/date-fns/issameminute.d.ts","./node_modules/date-fns/issamemonth.d.ts","./node_modules/date-fns/issamequarter.d.ts","./node_modules/date-fns/issamesecond.d.ts","./node_modules/date-fns/issameweek.d.ts","./node_modules/date-fns/issameyear.d.ts","./node_modules/date-fns/issaturday.d.ts","./node_modules/date-fns/issunday.d.ts","./node_modules/date-fns/isthishour.d.ts","./node_modules/date-fns/isthisisoweek.d.ts","./node_modules/date-fns/isthisminute.d.ts","./node_modules/date-fns/isthismonth.d.ts","./node_modules/date-fns/isthisquarter.d.ts","./node_modules/date-fns/isthissecond.d.ts","./node_modules/date-fns/isthisweek.d.ts","./node_modules/date-fns/isthisyear.d.ts","./node_modules/date-fns/isthursday.d.ts","./node_modules/date-fns/istoday.d.ts","./node_modules/date-fns/istomorrow.d.ts","./node_modules/date-fns/istuesday.d.ts","./node_modules/date-fns/isvalid.d.ts","./node_modules/date-fns/iswednesday.d.ts","./node_modules/date-fns/isweekend.d.ts","./node_modules/date-fns/iswithininterval.d.ts","./node_modules/date-fns/isyesterday.d.ts","./node_modules/date-fns/lastdayofdecade.d.ts","./node_modules/date-fns/lastdayofisoweek.d.ts","./node_modules/date-fns/lastdayofisoweekyear.d.ts","./node_modules/date-fns/lastdayofmonth.d.ts","./node_modules/date-fns/lastdayofquarter.d.ts","./node_modules/date-fns/lastdayofweek.d.ts","./node_modules/date-fns/lastdayofyear.d.ts","./node_modules/date-fns/_lib/format/lightformatters.d.ts","./node_modules/date-fns/lightformat.d.ts","./node_modules/date-fns/max.d.ts","./node_modules/date-fns/milliseconds.d.ts","./node_modules/date-fns/millisecondstohours.d.ts","./node_modules/date-fns/millisecondstominutes.d.ts","./node_modules/date-fns/millisecondstoseconds.d.ts","./node_modules/date-fns/min.d.ts","./node_modules/date-fns/minutestohours.d.ts","./node_modules/date-fns/minutestomilliseconds.d.ts","./node_modules/date-fns/minutestoseconds.d.ts","./node_modules/date-fns/monthstoquarters.d.ts","./node_modules/date-fns/monthstoyears.d.ts","./node_modules/date-fns/nextday.d.ts","./node_modules/date-fns/nextfriday.d.ts","./node_modules/date-fns/nextmonday.d.ts","./node_modules/date-fns/nextsaturday.d.ts","./node_modules/date-fns/nextsunday.d.ts","./node_modules/date-fns/nextthursday.d.ts","./node_modules/date-fns/nexttuesday.d.ts","./node_modules/date-fns/nextwednesday.d.ts","./node_modules/date-fns/parse/_lib/types.d.ts","./node_modules/date-fns/parse/_lib/setter.d.ts","./node_modules/date-fns/parse/_lib/parser.d.ts","./node_modules/date-fns/parse/_lib/parsers.d.ts","./node_modules/date-fns/parse.d.ts","./node_modules/date-fns/parseiso.d.ts","./node_modules/date-fns/parsejson.d.ts","./node_modules/date-fns/previousday.d.ts","./node_modules/date-fns/previousfriday.d.ts","./node_modules/date-fns/previousmonday.d.ts","./node_modules/date-fns/previoussaturday.d.ts","./node_modules/date-fns/previoussunday.d.ts","./node_modules/date-fns/previousthursday.d.ts","./node_modules/date-fns/previoustuesday.d.ts","./node_modules/date-fns/previouswednesday.d.ts","./node_modules/date-fns/quarterstomonths.d.ts","./node_modules/date-fns/quarterstoyears.d.ts","./node_modules/date-fns/roundtonearesthours.d.ts","./node_modules/date-fns/roundtonearestminutes.d.ts","./node_modules/date-fns/secondstohours.d.ts","./node_modules/date-fns/secondstomilliseconds.d.ts","./node_modules/date-fns/secondstominutes.d.ts","./node_modules/date-fns/set.d.ts","./node_modules/date-fns/setdate.d.ts","./node_modules/date-fns/setday.d.ts","./node_modules/date-fns/setdayofyear.d.ts","./node_modules/date-fns/setdefaultoptions.d.ts","./node_modules/date-fns/sethours.d.ts","./node_modules/date-fns/setisoday.d.ts","./node_modules/date-fns/setisoweek.d.ts","./node_modules/date-fns/setisoweekyear.d.ts","./node_modules/date-fns/setmilliseconds.d.ts","./node_modules/date-fns/setminutes.d.ts","./node_modules/date-fns/setmonth.d.ts","./node_modules/date-fns/setquarter.d.ts","./node_modules/date-fns/setseconds.d.ts","./node_modules/date-fns/setweek.d.ts","./node_modules/date-fns/setweekyear.d.ts","./node_modules/date-fns/setyear.d.ts","./node_modules/date-fns/startofday.d.ts","./node_modules/date-fns/startofdecade.d.ts","./node_modules/date-fns/startofhour.d.ts","./node_modules/date-fns/startofisoweek.d.ts","./node_modules/date-fns/startofisoweekyear.d.ts","./node_modules/date-fns/startofminute.d.ts","./node_modules/date-fns/startofmonth.d.ts","./node_modules/date-fns/startofquarter.d.ts","./node_modules/date-fns/startofsecond.d.ts","./node_modules/date-fns/startoftoday.d.ts","./node_modules/date-fns/startoftomorrow.d.ts","./node_modules/date-fns/startofweek.d.ts","./node_modules/date-fns/startofweekyear.d.ts","./node_modules/date-fns/startofyear.d.ts","./node_modules/date-fns/startofyesterday.d.ts","./node_modules/date-fns/sub.d.ts","./node_modules/date-fns/subbusinessdays.d.ts","./node_modules/date-fns/subdays.d.ts","./node_modules/date-fns/subhours.d.ts","./node_modules/date-fns/subisoweekyears.d.ts","./node_modules/date-fns/submilliseconds.d.ts","./node_modules/date-fns/subminutes.d.ts","./node_modules/date-fns/submonths.d.ts","./node_modules/date-fns/subquarters.d.ts","./node_modules/date-fns/subseconds.d.ts","./node_modules/date-fns/subweeks.d.ts","./node_modules/date-fns/subyears.d.ts","./node_modules/date-fns/todate.d.ts","./node_modules/date-fns/transpose.d.ts","./node_modules/date-fns/weekstodays.d.ts","./node_modules/date-fns/yearstodays.d.ts","./node_modules/date-fns/yearstomonths.d.ts","./node_modules/date-fns/yearstoquarters.d.ts","./node_modules/date-fns/index.d.mts","./src/components/admin/activityfeedmatrix.tsx","./src/components/admin/reconciliationgrid.tsx","./src/app/admin/dashboard/page.tsx","./src/app/admin/expenses/page.tsx","./src/app/admin/login/page.tsx","./src/components/ui/confirmbutton.tsx","./src/app/admin/products/page.tsx","./src/app/admin/purchases/page.tsx","./src/app/admin/reports/page.tsx","./src/app/admin/staff/page.tsx","./src/components/ui/vessellogo.tsx","./src/app/login/page.tsx","./src/app/platform/page.tsx","./src/app/signup/page.tsx","./src/components/terminal/productsizeselector.tsx","./src/components/terminal/quantitycounter.tsx","./src/components/terminal/paymentmethodtoggle.tsx","./src/components/terminal/paymentstatusoverlay.tsx","./src/app/terminal/page.tsx","./.next/types/app/layout.ts","./.next/types/app/admin/dashboard/page.ts","./.next/types/app/api/auth/login/route.ts","./.next/types/app/api/auth/me/route.ts","./.next/types/app/api/auth/refresh/route.ts","./.next/types/app/api/branches/route.ts","./.next/types/app/api/dashboard/reconciliation/route.ts","./.next/types/app/api/dashboard/summary/route.ts","./.next/types/app/api/platform/businesses/route.ts","./.next/types/app/api/transactions/route.ts","./.next/types/app/login/page.ts","./.next/types/app/platform/page.ts","./node_modules/@types/webidl-conversions/index.d.ts","./node_modules/@types/whatwg-url/index.d.ts"],"fileIdsList":[[99,145,360,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,814],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,501],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,503],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,504],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,510],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,511],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,512],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,523],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,529],[99,145,360,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,544],[99,145,360,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,823],[99,145,360,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,824],[99,145,408,409,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,495],[99,142,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,178,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,146,151,156,164,175,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,146,147,156,164,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[94,95,96,99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,148,187,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,149,150,157,165,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,175,183,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,151,153,156,164,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,152,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,153,154,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,155,156,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,156,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,157,158,175,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,157,158,171,175,178,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,153,156,159,164,175,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,157,159,160,164,175,183,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,161,175,183,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[97,98,99,100,101,102,103,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,162,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,163,186,191,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,153,156,164,175,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,165,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,166,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,167,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,169,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,170,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,171,172,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,171,173,187,189,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,175,176,178,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,177,178,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,176,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,178,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,179,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,142,145,175,180,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,181,182,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,181,182,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,164,175,183,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,184,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,164,185,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,170,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,187,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,188,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,163,189,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,190,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,140,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,140,145,156,158,167,175,178,186,189,191,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,192,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,197,198,199,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,197,198,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,91,99,145,196,361,404,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,91,99,145,195,361,404,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[84,85,86,99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,557],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,555,557],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,555],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,557,621,622],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,624],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,625],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,642],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,718],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,557,622,742],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,555,739,740],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,741],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,739],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,555,556],[99,145,186,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,534],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,535,536,537],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,535],[99,145,153,156,164,175,183,437,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466],[99,145,438,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,437,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466],[99,145,439,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,438,439,440,441,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,439,440,441,442,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,444,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,444,445,446,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,156,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466],[99,145,438,439,440,441,442,443,444,445,446,447,449,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,464,466],[99,145,439,440,441,442,443,444,445,446,447,448,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,444,445,446,447,449,450,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,439,440,441,442,443,444,445,446,447,449,450,451,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,455,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,456,457,458,459,460,461,462,463,464,466],[99,145,438,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,457,458,459,460,461,462,463,464,466],[99,145,437,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,458,459,460,461,462,463,464,465,466],[99,145,438,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,459,460,461,462,463,464,466],[99,145,437,438,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,460,461,462,463,464,465,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,466],[92,99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,365,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,367,368,369,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,371,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,212,218,220,361,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,209,211,214,232,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,214,339,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,267,285,300,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,309,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,212,219,253,263,336,337,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,219,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,263,264,265,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,219,253,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,219,220,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,293,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,193,292,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,286,287,288,306,307,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,286,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,276,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,275,277,381,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,286,287,304,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,282,307,393,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,391,392,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,226,390,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,279,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,193,226,242,275,276,277,278,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,304,306,307,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,304,306,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,304,305,307,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,170,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,274,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,193,211,213,270,271,272,273,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,203,384,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,186,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,219,251,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,219,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,249,254,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,250,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,91,99,145,159,193,195,196,361,402,403,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,361,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,201,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,354,355,356,357,358,359,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,356,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,250,286,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,286,362,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,286,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,213,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,210,211,222,240,242,274,279,280,302,304,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,271,274,279,287,289,290,291,293,294,295,296,297,298,299,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,272,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,170,193,211,212,240,242,243,245,270,302,303,307,361,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,213,214,226,227,275,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,212,214,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,175,193,210,213,214,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,170,186,193,210,211,212,213,214,219,222,223,233,234,236,239,240,242,243,244,245,269,270,303,304,312,314,317,319,322,324,325,326,327,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,175,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,203,204,210,211,361,364,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,175,186,193,207,338,340,341,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,170,186,193,207,210,213,230,234,236,237,238,243,270,317,328,330,336,350,351,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,216,270,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,210,212,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,223,318,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,320,321,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,320,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,318,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,320,323,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,206,207,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,206,246,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,206,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,208,223,316,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,315,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,207,208,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,208,313,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,207,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,302,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,210,222,241,261,267,281,284,301,304,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,255,256,257,258,259,260,282,283,307,362,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,311,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,210,222,241,247,308,310,312,361,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,186,193,203,210,212,269,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,266,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,344,349,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,233,242,269,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,332,336,350,353,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,216,336,344,345,353,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,202,212,233,244,347,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,212,219,244,331,332,342,343,346,348,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,194,240,241,242,361,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,170,186,193,208,210,211,213,216,221,222,230,233,234,236,237,238,239,243,245,269,270,314,328,329,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,210,212,216,330,352,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,211,213,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,159,170,193,201,203,210,211,214,222,239,240,242,243,245,311,361,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,170,186,193,205,208,209,213,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,206,268,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,206,211,222,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,212,223,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,226,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,225,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,227,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,224,226,230,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,212,224,226,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,159,193,205,212,213,219,227,228,229,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,304,305,306,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,262,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,203,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,236,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,194,239,242,245,361,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,203,384,385,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,254,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,170,186,193,201,248,250,252,253,364,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,213,219,236,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,235,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,157,159,170,193,201,254,263,361,362,363,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[83,87,88,89,90,99,145,195,196,361,404,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,150,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,333,334,335,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,333,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,373,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,375,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,377,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,379,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,382,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,386,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[91,93,99,145,361,366,370,372,374,376,378,380,383,387,389,395,396,398,405,406,407,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,388,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,394,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,250,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,397,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,144,145,227,228,229,230,399,400,401,404,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,91,99,145,159,161,170,193,195,196,197,199,201,214,353,360,364,404,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,426,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,424,426,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,415,423,424,425,427,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,413,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,416,421,426,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,412,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,416,417,420,421,422,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,416,417,418,420,421,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,413,414,415,416,417,421,422,423,425,426,427,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,411,413,414,415,416,417,418,420,421,422,423,424,425,426,427,428,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,411,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,416,418,419,421,422,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,420,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,421,422,426,429,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,414,424,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,431,432,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,430,433,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,116,145,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,145,175,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,107,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,109,112,145,183,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,164,183,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,107,145,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,109,112,145,164,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,104,105,108,111,145,156,175,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,119,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,104,110,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,133,134,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,108,112,145,178,186,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,133,145,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,106,107,145,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,106,107,108,109,110,111,112,113,114,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,134,135,136,137,138,139,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,127,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,119,120,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,110,112,120,121,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,111,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,104,107,112,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,112,116,120,121,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,116,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,110,112,115,145,186,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,104,109,112,119,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,175,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,107,112,133,145,191,193,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,492],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,483,484],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,480,481,483,485,486,491],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,481,483],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,491],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,483],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,480,481,483,486,487,488,489,490],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,480,481,482],[99,145,166,436,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,469,470,471,472,473,474,475,476,477],[99,145,166,436,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,468,469,471,477],[87,99,145,389,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541,543,546,547],[87,99,145,389,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541,542,543,546,547,549,550,551,553,554,812,813],[99,145,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[87,99,145,389,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541,543,546,547,817],[87,99,145,389,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541,543,546],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,469,470,477,493,494,497,498,499,500],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,494,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,469,470,476,477,499,500],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,469,494,498,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,468,469,471,476,477,493,494,498,499,507],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,468,469,470,493,499,507,509],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,472,473,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,472,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,470,474,493,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,470,473,493,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,472,517],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,472,499,520],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,470,471,472,493,499,507,520],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,476,477,493,500],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,469,470,476,477,500],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,471,493,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,470,471,475,493,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,472,474,475,499],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,467,471,472,493,499,507],[99,145,408,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,543],[87,99,145,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,532,533,822],[87,99,145,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541],[87,99,145,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541,543,546],[87,99,145,395,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,539,540,541,543,826,827,828,829],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,541,547,811],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,541],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,541,552],[99,145,389,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,532],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,538],[87,99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,533,541],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,469,498],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,493],[99,145,150,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,469,496,497],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,476],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,468],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,497,519],[99,145,405,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,469,499],[99,145,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466,469,470,476,477],[99,145,434,439,440,441,442,443,444,445,446,447,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,466]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3a8bddb66b659f6bd2ff641fc71df8a8165bafe0f4b799cc298be5cd3755bb20","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"ea53732769832d0f127ae16620bd5345991d26bf0b74e85e41b61b27d74ea90f","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"faa03dffb64286e8304a2ca96dd1317a77db6bfc7b3fb385163648f67e535d77","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"0131e203d8560edb39678abe10db42564a068f98c4ebd1ed9ffe7279c78b3c81","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"e650298721abc4f6ae851e60ae93ee8199791ceec4b544c3379862f81f43178c","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"58b49e5c1def740360b5ae22ae2405cfac295fee74abd88d74ac4ea42502dc03","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"847e160d709c74cc714fbe1f99c41d3425b74cd47b1be133df1623cd87014089","impliedFormat":1},{"version":"9fee04f1e1afa50524862289b9f0b0fdc3735b80e2a0d684cec3b9ff3d94cecc","impliedFormat":1},{"version":"5cdc27fbc5c166fc5c763a30ac21cbac9859dc5ba795d3230db6d4e52a1965bb","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"9dd9d642cdb87d4d5b3173217e0c45429b3e47a6f5cf5fb0ead6c644ec5fed01",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"38cb107048cd8ba54a70014ef9a30cf57bee0d9f10a0ca4cefa974056e1ee460","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"9a195d8476f48523446ece812e5450b5b1ec8c3b6abf99efb8ee2479524f6f56","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},"79fbdf1919fea4932858cca6c5f67ac18834b75e80a9ac9a0a8391548ebd1f22",{"version":"0c5f112b6d3377b9e8214d8920e1a69d8098b881d941f2ab3ca45234d13d68de","impliedFormat":1},{"version":"359e7188a3ad226e902c43443a45f17bd53bf279596aece7761dc72ffa22b30d","impliedFormat":1},{"version":"aa50d0691c396fb3677d7240e8fb0b842ebf7e113a14ef092833717e3cb2f7fa","impliedFormat":1},{"version":"5585e4ba561c8a827c2ad10b56135cac1aaf9aa84bfe3e9389ed8a84c439461f","impliedFormat":1},{"version":"0339d33fe49fbc1c70842c886195e01eafd37f7431dd7f32209dd0544c289474","impliedFormat":1},{"version":"cf70b9a6eab897a0168ef9d31ca13a9b8b69fe7f9da2c251528c076eea9c2db1","impliedFormat":1},{"version":"e308de1b989eb709570fdf26d45bb01f0031d8809273206a1d6fd53570e8dbac","impliedFormat":1},{"version":"13a4d931c625360ab1cbf68961b13a60969a17cf3247bd60e18a49fb498b68e5","impliedFormat":1},{"version":"55863858e28f768266adad3a19db6da5bbb5a057b07ba1b7cce099cf3f576df5","impliedFormat":1},{"version":"1201dba1a374e877db58ee8f5a208c68852ec2e152a0e8940950933d22cc5399","affectsGlobalScope":true,"impliedFormat":1},{"version":"89cbb41c032a8602412a55d89c9fbee8af199ffb3e89e52a0306d42518f491c3","impliedFormat":1},{"version":"3b251e4edc903f60ab560be43d72840f58a5bb6f6b297a78147436b6dba0bf51","impliedFormat":1},{"version":"021fbcae20ddc7ca7bf04cdb02a8c51f0d96afdde6a8462fb73b09ab4136ff7a","impliedFormat":1},{"version":"adaf4b35134a87afc9b749fb8a52ad062983e2460f9634c2638828b00c00c768","impliedFormat":1},{"version":"8443bbb1e167b4cca6d192eab6f9ab94442054f9b1c945f05070c23896396365","impliedFormat":1},{"version":"e345d3575db86233616b0b5e217149629e2b12c07526607b54ea4c18e85bb041","impliedFormat":1},{"version":"bbe98bf29952b80a91789cc6a3a3727aa958e652f32b145740229fe4b02f2a0a","impliedFormat":1},{"version":"134aa737ada947d36f2455992423d4cbd092c3cf325e52250c98ba9c8a8654df","impliedFormat":1},{"version":"3016511eadb560b6874050f8ff2ca671c64a663a48c60a24e3e7ddef92c3b095","impliedFormat":1},{"version":"542bc2aca628050c8b6b44abfbaae74f5c86de3eeb54718266456f58fc4152fb","impliedFormat":1},{"version":"1237f5aa64d4d5b9a261c07cf85678a60f6528378d9e91bc5a9abf4c31ac9b6f","impliedFormat":1},{"version":"b614199714e24dce517772560eadbe4cc829a7a4572c67aa0360d6b81f31a8b4","impliedFormat":1},{"version":"861b3b1cea0c4dbfd58cd3cb7a630ea8270b4ce92091941c263f4b4c6c21119b","impliedFormat":1},{"version":"8d35820323a2758d61684679eddc3f1d0cc051c55258b3243aee14b6b8e285c1","impliedFormat":1},{"version":"000876a9f6a3a17b1c7014047366cd2c7faa9c4849aae5a835b5f4c314ae5bc5","impliedFormat":1},{"version":"da440f879ec47f7113408fb75f239f437b9ee812fba67562c499f10ef012464a","impliedFormat":1},{"version":"ea75bec6e4b171bef2ea4378035c583803119f0c9caa626ff8ed3e60146a9daa","impliedFormat":1},{"version":"09bfb8631546d864b87078bcfd7099ba4bbd9dc106913605816f179aa4d6e28c","impliedFormat":1},{"version":"06b589495ccb33638b95551972542efef8658639a851c9832505641160b4356f","impliedFormat":1},{"version":"448ae408883377930fb80d69635f949f3425c0f32c49c5656c73f8a6ae90d702","impliedFormat":1},{"version":"46b659d0bf6a2389297cc2acf6fd430fdb379b897848656070cfb9d02d20524a","affectsGlobalScope":true,"impliedFormat":1},{"version":"bd100e695e8e49d997bd9e7102c885886c0a5c52f665955c03eb95aafa9a16f5","affectsGlobalScope":true},{"version":"a3d3f704c5339a36da3ca8c62b29072f87e86c783b8452d235992142ec71aa2d","impliedFormat":1},{"version":"ffa4095694b576198c7b79644d4cfe6111d2351904c8a4ea9ca0ae42777220f9","signature":"56ff06f5106d466dedfd264f1cf4956d3a810fb9e0fc06f5ac5d5457ab8fd36b"},{"version":"9f216d222c1479427caf29c88adfbac45e9f90efb50d2cf182833261f33ec958","signature":"20a815fad85873c9e0db1362408807056e3a4611999b2e0460fa5f522c419e21"},{"version":"f074bd70661d2b71370a39ca2f778b8cfe052aa53de33c2ed6384af7bc197d6f","signature":"3938fa209f8eacc4c61d22bd1b329e3edb630f4b8146d14aacfa12673919e595"},{"version":"17fe7abec9ac3b0490900713de3696725c172e42d40e0e9e5445dc2c5498bdb0","signature":"4bf2e9adffd2f8614ee9021d918e9a96a46d67c9976b6e4bc38992753ee0968c"},{"version":"a3be6116f7b2e96c4f95f9c70ada4d50b26dd4a7794d9a1afc4641211bb95f5a","signature":"b1fcb19dd3f00992e1c3c6d776184d83201451ed652ff76633406edb314de306"},{"version":"4f59c5aa22fb0779fae97a5cb9b6968b1503671d861934e7ce7b904b37185f09","signature":"ef835d7074644072fb6dde9465c404d7bde0c246bc9feff6eeaf258787664b78"},{"version":"28416c790a507d8758fc08666456984783edb02bfefc0a0a6959196c80485952","signature":"11f6206eda142fcf42a3605ed41c4580e7621a736371975cbb3624705c03eca6"},{"version":"7b2d040743c0dd4d66257a3edb657a7ed59db0106911e15d2cfe61f76191b1a1","signature":"c51626cb8b003111d35cab3c107694c5d14a3e3143a395588d43e24df41b1779"},{"version":"9827ad90f9c803a06fbe95b705c0dbf3685d89122562591497be9c23afc3f3cf","signature":"1af8e437be0ef8fd1a4ccc381c766e586f2f6d0149d1f3a174d3f39ff378fae1"},{"version":"418140221ddc50fbb39d75972a11a17dd628d3df03912afbc79baf357549e7ec","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},"f7cd21d469cf01664ba7be0711d9ea2906f881786a2e71358f5def31418c2483",{"version":"d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5","impliedFormat":1},{"version":"293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec","impliedFormat":1},{"version":"833e92c058d033cde3f29a6c7603f517001d1ddd8020bc94d2067a3bc69b2a8e","impliedFormat":1},{"version":"08b2fae7b0f553ad9f79faec864b179fc58bc172e295a70943e8585dd85f600c","impliedFormat":1},{"version":"f12edf1672a94c578eca32216839604f1e1c16b40a1896198deabf99c882b340","impliedFormat":1},{"version":"e3498cf5e428e6c6b9e97bd88736f26d6cf147dedbfa5a8ad3ed8e05e059af8a","impliedFormat":1},{"version":"dba3f34531fd9b1b6e072928b6f885aa4d28dd6789cbd0e93563d43f4b62da53","impliedFormat":1},{"version":"f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c","impliedFormat":1},{"version":"e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904","impliedFormat":1},{"version":"2329d90062487e1eaca87b5e06abcbbeeecf80a82f65f949fd332cfcf824b87b","impliedFormat":1},{"version":"25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262","impliedFormat":1},{"version":"4fdb529707247a1a917a4626bfb6a293d52cd8ee57ccf03830ec91d39d606d6d","impliedFormat":1},{"version":"a9ebb67d6bbead6044b43714b50dcb77b8f7541ffe803046fdec1714c1eba206","impliedFormat":1},{"version":"5780b706cece027f0d4444fbb4e1af62dc51e19da7c3d3719f67b22b033859b9","impliedFormat":1},{"version":"289fa71217a224c457ff1e2b690d5c2c60a450ba599c88321509978ab05c81c5","signature":"0a35385b1921b3a63357dfd1d13ef8d67592391d0ec3f6c024d062d66a1df553"},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"95da3c365e3d45709ad6e0b4daa5cdaf05e9076ba3c201e8f8081dd282c02f57","impliedFormat":1},"7f4b01c22920d2e89d74f7c11bbff9d155f661d9d553d27b1f8c2785bdd5892a",{"version":"085bb2d52064e32d057de1f57cf47840817ac4bc233038e1d06b25f7326c9e80","signature":"392a9cbc27c3a5844fc0add6a75ab66f03d88cbfa11dfeffc6b26709ced694a3"},"60e557a572d68282806c270ca3faee51ccd939ef8b1215edfbd532289f560b82",{"version":"764fa3dff5600c8994bd0d21c2dd72ef1bb756c9416f0fdda0f23f5ee25530fe","signature":"b2b05808ff4216ffe44db17634310cf81af753f0965710bc59334308c3925af4"},{"version":"3b36130b713fe846be3b272e26f3d05e51f3c1575620ef128167f2cfc5aee53e","signature":"a30a6893e8bbc071505ca13314ef9c3d496f7ddacc6ac57d506aa61c7f426647"},"05c2ec3b1b5536f9faf8f51c0ccfbf7e10308f1d1a94345ce08e9de65d08ff87",{"version":"d18bf4ed05f991e9366cffe3e809271c8c6cde78edbb5ce3b56a0a8ede75528d","signature":"64c7d3822f5741b41a3fc57fc147c9da1c5e489e7936a3cd2127497e75a001eb"},{"version":"8f6103c07624657b42ed9b01532ec824601baf6737f25e4d2a04f40972efee49","signature":"31444dac517ea0dd2859a0d4c051f567a8b3dda10e06eb1c800e0d41c17aa466"},"9fa627855848ea0bceea10d2481c05424fda60272dc93c8c3b61010861615219","2deb2183a366676a947907aa59bf4bd8bedea936115fb5a0b38dc055f1574e4f","931fdd288d1b1763e65debf2535d3269de6f91868e4bd7ef09a5d5c20cc28b1e",{"version":"06463d398ca86be6f18e4ed50e3e41a38876af38c98df909acfdcb8f5659e8a0","signature":"8d9fe77bdddca0bbd1870066aecc57771eac36879bb0c53d6b9c381e7539636d"},{"version":"ee61bf2972194e8f49bbae6287f89250f8ffdf7d4ebfd08e57df530133b649d1","signature":"3a37f1b4190910fe8b7088def6b1f86f24b6ca91648861195957afffd27e9272"},{"version":"b4f62d47f676d3f81bd1bbe3d9f5e46b57b56b56531b7ae07c53b83aacdadaa1","signature":"0dec5ab439dd8e9467ee79202d6e703341ed777042f08da85ff6b0035e43b393"},{"version":"f2aab42313544de6ed98af37580f935a2ffd5f0109bd4667cc072e98fb772c78","signature":"fd9fb16f26e5e18912548f363b1c34147ee67ded672457b857034d052899d426"},{"version":"c10c44bea9ee4f4a923300f656a9856d56a08e165318669aa038cfb11254a84f","signature":"c53574228fd5e2c4a773248bc8ec18e4e3bd7e5b12219b4c952326c731d4ffb0"},{"version":"2ee58a8d959f35c06eb20d418697fbcea592da6b8d37c189ea61c61c566c1643","signature":"0599cdd5f5d812c23c9ab657da0ff9ec1121d2fe18b382a1e59a46fb54d76e79"},"bed9d5b536a6f9db469dd48969cd4a126f33ef5bb8686a07d99049fcbb4669b8",{"version":"b7a59a9238a1d0934dfdb1c352bffd918d52900fd703a46c3b167d9e8d6def59","signature":"7a048a1217c301bd0e790e2db2cd55ab1ffbf0bfc59bdb94842358543f1173c3"},{"version":"d5ad17ee560d1ab05a2b273019d6284cc4075e162833b651c231eed30f40b6d2","signature":"7a048a1217c301bd0e790e2db2cd55ab1ffbf0bfc59bdb94842358543f1173c3"},{"version":"0f8b9cb7b7f9bd4812565b11a9da79b036b093a8977856f16f6112ce60c224db","signature":"f829b58b3574acba3c72603b2e7e84e6e223add472f465cc1a3c4bb9dd981e3a"},"db750ace38033ca24c41711725dad2eb2cd76bedf5c3ed1934ffbb1fafd8450b",{"version":"2368a31d44ccc59758e3b3c682fee74e820b08fad98623905a85092018c06e3b","impliedFormat":99},"9afa558d52ed1451d6a82b6629afbb2c6dd0f78959ca6e23ba4ab58291266a65",{"version":"d0c1ae9a57e07bada9efe63248a3dcae315162495fa1e640345375acb731c835","signature":"4ec573c9a7a6a942df52c7242a91390e4b60b15e0d2a465be493b481fff98466"},{"version":"ad96e9f845d390c33a2d66c1ff86d1dd629c0ef295386d69a23a4817d901c8b2","signature":"1a7e3e41e1f025efef637c8edd69cd8dc55f78f884c8a9199ff1290d7781c9df"},{"version":"f941a490d798df5b4c1e84aebbd28e692afb9731d73a5986ce2bd38ccce35153","signature":"626f2e910f610ce499cd162570ad35dcede253f50ed0026ebe36eb8ada102074"},{"version":"e94a44230e4359d75a9b0826cecc75e785baf3abaf00f2e414205b6b7e33dd3f","signature":"fe3b337be297ca99ec220df68ba8a022b482200968b9e9b509c6b8c4a2261b39"},{"version":"9bcbebb149df05903f89a57a18eea86a76e2fc459c4b39bfe5a2c847c8ff4983","signature":"a013b06a2e8a57de465c4e503166248b7c1bc6535724e08b6744e436524fd5b1"},{"version":"3716aa39c86058ec6df05bb9075b74adcb43f60e456874597a0175fa65713aba","signature":"8f08acc74bfbd8c5999dc7507efe290fe2fee2f8932d57aee442a1b5eb384071"},{"version":"de808afd1e8ff95b1d8cb0defa8507eae4effeeba69640fa5f072d564f86f5d3","signature":"e0bafcec66b60ded367d98275325d322b4f00bee4a678e2324e02e0cdf5e908c"},{"version":"245aa18157b0a06dd133ea1c943dace3c7a281265126a540a75ae6ce78284d7e","signature":"6b753881943e78df20654cfd982992f8f6f898e614ac86d2f4528a7acd282f92"},{"version":"da3276531088f7d90f2b2a94ff329d826ac24ac74377fb19eff193d13b4f6cfa","signature":"e1cbc696e5cb26cf9ce1fc1924c65e418365e388006405eece60ffb7815a3623"},{"version":"4f0148a45234c646fc4fb84e123c99a29f3988c6bce9c387bdb5d0d7c75ba29e","signature":"2c3343c9bfa711023b94b2fa5d82531c071e26036fc2e672aa2ed7afb243f501"},{"version":"d588236de984d1faadd2e2f40af42a2b8664ca69658af75f24dc48a8c66ec1e9","signature":"180b37ba95c76b96de613e7e72197a5e08094262b9d58ac9a11cc0d47d869a88"},"55e27b93ee17fc4ec50789886e6497a7846d3d4fa8f7a41b85606d65c3acf68f","b54bf9bae737e80a381ffbcb40aa82940f3bfabf89270f989d6023aff3e7df65",{"version":"6eec0b712ee4fa23cdd367f5d866d6719b7c8812bc46c623668ed6072587ee80","impliedFormat":99},{"version":"34200a82ddb7fdf868d86595bf9729aac18a0e2795034431550551d0a31e7e9b","impliedFormat":99},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":99},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":99},{"version":"1ab59b972136fb41d90d704d6faeaa695fcc51be2bcd390e3d44f7d8fa055edf","impliedFormat":99},"9da84763e0ff337b27daec0b690c0aa50ae5b58467396d5542804df319efc6c7","f00df59648c1caeec2dbfacc2627f76bf73b10e82e2955512079f2a9d8455f14",{"version":"e817c7e4a7ee814d6b5a4940b388b6022ee19ac73fd956a27c13a2097d883a37","signature":"054061efa96439d6b183e09fe647fd1a8fa775fc1c94840990ca2f25a86aeef4"},"ebb668db4066eab05c0c7806a99567945a19f299d53aec3bb7df45f2c7ec3a54","52d46fc76049096d476b44719e50eb099d7dfb51be1d99c446339cb9225ab129",{"version":"ca1e6555f27a43cb62289d9de6e3961b619422ba64786836ca5660bb0084e7a4","signature":"5fae83fdfe1c2fd7f017a6feb47a50e99ce7a179676a5a43f9200ae557b99510"},"3e0a026643b12df9a0042bafd81f7df71f48cc62f5079d12a68215b4d4ad337b","f78c2c5fb6699a31c9b9e4026921e20f505fc5a9696eb3d120ef3d16307cb828","871c57d29d1886a73d7b33308c9665152ea7024f96b6acdb87b643c49add4e4c",{"version":"34a62e7f6f152c8100aa3c7c308c78946e2657c839802779b14eeaac2b039a97","signature":"614ce8f7d06bb17ee82a6fafbf31b854b3b8312c0d6708dd5004c7d70e8ea931"},"b1886697878387c1a689c23259b30bb7e8c834552683dc1742a26b99fa4cda3f","8ce9f0363aa5c5e9bf0a05cc75490200270c4ec89b679aaf32627c21767abf36","477d0f48a415bc2c67ab5dc2d8df8e9700966d9d7275a908b490e78cf9a8165c","55fae388468d08c422caac8bd0b668d4b1ac195cc295afb0826ffd887fbbfc69","937a5e3ee146dc227d1a8bb4b62309a915c25d0376129c9f4b0e4216053272aa","1cf0e272d454a07c7e6476bbf75f81320723bdb9386c42a2721c73c5f4938f4e",{"version":"f4e8f4151c3490cf7b68c685aabe901cbab19f962aaa2f118a97550e22689a76","impliedFormat":1},{"version":"799003c0ab928582fca04977f47b8d85b43a8de610f4eef0ad2d069fbb9f9399","impliedFormat":1},{"version":"d998eea476c695d8e4ff9d007d5b46d49ca2ffa052f74dc20ca516425abd57b1","impliedFormat":1},{"version":"a0bd46d587005aad4819980f6cf2dbcd80ebf584ed1a946202326a27158ba70e","impliedFormat":1},{"version":"07fcbb61a71bd69a92a5bbde69e60654666cf966b5675c2010c3bf9f436f056a","impliedFormat":1},{"version":"88b2eb23d36692162f2bf1e50577ebcde26de017260473e03ed9a0e61e2726a4","impliedFormat":1},{"version":"23ffbd8c0e20a697d2ea5a0cf7513fb6e42c955a7648f021da12541728f62182","impliedFormat":1},{"version":"43fba5fc019a4ce721a6f53ddb97fdc34c55049cfb793bc544d5c864ee5560b9","impliedFormat":1},{"version":"f4e12292c9a7663a13d152195019711c427c552eb0fa02705e0f61370cd5547a","impliedFormat":1},{"version":"c127ebf14d1b59d1604865008fb072865c5ca52277621f566092fe1f42ce0954","impliedFormat":1},{"version":"def638da26d84825a312113a20649d3086861de7c06a18ea13121278702976fd","impliedFormat":1},{"version":"fbaf86f8ba11298dea2727ce0da84b4ab6ae6c265e1919d44aff7d9b2bbc578a","impliedFormat":1},{"version":"c1010caaeaca8e420c6e040c2e822dbe18702459c93a7d2d5de38597d477b8cd","impliedFormat":1},{"version":"e1f0d8392efd9d71f2644eb97d3f33d90827e30ea8051d93b6f92bb11dff520a","impliedFormat":1},{"version":"085211167559ca307d4053bb8d2298d5ad83cbc3d2ae9bb4c8435a4cabf59369","impliedFormat":1},{"version":"55fc49198d8a85a73cdb79e596d9381cfdc9de93c32c77d42e661c1c1e7268ef","impliedFormat":1},{"version":"6a53fb3df8dd32ed1a65502ca30aeae19cfe80990e78ba68162d6cb2a7fed129","impliedFormat":1},{"version":"b5dcc18d7902597a5584a43c1146ca4fe0295ceb5125f724c1348f6a851dd6ed","impliedFormat":1},{"version":"0c6b0f3fbe6eb6a3805170b3766a341118c92ed7b6d1f193b9f35aa82f594846","impliedFormat":1},{"version":"60eaadb36cf157c5cae9c40e84fa367d04f52a150db3920dbe35139780739143","impliedFormat":1},{"version":"4680a32b1098c49dc87881329af1e68af9af94e051e1b9e19fed555a786f6ce6","impliedFormat":1},{"version":"89fcd129ec37f321cddcdb6b258ffe562de4281e90ec3ccbe7c1199ba39359ca","impliedFormat":1},{"version":"4313011f692861c2c1f5205d7f9a473e763adab6444f9853b96937b187fb19f7","impliedFormat":1},{"version":"caa57157e7bdb8d5f1efe56826fb84a6c8f22a1927bba7fa21fd54e2a44ccba2","impliedFormat":1},{"version":"6b74700abfe4a9b88be957fd8e373cfd998efb1a5f6ad122da49a92997e183ad","impliedFormat":1},{"version":"9ef1342f193bd8bae86c64e450c3ac468ef08652110355e1f3cdd45362eb95c4","impliedFormat":1},{"version":"6853c91662c36a2bf4c8371a87177c819007c76a23c293ef3f686ce9157ae4c8","impliedFormat":1},{"version":"9be1c5dabce43380d13fc621100676b03d420b5687b08d1288f479bee68ab7a8","impliedFormat":1},{"version":"8996d218010896712678e6a0337d8ef8b81c1066ab76f637dd8253f0d6ff838d","impliedFormat":1},{"version":"a15603bf387fc45defe28a68f405a6c29105e135c4e8538eeb6d0a1ef5b69a81","impliedFormat":1},{"version":"84e2532e4d42949a2775cdd8bb7b2b97370dd6ddb683d0c199b21bf6978b152d","impliedFormat":1},{"version":"22bf5f19f620db3b8392cfece44bdd587cdbed80ba39c88a53697d427135bf37","impliedFormat":1},{"version":"23ebbd8d484d07e1c1d8783169c20570ed8409966b28f6be6cf8e970d76ef491","impliedFormat":1},{"version":"18b6fa2c778cad6489f2febf76433453f5e2432ec3535f2d45ae7d803b93cc17","impliedFormat":1},{"version":"609d0d7419999cf44529e6ba687e2944b2fc7ad2570d278fd4e6b1683c075149","impliedFormat":1},{"version":"249cf421b8878a3fe948d9c02f6b0bae65491b3bb974c2ffc612341406fa78ff","impliedFormat":1},{"version":"b4aa22522d653428c8148ddbf1dcc1fb3a3471e15eb1964429a67c390d8c7f38","impliedFormat":1},{"version":"30b2cee905b1848b61c7d28082ebfa2675dd5545c0d25d1c093ce21a905cdccc","impliedFormat":1},{"version":"0a2a2eed4137368735205de97c245f2a685af1a7f1bf8d636b918a0ee4ff4326","impliedFormat":1},{"version":"69f342ce86706aa2835a62898e93ea7a1f21b1d89c70845da69371441bb6cd56","impliedFormat":1},{"version":"b5ab4282affcfd860dd1cc3201653f591509a586d110f8e5b1b010508ba79b2c","impliedFormat":1},{"version":"d396233f6cd3edf0d33c2fbfc84ded029c3ea4a05af3c94d09d31a367cced111","impliedFormat":1},{"version":"bc41a726c817624a5136ae893d7aac7c4dc93c771e8d243a670324bccf39b02b","impliedFormat":1},{"version":"710728600e4b3197f834c4dd1956443be787d2e647a72f190bf6519f235aaadd","impliedFormat":1},{"version":"a45097e01ef30ba26640fed365376ab3ccd5faf97d03f20daff3355a7e60286a","impliedFormat":1},{"version":"763cbb7c22199f43fd5c2b1566af5ba96bf7366f125dd31a038a2291cbc89254","impliedFormat":1},{"version":"031933bf279b7563e11100b5e1746397caf3a278596796a87bc0db23cf68dc9e","impliedFormat":1},{"version":"a4a54c1f58fc6e25a82e2c0f651bf680058bd7f72cfb2d43b85ee0ab5fe2e87e","impliedFormat":1},{"version":"9613d789b6f1037f2523a8f70e1b736f1da4566b470593da062be5c9e13dac57","impliedFormat":1},{"version":"0d2a320763a0c9c71493f8f1069971018c8720a6e7e5a8f10c26b6de79aa2f7d","impliedFormat":1},{"version":"817e0df27a237a268dc16e5acffc19f9a74467093af7a0ba164ee927007a4d25","impliedFormat":1},{"version":"43102521b5ca50ff1865188c3c60790feaed94dc9262b25d4adec4dbc76f9035","impliedFormat":1},{"version":"f99947f8d873b960b0115e506ef9c43f4e40c2071b1d20375564538af4a6023b","impliedFormat":1},{"version":"c1e5ad5ca89d18d2a36d25e8ec105623648cf35615825e202c7d8295a49d61ab","impliedFormat":1},{"version":"2b6c9cb81da4e0a2e32a58230e8c0dec49fc5b345efb7f7a3648b98956be4b13","impliedFormat":1},{"version":"99e34af3ede50062dcc826a1c3ce2d45562060dfd0f29f8066381a6ef548bf2a","impliedFormat":1},{"version":"49f5c2a23ea5fc4b2cdb4426f09d1c8b83f8409fa2af13ef38845cc9b9d4bc3d","impliedFormat":1},{"version":"e935227675144b64ecde3489e4a5e242eeb25fdd6b7464b8c21ad1f7a0faa88b","impliedFormat":1},{"version":"b42e6bbe88dc79c2d6dc5605fb9c15184e70f64bdd7b8d4069b802b90ce86df6","impliedFormat":1},{"version":"b9cd712399fdc00fdae07e96c9b39c3cb311e2a8a5425f1bd583f13cab35e44b","impliedFormat":1},{"version":"5a978550ae131b7fef441d67372fd972abab98ea9fdb9fa266e8bdc89edcb8d6","impliedFormat":1},{"version":"4f287919cfc1d26420db9f0457cd5c8780b1ef0a9f949570936abe48d3a43d91","impliedFormat":1},{"version":"496b23b2fd07e614bc01d90dd4388996cb18cd5f3a612d98201e9f683e58ad2e","impliedFormat":1},{"version":"dcfbe42824f37c5fb6dc7b9427ef2500791ec0d30825ecb614f15b8d5bf5a667","impliedFormat":1},{"version":"390124ad2361b46bf01851d25e331cd7eed355d04451d8b2a4aa985c9de4f8ce","impliedFormat":1},{"version":"14d94f17772c3a58eda01b6603490983d845ee2012cd643f7497b4e22566aacb","impliedFormat":1},{"version":"03ef2386c683707ce741a1c30cb126e8c51a908aa0acc01c3471fafb9baaacd5","impliedFormat":1},{"version":"66a372e03c41d2d5e920df5282dadcec2acae4c629cb51cab850825d2a144cea","impliedFormat":1},{"version":"5b48ba9a30a93176a93c87f9e0abf26a9df457eeb808928009439ca578b56f27","impliedFormat":1},{"version":"4707625392316d3c16edbd0716f4ac310e8ff5d346d58f4d01a2b7e0533a23df","impliedFormat":1},{"version":"154d58a4b2d9c552dc864ea39c223d66efd0ed2dd8b55bd13db5225d14322915","impliedFormat":1},{"version":"6a830433fa072931b4ea3eb9aa5fa7d283f470080586a27bfe69837a0f12de9a","impliedFormat":1},{"version":"d25e930e181f4f69b2b128514538f2abb54ef1d48a046ad776ac6f1cda885a72","impliedFormat":1},{"version":"0259b4c21bc93b52ca82c755f97fc90481072bcc44a8010131b2ea7326cf03fe","impliedFormat":1},{"version":"bea43a13a1104a640da0cb049db85c6993f484a6cc03660496b97824719ecc91","impliedFormat":1},{"version":"0224239d61fe66d4900544d912b2e11c2cca24b4707d53fdb94b874a01e29f48","impliedFormat":1},{"version":"2bce8fd2d16a9432110bbe0ba1e663fd02f7d8b8968cd10178ea7bc306c4a5df","impliedFormat":1},{"version":"9c4ad63738346873d685e5c086acbf41199e7022eff5b72bb668931e9ca42404","impliedFormat":1},{"version":"cfb6329bf8ce324e83fe4bbdee537d866a0d5328246f149a0958b75d033de409","impliedFormat":1},{"version":"efc3816f19ea87a7050c84271ea3d3aad9631a517c168013c4f4b6724c287ce0","impliedFormat":1},{"version":"f99f6737336140047e8dd4ade3859f08331aa4b17bc2bd5f156a25c54e0febbc","impliedFormat":1},{"version":"12a2b25c7c9c05c8994adf193e65749926acfcc076381f7166c2f709a97bdf0a","impliedFormat":1},{"version":"0f93a3fdd517c1e45218cd0027c1d6b82237e379dc6b66d693aab1fe74c82e81","impliedFormat":1},{"version":"03c753da0bee80ad0d0f1819b9b42dfe9bf9f436664caf15325aa426246fd891","impliedFormat":1},{"version":"18f5bf1dae429c451f20171427c9e3223fade4346af4dfd817725cbeb247a09d","impliedFormat":1},{"version":"a4eece5fab202e840dd84f7239e511017a8162edb8fc8b54ff2851c5c844125c","impliedFormat":1},{"version":"c4a94af483a63bf947d89f97553a55df5107c605ec8a26f0b9b8bdcc14bd6d89","impliedFormat":1},{"version":"19de2915ccebc0a1482c2337b34cb178d446def2493bf775c4018a4ea355adb8","impliedFormat":1},{"version":"9be8fc03c8b5392cd17d40fd61063d73f08d0ee3457ecf075dcb3768ae1427bd","impliedFormat":1},{"version":"3b568b63f0e8b3873629a4d7a918dce4266ad41461004ab979f8dcdfd13532bb","impliedFormat":1},{"version":"a5e5223c775fe30d606b8aaa521953c925d5ad176a531c2b69437d2461aaabbd","impliedFormat":1},{"version":"8cbf41d2d1ce8ac2066783ae00613c33feef07493796f638e30beaf892e4354a","impliedFormat":1},{"version":"e22ad737718160df198cd428f18da707177d0467934cecdeed4be6e067b0c619","impliedFormat":1},{"version":"15bf5ed8cb7c1a1e1db53fa9b45bc1a1c73c0497735343a8d0c59fdb596a3744","impliedFormat":1},{"version":"791fce84bce8b6948e4f23422d9cbbd7d08c74b3f91cca12dcae83d96079798b","impliedFormat":1},{"version":"8a2619c8e24305f6b9700b35af178394b995dcb28690a57a71cca87ee7e709ae","impliedFormat":1},{"version":"f95fd2fc3cc164921a891f5d6c935fa0d014a576223dd098fc64677e696b0025","impliedFormat":1},{"version":"8c9cecaaa9caba9a8caa47f46dcf24b524b27899b286d8edcc75a81b370d2ba3","impliedFormat":1},{"version":"2b7a82692ecc877c5379df9653902e23f2d0d0bc9f210ec3cf9e47be54413c5c","impliedFormat":1},{"version":"e2ad09c011cf9d7ee128875406bef787eeb504659495f42656a0098c15fe646c","impliedFormat":1},{"version":"eb518567ea6b0b2623f9a6d37c364e1b1ac9d8b508d79e558f64ac05c17e2685","impliedFormat":1},{"version":"630a48fb8f6b07161588e0aee3f9d301c59c97e1532c884118f89368baf4073b","impliedFormat":1},{"version":"14736c608aa46120f8d6d0bc5e0721b46b927bc7eba20e479600571935f27062","impliedFormat":1},{"version":"7574803692d2230db13205a7749b9c3587dccaccdf9e76f003f9e08078bb6d09","impliedFormat":1},{"version":"f3cc1588e666651c51353b1728460bee8acbc6e0f36be8c025eaaf292dca525d","impliedFormat":1},{"version":"0d4ea8a20527dcf3ad6cf1bd188b8ad4e449df174fad09b9e540ed81080af834","impliedFormat":1},{"version":"aa82876d59912d25becff5a79ed7341af04c71bfeb2221cc0417bc34531125e2","impliedFormat":1},{"version":"6f4b0389f439adc84cba35d45428668eabcfbdd351ba17e459d414ca51ab8eb8","impliedFormat":1},{"version":"d5dd33d15fbb07668c264b38065ac542a07a7650af4917727bbc09b58570e862","impliedFormat":1},{"version":"7d90202d0212e9cdc91a20bfddf04a539c89f09fe1d64db3343546fa2eb37e71","impliedFormat":1},{"version":"1a5d073c95a3a4480b17d2fa7fd41862a9df0cb2afaee86834b13649e96bdb45","impliedFormat":1},{"version":"2092495a5b3116c760527a690c4529748f2d8b126cdd5f56b2ce2230b48aba3f","impliedFormat":1},{"version":"620b29d6adbd4061bc0a8fedf145fcc8e8fc9648fb6e0a39726e33babb4e07bc","impliedFormat":1},{"version":"931eda51b5977f7f3fa7a0d9afde01cfd8b0cc1df0bb66dcf8c2cf6e7090384e","impliedFormat":1},{"version":"b084a412374bdd124048c52c4e8a82d64f3adec6c0a9ad5ecbb7317636039b0f","impliedFormat":1},{"version":"11199daa694c3ced3cc2a382a3fa7bd64e95eb40f9bbc3979fc8fb43f5ba38cc","impliedFormat":1},{"version":"2c86f279d7db3c024de0f21cd9c8c2c972972f842357016bfbbd86955723b223","impliedFormat":1},{"version":"dfb53b9d748df3e140b0fddb75f74d21d7623e800bb1f233817a1a2118d4bb24","impliedFormat":1},{"version":"8cfc293b33082003cacbf7856b8b5e2d6dd3bde46abbd575b0c935dc83af4844","impliedFormat":1},{"version":"7730c538d6d35efe95d2c0d246b1371565b13037e893178033360b4c9d2ac863","impliedFormat":1},{"version":"b256694544b0d45495942720852d9597116979d52f2b53c559fda31f635c60df","impliedFormat":1},{"version":"794e8831c68cc471671430ee0998397ea7a62c3b706b30304efdc3eaff77545a","impliedFormat":1},{"version":"9cfc1b227477e31988e3fb18d26b6988618f4a5da9b7da6bc3df7fc12fb2602e","impliedFormat":1},{"version":"264a292b6024567dd901fdabbf3239a8742bea426432cdbda4cf390b224188e1","impliedFormat":1},{"version":"f1556a28bb8e33862dcfa9da7e6f1dca0b149faf433fe6a50153ae76f3362db1","impliedFormat":1},{"version":"1d321aea1c6a77b2a44e02e5c2aeff290e3f1675ead1a86652b6d77f5fea2b32","impliedFormat":1},{"version":"4910efc2ce1f96d6e71a9e7c9437812ffae5764b33ab3831c614663f62294124","impliedFormat":1},{"version":"e3ceab51a36e8b34ab787af1a7cf02b9312b6651bac67c750579b3f05af646c1","impliedFormat":1},{"version":"baf9f145bcee1b765bed6e79fd45e1ff0ca297a81315944de81eb5d6fff2d13d","impliedFormat":1},{"version":"2afd62362b83db93cd20de22489fe4d46c6f51822069802620589a51ccad4b99","impliedFormat":1},{"version":"9f0cd9bd4ab608123b88328c78814738cbdee620f29258b89ef8cd923f07ff9c","impliedFormat":1},{"version":"801186c9e765583c825f28dab63a7ad12db5609e36dc6d9acbdc97d23888a463","impliedFormat":1},{"version":"96c515141c6135ccd6fb655fb9e3500074a9216ba956fb685dc8edc33f689594","impliedFormat":1},{"version":"416af6d65fc76c9ced6795f255cb1096c9d7947bede75b82289732b74d902784","impliedFormat":1},{"version":"a280c68b128ebba35fb044965d67895201c2f83b6b28281bb8b023ade68bf665","impliedFormat":1},{"version":"6fa118f15723b099a41d3beea98ed059bcd1b3eda708acf98c5eff0c7e88832f","impliedFormat":1},{"version":"dcbf582243e20ea50d283f28f4f64e9990b4ed4a608757e996160c63cff6aa99","impliedFormat":1},{"version":"efa432d8fd562529c4e9f859fd936676dd8fef5d3b4bedb06f754e4740056ea9","impliedFormat":1},{"version":"a59b66720b2ccf2e0150fafb49e8da8dabdf4e1be36244a4ccd92f5bd18e1e9e","impliedFormat":1},{"version":"c657fb1ec3b727d6a14a24c71ea20c41cb7d26a503e8e41b726bb919eb964534","impliedFormat":1},{"version":"50d6d3174868f6e974355bf8e8db8c8b3fcf059315282a0c359ecf799d95514a","impliedFormat":1},{"version":"86bf79091014a1424fc55122caa47f08622b721a4d614b97dd620e3037711541","impliedFormat":1},{"version":"7a63313dff3a57f824a926e49a7262f7bd14e0e833cf45fa5af6da25286769c2","impliedFormat":1},{"version":"36dcaeffe1a1aed1cb84d4feba32895bf442795170edccc874fa32232b2354e5","impliedFormat":1},{"version":"686c6962d04d90edafc174aa5940acb9c9db8949c8d425131c01d796cf9a3aef","impliedFormat":1},{"version":"2b1dbc3d5762d6865744b6e7be94b8b9004097698c37e93e06983e42dd8fe93b","impliedFormat":1},{"version":"eb5e8f74826bdf3a6a0644d37a0f48133f8ad0b5298cc2c574102868542ba4eb","impliedFormat":1},{"version":"c6a82a9673ba517cf04dd0803513257d0adf101aed2e3b162a54d840c9a1a3b2","impliedFormat":1},{"version":"fc9f0f415abaa323efcecc4a4e0b6763bfe576e32043546d44f1de6541b6399b","impliedFormat":1},{"version":"2c4d772ac7ac56a44deef82903364eb7c78dd7bc997701123df0ce4639fe39bb","impliedFormat":1},{"version":"9369ef11eed17c1c223fdea9c0fa39e83f3722914ef390b1448db3d71620c93a","impliedFormat":1},{"version":"aa84130dbc9049bba6095f87932138698f53259b642635f6c9e92dd0ddc7512c","impliedFormat":1},{"version":"084ceadd21efabd4b58667dca00d4f644306099151d2ee18cd28a395855b8009","impliedFormat":1},{"version":"b9503e29f06c99b352b7cae052da19e3599fa42899509d32b23a27c9bb5bebf6","impliedFormat":1},{"version":"75188920fe6ccc14070fe9a65c036049f1141d968c627b623d4a897ec3587e15","impliedFormat":1},{"version":"e2e1df7f45013d2b34f8d08e6ae5a9339724b0ea251b5445fcca3e170e640105","impliedFormat":1},{"version":"af06feb5d18a6ea11c088b683bdb571800d1f76b98d848eecdf41e5ec8f317fd","impliedFormat":1},{"version":"0596af52b95e0c8adc2c07f49f109d746b164739c5866fa8bb394dd6329a3725","impliedFormat":1},{"version":"c3365d08fe7a1ccc3b8e8638edc30123007f3241b4604e2585b9f14422ab97d8","impliedFormat":1},{"version":"a7a3d96b04bb0ec8cb7d2669767c4756f97dd70d08548f9e6522dde4de8e8a03","impliedFormat":1},{"version":"745e960e885a4ba04c872225cbb44bd67a7490d169ceaefab7c0dfc444768676","impliedFormat":1},{"version":"0b1ce1768cde3535493a9daf99e3bbb8c7dcc3a7f9d8cd358cb846af71ce5cdf","impliedFormat":1},{"version":"48b9603f6e8a7c94b727277592a089f94261baa64e6c9d18165da0481663a69e","impliedFormat":1},{"version":"3c20a3bb0c50c819419f44aa55acc58476dad4754a16884cef06012d02b0722f","impliedFormat":1},{"version":"4dc64902cb86e677a928293593658fbf53388f9a30d2b934140c70a7267b07ec","impliedFormat":1},{"version":"cb4fd56539a61d163ea9befe6b0292c32aa68a104c1f68f61416f1bc769bcfba","impliedFormat":1},{"version":"0d852bdc2b72b22393a8eebe374ee3efe3e0d44e630037b5e1b6087985388e62","impliedFormat":1},{"version":"b6c9a2deefb6a57ff68d2a38d33c34407b9939487fc9ee9f32ba3ecf2987a88a","impliedFormat":1},{"version":"f6b371377bab3018dac2bca63e27502ecbd5d06f708ad7e312658d3b5315d948","impliedFormat":1},{"version":"faa72893e85cb8ebb1dafde6b427e5204e60bb5f3ee6576bb64c01db1f255bc8","impliedFormat":1},{"version":"95b7ed47b31a6eaddcdd853ee0871f2bb61e39ce36a01d03dfafb83766f6c10c","impliedFormat":1},{"version":"19287d6b76288c2814f1633bdd68d2b76748757ffd355e73e41151644e4773d6","impliedFormat":1},{"version":"fc4e6ec7dade5f9d422b153c5d8f6ad074bd9cc4e280415b7dc58fb5c52b5df1","impliedFormat":1},{"version":"3aea973106e1184db82d8880f0ca134388b6cbc420f7309d1c8947b842886349","impliedFormat":1},{"version":"765e278c464923da94dda7c2b281ece92f58981642421ae097862effe2bd30fa","impliedFormat":1},{"version":"de260bed7f7d25593f59e859bd7c7f8c6e6bb87e8686a0fcafa3774cb5ca02d8","impliedFormat":1},{"version":"d95c4eaad4df9e564859f0c74a177fa0b2e5f8a155939b52580566ab6b311c3f","impliedFormat":1},{"version":"7192a6d17bfa06e83ba14287907b7c671bef9b7111c146f59c6ea753cfc736b9","impliedFormat":1},{"version":"5156d3d392db5d77e1e2f3ea723c0a8bd3ca8acffe3b754b10c84b12f55a6e10","impliedFormat":1},{"version":"a6494e7833ee04386a9f0c686726f7cb05f52f6e069d9293475ccb1e791ee0da","impliedFormat":1},{"version":"d9af0c89a310256851238f509a22aa1071a464d35dc22ea8c2a0bae42dd81bc5","impliedFormat":1},{"version":"291642a66e55e6ca38b029bc6921c7301f5c7b7acf21ae588a5f352e6c1f6d58","impliedFormat":1},{"version":"43cd7c37298b051d1ce0307d94105bcd792c6c7e017282c9d13f1097c27408e8","impliedFormat":1},{"version":"e00d8cce6e2e627654e49c543b582568ad0bf27c1d4ad1018d26aff78d7599df","impliedFormat":1},{"version":"ed13354f0d96fb6d5878655b1fead51722b54875e91d5e53ef16de5b71a0e278","impliedFormat":1},{"version":"fcb934d0fcdee06a8571bd90aa3a63aa288c784b3ebcecfe7ae90d3104d321f4","impliedFormat":1},{"version":"af682dfabe85688289b420d939020a10eb61f0120e393d53c127f1968b3e9f66","impliedFormat":1},{"version":"0dca04006bf13f72240c6a6a502df9c0b49c41c3cab2be75e81e9b592dcd4ea8","impliedFormat":1},{"version":"7dc0b5e3d7be8e1f451f0545448c2eaa02683f230797d24434b36f9820d5a641","impliedFormat":1},{"version":"247af61cdc3f4ec7876b9e993a2ecdd069e10934ff790c9cee5811842bff49eb","impliedFormat":1},{"version":"4be8c2c63d5cd1381081d90021ddfaef106881df4129eddeeaba906f2d0f75d0","impliedFormat":1},{"version":"012f621d6eb28172afb1b2dc23898d8bc74cf35a6d76b63e5581aa8e50fa71b3","impliedFormat":1},{"version":"3a561fa91097e4580c5349ce72e69d247c31c11d29f39e1d0bd3716042ff2c0b","impliedFormat":1},{"version":"bc9981a79dda3badea61d716d368a280c370267e900f43321f828495f4fef23c","impliedFormat":1},{"version":"2ed3b93d55aea416d7be8d49fe25016430caab0fe64c87d641e4c2c551130d17","impliedFormat":1},{"version":"3d66dfc31dd26092c3663d9623b6fc5cec90878606941a19e2b884c4eacd1a24","impliedFormat":1},{"version":"6916c678060af14a8ce8d78a1929d84184e9507fba7ab75142c1bcb646e1c789","impliedFormat":1},{"version":"3eea74afae095028597b3954bde69390f568afc66d457f64fff56e416ea47811","impliedFormat":1},{"version":"549fb2d19deb7d7cae64922918ddddf190109508cc6c7c47033478f7359556d2","impliedFormat":1},{"version":"e7023afc677a74f03f8ccb567532fe9eedd1f5241ee74be7b75ac2336514f6f6","impliedFormat":1},{"version":"ff55505622eac7d104b9ab9570f4cc67166ba47dd8f3badfb85605d55dd6bdc9","impliedFormat":1},{"version":"102fac015b1eebfa13305cb90fd91a4f0bbcabb10f2343556b3483bbb0a04b62","impliedFormat":1},{"version":"18a1f4493f2dbad5fd4f7d9bfba683c98cf5ed5a4fa704fa0d9884e3876e2446","impliedFormat":1},{"version":"f57e6707d035ab89a03797d34faef37deefd3dd90aa17d90de2f33dce46a2c56","impliedFormat":1},{"version":"cc8b559b2cf9380ca72922c64576a43f000275c72042b2af2415ce0fb88d7077","impliedFormat":1},{"version":"1a337ca294c428ba8f2eb01e887b28d080ee4a4307ae87e02e468b1d26af4a74","impliedFormat":1},{"version":"310fe80ff40a158c2de408efbe9de11e249c53d2de5e33ca32798e6f3fbc8822","impliedFormat":1},{"version":"d6ce96c7bb34945c1d444101f44e0f8ba0bba8ab7587a6cc009a9934b538c335","impliedFormat":1},{"version":"1b10a2715917601939a9288d49beccd45b591723256495b229569cd67bbe48a8","impliedFormat":1},{"version":"7498dfdeed2e003ec49cdf726ff6c293002d1d7fdadbc398ce8aafe6d0688de7","impliedFormat":1},{"version":"8492306a4864a1dc6fc7e0cc0de0ae9279cbd37f3aae3e9dc1065afcdc83dddc","impliedFormat":1},{"version":"9c86abbc4fd0248f56abc12aaecd76854517389af405d5ec2eb187fdb00a606f","impliedFormat":1},{"version":"9ffd906f14f8b059d6b95d6640920f530507e596e548f7a595da58ab66e3ce76","impliedFormat":1},{"version":"1884bccc10ce40adca470c2c371c1c938b36824f169c56f7f43d860416ca0a4c","impliedFormat":1},{"version":"986b55b4f920c99d77c1845f2542df6f746cb5adc9ab93eb1545a7e6ef37590d","impliedFormat":1},{"version":"cd00906068b81fbd8a22d021580ac505e272844408174520fafed0ae00627a5d","impliedFormat":1},{"version":"69fab68a769c17a52a24b868aeb644f3ee14abaa5064115f575ddd59231105ce","impliedFormat":1},{"version":"e181eb86b2caf80fe18c72efce6b913bc226e4a69a5456eaf4f859f1c29c6fd6","impliedFormat":1},{"version":"93f7871380478bc6acf02ad9f3dc7da0c21997caebbe782eb93a11b7bd06a46d","impliedFormat":1},{"version":"d00279ab020713264f570d5181c89ca362b7de8abddf96733de86bce0eca082c","impliedFormat":1},{"version":"f7db473f1d5d2a124f14886ac9dbfeccfbb94a98bbe1610a47c30c2933afa279","impliedFormat":1},{"version":"f44cf6c6d608ef925831e550b19841b5d71bd87195bd346604ff05644fb0d29c","impliedFormat":1},{"version":"154f23902d7a3fcdace4c20b654da7355fee4b7f807d1f77d6c9a24a8756013a","impliedFormat":1},{"version":"562f4f3c75a497d3ad7709381f850bb8c7646a9c6e94fdf8e91928e23d155411","impliedFormat":1},{"version":"4583380b676ee59b70a9696b42acfa986cd5f32430f37672e04f31f40b05df74","impliedFormat":1},{"version":"ad0a13f35a0d88803979f8ea9050ad7441e09d21a509abf2f303e18c1267af17","impliedFormat":1},{"version":"ba9781c718ab3d09cbde1216029072698d2da6135f0d2f856ba387d6caceb13e","impliedFormat":1},{"version":"d7c597c14698ba5fc8010076afa426f029b2d8edabb5073270c070cc645ba638","impliedFormat":1},{"version":"bd2afc69cf1d85cd950a99813bc7eff007d8afa496e7c2142a845cd1181d0474","impliedFormat":1},{"version":"558b462b23ea186d094dbff158d652acd58c0988c9fd53af81a8903412aa5901","impliedFormat":1},{"version":"0e984ae642a15973d652fd7b0d2712a284787d0d7a1db99aa49af0121e47f1df","impliedFormat":1},{"version":"0ad53ee208a23eef2a5cb3d85f2a9dc1019fd5e69179c4b0c02dc56c40d611c4","impliedFormat":1},{"version":"7a6898b26947bd356f33f4efef3eb23e61174d85dca19f41a8780d6bb4bfb405","impliedFormat":1},{"version":"9fe30349d26f34e85209fb06340bac34177f7eae3d6bb69dc12cd179d2c13ddf","impliedFormat":1},{"version":"d568c51d2c4360fd407445e39f4d86891dba04083402602bf5f24fd3969cacbb","impliedFormat":1},{"version":"b2483a924349ec835f4d778dd6787447a2f8bfbb651164851bff29d5b3d990a6","impliedFormat":1},{"version":"aae66889332cff4b2f7586c5c8758abc394d8d1c48f9b04b0c257e58f629d285","impliedFormat":1},{"version":"0f86c85130c64d6dbe6a9090bb3df71c4b0987bce4a08afe1ac4ece597655b9c","impliedFormat":1},{"version":"0ce28ad2671baed24517e1c1f4f2a986029137635bce788ee8fb542f002ac5b8","impliedFormat":1},{"version":"cd12e4fe77d24db98d66049360a4269299bcfb9dc3a1b47078ab1b4afac394cb","impliedFormat":1},{"version":"1589e5ac394b2b2e64264da3e1798d0e103b4f408f5bae1527d9e706f98269c7","impliedFormat":1},{"version":"ff8181aa0fde5ec2d737aecc5ebaa9e881379041f13e5ce1745620e17f78dcf9","impliedFormat":1},{"version":"0b2e54504b568c08df1e7db11c105786742866ba51e20486ab9b2286637d268f","impliedFormat":1},{"version":"bc1ffc3a2dca8ee715571739be3ec74d079e60505e1d0d2446e4978f6c75ba5c","impliedFormat":1},{"version":"770a40373470dff27b3f7022937ea2668a0854d7977c9d22073e1c62af537727","impliedFormat":1},{"version":"a0f8ce72cb02247a112ce4a2fa0f122478a8e99c90a5e6b676b41a68b1891ad2","impliedFormat":1},{"version":"6e957ea18b2bf951cf3995d115ad9bfa439e8d891aeb1afc901d793202c0b90d","impliedFormat":1},{"version":"a1c65bd78725f9172b5846c3c58ddf4bcbb43a30ab19e951f0102552fbfd3d5d","impliedFormat":1},{"version":"04718c7325e7df4bac9a6d026a0a2bd5a8b54501f274aaf93a03b5d1d0635bd1","impliedFormat":1},{"version":"405205f932d4e0ce688a380fa3150b1c7ff60e7fc89909e11a33eab7af240edb","impliedFormat":1},{"version":"566fc1a6616a522f8b45082032a33e6d37ff7df3f7d4d63c3cce9017d0345178","impliedFormat":1},{"version":"3b699b08db04559803b85aa0809748e61427b3d831f77834b8206e9f2ed20c93","impliedFormat":1},{"version":"b27242dd3af2a5548d0c7231db7da63d6373636d6c4e72d9b616adaa2acef7e1","impliedFormat":1},{"version":"e0ee7ba0571b83c53a3d6ec761cf391e7128d8f8f590f8832c28661b73c21b68","impliedFormat":1},{"version":"072bfd97fc61c894ef260723f43a416d49ebd8b703696f647c8322671c598873","impliedFormat":1},{"version":"e70875232f5d5528f1650dd6f5c94a5bed344ecf04bdbb998f7f78a3c1317d02","impliedFormat":1},{"version":"8e495129cb6cd8008de6f4ff8ce34fe1302a9e0dcff8d13714bd5593be3f7898","impliedFormat":99},"ee315de689a95897bb54764f2b45c106490cb7b725651fc2c071cfa77932cd83","77b13aa8b7fdff6cbe52c96b1b2222418251567c037534ba16cbff2be825a26a",{"version":"771f42f64e7823d6f92018e12a1c653bfa2f51b3914f8906fb26917215bc400e","signature":"509e239a097a9e6ed3c511b283d0e9bfece76c1abb61bc6f53dd7b46d1cabb29"},"b461a0df72b65f68a1fc66868262f29a205a8c4b1247d63cfb526ff7347adc4d","7a93f2a3b37c35f1f1fbf39dceeef4c8f23bd5e9e8dab4d82290a2730cdca93b","7edf699514893140c9e3f253da0acaa31e8e675ef8f66505aeb25068e0148bac","1447246c0124b94488cb5508e6c754a768633d49b22681715fd1542fd9dc014b","754fc9bdaa3b0cfddcd1f1ab88e7cfd37ea4bcb29ec8c13f3873d040c3cb8b36","dc79e41b1c08ed08b7931a16103ccdd0e7145e64870ac423055b7778dc334a86","bb5eda2fa9febd02b7d3472b9235d4e8d3388817eedf7513623ec817646c37cd","5a256b049c236fa23f36483177df7a5209ca7e15cc5fe6674247aae73838b3b6",{"version":"d1604b3b1555d56a6b6471d444777099c2410c4fadba0600a634acd00be2f0df","signature":"45b373ad2e114de335dd3eaf62f9658266d71c2f34537489f88f3b4815fa72f8"},{"version":"d5e7f716f0f74bb25523f3d5237952f1ed9d987e14ace2746187521a4eb25f76","signature":"59b773c85d82f560f9c7492e2609f37dc329c247914603831f6655b1e5090aa2"},{"version":"a01c5938b5dc6e819c0a4fc1e86a90da723d42f28f9b23a4657be78c0e6b8a15","signature":"872981c7daf929ee5ed35e720f59692075154510ce720a68d7a59859e1da78b0"},"8419030131409201d3ad797bb319496d35572512cac2612ceafe6df8f2576079","b10d1bf48682b29b7a70fffbc44dcf1ee2e9a51abaf369ccba5a1249f7bf8f5b","3bdd6bb08fdf67ac70bf1960b9c4b530d9e45863887a4be42b36b5b9813c31c4","a3ef450d30cd8249ea3a63c47455f4c20a6e9731e20ed0eb46891922d65c7fb5","c0ee1684787b5c190d1aa9cbb0c0ec7c6c8a57b1533ea937a815f89c5a8faa69",{"version":"d59ee6238cc100b0802d28ef8af6c1b9f8e91bfcf4edb5af1afb167140b3a290","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"4949090d70dc78fca6706978b9d3efdb17f0558df3a827dc0ca86d4697dd8202","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"69779615a21bc23ba8d6b8337c1e40c3a7acde3e10b107f9ffe9402c29c195da","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"765fa8b3ed4af86af75d1c43ead9714b4d85c36cba7e9e25e8fa1a82663c9bc8","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"d07f59c14d8b4a708c74e9106a3b24518792a1db606ce0478ca99b03e175a943","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"e382126a0461c82085890372246391048d2fa475ccc20bde0de2a234ede2b0aa","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"d1f8574cbdc204413ae8fb1caf842dc157436725cc4cc97b7733f07df23c331f","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"89961a06f4659e4651b07bef4e094ff0827356496513cac9817e0385c0e09b8c","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"ef073dd3d5992b39e9d7a069d22342d5270ce8b9b9c31396df7e705ebf1d930e","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"d2b6f4f1ef3f7426227cdada24c67865a5b58ae0d64e89d7b5639404e8d4947d","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"dacc04fa5d817e860fd739b61a4f0e8a315bbeec3598d93a311f70b42cc72ca6","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"4e2fadedec679e373a74315d586af8451a801b2e11a31a320ff2f018402f700a","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},{"version":"f2f23fe34b735887db1d5597714ae37a6ffae530cafd6908c9d79d485667c956","impliedFormat":1},{"version":"5bba0e6cd8375fd37047e99a080d1bd9a808c95ecb7f3043e3adc125196f6607","impliedFormat":1}],"root":[410,435,467,[469,479],494,[497,518],[520,533],[539,554],[812,842]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":7},"referencedMap":[[832,1],[833,2],[834,3],[835,4],[836,5],[837,6],[838,7],[839,8],[840,9],[831,10],[841,11],[842,12],[410,13],[363,14],[468,14],[496,15],[495,14],[142,16],[143,16],[144,17],[99,18],[145,19],[146,20],[147,21],[94,14],[97,22],[95,14],[96,14],[148,23],[149,24],[150,25],[151,26],[152,27],[153,28],[154,28],[155,29],[156,30],[157,31],[158,32],[100,14],[98,14],[159,33],[160,34],[161,35],[193,36],[162,37],[163,38],[164,39],[165,40],[166,41],[167,42],[168,43],[169,44],[170,45],[171,46],[172,46],[173,47],[174,14],[175,48],[177,49],[176,50],[178,51],[179,52],[180,53],[181,54],[182,55],[183,56],[184,57],[185,58],[186,59],[187,60],[188,61],[189,62],[190,63],[101,14],[102,14],[103,14],[141,64],[191,65],[192,66],[86,14],[198,67],[199,68],[197,69],[195,70],[196,71],[84,14],[87,72],[286,69],[843,14],[844,14],[519,14],[437,14],[85,14],[642,73],[621,74],[718,14],[622,75],[558,73],[559,14],[560,14],[561,14],[562,14],[563,14],[564,14],[565,14],[566,14],[567,14],[568,14],[569,14],[570,73],[571,73],[572,14],[573,14],[574,14],[575,14],[576,14],[577,14],[578,14],[579,14],[580,14],[582,14],[581,14],[583,14],[584,14],[585,73],[586,14],[587,14],[588,73],[589,14],[590,14],[591,73],[592,14],[593,73],[594,73],[595,73],[596,14],[597,73],[598,73],[599,73],[600,73],[601,73],[603,73],[604,14],[605,14],[602,73],[606,73],[607,14],[608,14],[609,14],[610,14],[611,14],[612,14],[613,14],[614,14],[615,14],[616,14],[617,14],[618,73],[619,14],[620,14],[623,76],[624,73],[625,73],[626,77],[627,78],[628,73],[629,73],[630,73],[631,73],[634,73],[632,14],[633,14],[556,14],[635,14],[636,14],[637,14],[638,14],[639,14],[640,14],[641,14],[643,79],[644,14],[645,14],[646,14],[648,14],[647,14],[649,14],[650,14],[651,14],[652,73],[653,14],[654,14],[655,14],[656,14],[657,73],[658,73],[660,73],[659,73],[661,14],[662,14],[663,14],[664,14],[811,80],[665,73],[666,73],[667,14],[668,14],[669,14],[670,14],[671,14],[672,14],[673,14],[674,14],[675,14],[676,14],[677,14],[678,14],[679,73],[680,14],[681,14],[682,14],[683,14],[684,14],[685,14],[686,14],[687,14],[688,14],[689,14],[690,73],[691,14],[692,14],[693,14],[694,14],[695,14],[696,14],[697,14],[698,14],[699,14],[700,73],[701,14],[702,14],[703,14],[704,14],[705,14],[706,14],[707,14],[708,14],[709,73],[710,14],[711,14],[712,14],[713,14],[714,14],[715,14],[716,73],[717,14],[719,81],[555,73],[720,14],[721,73],[722,14],[723,14],[724,14],[725,14],[726,14],[727,14],[728,14],[729,14],[730,14],[731,73],[732,14],[733,14],[734,14],[735,14],[736,14],[737,14],[738,14],[743,82],[741,83],[742,84],[740,85],[739,73],[744,14],[745,14],[746,73],[747,14],[748,14],[749,14],[750,14],[751,14],[752,14],[753,14],[754,14],[755,14],[756,73],[757,73],[758,14],[759,14],[760,14],[761,73],[762,14],[763,73],[764,14],[765,79],[766,14],[767,14],[768,14],[769,14],[770,14],[771,14],[772,14],[773,14],[774,14],[775,73],[776,73],[777,14],[778,14],[779,14],[780,14],[781,14],[782,14],[783,14],[784,14],[785,14],[786,14],[787,14],[788,14],[789,73],[790,73],[791,14],[792,14],[793,73],[794,14],[795,14],[796,14],[797,14],[798,14],[799,14],[800,14],[801,14],[802,14],[803,14],[804,14],[805,14],[806,73],[557,86],[807,14],[808,14],[809,14],[810,14],[436,87],[537,14],[536,14],[535,88],[538,89],[534,90],[448,14],[438,91],[439,92],[465,93],[440,94],[441,95],[442,96],[443,97],[444,98],[445,99],[446,100],[447,101],[466,102],[450,103],[463,104],[462,14],[449,105],[451,106],[452,107],[453,108],[454,109],[455,110],[456,111],[457,112],[458,113],[459,114],[460,115],[461,116],[464,117],[93,118],[366,119],[370,120],[372,121],[219,122],[233,123],[337,124],[265,14],[340,125],[301,126],[310,127],[338,128],[220,129],[264,14],[266,130],[339,131],[240,132],[221,133],[245,132],[234,132],[204,132],[292,134],[293,135],[209,14],[289,136],[294,137],[381,138],[287,137],[382,139],[271,14],[290,140],[394,141],[393,142],[296,137],[392,14],[390,14],[391,143],[291,69],[278,144],[279,145],[288,146],[305,147],[306,148],[295,149],[273,150],[274,151],[385,152],[388,153],[252,154],[251,155],[250,156],[397,69],[249,157],[225,14],[400,14],[403,14],[402,69],[404,158],[200,14],[331,14],[232,159],[202,160],[354,14],[355,14],[357,14],[360,161],[356,14],[358,162],[359,162],[218,14],[231,14],[365,163],[373,164],[377,165],[214,166],[281,167],[280,14],[272,150],[300,168],[298,169],[297,14],[299,14],[304,170],[276,171],[213,172],[238,173],[328,174],[205,175],[212,176],[201,124],[342,177],[352,178],[341,14],[351,179],[239,14],[223,180],[319,181],[318,14],[325,182],[327,183],[320,184],[324,185],[326,182],[323,184],[322,182],[321,184],[261,186],[246,186],[313,187],[247,187],[207,188],[206,14],[317,189],[316,190],[315,191],[314,192],[208,193],[285,194],[302,195],[284,196],[309,197],[311,198],[308,196],[241,193],[194,14],[329,199],[267,200],[303,14],[350,201],[270,202],[345,203],[211,14],[346,204],[348,205],[349,206],[332,14],[344,175],[243,207],[330,208],[353,209],[215,14],[217,14],[222,210],[312,211],[210,212],[216,14],[269,213],[268,214],[224,215],[277,216],[275,217],[226,218],[228,219],[401,14],[227,220],[229,221],[368,14],[367,14],[369,14],[399,14],[230,222],[283,69],[92,14],[307,223],[253,14],[263,224],[242,14],[375,69],[384,225],[260,69],[379,137],[259,226],[362,227],[258,225],[203,14],[386,228],[256,69],[257,69],[248,14],[262,14],[255,229],[254,230],[244,231],[237,149],[347,14],[236,232],[235,14],[371,14],[282,69],[364,233],[83,14],[91,234],[88,69],[89,14],[90,14],[343,235],[336,236],[335,14],[334,237],[333,14],[374,238],[376,239],[378,240],[380,241],[383,242],[409,243],[387,243],[408,244],[389,245],[395,246],[396,247],[398,248],[405,249],[407,14],[406,250],[361,251],[427,252],[425,253],[426,254],[414,255],[415,253],[422,256],[413,257],[418,258],[428,14],[419,259],[424,260],[430,261],[429,262],[412,263],[420,264],[421,265],[416,266],[423,252],[417,267],[411,14],[433,268],[432,14],[431,14],[434,269],[81,14],[82,14],[13,14],[14,14],[16,14],[15,14],[2,14],[17,14],[18,14],[19,14],[20,14],[21,14],[22,14],[23,14],[24,14],[3,14],[25,14],[26,14],[4,14],[27,14],[31,14],[28,14],[29,14],[30,14],[32,14],[33,14],[34,14],[5,14],[35,14],[36,14],[37,14],[38,14],[6,14],[42,14],[39,14],[40,14],[41,14],[43,14],[7,14],[44,14],[49,14],[50,14],[45,14],[46,14],[47,14],[48,14],[8,14],[54,14],[51,14],[52,14],[53,14],[55,14],[9,14],[56,14],[57,14],[58,14],[60,14],[59,14],[61,14],[62,14],[10,14],[63,14],[64,14],[65,14],[11,14],[66,14],[67,14],[68,14],[69,14],[70,14],[1,14],[71,14],[72,14],[12,14],[76,14],[74,14],[79,14],[78,14],[73,14],[77,14],[75,14],[80,14],[119,270],[129,271],[118,270],[139,272],[110,273],[109,274],[138,250],[132,275],[137,276],[112,277],[126,278],[111,279],[135,280],[107,281],[106,250],[136,282],[108,283],[113,284],[114,14],[117,284],[104,14],[140,285],[130,286],[121,287],[122,288],[124,289],[120,290],[123,291],[133,250],[115,292],[116,293],[125,294],[105,295],[128,286],[127,284],[131,14],[134,296],[493,297],[485,298],[492,299],[487,14],[488,14],[486,300],[489,301],[480,14],[481,14],[482,297],[484,302],[490,14],[491,303],[483,304],[478,305],[479,306],[548,307],[814,308],[815,307],[816,309],[818,310],[819,307],[820,311],[821,307],[501,312],[502,313],[503,314],[504,315],[506,313],[505,313],[508,316],[510,317],[511,318],[512,319],[513,320],[514,321],[515,322],[516,322],[518,323],[521,324],[522,325],[524,326],[523,327],[526,328],[525,328],[527,329],[528,330],[530,331],[529,319],[531,317],[544,332],[823,333],[545,334],[824,335],[825,333],[830,336],[812,337],[554,338],[550,14],[549,338],[553,339],[551,14],[552,14],[813,338],[828,338],[829,14],[826,338],[827,14],[817,69],[547,340],[546,14],[543,69],[822,14],[533,341],[532,14],[539,342],[540,14],[542,343],[499,344],[467,14],[497,345],[498,346],[470,14],[477,347],[473,14],[474,14],[517,14],[471,14],[475,14],[472,14],[469,348],[494,14],[520,349],[507,14],[500,350],[509,351],[476,14],[541,14],[435,352]],"affectedFilesPendingEmit":[832,833,834,835,836,837,838,839,840,831,841,842,478,479,548,814,815,816,818,819,820,821,501,502,503,504,506,505,508,510,511,512,513,514,515,516,518,521,522,524,523,526,525,527,528,530,529,531,544,823,545,824,825,830,812,554,550,549,553,551,552,813,828,829,826,827,817,547,546,543,822,533,532,539,540,542,499,467,497,498,470,477,473,474,517,471,475,472,469,494,520,507,500,509,476,541,435],"version":"5.9.3"} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7f974ca --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + globalSetup: ["tests/globalSetup.ts"], + setupFiles: ["tests/setup.ts"], + // Route handlers share one cached mongoose connection per worker, so + // run files sequentially against the single in-memory server. + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 120_000, + }, +}); From 8817c0f52f360b7280140768f14898da40482332 Mon Sep 17 00:00:00 2001 From: denisireri Date: Sun, 11 Oct 2026 18:39:11 +0300 Subject: [PATCH 2/4] Add a stock ledger, inter-branch transfers and a sync protocol that cannot jam Stock (PRD 5.2): a branch's stock of a product is now the sum of its StockMovement rows. Every movement names the document that caused it and (type, refId) is unique, so replaying a sale, retrying a transfer or a second M-Pesa callback never moves the same stock twice. - Cash sales and confirmed M-Pesa payments deduct stock; purchases of a catalogue product add it. - POST /api/stock/transfers moves stock between two branches (owner: any two; manager: out of their own) and refuses to send more than the sending branch holds. Double submits are applied once. - POST /api/stock/adjustments records opening stock, breakage or a physical count (the ledger is corrected to the counted number). - GET /api/stock returns every branch x product with LOW/OUT flags (per-product lowStockThreshold, default 10); GET /api/stock/movements is the history. Sync (PRD 5.3/5.5): POST /api/transactions/cash now returns a result per sale - saved, duplicate or rejected - instead of silently skipping replays. The old client never learned a replayed sale had synced, so rows piled up until the batch passed the 100-sale limit and every flush failed with 400. Also: - A sale can hold several products (one Transaction per line, grouped by saleId); the old single-product payload is still accepted. - One bad sale is rejected on its own instead of failing the batch. - A queued sale is only accepted from the cashier who recorded it, and stays with the branch where it happened. - A product deactivated while offline is still recorded at its price; an invalid phone is dropped rather than losing the sale; the phone is now optional for cash sales. - The price the device showed is kept and a difference is flagged (priceMismatch) instead of being silently overwritten. Co-Authored-By: Claude Opus 5.5 --- src/app/api/mpesa/callback/route.ts | 2 + .../api/mpesa/status/[transactionId]/route.ts | 4 + src/app/api/purchases/route.ts | 19 +- src/app/api/stock/adjustments/route.ts | 83 +++++++ src/app/api/stock/movements/route.ts | 31 +++ src/app/api/stock/route.ts | 24 ++ src/app/api/stock/transfers/route.ts | 136 +++++++++++ src/app/api/transactions/cash/route.ts | 229 +++++++++++++----- src/lib/server/models/StockMovement.ts | 51 ++++ src/lib/server/models/StockTransfer.ts | 40 +++ src/lib/server/models/Transaction.ts | 18 +- src/lib/server/stock.ts | 140 +++++++++++ src/lib/syncProtocol.ts | 11 + tests/stock.test.ts | 134 ++++++++++ tests/sync.test.ts | 129 ++++++++++ 15 files changed, 982 insertions(+), 69 deletions(-) create mode 100644 src/app/api/stock/adjustments/route.ts create mode 100644 src/app/api/stock/movements/route.ts create mode 100644 src/app/api/stock/route.ts create mode 100644 src/app/api/stock/transfers/route.ts create mode 100644 src/lib/server/models/StockMovement.ts create mode 100644 src/lib/server/models/StockTransfer.ts create mode 100644 src/lib/server/stock.ts create mode 100644 src/lib/syncProtocol.ts create mode 100644 tests/stock.test.ts create mode 100644 tests/sync.test.ts diff --git a/src/app/api/mpesa/callback/route.ts b/src/app/api/mpesa/callback/route.ts index 653bbc6..c7a84c0 100644 --- a/src/app/api/mpesa/callback/route.ts +++ b/src/app/api/mpesa/callback/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { connectDb } from "@/lib/server/db"; import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; +import { recordSaleMovement } from "@/lib/server/stock"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; export const runtime = "nodejs"; @@ -99,6 +100,7 @@ export async function POST(req: NextRequest) { txn.mpesaDetails.resultDesc = resultDesc; txn.mpesaDetails.receiptNumber = mpesaReceiptNumber; await txn.save(); + await recordSaleMovement(txn); await MpesaCallbackLog.create({ sourceIp, diff --git a/src/app/api/mpesa/status/[transactionId]/route.ts b/src/app/api/mpesa/status/[transactionId]/route.ts index 3e2d8d9..a04ec25 100644 --- a/src/app/api/mpesa/status/[transactionId]/route.ts +++ b/src/app/api/mpesa/status/[transactionId]/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; +import { isValidObjectId } from "mongoose"; import { connectDb } from "@/lib/server/db"; import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; import { getAuth } from "@/lib/server/auth"; import { queryStkStatus } from "@/lib/server/mpesa"; +import { recordSaleMovement } from "@/lib/server/stock"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -14,6 +16,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI const auth = getAuth(req); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + if (!isValidObjectId(params.transactionId)) return NextResponse.json({ error: "Transaction not found" }, { status: 404 }); const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId }); if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 }); @@ -29,6 +32,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI fresh.mpesaDetails.resultCode = code; fresh.mpesaDetails.resultDesc = result.ResultDesc; await fresh.save(); + await recordSaleMovement(fresh); return NextResponse.json(fresh); } } diff --git a/src/app/api/purchases/route.ts b/src/app/api/purchases/route.ts index 00f6fc1..6bd08c5 100644 --- a/src/app/api/purchases/route.ts +++ b/src/app/api/purchases/route.ts @@ -5,6 +5,7 @@ import { Purchase } from "@/lib/server/models/Purchase"; import { Product } from "@/lib/server/models/Product"; import { Branch } from "@/lib/server/models/Branch"; import { objectId } from "@/lib/server/validation"; +import { recordMovement } from "@/lib/server/stock"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; export const runtime = "nodejs"; @@ -57,8 +58,9 @@ const createSchema = z.object({ // POST /api/purchases — records a supplier purchase. The buying price is // snapshotted (unitCost/totalCost) so historical records never shift when -// product prices change later. A completed purchase conceptually increases -// branch stock. +// product prices change later. A purchase of a catalogue product adds its +// quantity to the branch's stock ledger; a free-text item (no productId) +// is recorded as a cost only. export async function POST(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); @@ -125,5 +127,18 @@ export async function POST(req: NextRequest) { recordedBy: auth.sub, }); + if (productId) { + await recordMovement({ + businessId: auth.businessId, + branchId: branchId!, + productId, + type: "PURCHASE", + quantity: parsed.data.quantity, + refId: purchase._id.toString(), + note: `From ${parsed.data.supplierName}`, + recordedBy: auth.sub, + }); + } + return NextResponse.json({ purchase }, { status: 201 }); } diff --git a/src/app/api/stock/adjustments/route.ts b/src/app/api/stock/adjustments/route.ts new file mode 100644 index 0000000..06f5fd8 --- /dev/null +++ b/src/app/api/stock/adjustments/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { connectDb } from "@/lib/server/db"; +import { Branch } from "@/lib/server/models/Branch"; +import { Product } from "@/lib/server/models/Product"; +import { StockMovement } from "@/lib/server/models/StockMovement"; +import { authorize, MANAGERS } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; +import { clientRefId, recordMovement, stockOf } from "@/lib/server/stock"; + +export const runtime = "nodejs"; + +const adjustmentSchema = z.object({ + clientUuid: z.string().uuid(), + branchId: objectId.optional(), // owners must give one; managers default to their own branch + productId: objectId, + // COUNT: "we physically counted N", so the ledger is corrected to N. + // DELTA: "add or remove N", e.g. opening stock, breakage, or a delivery + // that was not recorded as a purchase. + mode: z.enum(["COUNT", "DELTA"]), + quantity: z.number().int().min(-1_000_000).max(1_000_000), + note: z.string().trim().max(300).optional(), +}); + +// POST /api/stock/adjustments — managers (own branch) and owners (any branch). +export async function POST(req: NextRequest) { + await connectDb(); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + const parsed = adjustmentSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); + } + const data = parsed.data; + if (data.mode === "COUNT" && data.quantity < 0) { + return NextResponse.json({ error: "A counted quantity cannot be negative" }, { status: 400 }); + } + + let branchId: string | null; + if (auth.role === "SUPER_ADMIN") { + branchId = data.branchId ?? null; + if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); + } else { + branchId = auth.branchId; + if (data.branchId && data.branchId !== auth.branchId) { + return NextResponse.json({ error: "Cannot adjust stock for another branch" }, { status: 403 }); + } + } + if (!branchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 }); + + const [branch, product] = await Promise.all([ + Branch.exists({ _id: branchId, businessId: auth.businessId }), + Product.exists({ _id: data.productId, businessId: auth.businessId }), + ]); + if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); + if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 }); + + // A retried submit with the same clientUuid is a no-op. + const refId = clientRefId(auth.businessId, data.clientUuid); + const already = await StockMovement.exists({ type: "ADJUSTMENT", refId }); + if (!already) { + const before = await stockOf(auth.businessId, branchId, data.productId); + const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity; + if (delta !== 0) { + const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment"; + await recordMovement({ + businessId: auth.businessId, + branchId, + productId: data.productId, + type: "ADJUSTMENT", + quantity: delta, + refId, + note: data.note ? `${label}. ${data.note}` : label, + recordedBy: auth.sub, + }); + } + } + + const quantity = await stockOf(auth.businessId, branchId, data.productId); + return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 }); +} diff --git a/src/app/api/stock/movements/route.ts b/src/app/api/stock/movements/route.ts new file mode 100644 index 0000000..5baa387 --- /dev/null +++ b/src/app/api/stock/movements/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { connectDb } from "@/lib/server/db"; +import { StockMovement } from "@/lib/server/models/StockMovement"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/stock/movements?branchId=&limit= — recent ledger entries, newest first. +export async function GET(req: NextRequest) { + await connectDb(); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200); + + const filter: Record = { businessId: auth.businessId }; + if (scope.branchId) filter.branchId = scope.branchId; + + const movements = await StockMovement.find(filter) + .sort({ createdAt: -1 }) + .limit(limit) + .populate("branchId", "branchName") + .populate("productId", "label unit") + .populate("recordedBy", "name") + .lean(); + return NextResponse.json({ movements }); +} diff --git a/src/app/api/stock/route.ts b/src/app/api/stock/route.ts new file mode 100644 index 0000000..190cab8 --- /dev/null +++ b/src/app/api/stock/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { connectDb } from "@/lib/server/db"; +import { authorize, branchParam } from "@/lib/server/auth"; +import { getStockLevels } from "@/lib/server/stock"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/stock?branchId= — current stock per branch and product. +// Owners see every branch (or one, via branchId); everyone else sees only +// their own branch. +export async function GET(req: NextRequest) { + await connectDb(); + const gate = authorize(req); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + if (auth.role !== "SUPER_ADMIN" && !scope.branchId) return NextResponse.json({ levels: [] }); + + const levels = await getStockLevels(auth.businessId, scope.branchId); + return NextResponse.json({ levels }); +} diff --git a/src/app/api/stock/transfers/route.ts b/src/app/api/stock/transfers/route.ts new file mode 100644 index 0000000..b843ee6 --- /dev/null +++ b/src/app/api/stock/transfers/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { connectDb } from "@/lib/server/db"; +import { Branch } from "@/lib/server/models/Branch"; +import { Product } from "@/lib/server/models/Product"; +import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; +import { objectId } from "@/lib/server/validation"; +import { recordMovement, stockOf } from "@/lib/server/stock"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/stock/transfers?branchId= — recent transfers into or out of the branch(es) in scope. +export async function GET(req: NextRequest) { + await connectDb(); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); + if (!scope.ok) return scope.response; + + const filter: Record = { businessId: auth.businessId }; + if (scope.branchId) filter.$or = [{ fromBranchId: scope.branchId }, { toBranchId: scope.branchId }]; + + const transfers = await StockTransfer.find(filter) + .sort({ createdAt: -1 }) + .limit(100) + .populate("fromBranchId", "branchName") + .populate("toBranchId", "branchName") + .populate("productId", "label unit") + .populate("recordedBy", "name") + .lean(); + return NextResponse.json({ transfers }); +} + +const transferSchema = z.object({ + clientUuid: z.string().uuid(), + fromBranchId: objectId.optional(), // owners must give one; managers send from their own branch + toBranchId: objectId, + productId: objectId, + quantity: z.number().int().min(1).max(1_000_000), + note: z.string().trim().max(300).optional(), +}); + +/** Both ledger sides of a transfer. Idempotent, so it is safe to re-run on a retry. */ +async function applyTransfer(t: IStockTransfer) { + const common = { + businessId: t.businessId, + productId: t.productId, + refId: t._id.toString(), + recordedBy: t.recordedBy, + note: t.note, + }; + await recordMovement({ ...common, branchId: t.fromBranchId, type: "TRANSFER_OUT", quantity: -t.quantity }); + await recordMovement({ ...common, branchId: t.toBranchId, type: "TRANSFER_IN", quantity: t.quantity }); +} + +// POST /api/stock/transfers — move stock from one branch to another. +// Owners: between any two branches. Managers: out of their own branch only. +export async function POST(req: NextRequest) { + await connectDb(); + const gate = authorize(req, MANAGERS); + if (!gate.ok) return gate.response; + const auth = gate.auth; + + const parsed = transferSchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); + } + const data = parsed.data; + + // A retried submit returns the transfer that already happened. + const existing = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }); + if (existing) { + await applyTransfer(existing); + return NextResponse.json({ transfer: existing }, { status: 200 }); + } + + let fromBranchId: string | null; + if (auth.role === "SUPER_ADMIN") { + fromBranchId = data.fromBranchId ?? null; + if (!fromBranchId) return NextResponse.json({ error: "fromBranchId is required" }, { status: 400 }); + } else { + fromBranchId = auth.branchId; + if (data.fromBranchId && data.fromBranchId !== auth.branchId) { + return NextResponse.json( + { error: "Branch managers can only transfer stock out of their own branch" }, + { status: 403 } + ); + } + } + if (!fromBranchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 }); + if (fromBranchId === data.toBranchId) { + return NextResponse.json({ error: "Choose two different branches" }, { status: 400 }); + } + + const [branchCount, product] = await Promise.all([ + Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId }), + Product.exists({ _id: data.productId, businessId: auth.businessId }), + ]); + if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); + if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 }); + + // Transfers are made online by a manager, so unlike offline sales they + // can be refused when the sending branch does not have the stock. + const available = await stockOf(auth.businessId, fromBranchId, data.productId); + if (available < data.quantity) { + return NextResponse.json( + { error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available }, + { status: 409 } + ); + } + + let transfer: IStockTransfer; + try { + transfer = await StockTransfer.create({ + businessId: auth.businessId, + fromBranchId, + toBranchId: data.toBranchId, + productId: data.productId, + quantity: data.quantity, + clientUuid: data.clientUuid, + note: data.note ?? null, + recordedBy: auth.sub, + }); + } catch (err: unknown) { + // Two identical submits raced and the other one created it. + if ((err as { code?: number })?.code !== 11000) throw err; + transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!; + } + await applyTransfer(transfer); + + return NextResponse.json({ transfer }, { status: 201 }); +} diff --git a/src/app/api/transactions/cash/route.ts b/src/app/api/transactions/cash/route.ts index 67a41a4..b87db58 100644 --- a/src/app/api/transactions/cash/route.ts +++ b/src/app/api/transactions/cash/route.ts @@ -1,29 +1,67 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; -import { Transaction } from "@/lib/server/models/Transaction"; +import { Transaction, ITransaction } from "@/lib/server/models/Transaction"; import { Product, BranchPricing } from "@/lib/server/models/Product"; -import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; +import { Branch } from "@/lib/server/models/Branch"; +import { authorize } from "@/lib/server/auth"; import { normalizeKenyanPhone } from "@/lib/server/phone"; +import { objectId } from "@/lib/server/validation"; +import { recordSaleMovement } from "@/lib/server/stock"; +import { MAX_LINES_PER_SALE, MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol"; export const runtime = "nodejs"; -const saleSchema = z.object({ - clientUuid: z.string().uuid(), - customerName: z.string().trim().max(120).optional(), - customerPhone: z.string().trim().min(9).max(15), - productId: z.string().min(1), +const lineSchema = z.object({ + productId: objectId, quantity: z.number().int().min(1).max(500), - clientSubmittedAt: z.string().datetime().optional(), + unitPrice: z.number().nonnegative().optional(), // what the device showed; informational }); -const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) }); +const saleSchema = z.preprocess( + // Older clients sent one product per sale at the top level. + (raw) => { + const r = raw as Record | null; + if (r && !Array.isArray(r.lines) && r.productId) { + return { ...r, lines: [{ productId: r.productId, quantity: r.quantity }] }; + } + return raw; + }, + z.object({ + clientUuid: z.string().uuid(), + staffId: objectId.optional(), // who recorded it on the device + branchId: objectId.optional(), // where it was recorded + customerName: z.string().trim().max(120).optional(), + customerPhone: z.string().trim().max(20).optional(), + clientSubmittedAt: z.string().datetime().optional(), + lines: z.array(lineSchema).min(1).max(MAX_LINES_PER_SALE), + }) +); +type SaleInput = z.infer; + +const bodySchema = z.object({ sales: z.array(z.unknown()).min(1).max(MAX_SALES_PER_REQUEST) }); + +/** + * POST /api/transactions/cash — record cash sales, one or many, live or + * replayed from the device's offline queue. + * + * Every sale gets its own result so the device knows exactly what to do + * with each queued row: + * saved newly recorded -> mark synced + * duplicate already recorded earlier -> mark synced (this is a replay) + * rejected can never be recorded -> stop retrying, show the cashier + * One bad sale never blocks the others in the batch. + * + * Idempotency: each line's clientUuid is `${saleUuid}:${index}` and is + * unique in the database; the stock movement for each line is keyed on + * the transaction id. Replaying a sale any number of times records it, + * and deducts its stock, exactly once. + */ export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, ["DELIVERY", "BRANCH_MANAGER"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const normalized = Array.isArray(json?.sales) ? json : { sales: [json] }; @@ -31,61 +69,124 @@ export async function POST(req: NextRequest) { if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - - const branchId = resolveBranchScope(auth!); - if (!branchId) { + if (!auth.branchId) { return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 }); } - const { sales } = parsed.data; - const productIds = Array.from(new Set(sales.map((s) => s.productId))); - const products = await Product.find({ _id: { $in: productIds }, businessId: auth!.businessId }).lean(); - const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds }, businessId: auth!.businessId }).lean(); - const productMap = new Map(products.map((p) => [p._id.toString(), p])); - const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); - - const rejected: { clientUuid: string; error: string }[] = []; - const inserted: any[] = []; - - for (const sale of sales) { - let phone: string; - try { - phone = normalizeKenyanPhone(sale.customerPhone); - } catch (e: any) { - rejected.push({ clientUuid: sale.clientUuid, error: e.message }); - continue; - } - const product = productMap.get(sale.productId); - if (!product || !product.isActive) { - rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" }); - continue; - } - const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice; - - try { - const created = await Transaction.create({ - businessId: auth!.businessId, - branchId, - staffId: auth!.sub, - customerName: sale.customerName ?? null, - customerPhone: phone, - productId: product._id, - sizeLiters: product.sizeLiters, - quantity: sale.quantity, - unitPrice, - amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100, - paymentMethod: "CASH", - paymentStatus: "SUCCESS", - clientUuid: sale.clientUuid, - syncedFromOffline: !!sale.clientSubmittedAt, - clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null, - }); - inserted.push(created); - } catch (err: any) { - if (err?.code === 11000) continue; - rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" }); + const sales: SaleInput[] = []; + const results: SaleResult[] = []; + for (const raw of parsed.data.sales) { + const sale = saleSchema.safeParse(raw); + if (sale.success) { + sales.push(sale.data); + } else { + const clientUuid = String((raw as { clientUuid?: unknown })?.clientUuid ?? ""); + results.push({ clientUuid, status: "rejected", error: sale.error.issues[0]?.message ?? "Invalid sale" }); } } - return NextResponse.json({ inserted, rejected }); + // Tenant data needed to price every line in the batch, in three queries. + const productIds = Array.from(new Set(sales.flatMap((s) => s.lines.map((l) => l.productId)))); + const branchIds = Array.from(new Set([auth.branchId, ...sales.map((s) => s.branchId ?? auth.branchId!)])); + const [products, overrides, branches] = await Promise.all([ + Product.find({ _id: { $in: productIds }, businessId: auth.businessId }).lean(), + BranchPricing.find({ branchId: { $in: branchIds }, productId: { $in: productIds }, businessId: auth.businessId }).lean(), + Branch.find({ _id: { $in: branchIds }, businessId: auth.businessId }).select("_id").lean(), + ]); + const productMap = new Map(products.map((p) => [p._id.toString(), p])); + const overrideMap = new Map(overrides.map((o) => [`${o.branchId}:${o.productId}`, o.unitPrice])); + const tenantBranches = new Set(branches.map((b) => b._id.toString())); + + for (const sale of sales) { + results.push(await recordSale(sale)); + } + + async function recordSale(sale: SaleInput): Promise { + const reject = (error: string): SaleResult => ({ clientUuid: sale.clientUuid, status: "rejected", error }); + + // A queued sale belongs to the cashier who made it. The device only + // sends its signed-in user's rows; anything else is refused rather than + // credited to the wrong person. + if (sale.staffId && sale.staffId !== auth.sub) { + return reject("This sale was recorded by a different user. Sign in as them to sync it."); + } + // The sale stays with the branch where it happened, even if the + // cashier has since been moved — as long as that branch is in this business. + const branchId = sale.branchId ?? auth.branchId!; + if (!tenantBranches.has(branchId)) return reject("This sale's branch is not part of your business."); + + for (const line of sale.lines) { + if (!productMap.has(line.productId)) return reject("A product in this sale no longer exists."); + } + + // A bad phone number must not lose a sale that physically happened. + let customerPhone: string | null = null; + if (sale.customerPhone) { + try { + customerPhone = normalizeKenyanPhone(sale.customerPhone); + } catch { + customerPhone = null; + } + } + + let newLines = 0; + let amountTotal = 0; + let priceMismatch = false; + for (const [index, line] of sale.lines.entries()) { + // A product deactivated while the device was offline is still + // recorded at its price: the sale already happened. + const product = productMap.get(line.productId)!; + const unitPrice = overrideMap.get(`${branchId}:${line.productId}`) ?? product.unitPrice; + const lineTotal = Math.round(line.quantity * unitPrice * 100) / 100; + const mismatch = line.unitPrice !== undefined && line.unitPrice !== unitPrice; + + let txn: ITransaction; + try { + txn = await Transaction.create({ + businessId: auth.businessId, + branchId, + staffId: auth.sub, + customerName: sale.customerName || null, + customerPhone, + productId: product._id, + sizeLiters: product.sizeLiters, + quantity: line.quantity, + unitPrice, + amountTotal: lineTotal, + paymentMethod: "CASH", + paymentStatus: "SUCCESS", + clientUuid: `${sale.clientUuid}:${index}`, + saleId: sale.clientUuid, + lineIndex: index, + syncedFromOffline: !!sale.clientSubmittedAt, + clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null, + clientUnitPrice: line.unitPrice ?? null, + priceMismatch: mismatch, + }); + newLines += 1; + } catch (err: unknown) { + if ((err as { code?: number })?.code !== 11000) throw err; + // Already recorded by an earlier attempt: reuse it. + const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` }); + if (!existing || existing.businessId.toString() !== auth.businessId) { + return reject("This sale id is already in use."); + } + txn = existing; + } + // Idempotent, and also repairs a line whose stock movement was not + // written because an earlier attempt failed midway. + await recordSaleMovement(txn); + amountTotal += txn.amountTotal; + priceMismatch = priceMismatch || txn.priceMismatch; + } + + return { + clientUuid: sale.clientUuid, + status: newLines === 0 ? "duplicate" : "saved", + amountTotal: Math.round(amountTotal * 100) / 100, + priceMismatch, + }; + } + + return NextResponse.json({ results }); } diff --git a/src/lib/server/models/StockMovement.ts b/src/lib/server/models/StockMovement.ts new file mode 100644 index 0000000..52b3977 --- /dev/null +++ b/src/lib/server/models/StockMovement.ts @@ -0,0 +1,51 @@ +import { Schema, model, models, Model, Types, Document } from "mongoose"; + +export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const; +export type MovementType = (typeof MOVEMENT_TYPES)[number]; + +export interface IStockMovement extends Document { + _id: Types.ObjectId; + businessId: Types.ObjectId; + branchId: Types.ObjectId; + productId: Types.ObjectId; + type: MovementType; + quantity: number; // signed: + adds stock to the branch, - removes it + refId: string; // the document that caused it (transaction, purchase, transfer, adjustment) + note: string | null; + recordedBy: Types.ObjectId | null; + createdAt: Date; // authoritative — server clock +} + +/** + * The stock ledger. A branch's stock of a product is the SUM of its + * movements — there is no separately stored balance that could drift out + * of step with history. + * + * Every movement names the document that caused it, and (type, refId) is + * unique. Writing a movement is therefore idempotent: replaying an offline + * sale, retrying a transfer, or a second M-Pesa callback can never deduct + * or add the same stock twice. A transfer writes two movements with the + * same refId but different types (TRANSFER_OUT, TRANSFER_IN). + */ +const stockMovementSchema = new Schema( + { + businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true }, + branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, + type: { type: String, enum: MOVEMENT_TYPES, required: true }, + quantity: { type: Number, required: true }, + refId: { type: String, required: true }, + note: { type: String, default: null, trim: true }, + recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true }); +// Stock-level aggregation and the per-branch movement history. +stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 }); +stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 }); + +export const StockMovement = + (models.StockMovement as Model) || + model("StockMovement", stockMovementSchema); diff --git a/src/lib/server/models/StockTransfer.ts b/src/lib/server/models/StockTransfer.ts new file mode 100644 index 0000000..14856a2 --- /dev/null +++ b/src/lib/server/models/StockTransfer.ts @@ -0,0 +1,40 @@ +import { Schema, model, models, Model, Types, Document } from "mongoose"; + +export interface IStockTransfer extends Document { + _id: Types.ObjectId; + businessId: Types.ObjectId; + fromBranchId: Types.ObjectId; + toBranchId: Types.ObjectId; + productId: Types.ObjectId; + quantity: number; + clientUuid: string; // idempotency key from the form, so a double-submit moves stock once + note: string | null; + recordedBy: Types.ObjectId; + createdAt: Date; +} + +/** + * An inter-branch stock transfer. The stock itself moves through two + * StockMovement rows (TRANSFER_OUT at the source, TRANSFER_IN at the + * destination) whose refId is this transfer's id. + */ +const stockTransferSchema = new Schema( + { + businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true }, + fromBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + toBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, + quantity: { type: Number, required: true, min: 1 }, + clientUuid: { type: String, required: true }, + note: { type: String, default: null, trim: true }, + recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true }); +stockTransferSchema.index({ businessId: 1, createdAt: -1 }); + +export const StockTransfer = + (models.StockTransfer as Model) || + model("StockTransfer", stockTransferSchema); diff --git a/src/lib/server/models/Transaction.ts b/src/lib/server/models/Transaction.ts index 9b02ac8..ae15738 100644 --- a/src/lib/server/models/Transaction.ts +++ b/src/lib/server/models/Transaction.ts @@ -9,7 +9,7 @@ export interface ITransaction extends Document { branchId: Types.ObjectId; staffId: Types.ObjectId; customerName: string | null; - customerPhone: string; + customerPhone: string | null; // optional for cash sales; required for M-Pesa productId: Types.ObjectId; sizeLiters: number; // snapshotted at sale time quantity: number; @@ -26,8 +26,15 @@ export interface ITransaction extends Document { resultDesc: string | null; }; - clientUuid: string; // idempotency key generated on-device + clientUuid: string; // idempotency key for this line: `${saleId}:${lineIndex}` for cash sales + saleId: string | null; // groups the lines of one multi-item sale (the device's sale UUID) + lineIndex: number; syncedFromOffline: boolean; + // What the device showed and collected per unit. The server price stays + // authoritative; a difference (e.g. the price changed while the device + // was offline) is kept and flagged rather than silently overwritten. + clientUnitPrice: number | null; + priceMismatch: boolean; // TAMPER PREVENTION: this is what the device *claimed* the time was when // the sale happened — informational only, e.g. for offline-queue @@ -50,7 +57,7 @@ const transactionSchema = new Schema( branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, staffId: { type: Schema.Types.ObjectId, ref: "User", required: true }, customerName: { type: String, default: null, trim: true }, - customerPhone: { type: String, required: true }, + customerPhone: { type: String, default: null }, productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, sizeLiters: { type: Number, required: true }, quantity: { type: Number, required: true, min: 1 }, @@ -73,7 +80,11 @@ const transactionSchema = new Schema( }, clientUuid: { type: String, required: true }, + saleId: { type: String, default: null }, + lineIndex: { type: Number, default: 0 }, syncedFromOffline: { type: Boolean, default: false }, + clientUnitPrice: { type: Number, default: null }, + priceMismatch: { type: Boolean, default: false }, clientSubmittedAt: { type: Date, default: null }, }, { timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input @@ -92,6 +103,7 @@ transactionSchema.index({ clientUuid: 1 }, { unique: true }); transactionSchema.index({ branchId: 1, createdAt: -1 }); transactionSchema.index({ businessId: 1, createdAt: -1 }); transactionSchema.index({ paymentStatus: 1 }); +transactionSchema.index({ businessId: 1, saleId: 1 }); export const Transaction = (models.Transaction as Model) || diff --git a/src/lib/server/stock.ts b/src/lib/server/stock.ts new file mode 100644 index 0000000..71418a4 --- /dev/null +++ b/src/lib/server/stock.ts @@ -0,0 +1,140 @@ +import mongoose, { Types } from "mongoose"; +import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement"; +import { Branch } from "@/lib/server/models/Branch"; +import { Product } from "@/lib/server/models/Product"; +import type { ITransaction } from "@/lib/server/models/Transaction"; + +type Id = Types.ObjectId | string; + +export interface MovementInput { + businessId: Id; + branchId: Id; + productId: Id; + type: MovementType; + quantity: number; // signed + refId: string; + note?: string | null; + recordedBy?: Id | null; +} + +/** + * Write one ledger movement, idempotently: if a movement with the same + * (type, refId) already exists nothing changes. Safe to call again on any + * retry or replay. + */ +export async function recordMovement(m: MovementInput): Promise { + await StockMovement.updateOne( + { type: m.type, refId: m.refId }, + { + $setOnInsert: { + businessId: m.businessId, + branchId: m.branchId, + productId: m.productId, + quantity: m.quantity, + note: m.note ?? null, + recordedBy: m.recordedBy ?? null, + }, + }, + { upsert: true } + ); +} + +/** + * Deduct a completed sale from its branch's stock. Only SUCCESS sales move + * stock — a PENDING or FAILED M-Pesa prompt never does. + */ +export async function recordSaleMovement( + txn: Pick +): Promise { + if (txn.paymentStatus !== "SUCCESS") return; + await recordMovement({ + businessId: txn.businessId, + branchId: txn.branchId, + productId: txn.productId, + type: "SALE", + quantity: -Math.abs(txn.quantity), + refId: txn._id.toString(), + recordedBy: txn.staffId, + }); +} + +/** Ledger refIds derived from client-generated keys are namespaced by tenant. */ +export function clientRefId(businessId: Id, clientUuid: string): string { + return `${businessId.toString()}:${clientUuid}`; +} + +/** Current stock of one product at one branch (the sum of its movements). */ +export async function stockOf(businessId: Id, branchId: Id, productId: Id): Promise { + const [row] = await StockMovement.aggregate<{ total: number }>([ + { + $match: { + businessId: new mongoose.Types.ObjectId(businessId.toString()), + branchId: new mongoose.Types.ObjectId(branchId.toString()), + productId: new mongoose.Types.ObjectId(productId.toString()), + }, + }, + { $group: { _id: null, total: { $sum: "$quantity" } } }, + ]); + return row?.total ?? 0; +} + +export type StockStatus = "OK" | "LOW" | "OUT"; + +export interface StockLevel { + branchId: string; + branchName: string; + productId: string; + productLabel: string; + unit: string; + quantity: number; + lowStockThreshold: number; + status: StockStatus; +} + +export function stockStatus(quantity: number, threshold: number): StockStatus { + if (quantity <= 0) return "OUT"; + if (quantity <= threshold) return "LOW"; + return "OK"; +} + +/** + * Stock of every active product at every branch in scope — including + * zero rows, so an owner sees "0" rather than a missing product. A + * negative quantity is reported as-is: it means sales were recorded + * (typically offline) for stock the ledger never received, and the branch + * needs a count/adjustment. + */ +export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise { + const bizObj = new mongoose.Types.ObjectId(businessId.toString()); + const branchFilter: Record = { businessId: bizObj }; + if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString()); + + const [branches, products, totals] = await Promise.all([ + Branch.find(branchFilter).sort({ branchName: 1 }).lean(), + Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(), + StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([ + { $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } }, + { $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } }, + ]), + ]); + + const totalByKey = new Map(totals.map((t) => [`${t._id.branchId}:${t._id.productId}`, t.total])); + const levels: StockLevel[] = []; + for (const b of branches) { + for (const p of products) { + const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0; + const lowStockThreshold = p.lowStockThreshold ?? 10; + levels.push({ + branchId: b._id.toString(), + branchName: b.branchName, + productId: p._id.toString(), + productLabel: p.label, + unit: p.unit ?? "L", + quantity, + lowStockThreshold, + status: stockStatus(quantity, lowStockThreshold), + }); + } + } + return levels; +} diff --git a/src/lib/syncProtocol.ts b/src/lib/syncProtocol.ts new file mode 100644 index 0000000..5f1a5e8 --- /dev/null +++ b/src/lib/syncProtocol.ts @@ -0,0 +1,11 @@ +// Shared between the terminal's offline queue and POST /api/transactions/cash. + +/** Largest batch the offline queue sends in one request. */ +export const MAX_SALES_PER_REQUEST = 50; + +/** Largest number of product lines in one sale. */ +export const MAX_LINES_PER_SALE = 20; + +export type SaleResult = + | { clientUuid: string; status: "saved" | "duplicate"; amountTotal: number; priceMismatch: boolean } + | { clientUuid: string; status: "rejected"; error: string }; diff --git a/tests/stock.test.ts b/tests/stock.test.ts new file mode 100644 index 0000000..fde6fde --- /dev/null +++ b/tests/stock.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { stockOf } from "@/lib/server/stock"; +import * as stock from "@/app/api/stock/route"; +import * as adjustments from "@/app/api/stock/adjustments/route"; +import * as transfers from "@/app/api/stock/transfers/route"; +import * as purchases from "@/app/api/purchases/route"; +import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; + +async function addStock(t: Tenant, as: Actor, branchId: string, productId: string, quantity: number) { + const res = await adjustments.POST( + req("POST", "/x", { as, body: { clientUuid: uuid(), branchId, productId, mode: "DELTA", quantity, note: "opening" } }) + ); + expect(res.status).toBe(201); +} + +describe("stock ledger", () => { + it("adds purchased stock to the receiving branch", async () => { + const t = await makeTenant(); + const res = await purchases.POST( + req("POST", "/x", { + as: t.managerA, + body: { supplierName: "Kiwanja Water", productId: t.water20, quantity: 40, unitCost: 120 }, + }) + ); + expect(res.status).toBe(201); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(40); + }); + + it("corrects stock to a physical count, once per submit", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 50); + const body = { clientUuid: uuid(), productId: t.water20, mode: "COUNT", quantity: 47, note: "evening count" }; + const first = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body }))); + expect(first.body.quantity).toBe(47); + // A double-submit of the same count changes nothing further. + const again = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body }))); + expect(again.body.quantity).toBe(47); + }); + + it("does not let a manager adjust another branch", async () => { + const t = await makeTenant(); + const res = await adjustments.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 5 }, + }) + ); + expect(res.status).toBe(403); + }); + + it("reports stock per branch with low and out-of-stock flags", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 8); // threshold defaults to 10 + await addStock(t, t.owner, t.branchA, t.water5, 30); + const { body } = await json(await stock.GET(req("GET", "/api/stock", { as: t.owner }))); + const find = (b: string, p: string) => body.levels.find((l: any) => l.branchId === b && l.productId === p); + expect(body.levels).toHaveLength(4); // 2 branches x 2 products, zero rows included + expect(find(t.branchA, t.water20)).toMatchObject({ quantity: 8, status: "LOW" }); + expect(find(t.branchA, t.water5)).toMatchObject({ quantity: 30, status: "OK" }); + expect(find(t.branchB, t.water20)).toMatchObject({ quantity: 0, status: "OUT" }); + }); + + it("shows a cashier only their own branch's stock", async () => { + const t = await makeTenant(); + const { body } = await json(await stock.GET(req("GET", "/api/stock?branchId=" + t.branchB, { as: t.cashierA }))); + expect(new Set(body.levels.map((l: any) => l.branchId))).toEqual(new Set([t.branchA])); + }); +}); + +describe("inter-branch transfers", () => { + it("moves stock out of one branch and into the other", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 30); + const res = await transfers.POST( + req("POST", "/x", { + as: t.owner, + body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 12 }, + }) + ); + expect(res.status).toBe(201); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(18); + expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(12); + }); + + it("applies a double-submitted transfer once", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 30); + const body = { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 10 }; + await transfers.POST(req("POST", "/x", { as: t.managerA, body })); + const second = await transfers.POST(req("POST", "/x", { as: t.managerA, body })); + expect(second.status).toBe(200); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20); + expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(10); + }); + + it("refuses to send more than the branch has", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 6 }, + }) + ); + expect(res.status).toBe(409); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5); + }); + + it("only lets a manager send from their own branch", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchB, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 1 }, + }) + ); + expect(res.status).toBe(403); + }); + + it("cannot move stock into another tenant's branch", async () => { + const t = await makeTenant("Alpha"); + const other = await makeTenant("Beta"); + await addStock(t, t.owner, t.branchA, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.owner, + body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: other.branchA, productId: t.water20, quantity: 1 }, + }) + ); + expect(res.status).toBe(404); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5); + }); +}); diff --git a/tests/sync.test.ts b/tests/sync.test.ts new file mode 100644 index 0000000..dc0c19b --- /dev/null +++ b/tests/sync.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { Transaction } from "@/lib/server/models/Transaction"; +import { Product, BranchPricing } from "@/lib/server/models/Product"; +import { stockOf } from "@/lib/server/stock"; +import * as cash from "@/app/api/transactions/cash/route"; +import type { SaleResult } from "@/lib/syncProtocol"; +import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; + +function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) { + return { + clientUuid: uuid(), + staffId: cashier.id, + branchId: cashier.branchId!, + clientSubmittedAt: new Date().toISOString(), + lines, + ...extra, + }; +} + +async function post(as: Actor, sales: unknown[]) { + return json<{ results: SaleResult[] }>(await cash.POST(req("POST", "/api/transactions/cash", { as, body: { sales } }))); +} + +describe("offline sale sync", () => { + it("records a multi-item sale as one line per product and deducts each from stock", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [ + { productId: t.water20, quantity: 2, unitPrice: 200 }, + { productId: t.water5, quantity: 3, unitPrice: 60 }, + ]); + const { status, body } = await post(t.cashierA, [s]); + expect(status).toBe(200); + expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]); + expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2); + expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3); + }); + + it("reports a replayed sale as duplicate, so the device can stop resending it", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + await post(t.cashierA, [s]); + const replay = await post(t.cashierA, [s]); + expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" }); + expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1); + }); + + it("syncs a 120-sale backlog in chunks with no duplicates (the old 100-row dead end)", async () => { + const t = await makeTenant(); + const backlog = Array.from({ length: 120 }, () => sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }])); + // An over-sized batch is refused outright... + const tooBig = await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: backlog } })); + expect(tooBig.status).toBe(400); + // ...and the device sends it in chunks of 50, twice over (a full replay). + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < backlog.length; i += 50) { + const { status } = await post(t.cashierA, backlog.slice(i, i + 50)); + expect(status).toBe(200); + } + } + expect(await Transaction.countDocuments({ businessId: t.businessId })).toBe(120); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-120); + }); + + it("rejects one bad sale without blocking the rest of the batch", async () => { + const t = await makeTenant(); + const good = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + const malformed = { clientUuid: uuid(), lines: [] }; + const { body } = await post(t.cashierA, [malformed, good]); + const byId = Object.fromEntries(body.results.map((r) => [r.clientUuid, r.status])); + expect(byId[malformed.clientUuid]).toBe("rejected"); + expect(byId[good.clientUuid]).toBe("saved"); + }); + + it("refuses to credit a queued sale to whoever happens to be signed in", async () => { + const t = await makeTenant(); + const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]); + const { body } = await post(t.cashierA, [otherCashiersSale]); + expect(body.results[0].status).toBe("rejected"); + expect(await Transaction.countDocuments({})).toBe(0); + }); + + it("keeps a sale at the branch where it happened after the cashier is moved", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A + const moved = { ...t.cashierA, branchId: t.branchB }; + await post(moved, [s]); + const txn = await Transaction.findOne({ saleId: s.clientUuid }); + expect(txn!.branchId.toString()).toBe(t.branchA); + }); + + it("rejects a sale naming another tenant's product or branch", async () => { + const t = await makeTenant("Alpha"); + const other = await makeTenant("Beta"); + const foreignProduct = sale(t, t.cashierA, [{ productId: other.water20, quantity: 1 }]); + const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA }); + const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]); + expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]); + expect(await Transaction.countDocuments({})).toBe(0); + }); + + it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => { + const t = await makeTenant(); + await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 }); + await Product.updateOne({ _id: t.water5 }, { isActive: false }); + const s = sale(t, t.cashierA, [ + { productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device + { productId: t.water5, quantity: 1, unitPrice: 60 }, + ]); + const { body } = await post(t.cashierA, [s]); + expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 240, priceMismatch: true }); + }); + + it("accepts a cash sale with no customer phone, and keeps one with a bad phone", async () => { + const t = await makeTenant(); + const noPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + const badPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { customerPhone: "12" }); + const { body } = await post(t.cashierA, [noPhone, badPhone]); + expect(body.results.map((r) => r.status)).toEqual(["saved", "saved"]); + }); + + it("still accepts the old single-product payload", async () => { + const t = await makeTenant(); + const legacy = { clientUuid: uuid(), customerPhone: "0712345678", productId: t.water20, quantity: 2 }; + const { body } = await post(t.cashierA, [legacy]); + expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 400 }); + }); +}); From 472573a1a8496fe20974fe78582e8b46a94f8c6b Mon Sep 17 00:00:00 2001 From: denisireri Date: Sun, 11 Oct 2026 18:46:23 +0300 Subject: [PATCH 3/4] Make the terminal work offline end to end, and add stock screens Terminal (PRD 5.3/5.5): - Opens with no network: a service worker caches the app shell and build assets, and the last confirmed session and product list are kept on the phone. Only a definitive 401/403 sends the cashier to the login page. - The offline queue is keyed by cashier and branch, sends only the signed-in cashier's sales, chunks to the server's batch limit, and acts on the per-sale result: synced, or rejected with the reason shown and never retried. Rows from the old queue are adopted on first sync. - Several products per sale (tap to add), optional customer details, and a printable receipt. The banner shows sales waiting to sync and any that need attention; sign-out warns about unsynced sales. - M-Pesa only appears when FEATURE_MPESA is on. A timed-out prompt now tells the cashier not to take cash until the payment is checked, instead of reporting it as failed. Admin: - New Stock page: stock per branch with low/out flags, physical counts and adjustments, inter-branch transfers, morning dispatch and evening count entry, and recent movements. - Dashboard shows stock on hand per branch and links to Stock. - Staff page can deactivate and reactivate accounts. - Products page is read-only for branch managers and edits the low-stock alert level. Co-Authored-By: Claude Opus 5.5 --- public/sw.js | 86 +++ src/app/admin/dashboard/page.tsx | 13 +- src/app/admin/products/page.tsx | 43 +- src/app/admin/staff/page.tsx | 48 +- src/app/admin/stock/page.tsx | 456 ++++++++++++++++ src/app/globals.css | 22 + src/app/layout.tsx | 2 + src/app/manifest.ts | 13 + src/app/terminal/page.tsx | 502 +++++++++++++----- src/components/ServiceWorkerRegister.tsx | 18 + src/components/admin/ActivityFeedMatrix.tsx | 2 +- src/components/admin/StockSummaryCard.tsx | 60 +++ src/components/terminal/ProductGrid.tsx | 51 ++ .../terminal/ProductSizeSelector.tsx | 54 -- src/components/terminal/QuantityCounter.tsx | 48 -- src/components/terminal/Receipt.tsx | 56 ++ src/lib/offlineQueue.ts | 230 ++++++-- src/lib/sessionCache.ts | 50 ++ src/lib/types.ts | 26 +- 19 files changed, 1506 insertions(+), 274 deletions(-) create mode 100644 public/sw.js create mode 100644 src/app/admin/stock/page.tsx create mode 100644 src/app/manifest.ts create mode 100644 src/components/ServiceWorkerRegister.tsx create mode 100644 src/components/admin/StockSummaryCard.tsx create mode 100644 src/components/terminal/ProductGrid.tsx delete mode 100644 src/components/terminal/ProductSizeSelector.tsx delete mode 100644 src/components/terminal/QuantityCounter.tsx create mode 100644 src/components/terminal/Receipt.tsx create mode 100644 src/lib/sessionCache.ts diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..0b11b0f --- /dev/null +++ b/public/sw.js @@ -0,0 +1,86 @@ +/* AquaFlow service worker: lets the cashier terminal open and run with no network. + * + * - Page loads (navigations): network first, falling back to the cached copy, + * and to the cached /terminal page for anything else, so a cashier who opens + * the app offline still lands on the till. + * - Next.js build assets (/_next/static/*): cache first. They are content-hashed + * and immutable, so a cached copy is always correct. + * - API calls (/api/*) and every non-GET request: never cached. Sales go through + * the IndexedDB queue in the page, not through this worker. + * + * Bump CACHE_VERSION to drop every cached page and asset on the next visit. + */ +const CACHE_VERSION = "aquaflow-v1"; +const APP_SHELL = ["/terminal", "/login"]; + +async function cacheShell() { + const cache = await caches.open(CACHE_VERSION); + for (const path of APP_SHELL) { + try { + const res = await fetch(path, { credentials: "same-origin" }); + if (!res.ok) continue; + await cache.put(path, res.clone()); + // Also cache the scripts and styles the page needs to boot. + const html = await res.text(); + const assets = Array.from(html.matchAll(/(?:src|href)="(\/_next\/static\/[^"]+)"/g), (m) => m[1]); + await Promise.all( + Array.from(new Set(assets)).map((url) => + cache.match(url).then((hit) => hit || fetch(url).then((r) => r.ok && cache.put(url, r))) + ) + ); + } catch (e) { + // Offline during install: the runtime handlers below fill the cache later. + } + } +} + +self.addEventListener("install", (event) => { + event.waitUntil(cacheShell().then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/")) return; + + if (req.mode === "navigate") { + event.respondWith( + fetch(req) + .then((res) => { + if (res.ok) { + const copy = res.clone(); + caches.open(CACHE_VERSION).then((c) => c.put(url.pathname, copy)); + } + return res; + }) + .catch(async () => { + const cache = await caches.open(CACHE_VERSION); + return (await cache.match(url.pathname)) || (await cache.match("/terminal")) || Response.error(); + }) + ); + return; + } + + if (url.pathname.startsWith("/_next/static/")) { + event.respondWith( + caches.open(CACHE_VERSION).then(async (cache) => { + const hit = await cache.match(req); + if (hit) return hit; + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + }) + ); + } +}); diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 56ebf93..640b65a 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { apiFetch, ApiError } from "@/lib/api"; import { useTransactionFeed } from "@/lib/pollingFeed"; -import { AuthUser, Branch, DashboardSummary, ReconciliationRow } from "@/lib/types"; +import { AuthUser, Branch, DashboardSummary, ReconciliationRow, StockLevel } from "@/lib/types"; import { useToast } from "@/components/ui/Toast"; import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton"; import EmptyState from "@/components/ui/EmptyState"; @@ -16,6 +16,7 @@ import LiveStatsRow from "@/components/admin/LiveStatsRow"; import BranchRankingList from "@/components/admin/BranchRankingList"; import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix"; import ReconciliationGrid from "@/components/admin/ReconciliationGrid"; +import StockSummaryCard from "@/components/admin/StockSummaryCard"; export default function AdminDashboardPage() { const router = useRouter(); @@ -27,6 +28,7 @@ export default function AdminDashboardPage() { const [selectedBranchId, setSelectedBranchId] = useState("ALL"); const [summary, setSummary] = useState(null); const [reconciliation, setReconciliation] = useState([]); + const [stock, setStock] = useState([]); const [loading, setLoading] = useState(true); const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50); @@ -75,12 +77,14 @@ export default function AdminDashboardPage() { setLoading(true); try { const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId; - const [summaryRes, reconRes] = await Promise.all([ + const [summaryRes, reconRes, stockRes] = await Promise.all([ apiFetch("/api/dashboard/summary" + branchParam), apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam), + apiFetch<{ levels: StockLevel[] }>("/api/stock" + branchParam), ]); setSummary(summaryRes); setReconciliation(reconRes.rows); + setStock(stockRes.levels); } catch (err) { show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error"); } @@ -138,6 +142,9 @@ export default function AdminDashboardPage() { Platform )} + + Stock + Sizes @@ -183,6 +190,8 @@ export default function AdminDashboardPage() { pendingMpesa={summary.totals.pendingMpesa} /> + +
(null); const [editPrice, setEditPrice] = useState(""); + const [editThreshold, setEditThreshold] = useState(""); + + // Products and prices apply to every branch, so only the owner edits them. + const canEdit = me?.role === "SUPER_ADMIN"; useEffect(() => { (async () => { @@ -82,14 +86,22 @@ export default function ProductsPage() { async function handlePriceUpdate(id: string) { const price = parseFloat(editPrice); + const threshold = parseInt(editThreshold, 10); if (!Number.isFinite(price) || price < 0) { setError("Enter a valid price."); return; } + if (!Number.isInteger(threshold) || threshold < 0) { + setError("Enter a whole number for the low-stock alert."); + return; + } setSaving(true); try { - await apiFetch("/api/products/" + id, { method: "PATCH", body: JSON.stringify({ unitPrice: price }) }); - show("Price updated", "success"); + await apiFetch("/api/products/" + id, { + method: "PATCH", + body: JSON.stringify({ unitPrice: price, lowStockThreshold: threshold }), + }); + show("Product updated", "success"); setEditingId(null); await loadProducts(); } catch (err) { @@ -152,6 +164,7 @@ export default function ProductsPage() {

Sizes shown here appear as options on every branch's terminal. Deactivating a size hides it from new sales without touching past transactions. + {!canEdit && " Only the business owner can change products and prices."}

@@ -170,8 +183,12 @@ export default function ProductsPage() {

{p.label} · {p.sizeLiters}L

- {editingId === p._id ? ( -
+ {!canEdit ? ( +

+ KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} +

+ ) : editingId === p._id ? ( +
KES setEditPrice(e.target.value)} + aria-label="Price" className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm" /> + low stock at + setEditThreshold(e.target.value)} + aria-label="Low-stock alert level" + className="w-20 rounded-lg border border-depth-200 px-2 py-1 text-sm" + /> @@ -194,10 +222,11 @@ export default function ProductsPage() { onClick={() => { setEditingId(p._id); setEditPrice(String(p.unitPrice)); + setEditThreshold(String(p.lowStockThreshold ?? 10)); }} className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2" > - KES {p.unitPrice.toLocaleString()} · edit + KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} · edit )}
@@ -205,7 +234,7 @@ export default function ProductsPage() { {p.isActive ? "Active" : "Inactive"} - {p.isActive ? ( + {!canEdit ? null : p.isActive ? ( + {canEdit && (

Add a new size

@@ -254,6 +284,7 @@ export default function ProductsPage() { {saving ? "Adding…" : "Add size"} + )}
); diff --git a/src/app/admin/staff/page.tsx b/src/app/admin/staff/page.tsx index 440d802..413d797 100644 --- a/src/app/admin/staff/page.tsx +++ b/src/app/admin/staff/page.tsx @@ -109,6 +109,26 @@ export default function StaffPage() { setSaving(false); } + function canManage(s: StaffMember): boolean { + if (!me || s._id === me.id) return false; + if (isSuperAdmin) return true; + return s.role === "DELIVERY" && s.branchId === me.branchId; + } + + async function toggleActive(s: StaffMember) { + const next = !s.isActive; + if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) { + return; + } + try { + await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ isActive: next }) }); + show(`${s.name} ${next ? "reactivated" : "deactivated"}`, "success"); + await loadStaff(); + } catch (err) { + show(err instanceof ApiError ? err.message : "Could not update this account", "error"); + } + } + async function handleCreate(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -186,17 +206,33 @@ export default function StaffPage() { {staff.map((s) => { const branchName = branches.find((b) => b._id === s.branchId)?.branchName; return ( -
  • -
    -

    {s.name}

    +
  • +
    +

    + {s.name} + {!s.isActive && Deactivated} +

    {s.email} {branchName ? " · " + branchName : ""}

    - - {s.role.replace("_", " ")} - +
    + + {s.role.replace("_", " ")} + + {canManage(s) && ( + + )} +
  • ); })} diff --git a/src/app/admin/stock/page.tsx b/src/app/admin/stock/page.tsx new file mode 100644 index 0000000..d11a4ae --- /dev/null +++ b/src/app/admin/stock/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { apiFetch, ApiError } from "@/lib/api"; +import { AuthUser, Branch, Product, StockLevel, StockMovementRow } from "@/lib/types"; +import { useToast } from "@/components/ui/Toast"; +import { SkeletonList } from "@/components/ui/Skeleton"; +import EmptyState from "@/components/ui/EmptyState"; +import GlobalSwitcher from "@/components/admin/GlobalSwitcher"; + +const statusStyles: Record = { + OK: "bg-confirm-500/15 text-confirm-600", + LOW: "bg-cash-500/15 text-cash-700", + OUT: "bg-alert-500/15 text-alert-600", +}; +const statusLabel: Record = { OK: "In stock", LOW: "Low", OUT: "Out" }; + +const movementLabel: Record = { + SALE: "Sale", + PURCHASE: "Purchase", + TRANSFER_OUT: "Transfer out", + TRANSFER_IN: "Transfer in", + ADJUSTMENT: "Adjustment", +}; + +type StockForm = "count" | "transfer" | "daily"; + +function newUuid() { + return crypto.randomUUID(); +} + +export default function StockPage() { + const router = useRouter(); + const { show } = useToast(); + const [me, setMe] = useState(null); + const [branches, setBranches] = useState([]); + const [products, setProducts] = useState([]); + const [levels, setLevels] = useState([]); + const [movements, setMovements] = useState([]); + const [selectedBranchId, setSelectedBranchId] = useState("ALL"); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState("count"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Shared form fields. A fresh clientUuid per submission makes a double + // tap on "Save" record the change once. + const [formBranchId, setFormBranchId] = useState(""); + const [toBranchId, setToBranchId] = useState(""); + const [productId, setProductId] = useState(""); + const [countMode, setCountMode] = useState<"COUNT" | "DELTA">("COUNT"); + const [dailyMode, setDailyMode] = useState<"dispatch" | "evening">("dispatch"); + const [quantity, setQuantity] = useState(""); + const [note, setNote] = useState(""); + const [submissionId, setSubmissionId] = useState(newUuid); + + const isSuper = me?.role === "SUPER_ADMIN"; + + useEffect(() => { + (async () => { + try { + const result = await apiFetch<{ user: AuthUser }>("/api/auth/me"); + if (result.user.role === "DELIVERY") { + router.replace("/terminal"); + return; + } + setMe(result.user); + if (result.user.role !== "SUPER_ADMIN" && result.user.branchId) { + setSelectedBranchId(result.user.branchId); + setFormBranchId(result.user.branchId); + } + const [br, pr] = await Promise.all([ + apiFetch<{ branches: Branch[] }>("/api/branches"), + apiFetch<{ products: Product[] }>("/api/products"), + ]); + setBranches(br.branches); + setProducts(pr.products); + } catch { + router.replace("/login"); + return; + } + setLoading(false); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!me) return; + loadStock(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me, selectedBranchId]); + + async function loadStock() { + const q = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId; + try { + const [s, m] = await Promise.all([ + apiFetch<{ levels: StockLevel[] }>("/api/stock" + q), + apiFetch<{ movements: StockMovementRow[] }>("/api/stock/movements" + q + (q ? "&" : "?") + "limit=30"), + ]); + setLevels(s.levels); + setMovements(m.movements); + } catch (err) { + show(err instanceof ApiError ? err.message : "Could not load stock", "error"); + } + } + + const byBranch = useMemo(() => { + const groups = new Map(); + for (const l of levels) { + if (!groups.has(l.branchId)) groups.set(l.branchId, { branchName: l.branchName, rows: [] }); + groups.get(l.branchId)!.rows.push(l); + } + return Array.from(groups.entries()); + }, [levels]); + + const alerts = levels.filter((l) => l.status !== "OK").length; + + function resetForm() { + setQuantity(""); + setNote(""); + setSubmissionId(newUuid()); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const qty = parseInt(quantity, 10); + const branchId = isSuper ? formBranchId : me?.branchId ?? ""; + + if (!productId) return setError("Choose a product."); + if (!branchId) return setError(form === "transfer" ? "Choose the sending branch." : "Choose a branch."); + if (!Number.isInteger(qty)) return setError("Enter a whole number."); + + setSaving(true); + try { + if (form === "count") { + if (countMode === "COUNT" && qty < 0) throw new Error("A counted quantity cannot be negative."); + if (countMode === "DELTA" && qty === 0) throw new Error("Enter a non-zero quantity to add or remove."); + const res = await apiFetch<{ quantity: number }>("/api/stock/adjustments", { + method: "POST", + body: JSON.stringify({ + clientUuid: submissionId, + branchId, + productId, + mode: countMode, + quantity: qty, + note: note.trim() || undefined, + }), + }); + show(`Stock updated - now ${res.quantity}`, "success"); + } else if (form === "transfer") { + if (qty <= 0) throw new Error("Enter how many to transfer."); + if (!toBranchId) throw new Error("Choose the receiving branch."); + await apiFetch("/api/stock/transfers", { + method: "POST", + body: JSON.stringify({ + clientUuid: submissionId, + fromBranchId: branchId, + toBranchId, + productId, + quantity: qty, + note: note.trim() || undefined, + }), + }); + show("Transfer recorded at both branches", "success"); + } else { + if (qty < 0) throw new Error("Enter zero or more."); + const path = dailyMode === "dispatch" ? "/api/inventory/dispatch" : "/api/inventory/evening-count"; + const field = dailyMode === "dispatch" ? "morningDispatched" : "eveningPhysicalCount"; + await apiFetch(path, { method: "POST", body: JSON.stringify({ branchId, productId, [field]: qty }) }); + show(dailyMode === "dispatch" ? "Morning dispatch saved" : "Evening count saved", "success"); + } + resetForm(); + await loadStock(); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not save"; + setError(message); + show(message, "error"); + } + setSaving(false); + } + + if (loading || !me) { + return ( +
    +
    +
    +
    +
    + +
    +
    + ); + } + + const tabs: { id: StockForm; label: string }[] = [ + { id: "count", label: "Count / adjust" }, + { id: "transfer", label: "Transfer" }, + { id: "daily", label: "Daily dispatch" }, + ]; + const inputClass = "tap-target w-full rounded-xl border border-depth-200 px-3"; + + return ( +
    +
    +
    +
    +

    {me.businessName ?? "Vessel"}

    +

    Stock

    +
    +
    + + + ← Dashboard + +
    +
    +
    + +
    + {alerts > 0 && ( +
    + {alerts} product line(s) are low or out of stock. +
    + )} + + {byBranch.length === 0 ? ( +
    + +
    + ) : ( +
    + {byBranch.map(([branchId, group]) => ( +
    +

    + {group.branchName} +

    +
      + {group.rows.map((l) => ( +
    • + {l.productLabel} + + + {l.quantity} + + + {statusLabel[l.status]} + + +
    • + ))} +
    +
    + ))} +
    + )} + {levels.some((l) => l.quantity < 0) && ( +

    + A negative number means sales were recorded for stock the system never received. Do a physical count + to correct it. +

    + )} + +
    +
    + {tabs.map((t) => ( + + ))} +
    + +

    + {form === "count" && + "Record a physical count (the system is corrected to what you counted), or add/remove stock for opening balances, breakage or deliveries not entered as purchases."} + {form === "transfer" && "Moves stock out of one branch and into another. Both branches update immediately."} + {form === "daily" && + "For delivery crews: how many went out this morning, and how many came back this evening. Feeds the dashboard's reconciliation."} +

    + +
    + {isSuper && ( +
    + + +
    + )} + {form === "transfer" && ( +
    + + +
    + )} +
    + + +
    + {form === "count" && ( +
    + + +
    + )} + {form === "daily" && ( +
    + + +
    + )} +
    + + setQuantity(e.target.value)} + className={inputClass} + /> +
    + {form !== "daily" && ( +
    + + setNote(e.target.value)} + placeholder={form === "transfer" ? "e.g. Sent with the Thursday van" : "e.g. Opening stock, 3 cans leaking"} + className={inputClass} + /> +
    + )} +
    + + {error &&

    {error}

    } + +
    + +
    +

    Recent stock movements

    + {movements.length === 0 ? ( +

    No movements yet.

    + ) : ( +
      + {movements.map((m) => ( +
    • +
      +

      + {movementLabel[m.type]} · {m.productId?.label ?? "Product"} +

      +

      + {m.branchId?.branchName ?? ""} · {new Date(m.createdAt).toLocaleString("en-KE")} + {m.recordedBy?.name ? " · " + m.recordedBy.name : ""} + {m.note ? " · " + m.note : ""} +

      +
      + + {m.quantity > 0 ? "+" : ""} + {m.quantity} + +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 85d59f1..28987fc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -28,3 +28,25 @@ body { .tap-target { min-height: 3.25rem; } + +/* Receipts: printing from the terminal prints only the receipt. */ +@media print { + body * { + visibility: hidden; + } + .print-receipt, + .print-receipt * { + visibility: visible; + } + .print-receipt { + position: absolute; + left: 0; + top: 0; + width: 72mm; + box-shadow: none !important; + border: none !important; + } + .no-print { + display: none !important; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 975f5a2..73a1721 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { ToastProvider } from "@/components/ui/Toast"; +import ServiceWorkerRegister from "@/components/ServiceWorkerRegister"; // NOTE: We deliberately do NOT use `next/font/google` here. That loader // downloads the font files from Google at *build time*, which fails in @@ -34,6 +35,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) /> + {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..91725a0 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next"; + +// Lets cashiers add the terminal to their home screen and open it full-screen. +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Vessel Branch Terminal", + short_name: "Vessel", + start_url: "/terminal", + display: "standalone", + background_color: "#F5F2EF", + theme_color: "#0C0C0C", + }; +} diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 5ed93e7..8e74308 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -1,37 +1,57 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { apiFetch, ApiError } from "@/lib/api"; import { isValidKenyanPhone } from "@/lib/phone"; import { AuthUser, PaymentMethod, Product } from "@/lib/types"; -import { generateClientUuid, queueCashSale, registerOfflineSync } from "@/lib/offlineQueue"; +import { + dismissRejected, + flushQueue, + generateClientUuid, + queueCounts, + queueSale, + registerOfflineSync, + salesFor, + type QueuedSale, + type QueueOwner, +} from "@/lib/offlineQueue"; +import { clearSession, loadSession, saveSession } from "@/lib/sessionCache"; +import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol"; import { useToast } from "@/components/ui/Toast"; -import ProductSizeSelector from "@/components/terminal/ProductSizeSelector"; -import QuantityCounter from "@/components/terminal/QuantityCounter"; +import ProductGrid from "@/components/terminal/ProductGrid"; +import Receipt from "@/components/terminal/Receipt"; import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle"; import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay"; type ScreenState = | { kind: "idle" } - | { kind: "cash_saved" } + | { kind: "receipt"; sale: QueuedSale } | { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null }; +interface CartLine { + productId: string; + quantity: number; +} + export default function TerminalPage() { const router = useRouter(); const { show } = useToast(); const [me, setMe] = useState(null); - const [authChecked, setAuthChecked] = useState(false); + const [mpesaEnabled, setMpesaEnabled] = useState(false); + const [bootError, setBootError] = useState(null); const [online, setOnline] = useState(true); - const [pendingCount, setPendingCount] = useState(0); + const [counts, setCounts] = useState({ pending: 0, rejected: 0 }); + const [showQueue, setShowQueue] = useState(false); + const [queueRows, setQueueRows] = useState([]); const [products, setProducts] = useState([]); const [productsLoading, setProductsLoading] = useState(true); - const [selectedProductId, setSelectedProductId] = useState(null); + const [cart, setCart] = useState([]); + const [showCustomer, setShowCustomer] = useState(false); const [customerName, setCustomerName] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); - const [quantity, setQuantity] = useState(1); const [method, setMethod] = useState("CASH"); const [phoneError, setPhoneError] = useState(null); const [formError, setFormError] = useState(null); @@ -40,49 +60,87 @@ export default function TerminalPage() { const [screen, setScreen] = useState({ kind: "idle" }); const pollRef = useRef | null>(null); - const selectedProduct = products.find((p) => p._id === selectedProductId) ?? null; - const amount = selectedProduct ? quantity * selectedProduct.unitPrice : 0; + const owner: QueueOwner | null = me ? { id: me.id, businessId: me.businessId, branchId: me.branchId } : null; + const productById = new Map(products.map((p) => [p._id, p])); + const lines = cart + .map((c) => ({ ...c, product: productById.get(c.productId) })) + .filter((c): c is CartLine & { product: Product } => !!c.product); + const total = lines.reduce((sum, l) => sum + l.quantity * l.product.unitPrice, 0); + const refreshCounts = useCallback(async (userId: string) => { + setCounts(await queueCounts(userId)); + }, []); + + // Boot: confirm the session online, or fall back to the last confirmed + // session on this device so a cashier can keep selling with no network. useEffect(() => { (async () => { + const cached = loadSession(); + let user: AuthUser; try { - const result = await apiFetch<{ user: AuthUser }>("/api/auth/me"); + const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me"); if (result.user.role === "SUPER_ADMIN") { router.replace("/admin/dashboard"); return; } - setMe(result.user); - } catch { - router.replace("/login"); - return; + user = result.user; + setMpesaEnabled(!!result.features?.mpesa); + saveSession({ user, features: { mpesa: !!result.features?.mpesa } }); + } catch (err) { + // Signed out or blocked: go to login. Anything else (no network, + // server unreachable) falls back to the saved session. + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + router.replace("/login"); + return; + } + if (!cached) { + setBootError("You're offline. Connect to the internet once to sign in on this phone."); + return; + } + user = cached.user; + setMpesaEnabled(false); // M-Pesa needs a connection anyway + show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info"); } - setAuthChecked(true); + setMe(user); + await refreshCounts(user.id); try { const productsResult = await apiFetch<{ products: Product[] }>("/api/products"); setProducts(productsResult.products); - if (productsResult.products.length > 0) setSelectedProductId(productsResult.products[0]._id); + saveSession({ user, products: productsResult.products }); } catch { - show("Could not load jerrycan sizes — check your connection", "error"); + const saved = loadSession(); + if (saved?.user.id === user.id && saved.products.length > 0) { + setProducts(saved.products); + } else { + show("Could not load products - check your connection", "error"); + } } setProductsLoading(false); })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Connectivity + background sync for this cashier's queue. + useEffect(() => { + if (!me) return; setOnline(navigator.onLine); const goOnline = () => { setOnline(true); - show("Back online — syncing queued sales", "info"); + show("Back online - syncing saved sales", "info"); }; const goOffline = () => { setOnline(false); - show("You're offline — cash sales will keep saving and sync automatically", "info"); + show("You're offline - cash sales keep saving and sync automatically", "info"); }; window.addEventListener("online", goOnline); window.addEventListener("offline", goOffline); - const unregister = registerOfflineSync((r) => { - if (r.synced > 0) { - setPendingCount((c) => Math.max(0, c - r.synced)); - show(r.synced + " queued sale(s) synced", "success"); + const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => { + setCounts((c) => ({ ...c, pending: r.pending })); + if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success"); + if (r.rejected > 0) { + show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error"); + refreshCounts(me.id); } }); @@ -93,24 +151,46 @@ export default function TerminalPage() { if (pollRef.current) clearInterval(pollRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [me?.id]); + + function addProduct(productId: string) { + setFormError(null); + setCart((prev) => { + const existing = prev.find((l) => l.productId === productId); + if (existing) { + return prev.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + 1) } : l)); + } + if (prev.length >= MAX_LINES_PER_SALE) return prev; + return [...prev, { productId, quantity: 1 }]; + }); + } + + function changeQuantity(productId: string, delta: number) { + setCart((prev) => + prev + .map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + delta) } : l)) + .filter((l) => l.quantity > 0) + ); + } function resetForm() { + setCart([]); setCustomerName(""); setCustomerPhone(""); - setQuantity(1); + setShowCustomer(false); setMethod("CASH"); setPhoneError(null); setFormError(null); setScreen({ kind: "idle" }); } - function validate(): boolean { - if (!selectedProduct) { - setFormError("Choose a jerrycan size first."); + function validate(requirePhone: boolean): boolean { + if (lines.length === 0) { + setFormError("Add at least one product."); return false; } - if (!isValidKenyanPhone(customerPhone)) { + if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) { + setShowCustomer(true); setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX"); return false; } @@ -120,51 +200,55 @@ export default function TerminalPage() { } async function handleCashSale() { - if (!validate() || !selectedProduct) return; + if (!me || !owner || !validate(false)) return; + if (!me.branchId) { + setFormError("Your account is not assigned to a branch."); + return; + } setSubmitting(true); - const sale = { + // The IndexedDB write is the only step that must finish before we show + // success: it is what keeps the sale if the phone dies before syncing. + const sale = await queueSale({ clientUuid: generateClientUuid(), - customerName: customerName || undefined, - customerPhone, - productId: selectedProduct._id, - quantity, + userId: me.id, + businessId: me.businessId ?? "", + branchId: me.branchId, + customerName: customerName.trim() || undefined, + customerPhone: customerPhone.trim() || undefined, + lines: lines.map((l) => ({ + productId: l.productId, + label: l.product.label, + quantity: l.quantity, + unitPrice: l.product.unitPrice, + })), + total, clientSubmittedAt: new Date().toISOString(), - }; - - // IndexedDB write is the only step that must complete before we can - // safely show success — it's what guarantees the sale survives even if - // the app crashes or the device loses power before the network sync - // below finishes. It's also fast (a few ms), so this doesn't add - // perceptible delay. - await queueCashSale(sale); - setPendingCount((c) => c + 1); + }); setSubmitting(false); + setScreen({ kind: "receipt", sale }); + setCart([]); + await refreshCounts(me.id); - // OPTIMISTIC UI: show the confirmation screen immediately rather than - // waiting on the network round trip. The worker handed over the - // jerrycan and took the cash — from their perspective the sale is - // already done, and the app should agree instantly. The actual sync - // to the server happens in the background; if it fails here, the sale - // is NOT lost — it stays in the offline queue and the 'online' - // listener / interval in offlineQueue.ts retries it automatically. - setScreen({ kind: "cash_saved" }); - + // Sync in the background; a failure leaves the sale queued for the + // next automatic retry. if (navigator.onLine) { - apiFetch("/api/transactions/cash", { - method: "POST", - body: JSON.stringify({ sales: [sale] }), - }) - .then(() => setPendingCount((c) => Math.max(0, c - 1))) - .catch(() => { - // stays queued; offline sync interval will retry — no toast here, - // since this is a routine background retry, not a user-facing error - }); + flushQueue(owner).then(async (r) => { + setCounts((c) => ({ ...c, pending: r.pending })); + const rows = await salesFor(me.id); + const updated = rows.find((row) => row.clientUuid === sale.clientUuid); + if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s)); + if (r.rejected > 0) refreshCounts(me.id); + }); } } async function startMpesaPush() { - if (!validate() || !selectedProduct) return; + if (!validate(true)) return; + if (lines.length !== 1) { + setFormError("M-Pesa payments support one product per payment. Use cash for mixed sales."); + return; + } if (!navigator.onLine) { setFormError("M-Pesa requires an active network connection."); return; @@ -177,8 +261,8 @@ export default function TerminalPage() { body: JSON.stringify({ customerName: customerName || undefined, customerPhone, - productId: selectedProduct._id, - quantity, + productId: lines[0].productId, + quantity: lines[0].quantity, }), }); setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" }); @@ -196,7 +280,10 @@ export default function TerminalPage() { pollRef.current = setInterval(async () => { try { - const data = await apiFetch("/api/mpesa/status/" + transactionId); + const data = await apiFetch<{ + paymentStatus: string; + mpesaDetails?: { receiptNumber?: string | null; resultDesc?: string | null }; + }>("/api/mpesa/status/" + transactionId); if (data.paymentStatus === "SUCCESS") { clearInterval(pollRef.current!); setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber }); @@ -210,7 +297,15 @@ export default function TerminalPage() { }); } else if (Date.now() - start > 60000) { clearInterval(pollRef.current!); - setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No response from customer in time." }); + // Not "failed": the customer may still approve it, and the callback + // would then record it as paid. Taking cash now would count it twice. + setScreen({ + kind: "mpesa", + transactionId, + ui: "FAILED", + reason: + "No confirmation yet. Do NOT take cash for this sale until a manager has checked whether the M-Pesa payment arrived.", + }); } } catch { // transient network hiccup while polling; keep trying until timeout above @@ -218,7 +313,53 @@ export default function TerminalPage() { }, 3000); } - if (!authChecked || !me) { + async function openQueue() { + if (!me) return; + setQueueRows((await salesFor(me.id)).filter((r) => r.state !== "synced")); + setShowQueue(true); + } + + async function handleDismiss(clientUuid: string) { + if (!me) return; + if (!window.confirm("Remove this sale from the phone? Make sure your manager has a record of it first.")) return; + await dismissRejected(clientUuid); + setQueueRows((rows) => rows.filter((r) => r.clientUuid !== clientUuid)); + await refreshCounts(me.id); + } + + async function handleSignOut() { + if (counts.pending > 0) { + const ok = window.confirm( + `${counts.pending} sale(s) have not synced yet. They stay safely on this phone and sync the next time you sign in here. Sign out anyway?` + ); + if (!ok) return; + } + try { + await apiFetch("/api/auth/logout", { method: "POST" }); + } catch { + // Offline: the cookies expire on their own; still clear this device. + } + clearSession(); + router.replace("/login"); + } + + if (bootError) { + return ( +
    +
    +

    {bootError}

    + +
    +
    + ); + } + + if (!me) { return (
    @@ -232,95 +373,176 @@ export default function TerminalPage() { ); } + const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity])); + return (
    -
    -
    +
    +

    {me.branchName || "Branch"}

    {me.name}

    - + {online ? "● Online" : "● Offline"} - {pendingCount > 0 && {pendingCount} cash sale(s) syncing…} +
    - {screen.kind === "cash_saved" ? ( -
    -
    💵
    -

    Cash Sale Recorded

    -

    - KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans -

    - + {(counts.pending > 0 || counts.rejected > 0) && ( + + )} + + {screen.kind === "receipt" ? ( +
    + +
    + + +
    ) : ( <> -
    - - setCustomerName(e.target.value)} - placeholder="e.g. Jane Wambui" - className="tap-target w-full rounded-xl border border-depth-200 px-3 mb-3" - /> - - { - setCustomerPhone(e.target.value); - if (phoneError) setPhoneError(null); - }} - placeholder="07XXXXXXXX" - className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")} - /> - {phoneError &&

    {phoneError}

    } -
    - {productsLoading ? (
    ) : ( - + )} - {selectedProduct && ( - + {lines.length > 0 && ( +
    +

    This sale

    +
      + {lines.map((l) => ( +
    • +
      +

      {l.product.label}

      +

      + KES {(l.quantity * l.product.unitPrice).toLocaleString()} +

      +
      +
      + + {l.quantity} + +
      +
    • + ))} +
    +
    + Total + KES {total.toLocaleString()} +
    +
    )} +
    + {!showCustomer ? ( + + ) : ( + <> + + setCustomerName(e.target.value)} + placeholder="e.g. Jane Wambui" + className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3" + /> + + { + setCustomerPhone(e.target.value); + if (phoneError) setPhoneError(null); + }} + placeholder="07XXXXXXXX" + className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")} + /> + {phoneError &&

    {phoneError}

    } + + )} +
    + {formError &&

    {formError}

    }
    - + {mpesaEnabled && } - {method === "CASH" ? ( + {method === "CASH" || !mpesaEnabled ? ( ) : ( )}
    @@ -328,10 +550,46 @@ export default function TerminalPage() { )}
    + {showQueue && ( +
    +
    +
    +

    Sales on this phone

    + +
    + {queueRows.length === 0 &&

    Everything is synced.

    } +
      + {queueRows.map((r) => ( +
    • +
      + {new Date(r.clientSubmittedAt).toLocaleString("en-KE")} + KES {r.total.toLocaleString()} +
      +

      {r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}

      + {r.state === "pending" ? ( +

      Waiting to sync

      + ) : ( + <> +

      Not accepted: {r.error}

      +

      Tell your manager, then remove it from this phone.

      + + + )} +
    • + ))} +
    +
    +
    + )} + {screen.kind === "mpesa" && ( { diff --git a/src/components/ServiceWorkerRegister.tsx b/src/components/ServiceWorkerRegister.tsx new file mode 100644 index 0000000..7beebfb --- /dev/null +++ b/src/components/ServiceWorkerRegister.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Registers public/sw.js so the terminal can open offline. Production only: + * in `next dev` the build assets are not content-hashed, and caching them + * would serve stale code while developing. + */ +export default function ServiceWorkerRegister() { + useEffect(() => { + if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return; + navigator.serviceWorker.register("/sw.js").catch(() => { + // Unsupported or blocked: the app still works online. + }); + }, []); + return null; +} diff --git a/src/components/admin/ActivityFeedMatrix.tsx b/src/components/admin/ActivityFeedMatrix.tsx index 0b6ce58..44e3332 100644 --- a/src/components/admin/ActivityFeedMatrix.tsx +++ b/src/components/admin/ActivityFeedMatrix.tsx @@ -45,7 +45,7 @@ export default function ActivityFeedMatrix({ transactions, branchNames, connecte
  • -

    {t.customerName || t.customerPhone}

    +

    {t.customerName || t.customerPhone || "Walk-in customer"}

    {scope === "global" && ( {branchLabel} diff --git a/src/components/admin/StockSummaryCard.tsx b/src/components/admin/StockSummaryCard.tsx new file mode 100644 index 0000000..3f5af6a --- /dev/null +++ b/src/components/admin/StockSummaryCard.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Link from "next/link"; +import { StockLevel } from "@/lib/types"; + +interface Props { + levels: StockLevel[]; +} + +/** Current stock per branch at a glance, with anything low or out listed first. */ +export default function StockSummaryCard({ levels }: Props) { + const totalsByBranch = new Map(); + for (const l of levels) { + const entry = totalsByBranch.get(l.branchId) ?? { name: l.branchName, units: 0, alerts: 0 }; + entry.units += Math.max(l.quantity, 0); + if (l.status !== "OK") entry.alerts += 1; + totalsByBranch.set(l.branchId, entry); + } + const alerts = levels.filter((l) => l.status !== "OK").sort((a, b) => a.quantity - b.quantity); + + return ( +
    +
    +

    Stock on hand

    + + Manage stock + +
    + {levels.length === 0 ? ( +

    No stock recorded yet.

    + ) : ( + <> +
    + {Array.from(totalsByBranch.entries()).map(([id, b]) => ( +
    +

    {b.name}

    +

    {b.units.toLocaleString()} units

    + {b.alerts > 0 &&

    {b.alerts} low/out

    } +
    + ))} +
    + {alerts.length > 0 && ( +
      + {alerts.slice(0, 6).map((l) => ( +
    • + + {l.productLabel} · {l.branchName} + + + {l.quantity} + +
    • + ))} +
    + )} + + )} +
    + ); +} diff --git a/src/components/terminal/ProductGrid.tsx b/src/components/terminal/ProductGrid.tsx new file mode 100644 index 0000000..3de8d0b --- /dev/null +++ b/src/components/terminal/ProductGrid.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Product } from "@/lib/types"; + +interface Props { + products: Product[]; + quantities: Record; + onAdd: (productId: string) => void; +} + +/** One tap adds one unit of a product to the current sale. */ +export default function ProductGrid({ products, quantities, onAdd }: Props) { + if (products.length === 0) { + return ( +
    + No products configured yet. Ask the owner to add them. +
    + ); + } + + return ( +
    + Tap a product to add it +
    + {products.map((p) => { + const inCart = quantities[p._id] ?? 0; + return ( + + ); + })} +
    +
    + ); +} diff --git a/src/components/terminal/ProductSizeSelector.tsx b/src/components/terminal/ProductSizeSelector.tsx deleted file mode 100644 index e3b8f4d..0000000 --- a/src/components/terminal/ProductSizeSelector.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { Product } from "@/lib/types"; - -interface Props { - products: Product[]; - selectedId: string | null; - onSelect: (id: string) => void; -} - -export default function ProductSizeSelector({ products, selectedId, onSelect }: Props) { - if (products.length === 0) { - return ( -
    - No jerrycan sizes configured yet. -
    - ); - } - const maxSize = Math.max(...products.map((p) => p.sizeLiters)); - - return ( -
    - Jerrycan size -
    - {products.map((p) => { - const isSelected = p._id === selectedId; - const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize); - return ( - - ); - })} -
    -
    - ); -} diff --git a/src/components/terminal/QuantityCounter.tsx b/src/components/terminal/QuantityCounter.tsx deleted file mode 100644 index 651eb0d..0000000 --- a/src/components/terminal/QuantityCounter.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -interface Props { - quantity: number; - onChange: (q: number) => void; - unitPrice: number; - sizeLabel: string; - max?: number; -} - -export default function QuantityCounter({ quantity, onChange, unitPrice, sizeLabel, max = 200 }: Props) { - const dec = () => onChange(Math.max(1, quantity - 1)); - const inc = () => onChange(Math.min(max, quantity + 1)); - - return ( -
    -
    - {sizeLabel} jerrycans - KES {unitPrice.toLocaleString()} each -
    - -
    - - -
    -
    {quantity}
    -
    KES {(quantity * unitPrice).toLocaleString()}
    -
    - - -
    -
    - ); -} diff --git a/src/components/terminal/Receipt.tsx b/src/components/terminal/Receipt.tsx new file mode 100644 index 0000000..c876408 --- /dev/null +++ b/src/components/terminal/Receipt.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { QueuedSale } from "@/lib/offlineQueue"; + +interface Props { + sale: QueuedSale; + businessName?: string | null; + branchName?: string | null; + cashierName: string; + synced: boolean; +} + +/** A printable sale receipt. Printing hides everything but `.print-receipt` (see globals.css). */ +export default function Receipt({ sale, businessName, branchName, cashierName, synced }: Props) { + const when = new Date(sale.clientSubmittedAt); + const reference = sale.clientUuid.slice(0, 8).toUpperCase(); + + return ( +
    +
    +

    {businessName ?? "Receipt"}

    + {branchName &&

    {branchName}

    } +

    + {when.toLocaleDateString("en-KE")} {when.toLocaleTimeString("en-KE", { hour: "2-digit", minute: "2-digit" })} +

    +

    Ref {reference}

    +
    + + + + {sale.lines.map((l) => ( + + + + + ))} + +
    + {l.quantity} x {l.label} + @ KES {l.unitPrice.toLocaleString()} + {(l.quantity * l.unitPrice).toLocaleString()}
    + +
    + TOTAL (KES) + {sale.total.toLocaleString()} +
    +

    Paid: Cash

    + {sale.customerName &&

    Customer: {sale.customerName}

    } +

    Served by: {cashierName}

    + +

    + {synced ? "Synced to head office" : "Saved on this phone - will sync automatically"} +

    +
    + ); +} diff --git a/src/lib/offlineQueue.ts b/src/lib/offlineQueue.ts index acd9341..0335090 100644 --- a/src/lib/offlineQueue.ts +++ b/src/lib/offlineQueue.ts @@ -1,7 +1,49 @@ import { openDB, DBSchema, IDBPDatabase } from "idb"; -import { apiFetch } from "@/lib/api"; +import { apiFetch, ApiError } from "@/lib/api"; +import { MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol"; -export interface QueuedCashSale { +/** + * The terminal's durable sales queue (IndexedDB). + * + * Every cash sale is written here BEFORE the cashier sees "recorded", and + * stays here until the server has confirmed it. Each row carries the + * cashier and branch that recorded it, and a device only ever sends the + * signed-in cashier's rows — so on a shared phone, sales can never be + * credited to whoever signs in next. + * + * Row lifecycle: + * pending -> sent on the next sync (immediately when online) + * synced -> server answered saved/duplicate; kept 7 days for the receipt history + * rejected -> server can never accept it (reason stored); shown to the cashier, + * never retried, so it can't block the queue + */ + +export interface SaleLine { + productId: string; + label: string; + quantity: number; + unitPrice: number; // the price the cashier saw and charged +} + +export type SaleState = "pending" | "synced" | "rejected"; + +export interface QueuedSale { + clientUuid: string; + userId: string; + businessId: string; + branchId: string; + customerName?: string; + customerPhone?: string; + lines: SaleLine[]; + total: number; + clientSubmittedAt: string; + state: SaleState; + error?: string; + syncedAt?: string; +} + +/** Rows written by the pre-v2 terminal (no owner, one product). */ +interface LegacyCashSale { clientUuid: string; customerName?: string; customerPhone: string; @@ -12,19 +54,30 @@ export interface QueuedCashSale { } interface QueueDB extends DBSchema { - cash_sales: { key: string; value: QueuedCashSale }; + sales: { key: string; value: QueuedSale; indexes: { byUser: string } }; + cash_sales: { key: string; value: LegacyCashSale }; +} + +export interface QueueOwner { + id: string; + businessId?: string; + branchId: string | null; } const DB_NAME = "aquaflow-offline"; -const STORE = "cash_sales"; +const KEEP_SYNCED_MS = 7 * 24 * 60 * 60 * 1000; let dbPromise: Promise> | null = null; function getDb() { if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser"); if (!dbPromise) { - dbPromise = openDB(DB_NAME, 1, { - upgrade(db) { - db.createObjectStore(STORE, { keyPath: "clientUuid" }); + dbPromise = openDB(DB_NAME, 2, { + upgrade(db, oldVersion) { + if (oldVersion < 1) db.createObjectStore("cash_sales", { keyPath: "clientUuid" }); + if (oldVersion < 2) { + const sales = db.createObjectStore("sales", { keyPath: "clientUuid" }); + sales.createIndex("byUser", "userId"); + } }, }); } @@ -35,52 +88,163 @@ export function generateClientUuid(): string { return crypto.randomUUID(); } -export async function queueCashSale(sale: Omit): Promise { +export async function queueSale(sale: Omit): Promise { + const row: QueuedSale = { ...sale, state: "pending" }; const db = await getDb(); - await db.put(STORE, { ...sale, synced: false }); + await db.put("sales", row); + return row; } -export async function getPendingSales(): Promise { +export async function salesFor(userId: string): Promise { const db = await getDb(); - const all = await db.getAll(STORE); - return all.filter((s) => !s.synced); + return db.getAllFromIndex("sales", "byUser", userId); } -async function markSynced(clientUuids: string[]) { +export async function queueCounts(userId: string): Promise<{ pending: number; rejected: number }> { + const rows = await salesFor(userId); + return { + pending: rows.filter((r) => r.state === "pending").length, + rejected: rows.filter((r) => r.state === "rejected").length, + }; +} + +/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */ +export async function dismissRejected(clientUuid: string): Promise { const db = await getDb(); - const tx = db.transaction(STORE, "readwrite"); - for (const id of clientUuids) { - const row = await tx.store.get(id); - if (row) { - row.synced = true; - await tx.store.put(row); + const row = await db.get("sales", clientUuid); + if (row?.state === "rejected") await db.delete("sales", clientUuid); +} + +async function applyResults(results: SaleResult[]) { + const db = await getDb(); + const tx = db.transaction("sales", "readwrite"); + for (const r of results) { + const row = await tx.store.get(r.clientUuid); + if (!row) continue; + if (r.status === "rejected") { + row.state = "rejected"; + row.error = r.error; + } else { + row.state = "synced"; + row.syncedAt = new Date().toISOString(); + delete row.error; } + await tx.store.put(row); } await tx.done; } -export async function flushQueue(): Promise<{ synced: number; failed: number }> { - const pending = await getPendingSales(); - if (pending.length === 0) return { synced: 0, failed: 0 }; +function toPayload(s: QueuedSale) { + return { + clientUuid: s.clientUuid, + staffId: s.userId, + branchId: s.branchId, + customerName: s.customerName || undefined, + customerPhone: s.customerPhone || undefined, + clientSubmittedAt: s.clientSubmittedAt, + lines: s.lines.map((l) => ({ + productId: l.productId, + quantity: l.quantity, + // Adopted legacy rows never recorded a price (0); don't flag those as mismatches. + unitPrice: l.unitPrice > 0 ? l.unitPrice : undefined, + })), + }; +} - try { - const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", { - method: "POST", - body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }), +/** + * Pre-v2 rows had no owner. They are adopted by the first cashier who + * syncs on this device — exactly what the old terminal did with them. + */ +async function adoptLegacySales(owner: QueueOwner) { + if (!owner.branchId) return; + const db = await getDb(); + const legacy = (await db.getAll("cash_sales")).filter((s) => !s.synced); + for (const s of legacy) { + await db.put("sales", { + clientUuid: s.clientUuid, + userId: owner.id, + businessId: owner.businessId ?? "", + branchId: owner.branchId, + customerName: s.customerName, + customerPhone: s.customerPhone, + lines: [{ productId: s.productId, label: "Sale", quantity: s.quantity, unitPrice: 0 }], + total: 0, + clientSubmittedAt: s.clientSubmittedAt, + state: "pending", }); - const syncedIds = result.inserted.map((r) => r.clientUuid); - await markSynced(syncedIds); - return { synced: syncedIds.length, failed: pending.length - syncedIds.length }; - } catch { - return { synced: 0, failed: pending.length }; + } + await db.clear("cash_sales"); +} + +async function pruneSynced(userId: string) { + const db = await getDb(); + const cutoff = Date.now() - KEEP_SYNCED_MS; + for (const row of await salesFor(userId)) { + if (row.state === "synced" && row.syncedAt && Date.parse(row.syncedAt) < cutoff) { + await db.delete("sales", row.clientUuid); + } } } -export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) { +export interface FlushResult { + synced: number; + rejected: number; + pending: number; + offline: boolean; +} + +let flushInFlight: Promise | null = null; + +/** + * Send this cashier's pending sales in chunks the server accepts. Stops at + * the first network/auth failure (everything unsent stays pending). + */ +export function flushQueue(owner: QueueOwner): Promise { + if (!flushInFlight) { + flushInFlight = doFlush(owner).finally(() => { + flushInFlight = null; + }); + } + return flushInFlight; +} + +async function doFlush(owner: QueueOwner): Promise { + await adoptLegacySales(owner); + const pending = (await salesFor(owner.id)) + .filter((s) => s.state === "pending") + .sort((a, b) => a.clientSubmittedAt.localeCompare(b.clientSubmittedAt)); + + let synced = 0; + let rejected = 0; + let offline = false; + for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) { + const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST); + try { + const { results } = await apiFetch<{ results: SaleResult[] }>("/api/transactions/cash", { + method: "POST", + body: JSON.stringify({ sales: chunk.map(toPayload) }), + }); + await applyResults(results); + synced += results.filter((r) => r.status !== "rejected").length; + rejected += results.filter((r) => r.status === "rejected").length; + } catch (err) { + // 401/403: signed out or blocked — keep everything for later. + // Network error: offline — same. Nothing is ever dropped here. + offline = !(err instanceof ApiError); + break; + } + } + await pruneSynced(owner.id); + const counts = await queueCounts(owner.id); + return { synced, rejected, pending: counts.pending, offline }; +} + +/** Flush on reconnect and every 30s while online. Returns an unsubscribe. */ +export function registerOfflineSync(owner: QueueOwner, onFlush?: (r: FlushResult) => void) { if (typeof window === "undefined") return () => {}; const handler = async () => { - const result = await flushQueue(); + const result = await flushQueue(owner); onFlush?.(result); }; diff --git a/src/lib/sessionCache.ts b/src/lib/sessionCache.ts new file mode 100644 index 0000000..b19cc19 --- /dev/null +++ b/src/lib/sessionCache.ts @@ -0,0 +1,50 @@ +import type { AuthUser, Product } from "@/lib/types"; + +/** + * The last confirmed session and catalogue, so the terminal can start and + * keep selling with no network. Only a snapshot for display and for + * labelling queued sales: the server still authenticates every sync with + * the httpOnly cookies and re-prices every line. + */ + +const KEY = "aquaflow_terminal_session"; + +export interface SessionSnapshot { + user: AuthUser; + features: { mpesa: boolean }; + products: Product[]; + savedAt: string; +} + +export function loadSession(): SessionSnapshot | null { + try { + const raw = localStorage.getItem(KEY); + return raw ? (JSON.parse(raw) as SessionSnapshot) : null; + } catch { + return null; + } +} + +export function saveSession(update: Partial & { user: AuthUser }): void { + try { + const prev = loadSession(); + const sameUser = prev?.user.id === update.user.id; + const next: SessionSnapshot = { + features: update.features ?? (sameUser ? prev!.features : { mpesa: false }), + products: update.products ?? (sameUser ? prev!.products : []), + user: update.user, + savedAt: new Date().toISOString(), + }; + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + // Storage full or blocked: the terminal still works online. + } +} + +export function clearSession(): void { + try { + localStorage.removeItem(KEY); + } catch { + // ignore + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7195b24..498616e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -31,6 +31,7 @@ export interface Product { unitPrice: number; sortOrder: number; isActive: boolean; + lowStockThreshold?: number; } export interface Transaction { @@ -38,7 +39,7 @@ export interface Transaction { branchId: string | { _id: string; branchName: string }; staffId: string; customerName: string | null; - customerPhone: string; + customerPhone: string | null; sizeLiters: number; quantity: number; unitPrice: number; @@ -83,4 +84,25 @@ export interface ReconciliationRow { totalSold: number; expectedRemaining: number; reconciledOk: boolean | null; -} \ No newline at end of file +} +export interface StockLevel { + branchId: string; + branchName: string; + productId: string; + productLabel: string; + unit: string; + quantity: number; + lowStockThreshold: number; + status: "OK" | "LOW" | "OUT"; +} + +export interface StockMovementRow { + _id: string; + type: "SALE" | "PURCHASE" | "TRANSFER_OUT" | "TRANSFER_IN" | "ADJUSTMENT"; + quantity: number; + note: string | null; + createdAt: string; + branchId: { _id: string; branchName: string } | null; + productId: { _id: string; label: string; unit?: string } | null; + recordedBy: { _id: string; name: string } | null; +} From 8fe50af341077cafa35dc5f20a96af119f57f10e Mon Sep 17 00:00:00 2001 From: denisireri Date: Sun, 11 Oct 2026 18:54:24 +0300 Subject: [PATCH 4/4] Show Offline when the server is unreachable, and document the pilot changes The terminal's badge only looked at navigator.onLine, so it said Online while the server was down. It now also reflects whether the last sync or health check reached the server. The cart badge no longer covers the product name. docs/PILOT_READINESS.md records what changed against the PRD review, the decisions taken (offline sales may take stock negative, transfers may not, server price wins but differences are flagged), rollout notes, and what is still open. Co-Authored-By: Claude Opus 5.5 --- docs/PILOT_READINESS.md | 104 ++++++++++++++++++++++++ src/app/terminal/page.tsx | 11 ++- src/components/terminal/ProductGrid.tsx | 8 +- src/lib/offlineQueue.ts | 5 ++ 4 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 docs/PILOT_READINESS.md diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md new file mode 100644 index 0000000..d733b69 --- /dev/null +++ b/docs/PILOT_READINESS.md @@ -0,0 +1,104 @@ +# Pilot readiness changes + +This branch (`fix/pilot-readiness`) closes the gaps found when the MVP was +reviewed against `PRD.md` and `REVIEW.md`. This page records what changed, +the decisions taken along the way, and what is still open. + +## What changed + +### Tenant isolation and roles (PRD 5.1) +- Every role-limited API route goes through `authorize()` in + `src/lib/server/auth.ts`: **401** when signed out, **403** for the wrong + role. Cashiers can no longer read profit, expenses or purchases, or + record expenses and purchases. Only the owner creates or edits products + and prices. +- `PATCH /api/users/:id` deactivates and reactivates staff (managers: only + their own cashiers). Deactivating or moving a user revokes their device + sessions. +- `PATCH /api/branches/:id` renames a branch (owner only). +- Login and token refresh refuse an inactive or `SUSPENDED` business. +- Public signup refuses any address in `PLATFORM_ADMIN_EMAILS`. +- Ids in query strings and bodies are validated (400, not a 500), inventory + routes check the product belongs to the tenant, and reconciliation + lookups are tenant-scoped. + +### Stock ledger and transfers (PRD 5.2) +- `StockMovement` is the ledger: a branch's stock of a product is the sum + of its movements. Each movement names its cause, and `(type, refId)` is + unique, so a replayed sale or retried transfer never moves stock twice. +- Sales and confirmed M-Pesa payments deduct stock; purchases of a + catalogue product add it. +- `POST /api/stock/transfers` moves stock between branches. + `POST /api/stock/adjustments` records opening stock, breakage and + physical counts. `GET /api/stock` returns stock per branch with LOW/OUT + flags (per-product `lowStockThreshold`, default 10). +- The admin **Stock** page (`/admin/stock`) covers all of this, plus the + existing morning dispatch and evening count. The dashboard shows stock + on hand per branch. + +### Offline POS and sync (PRD 5.3, 5.5) +- `POST /api/transactions/cash` answers each sale with `saved`, + `duplicate` or `rejected`. Previously a replayed sale was skipped + silently, the device never marked it synced, and once more than 100 rows + piled up every sync failed. +- The device queue (`src/lib/offlineQueue.ts`) is keyed by cashier and + branch, sends only the signed-in cashier's sales, chunks to 50 per + request, and stops retrying rejected sales (they are shown to the + cashier instead). +- A service worker (`public/sw.js`) caches the app shell, and the last + session and product list are kept on the phone, so the terminal opens + and sells with no network. +- Sales can contain several products, the customer phone is optional for + cash, and the terminal shows a printable receipt. + +### Other +- "Today" and "this month" follow Africa/Nairobi, not the server's UTC + clock (`src/lib/server/businessDay.ts`). +- M-Pesa is behind `FEATURE_MPESA` (off by default; out of scope for the + pilot per PRD section 4). + +## Decisions taken + +| Decision | Why | +|---|---| +| Offline sales are always accepted, even if they take stock below zero. | The sale already happened. Refusing it would lose real cash. Negative stock is shown on the Stock page and fixed with a physical count. | +| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. | +| The server price is authoritative; a different price shown on the device is kept (`clientUnitPrice`) and flagged (`priceMismatch`). | Trusting the device's price would let a cashier under-charge. Overwriting it silently would hide a cash discrepancy. | +| A queued sale stays with the branch where it was recorded, even if the cashier has since moved. | That is where the stock left and the cash was taken. | +| A product deactivated while a phone was offline is still recorded at its price. | Same reason as the first row: the sale happened. | + +## Rolling out to an existing database +- No migration is needed: new fields have defaults, and Mongoose creates + the new indexes on start-up. +- Existing branches start with **no stock on record**. Before the pilot, + each branch should record an opening count on the Stock page. +- Cashiers' phones upgrade their offline queue automatically; any unsynced + sales from the old version are sent on the next sync. + +## Testing +- `npm test` runs 37 API tests against an in-memory MongoDB, covering role + enforcement, tenant isolation for sales, stock and transfers, suspension, + the sync protocol (including a 120-sale backlog replayed twice), and the + business day. +- The offline flow was also checked end to end in headless Chrome: sign + in, stop the server, reload the terminal, record two sales, restart the + server, confirm they sync once and deduct stock. + +## Still open +- **Removing a branch.** Branches can be renamed but not removed. Someone + needs to decide what happens to a removed branch's stock and history. +- **Setting branch prices.** Branch price overrides are applied everywhere, + but there is no screen or API to set them yet. +- **Global email and phone uniqueness.** One person cannot work for two + businesses, and signup reveals whether an address is already registered. +- **Isolation by convention.** Tenant filters are still written per route. + The tests cover the sales, stock, transfer and access paths, not every + route. +- **Concurrent transfers.** Two transfers submitted at the same moment + could together send more than the branch holds. +- **Cash-basis profit.** The profit report counts all purchases in the + period as cost of goods. +- **M-Pesa, if turned on.** Only one product per payment. A prompt that + times out still needs a manual check before taking cash. +- **Not modelled yet:** invites and email verification, empty-bottle and + deposit tracking, home-screen icons for the PWA. diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 8e74308..0eb8987 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -41,6 +41,9 @@ export default function TerminalPage() { const [mpesaEnabled, setMpesaEnabled] = useState(false); const [bootError, setBootError] = useState(null); const [online, setOnline] = useState(true); + // navigator.onLine only knows about the phone's own connection; this + // also goes false when the server could not be reached. + const [serverReachable, setServerReachable] = useState(true); const [counts, setCounts] = useState({ pending: 0, rejected: 0 }); const [showQueue, setShowQueue] = useState(false); const [queueRows, setQueueRows] = useState([]); @@ -98,6 +101,7 @@ export default function TerminalPage() { return; } user = cached.user; + setServerReachable(false); setMpesaEnabled(false); // M-Pesa needs a connection anyway show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info"); } @@ -137,6 +141,7 @@ export default function TerminalPage() { window.addEventListener("offline", goOffline); const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => { setCounts((c) => ({ ...c, pending: r.pending })); + setServerReachable(!r.offline); if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success"); if (r.rejected > 0) { show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error"); @@ -235,6 +240,7 @@ export default function TerminalPage() { if (navigator.onLine) { flushQueue(owner).then(async (r) => { setCounts((c) => ({ ...c, pending: r.pending })); + setServerReachable(!r.offline); const rows = await salesFor(me.id); const updated = rows.find((row) => row.clientUuid === sale.clientUuid); if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s)); @@ -374,6 +380,7 @@ export default function TerminalPage() { } const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity])); + const connected = online && serverReachable; return (
    @@ -387,10 +394,10 @@ export default function TerminalPage() { - {online ? "● Online" : "● Offline"} + {connected ? "● Online" : "● Offline"}