diff --git a/.githooks/security-audit.js b/.githooks/security-audit.js old mode 100644 new mode 100755 diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 97% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md index c76625809..a8adf9e61 100644 --- a/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -77,7 +77,7 @@ Roughly in order, starting at the lowest step that fits: severe act — harassment, threats, or deliberately putting shops' data at risk. Decisions are made by the maintainers, currently as described in -[GOVERNANCE.md](GOVERNANCE.md#where-the-project-is-today). If you believe one +[GOVERNANCE.md](../docs/GOVERNANCE.md#where-the-project-is-today). If you believe one was wrong, say so at the same address; where a second maintainer exists, they review it. diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 59% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index 9b894376a..66f12bb9e 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,6 +6,11 @@ hardware quirks from real shops. ## Getting started +If this is your first Posnic contribution, start with the +[contributor quickstart](../docs/CONTRIBUTOR_QUICKSTART.md). It has the local +setup commands, common test commands, and task map for translations, GST, +e-invoicing, AI helper work, integrations and hardware evidence. + ```bash git clone https://github.com/Posnic/POS.git cd POS @@ -18,20 +23,28 @@ You do **not** need to install MongoDB. Posnic bundles its own and picks a port derived from the application name, so it never collides with a database you already run. The first launch takes a few minutes while it unpacks. -- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — full developer guide: tests, +- [Contributor quickstart](../docs/CONTRIBUTOR_QUICKSTART.md) — first setup, + task map and test matrix +- [docs/DEVELOPMENT.md](../docs/DEVELOPMENT.md) — full developer guide: tests, linting, conventions, and what not to rename -- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the pieces fit together -- [docs/API.md](docs/API.md) — REST reference, 478 endpoints -- [docs/BUILD_INSTRUCTIONS.md](docs/BUILD_INSTRUCTIONS.md) — installer builds +- [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) — how the pieces fit together +- [docs/API.md](../docs/API.md) — REST reference +- [docs/BUILD_INSTRUCTIONS.md](../docs/BUILD_INSTRUCTIONS.md) — installer builds ## How to contribute 1. **Open an issue first** for anything non-trivial — a bug report or a short proposal for a feature. It avoids wasted work on both sides. -2. Fork, create a branch (`fix/receipt-rounding`, `feat/tamil-translation`). -3. Keep pull requests focused — one change per PR. -4. Match the style of the surrounding code; avoid drive-by reformatting. -5. Run the tests: `cd api && npm test` (7,744 unit tests, about a minute), plus +2. Pick a task from the + [contributor roadmap](https://github.com/Posnic/POS/issues/35) or the + [`good first issue`](https://github.com/Posnic/POS/labels/good%20first%20issue) + label. +3. Read the issue acceptance criteria and choose one small checkbox or PR slice. + If the issue does not yet define "done", start by proposing that checklist. +4. Fork, create a branch (`fix/receipt-rounding`, `feat/tamil-translation`). +5. Keep pull requests focused — one change per PR. +6. Match the style of the surrounding code; avoid drive-by reformatting. +7. Run the tests: `cd api && npm test` (7,744 unit tests, about a minute), plus `npm test` at the repo root for the desktop side (294 more). Both should pass on a clean checkout — if they do not, that is worth an issue on its own. @@ -46,7 +59,7 @@ that one is yours. **Do not rename a persisted field or collection in a pull request.** Those names travel over the sync wire to desktop installations that cannot be force-updated, so renaming them needs a versioned migration. See -[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#what-not-to-rename). +[docs/DEVELOPMENT.md](../docs/DEVELOPMENT.md#what-not-to-rename). ## What happens to your pull request @@ -55,7 +68,7 @@ So you know what you are waiting for. 1. **CI runs** — unit tests, the desktop tests, lint, the packaging check and the API docs check. All of them gate: if CI is red, the pull request waits. 2. **A maintainer reads it.** Usually within a week. The project is small; see - [GOVERNANCE.md](GOVERNANCE.md). + [GOVERNANCE.md](../docs/GOVERNANCE.md). 3. **You may get comments.** Comments are not rejection. A pull request with twenty comments is one someone is taking seriously. 4. **It merges**, or it does not — and if it does not, the reason is written in @@ -99,6 +112,38 @@ Two things worth repeating here: are trying to do tells us more than the button you imagined, and often has a better answer. +## Contribute evidence without changing code + +Reproducible observations are useful contributions. They help turn a broad +feature statement into a versioned result with an exact input, environment and +limitation. + +- If you are reviewing Posnic independently, start with the public + [review brief](https://posnic.com/assets/posnic-independent-review-brief.txt) + and [24-control protocol](https://posnic.com/assets/posnic-independent-review-protocol.csv). + Review access does not require payment, positive coverage, a backlink or + advance approval. Publish failures, conflicts and untested areas as well as + successful results. +- Run the public + [vendor-neutral POS acceptance fixture](https://posnic.com/open-source-pos-benchmark#vendor-neutral-pos-acceptance-fixture) + and submit the structured + [acceptance result form](https://github.com/Posnic/POS/issues/new?template=pos_acceptance_run.yml). +- Test an exact printer, scanner, cash drawer, scale or display against the + [hardware matrix](../docs/HARDWARE_MATRIX.md), then submit the structured + [hardware evidence form](https://github.com/Posnic/POS/issues/new?template=hardware_evidence.yml). +- If you operate, piloted, installed, evaluated or stopped using Posnic, read + the [adoption evidence policy](../docs/ADOPTION_EVIDENCE.md) and submit a + structured + [deployment evidence report](https://github.com/Posnic/POS/issues/new?template=deployment_evidence.yml). + Failures, workarounds and reasons not to adopt are useful evidence. +- Read [ROADMAP.md](../docs/ROADMAP.md) for the current evidence gaps and the boundary + between released, reproduced, in-validation and planned work. + +An evidence issue is public and is not a certification or testimonial. Use +fictional transactions and remove customer data, payment data, credentials, +tokens, production logs and database files before attaching anything. Security +problems still go through [SECURITY.md](SECURITY.md), never a public issue. + ## Sign your commits (DCO) We use the [Developer Certificate of Origin](https://developercertificate.org/). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d07e976bb..da1396974 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,6 +6,10 @@ blank_issues_enabled: false contact_links: + - name: Start contributing + url: https://github.com/Posnic/POS/blob/main/docs/CONTRIBUTOR_QUICKSTART.md + about: Local setup, test commands, good first issues, PR flow and safety rules. + - name: Question or help using Posnic url: https://github.com/Posnic/POS/discussions/categories/q-a about: How do I do X, is this normal, hardware advice. Faster than an issue. diff --git a/.github/ISSUE_TEMPLATE/deployment_evidence.yml b/.github/ISSUE_TEMPLATE/deployment_evidence.yml new file mode 100644 index 000000000..c4fd2f64f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/deployment_evidence.yml @@ -0,0 +1,154 @@ +name: Real deployment evidence report +description: Report bounded, versioned experience from operating or evaluating Posnic +title: "[Deployment evidence]: " +labels: [] +body: + - type: markdown + attributes: + value: | + Use this form for honest experience from a real deployment, pilot, installation, or sustained evaluation. Failures, workarounds, stopped trials, and reasons Posnic was not selected are as useful as successful use. + + GitHub issues are public. Do not include customer records, card or bank data, credentials, tokens, tax identifiers, staff identities, production database files, private logs, an exact shop address, or anything you are not authorised to publish. + + A submitted report is not automatically a testimonial, case study, certification, endorsement, uptime claim, or proof that another business will obtain the same result. + + - type: dropdown + id: reporter_relationship + attributes: + label: Your relationship to this deployment + options: + - Business owner or operator + - Employee using Posnic at work + - Installer, consultant, or implementation partner + - Independent evaluator or researcher + - Posnic employee, contractor, affiliate, or investor + - Former user or stopped pilot + - Other relationship explained below + validations: + required: true + + - type: textarea + id: relationship_details + attributes: + label: Relationship and financial disclosure + description: State who selected, configured, paid for, supplied, or reviewed the software and disclose any payment, free service, discount, employment, referral, or other material connection to Posnic. + placeholder: I operate the shop and used the free local edition; no payment or incentive was received for this report. + validations: + required: true + + - type: input + id: product_identity + attributes: + label: Exact Posnic version or source commit + description: Include the release tag, About-screen version, or full source commit. Add the package filename and SHA-256 when available. + placeholder: v1.3.0, Posnic-1.3.0-windows-x64-installer.exe, SHA-256 ... + validations: + required: true + + - type: dropdown + id: deployment_status + attributes: + label: Deployment status + options: + - Currently used for live business operations + - Time-bounded live pilot + - Production-like evaluation with synthetic data + - Installation or migration attempt that stopped + - Previously used and no longer in use + validations: + required: true + + - type: input + id: observation_window + attributes: + label: Observation window + description: Give calendar dates or a bounded duration. Do not say "for a while". + placeholder: 1 June 2026 to 31 July 2026, or 12 operating days + validations: + required: true + + - type: input + id: market_scope + attributes: + label: Country, business type, and deployment size + description: Country and state or region are enough. Do not publish an exact address. Use a range for staff or transaction volume if the exact value is sensitive. + placeholder: India, Tamil Nadu; one bakery; one till; 1-5 staff + validations: + required: true + + - type: input + id: environment + attributes: + label: Operating system and relevant hardware + description: Include OS version, architecture, installation type, and only the exact device models relevant to your observations. + placeholder: Windows 11 23H2 x64; local edition; printer model and connection if discussed + validations: + required: true + + - type: checkboxes + id: workflows_observed + attributes: + label: Workflows actually observed + description: Select only paths used during the stated window. Explain depth and exceptions below. + options: + - label: Installation, setup, or migration + - label: Catalog, stock, or purchasing + - label: Sale, tender recording, receipt, or return + - label: Restaurant, table, kitchen, or kiosk workflow + - label: Register, close, report, or export + - label: Backup, restore, restart, or outage recovery + - label: Printer, scanner, drawer, scale, or display + - label: Multi-till, branch, sync, or Posnic Cloud + - label: Support, issue reporting, or update process + validations: + required: true + + - type: textarea + id: observed_results + attributes: + label: What happened + description: Describe expected and observed behavior with counts or dates where you can support them. Include failures, retries, workarounds, abandoned paths, and unresolved issues. + placeholder: What was attempted; what worked; what failed; what changed; links to related public issues. + validations: + required: true + + - type: textarea + id: evidence_and_method + attributes: + label: Evidence and method + description: Explain how the observations were recorded. Link public issues or attach only synthetic, aggregated, or fully redacted material. + placeholder: Version screen, synthetic test receipt, issue links, redacted setup notes, or measured aggregate with its calculation. + validations: + required: true + + - type: textarea + id: limitations + attributes: + label: Limitations and untested paths + description: State what this report does not establish, including workflows, platforms, hardware, providers, countries, loads, recovery conditions, or time periods not observed. + validations: + required: true + + - type: dropdown + id: citation_permission + attributes: + label: Permission to cite this report + description: This choice can be changed later by commenting on the issue. Separate written approval is still required before publishing a named business case study, logo, quotation, or private evidence. + options: + - Do not use this as marketing; keep it as a public issue only + - Posnic may link to this public issue with my GitHub username only + - Posnic may summarise this public report after I approve the exact wording + validations: + required: true + + - type: checkboxes + id: public_data_confirmation + attributes: + label: Public report confirmation + options: + - label: I am authorised to publish this report and removed private customer, payment, staff, credential, tax, address, log, and database data. + required: true + - label: I disclosed my relationship and any payment, free service, discount, referral, employment, or other material connection. + required: true + - label: I understand that maintainers may ask for correction, label the result unverified, or close it without turning it into a marketing claim. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index cfec07365..9c9ccbf33 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -33,6 +33,19 @@ the one you had in mind. +## Acceptance criteria + + + +- [ ] The user can... +- [ ] The app still... +- [ ] Tests or evidence prove... +- [ ] Documentation or operator wording explains... + ## Does it need an internet connection? + ## What does this PR do? +## Acceptance criteria covered + + + +- [ ] Linked issue has clear acceptance criteria, or this PR adds them first +- [ ] This PR completes the listed criteria or names the remaining work + ## How was it tested? -- [ ] Ran locally (`npm start`) +- [ ] Ran locally (`npm start` for the desktop app, or `npm run dev` for a browser at http://localhost:3000) - [ ] Built the installer (`npm run build`) if build/packaging was touched - [ ] Relevant tests pass +- [ ] Used the relevant checks from [`docs/CONTRIBUTOR_QUICKSTART.md`](../docs/CONTRIBUTOR_QUICKSTART.md#test-the-right-thing) ## Checklist @@ -14,3 +31,4 @@ - [ ] Commits are signed off (`git commit -s`, DCO) - [ ] Works fully offline (no new external network calls in the local edition) - [ ] Matches surrounding code style +- [ ] No real customer, tax, payment, credential, token, or production data is included diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 94% rename from SECURITY.md rename to .github/SECURITY.md index ebe802370..68bc6310f 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -29,7 +29,7 @@ follows, so it is worth being explicit rather than leaving people to discover it |---|---| | The network | MongoDB binds to `127.0.0.1`. Nothing on the LAN or the internet can reach it. | | Other Windows accounts | The database requires a password, generated per install and stored in the shop's own user profile. No shared secret between shops. | -| A stolen or resold disk | With device encryption enabled — see the [user guide](docs/USER_GUIDE.md#keeping-your-till-secure). Without it, the disk is readable. | +| A stolen or resold disk | With device encryption enabled — see the [user guide](../docs/USER_GUIDE.md#keeping-your-till-secure). Without it, the disk is readable. | | The till while unattended | PIN lock, and close-to-tray locks the screen. | **What is not protected, and cannot be** @@ -62,7 +62,7 @@ these are the properties to weigh. Run the till on a standard, non-administrator Windows account, keep a separate administrator account with a real password, and turn on device encryption. Those three steps do more than anything in this codebase can. The -[user guide](docs/USER_GUIDE.md#keeping-your-till-secure) walks through them. +[user guide](../docs/USER_GUIDE.md#keeping-your-till-secure) walks through them. ## Scope @@ -97,8 +97,8 @@ rather than left quiet. | Secret scan of the full git history | Every CI run — `scripts/scan-git-history.js` | | Electron and Node major versions | Quarterly, and within 30 days of a security release | | Review of IPC surface, permissions and navigation guards | Every release that adds an IPC channel | -| Backup restore drill on a clean machine | Every release — see [docs/DISASTER_RECOVERY.md](docs/DISASTER_RECOVERY.md) | -| Incident response walk-through | Annually — see [docs/INCIDENT_RESPONSE.md](docs/INCIDENT_RESPONSE.md) | +| Backup restore drill on a clean machine | Every release — see [docs/DISASTER_RECOVERY.md](../docs/DISASTER_RECOVERY.md) | +| Incident response walk-through | Annually — see [docs/INCIDENT_RESPONSE.md](../docs/INCIDENT_RESPONSE.md) | | Third-party licence and notice review | Annually, and whenever a bundled asset changes | An independent security audit has **not** been commissioned. When one is, the diff --git a/SUPPORT.md b/.github/SUPPORT.md similarity index 98% rename from SUPPORT.md rename to .github/SUPPORT.md index e255f6a88..ffd4a07be 100644 --- a/SUPPORT.md +++ b/.github/SUPPORT.md @@ -49,7 +49,7 @@ attaching them. Issues are public. ## What happens next **Within a few days** someone reads it and adds labels. That is a person, not a -bot, and the project is small — see [GOVERNANCE.md](GOVERNANCE.md) for who. +bot, and the project is small — see [GOVERNANCE.md](../docs/GOVERNANCE.md) for who. Every issue ends up in one of these: diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 5be1b245f..50cbfc245 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -115,8 +115,8 @@ jobs: shell: bash run: | set -euo pipefail - VERSION=$(grep -oE 'set MONGODB_VERSION=[0-9.]+' download-mongodb.bat | cut -d= -f2) - echo "MongoDB $VERSION, the version download-mongodb.bat pins" + VERSION=$(grep -oE 'set MONGODB_VERSION=[0-9.]+' src/download-mongodb.bat | cut -d= -f2) + echo "MongoDB $VERSION, the version src/download-mongodb.bat pins" fetch_tgz() { # url, destination bin directory curl -fsSL -o mongo.tgz "$1" @@ -154,8 +154,13 @@ jobs: - name: Prepare everything the installer bundles run: npm run prepare:bundle + # Betas are deliberately unsigned test builds, so THEY must not bake in + # update-signature verification: a beta till that verified would refuse + # the next unsigned beta and silently stop updating. Stable keeps the + # repo default (verifyUpdateCodeSignature: true) - the desk build signs, + # so stable tills verify every download came from the same publisher. - name: Build - run: npx electron-builder ${{ matrix.flag }} --publish never + run: npx electron-builder ${{ matrix.flag }} --publish never --config.win.verifyUpdateCodeSignature=false env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -182,7 +187,13 @@ jobs: cp -v "$f" "dist/$(basename "$f" | sed 's/^beta/latest/')" done echo "manifests present:" - ls -1 dist/*.yml || echo " NONE - the update path cannot work" + shopt -s nullglob + latest_manifests=(dist/latest*.yml) + if (( ${#latest_manifests[@]} == 0 )); then + echo " NONE - the update path cannot work" + exit 1 + fi + printf ' %s\n' "${latest_manifests[@]}" - name: Upload installers uses: actions/upload-artifact@v7 @@ -196,7 +207,8 @@ jobs: dist/*.deb # Without these an installed tester is never offered the build, and # the Updates screen reports that it cannot reach the update server. - dist/*.yml + # builder-debug.yml is build diagnostics, not updater input. + dist/latest*.yml dist/*.blockmap if-no-files-found: error retention-days: 7 @@ -205,6 +217,11 @@ jobs: name: Publish test build needs: [version, build] runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 @@ -214,12 +231,19 @@ jobs: run: | mkdir -p release find artifacts -type f \( -name '*.exe' -o -name '*.dmg' -o -name '*.zip' \ - -o -name '*.AppImage' -o -name '*.deb' -o -name '*.yml' \ + -o -name '*.AppImage' -o -name '*.deb' -o -name 'latest*.yml' \ -o -name '*.blockmap' \) -exec mv {} release/ \; cd release sha256sum $(ls | grep -vE 'latest.*\.yml$|\.blockmap$') > SHA256SUMS.txt echo "Publishing:"; ls -lh + # Test builds are downloadable executables too. Attest only the exact + # package subjects already named by the published checksum file. + - name: Attest test-build artifact provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: release/SHA256SUMS.txt + - uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.version.outputs.tag }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a3cb639f..fc57661ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: # Release calls this so a tag is gated on exactly what a pull request meets. # It used to run the API unit suite alone, which meant a release could ship @@ -48,6 +48,11 @@ concurrency: # silently does nothing on the runner - which is the one place it was needed. env: TZ: Asia/Kolkata + # puppeteer arrives as a transitive dependency (whatsapp-web.js) and its + # postinstall downloads a whole Chrome from Google's CDN - which 403s + # often enough to fail unrelated commits at `npm ci`. No CI job launches + # a browser, so the download is pure risk with no benefit. + PUPPETEER_SKIP_DOWNLOAD: '1' # Least privilege for GITHUB_TOKEN. # @@ -237,6 +242,10 @@ jobs: - name: Install run: npm ci --omit=optional --ignore-scripts + - name: Install API dependencies used by desktop contract tests + working-directory: api + run: npm ci + # The rollback drill releases the REAL artifact - the manifest signed # over an actually-built frontend/public - so the suite needs the pages # built first. Same steps as deploy-frontend.yml. Without this the drill @@ -260,6 +269,15 @@ jobs: with: node-version: 22 + # Linux software centers and AppImage catalogs read this file before a + # user installs Posnic. Validate it with the reference implementation so + # a typo cannot silently remove the app identity, screenshot or links. + - name: AppStream metadata is valid + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y appstream + appstreamcli validate --pedantic builds/linux/com.posnic.app.metainfo.xml + # A module that is required but never packaged produces "Cannot find # module" on a user's machine and nowhere else. This has happened, so it # is checked here rather than after an installer ships. diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 196da51b8..d6a821cb6 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -57,6 +57,8 @@ jobs: AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + BREVO_API_KEY: ${{ secrets.BREVO_API_KEY }} + EMAIL_FROM: ${{ secrets.EMAIL_FROM }} run: | if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ] || [ -z "$AWS_S3_BUCKET" ]; then echo "::warning::S3 secrets not set in this repo; skipping env write - uploads will keep falling back to local disk" @@ -68,6 +70,10 @@ jobs: echo "AWS_REGION=${AWS_REGION:-ap-south-1}" echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" + # Platform mail (Brevo) - the proven send path; empty until the + # BREVO_API_KEY secret is set, and harmless while empty. + [ -n "$BREVO_API_KEY" ] && echo "BREVO_API_KEY=$BREVO_API_KEY" + [ -n "$EMAIL_FROM" ] && echo "EMAIL_FROM=$EMAIL_FROM" } | ssh -i ~/.ssh/deploy.pem "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ 'umask 077; cat > ~/apps/tenants/app/api/.env && echo "storage env written ($(grep -c = ~/apps/tenants/app/api/.env) keys)"' diff --git a/.github/workflows/deploy-develop.yml b/.github/workflows/deploy-develop.yml new file mode 100644 index 000000000..daf35f96e --- /dev/null +++ b/.github/workflows/deploy-develop.yml @@ -0,0 +1,130 @@ +# Puts whatever is on develop onto develop.posnic.io, so anybody can try it. +# +# THIS MACHINE RUNS UNREVIEWED CONTRIBUTOR CODE. That single fact decides +# everything below. +# +# It is NOT registered in the estate console. A registered instance can be +# handed a real customer's shop by the provisioner, and no amount of care +# afterwards would undo that. +# +# It gets its OWN database and its OWN secrets. It never receives the +# control-plane credentials, the S3 keys, the payment keys or the mail keys +# that the production deploys carry - deliberately, by simply not sending +# them, so nothing on that box can reach anything real. +# +# It holds demo data only, and says so on every page. +# +# It is a public sandbox that happens to run our code. Treat anything on it as +# readable by anyone, because it is. +name: Deploy develop + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-develop + cancel-in-progress: false + +jobs: + deploy: + # Off until the machine exists and DEVELOP_DEPLOY_ENABLED is set to true. + # A workflow that fails on every push teaches people to ignore red marks. + if: vars.DEVELOP_DEPLOY_ENABLED == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + + # Built here rather than on the box: a 2 GB machine running mongod and + # the API should not also be running a gulp build, and building in CI + # means a broken build never reaches the sandbox at all. + # Every language ships in every build now, the unreviewed ones marked + # beta in the menu (frontend/gulpfile.js/config.js). Nothing to switch + # on here: the sandbox shows exactly what an installer shows. + - name: Build the frontend + run: | + npm --prefix frontend install --no-audit --no-fund + cd frontend && npx gulp build + + - name: Check discovery files + run: | + set -e + for f in \ + frontend/public/robots.txt \ + frontend/public/sitemap.xml \ + frontend/public/llms.txt + do + test -s "$f" || { echo "missing or empty after build: $f"; exit 1; } + done + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEVELOP_SSH_KEY }}" > ~/.ssh/develop.pem + chmod 600 ~/.ssh/develop.pem + ssh-keyscan -H "${{ secrets.DEVELOP_HOST }}" >> ~/.ssh/known_hosts + + - name: Sync the app + run: | + # .env is excluded on purpose. The sandbox generates its own secrets + # once and keeps them; overwriting them on every deploy would sign + # out every tester mid-test. + rsync -az --delete \ + --exclude node_modules --exclude .env --exclude uploads \ + -e "ssh -i ~/.ssh/develop.pem" \ + api/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/api/" + rsync -az --delete \ + -e "ssh -i ~/.ssh/develop.pem" \ + frontend/public/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/frontend/public/" + rsync -az \ + -e "ssh -i ~/.ssh/develop.pem" \ + languages/ "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}:~/posnic-develop/languages/" + + - name: Install and restart + run: | + ssh -i ~/.ssh/develop.pem \ + "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}" \ + 'bash -lc "cd ~/posnic-develop/api && npm install --omit=dev --no-audit --no-fund && pm2 reload posnic-develop --update-env || pm2 start server.js --name posnic-develop"' + + - name: Prove it is actually serving + run: | + # A deploy that reports success without checking is how a box sits + # broken for a week while the badge stays green. + # + # Asked of the APP, on the box, over SSH - not through nginx and not + # through Cloudflare. The deploy changed the application; nginx, DNS + # and TLS are separate concerns owned elsewhere, and a job that goes + # red because of one of those is reporting on somebody else's work. + # + # This checked http:// until TLS was set up, at which point nginx + # started answering 301 and every deploy failed on a healthy box - + # the check was measuring the redirect, not the app. + for i in 1 2 3 4 5 6; do + CODE=$(ssh -i ~/.ssh/develop.pem -o StrictHostKeyChecking=no \ + "${{ secrets.DEVELOP_USER }}@${{ secrets.DEVELOP_HOST }}" \ + "curl -s -o /dev/null -m 10 -w '%{http_code}' http://127.0.0.1:3000/public/login.html" 2>/dev/null || echo 000) + echo "attempt $i: $CODE" + if [ "$CODE" = "200" ]; then + echo "the application is serving on the box" + # Reported, never required: this depends on DNS, Cloudflare and a + # certificate, none of which this deploy touched. + PUB=$(curl -s -o /dev/null -m 20 -w '%{http_code}' https://develop.posnic.io/public/login.html || echo 000) + if [ "$PUB" = "200" ]; then + echo "develop.posnic.io is live" + else + echo "::notice::develop.posnic.io answered ${PUB}. The deploy itself succeeded - check DNS, Cloudflare or the certificate." + fi + exit 0 + fi + sleep 10 + done + echo "::error::the application did not answer 200 on the box after the deploy" + exit 1 diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 08e25eeb6..5c0bd2c01 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -62,7 +62,10 @@ jobs: set -e for f in \ frontend/public/dashboard.html \ - frontend/public/login.html + frontend/public/login.html \ + frontend/public/robots.txt \ + frontend/public/sitemap.xml \ + frontend/public/llms.txt do test -s "$f" || { echo "missing or empty after build: $f"; exit 1; } done @@ -93,14 +96,44 @@ jobs: # Keeping it means an instance is a mirror of a known build rather than # a pile of whatever any deploy ever left behind, which is what made the # original failure so hard to see. + # Hashed bundles are PROTECTED from --delete (rsync filter), because a + # page already open in a browser still references the previous hashes - + # deleting them mid-session strips a working shop of its stylesheet + # ("mobile view add category shown and page broken. crashed" was an open + # tab meeting a deploy that had pruned style/dashboard..css). + # The CDN workflow learned this on day one; this one learns it now. + # The mirror principle survives via the retention step below: newest 3 + # per bundle family stay, older ones go. - name: Sync to the tenant instance run: | rsync -az --delete \ + --filter='protect public/script/*.js' \ + --filter='protect public/style/*.css' \ --exclude node_modules --exclude .git --exclude '.gitignore' \ --exclude 'public/all.zip' \ -e "ssh -i ~/.ssh/deploy.pem" \ frontend/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/apps/tenants/app/frontend/" + # Retention: for every hashed bundle family (name..ext), keep the + # newest 3 and remove the rest. An open tab survives at least two more + # deploys; the instance never accumulates unboundedly. + - name: Prune old hashed bundles (keep newest 3 per family) + run: | + ssh -i ~/.ssh/deploy.pem "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" 'bash -s' <<'REMOTE' + set -u + for d in ~/apps/tenants/app/frontend/public/script ~/apps/tenants/app/frontend/public/style; do + [ -d "$d" ] || continue + cd "$d" + ls -1 2>/dev/null | grep -E '^[A-Za-z0-9_-]+\.[0-9a-f]{8}\.(js|css)$' \ + | sed -E 's/\.[0-9a-f]{8}\.(js|css)$/.\1/' | sort -u \ + | while read -r fam; do + base="${fam%.*}"; ext="${fam##*.}" + ls -1t "$base".????????."$ext" 2>/dev/null | tail -n +4 \ + | while read -r old; do rm -f -- "$old" && echo "pruned $d/$old"; done + done + done + REMOTE + # No pm2 restart on purpose: these are static files read from disk per # request, so the tenants pick them up with no interruption to a shop # that is mid-sale. @@ -117,7 +150,7 @@ jobs: # The pages by name; the bundles by hashed pattern (the name # carries a content hash and changes every deploy). - for f in public/dashboard.html public/login.html; do + for f in public/dashboard.html public/login.html public/robots.txt public/sitemap.xml public/llms.txt; do if [ -s "$f" ]; then echo " ok $f"; else echo " MISSING $f"; fail=1; fi done ls public/style/dashboard.*.css >/dev/null 2>&1 && echo " ok hashed dashboard css" || { echo " MISSING hashed dashboard css"; fail=1; } diff --git a/.github/workflows/develop-qa.yml b/.github/workflows/develop-qa.yml new file mode 100644 index 000000000..8bc3f8645 --- /dev/null +++ b/.github/workflows/develop-qa.yml @@ -0,0 +1,71 @@ +# When a change lands on develop, say so on the pull request. +# +# The QA loop only works if somebody knows there is something to test. Left to +# people to remember, "ready for QA" gets set for the first week and then +# never again, and the label stops meaning anything. +# +# This labels the merged PR and comments where to try it, so a tester can find +# work by filtering one label rather than reading the commit log. +name: Develop QA + +on: + pull_request_target: + types: [closed] + branches: [develop] + +# pull_request_target runs with a token that can write to the repository, so it +# is deliberately given the least that allows: labels and a comment, nothing +# that touches code. It never checks the pull request out, which is the thing +# that makes this trigger dangerous. +permissions: + pull-requests: write + issues: write + +jobs: + label: + # Closed is not merged. A PR somebody abandoned should not be announced as + # something to test. + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Mark ready for QA + uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + const common = { owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }; + + await github.rest.issues.addLabels({ ...common, labels: ['ready for QA'] }); + + // A previous round's verdict is about a previous version of the + // change. Leaving it would let a stale "QA passed" travel with a + // commit nobody tested. + for (const stale of ['QA passed', 'QA failed', 'needs rebase']) { + try { + await github.rest.issues.removeLabel({ ...common, name: stale }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + + await github.rest.issues.createComment({ + ...common, + body: [ + 'Merged to `develop`. **Anyone can test this** - you do not need write access.', + '', + 'Try it at https://develop.posnic.io, or run it yourself:', + '', + '```bash', + 'git fetch origin develop && git checkout develop', + 'npm install && npm --prefix api install', + 'npm run dev # then http://localhost:3000', + '```', + '', + 'When you have tested it, say what you did and what happened, and set', + '`QA passed` or `QA failed`. If you cannot set labels, just comment -', + 'a maintainer will.', + '', + 'Reporting that something is broken is as useful as fixing it. It is', + 'better found here than by a shopkeeper.', + ].join('\n'), + }); diff --git a/.github/workflows/notify-site-of-release.yml b/.github/workflows/notify-site-of-release.yml new file mode 100644 index 000000000..1d3ebd1b6 --- /dev/null +++ b/.github/workflows/notify-site-of-release.yml @@ -0,0 +1,65 @@ +# Tell the website when a release is published, so the download page follows. +# +# The download page names the version in about a dozen places, and those names +# come from a manifest in the web-frontend repo that somebody updates by +# running a script. Nobody remembers every time, and the failure is silent - +# every link still works, it just hands new customers an older build than the +# one we shipped. +# +# This fires on `release: published`, which is the moment the decision is made, +# rather than on the tag push that starts the build. A tag exists long before +# the artefacts do, and pointing the download page at a release whose installers +# have not finished uploading is worse than pointing it at the previous one. +# +# Deliberately narrow: pre-releases and drafts are skipped. The site tool +# refuses them anyway - it reads GitHub's own /releases/latest, which never +# returns either - so dispatching for them would only produce a run that fails +# for a reason that is not a fault. +# +# The site also checks daily on its own, so a missed dispatch costs a day +# rather than costing the update. + +name: Tell the site about a published release + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + notify: + # `prerelease` is false for a stable publish; drafts never reach this event. + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: ubuntu-latest + steps: + - name: Dispatch to web-frontend + env: + # A token that can dispatch into the other repo. Without it this step + # is skipped rather than failed: the site's daily check still catches + # up, and a release must never be held up by a notification. + TOKEN: ${{ secrets.SITE_DISPATCH_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "SITE_DISPATCH_TOKEN is not set - skipping." + echo "The site checks for new releases daily, so it will catch up on its own." + exit 0 + fi + + tag="${{ github.event.release.tag_name }}" + echo "telling web-frontend about ${tag:-a new release}" + + code=$(curl -sS -o /tmp/dispatch.out -w '%{http_code}' \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/Posnic/web-frontend/dispatches \ + -d "{\"event_type\":\"stable-release-published\",\"client_payload\":{\"tag\":\"$tag\"}}") + + if [ "$code" != "204" ]; then + echo "dispatch returned HTTP $code" + cat /tmp/dispatch.out || true + echo "The site's daily check will still pick this release up." + exit 1 + fi + echo "dispatched." diff --git a/.github/workflows/publish-apt.yml b/.github/workflows/publish-apt.yml new file mode 100644 index 000000000..1bdde6ff8 --- /dev/null +++ b/.github/workflows/publish-apt.yml @@ -0,0 +1,111 @@ +# packages.posnic.com - the signed APT repository (RELEASE_TRUST_PLAN §4). +# +# Runs when a release is PUBLISHED - not when the draft is assembled - so the +# apt repo can never get ahead of the human decision that makes a release +# public. Everything is dormant until the Linux signing secrets exist; until +# then each run says exactly what is missing and succeeds without publishing. +# +# What it does with the secrets in place: +# 1. downloads the published release's .deb and AppImage, +# 2. signs them (detached .asc) and uploads the signatures back to the +# release beside the artifacts, +# 3. rebuilds the APT repository tree with the new .deb and syncs it to the +# packages bucket that Cloudflare serves as packages.posnic.com. +# +# Secrets/vars it waits for: +# LINUX_GPG_KEY - armored private signing subkey (offline master +# stays on the desk - see the key ceremony notes) +# LINUX_GPG_KEY_ID - fingerprint of that subkey +# PACKAGES_AWS_KEY_ID / PACKAGES_AWS_SECRET - scoped to the bucket only +# vars.PACKAGES_BUCKET - e.g. posnic-packages +name: Publish APT repository + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to (re)publish, e.g. v1.6.0" + required: true + +permissions: + contents: write # uploads .asc signatures back to the release + +jobs: + publish: + runs-on: ubuntu-latest + # secrets are not legal in step-level `if:` - presence is computed here + env: + HAVE_GPG: ${{ secrets.LINUX_GPG_KEY != '' }} + HAVE_BUCKET: ${{ vars.PACKAGES_BUCKET != '' }} + steps: + - uses: actions/checkout@v7 + + - name: Explain and stop when signing is not configured yet + if: env.HAVE_GPG != 'true' + run: | + echo "::warning::Linux signing is not configured (LINUX_GPG_KEY missing) - the apt repo was NOT updated. See RELEASE_TRUST_PLAN.md §4." + + - name: Download the release artifacts + if: env.HAVE_GPG == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + mkdir -p artifacts + gh release download "$TAG" --dir artifacts --pattern '*.deb' --pattern '*.AppImage' + ls -l artifacts + + - name: Import the signing subkey + if: env.HAVE_GPG == 'true' + env: + LINUX_GPG_KEY: ${{ secrets.LINUX_GPG_KEY }} + run: | + set -euo pipefail + echo "$LINUX_GPG_KEY" | gpg --batch --import + gpg --list-secret-keys --keyid-format long + + - name: Sign the artifacts and upload signatures to the release + if: env.HAVE_GPG == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + KEY_ID: ${{ secrets.LINUX_GPG_KEY_ID }} + run: | + set -euo pipefail + cd artifacts + for f in *.deb *.AppImage; do + [ -e "$f" ] || continue + gpg --batch --yes -u "$KEY_ID" --armor --detach-sign "$f" + sha256sum "$f" > "$f.sha256" + done + gh release upload "$TAG" ./*.asc ./*.sha256 --clobber + + - name: Build the APT repository + if: env.HAVE_GPG == 'true' + env: + APT_GPG_KEY_ID: ${{ secrets.LINUX_GPG_KEY_ID }} + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq reprepro + bash scripts/build-apt-repo.sh artifacts/*.deb apt-repo + + - name: Sync to packages.posnic.com + if: env.HAVE_GPG == 'true' && env.HAVE_BUCKET == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.PACKAGES_AWS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PACKAGES_AWS_SECRET }} + AWS_DEFAULT_REGION: ap-south-1 + BUCKET: ${{ vars.PACKAGES_BUCKET }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + # pool/ is content-addressed and append-only; dists/ metadata must + # replace atomically enough that apt never sees a half-written index, + # so the pool syncs first and the signed index last. + aws s3 sync apt-repo/pool "s3://$BUCKET/apt/pool" + aws s3 sync apt-repo/dists "s3://$BUCKET/apt/dists" --delete + aws s3 cp apt-repo/gpg.key "s3://$BUCKET/gpg.key" + echo "packages.posnic.com updated for $TAG" diff --git a/.github/workflows/publish-snap.yml b/.github/workflows/publish-snap.yml new file mode 100644 index 000000000..da9c57342 --- /dev/null +++ b/.github/workflows/publish-snap.yml @@ -0,0 +1,64 @@ +# Snap Store publication (RELEASE_TRUST_PLAN §4, phase approved 2026-08-27). +# +# Same shape and same reasoning as publish-apt.yml: runs only when a release +# is PUBLISHED - the human decision - and stays dormant, loudly, until the +# store credential exists. The .snap itself is built by the normal release +# matrix (electron-builder's snap target); this job only moves it to the +# store, where Canonical's infrastructure handles trust and updates. +# +# Secrets it waits for: +# SNAPCRAFT_STORE_CREDENTIALS - `snapcraft export-login` output for the +# account that registered the name `posnic` +name: Publish to Snap Store + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to (re)publish, e.g. v1.6.0" + required: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + # secrets are not legal in step-level `if:` - presence is computed here + env: + HAVE_SNAP_LOGIN: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS != '' }} + steps: + - name: Explain and stop when the store login is not configured yet + if: env.HAVE_SNAP_LOGIN != 'true' + run: | + echo "::warning::Snap publishing is not configured (SNAPCRAFT_STORE_CREDENTIALS missing) - the store was NOT updated. See RELEASE_TRUST_PLAN.md Appendix D." + + - name: Download the release .snap + if: env.HAVE_SNAP_LOGIN == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh release download "$TAG" --repo "$REPO" --pattern '*.snap' --dir . + ls -l ./*.snap + + - name: Upload to the Snap Store + if: env.HAVE_SNAP_LOGIN == 'true' + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + set -euo pipefail + sudo snap install snapcraft --classic + # Betas land in the beta channel; stable tags in stable. A visitor + # running `snap install posnic` only ever gets the stable channel. + TAG="${{ inputs.tag || github.event.release.tag_name }}" + case "$TAG" in + *beta*) CHANNEL=beta ;; + *) CHANNEL=stable ;; + esac + snapcraft upload ./*.snap --release "$CHANNEL" + echo "published to the $CHANNEL channel" diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml new file mode 100644 index 000000000..21ace3dc6 --- /dev/null +++ b/.github/workflows/release-promote.yml @@ -0,0 +1,96 @@ +# Open the pull request that promotes develop to main. +# +# The release itself is tag-driven and unchanged: tag main, release.yml builds +# and publishes. This is the step before that - turning "develop has some +# tested work on it" into one reviewable thing the owner can read and merge. +# +# Run by hand, never on a schedule. Deciding that what is on develop is worth +# releasing is a judgement about whether it has been tested enough, and that +# judgement is the owner's. A timer would only ever guess. +name: Promote develop to main + +on: + workflow_dispatch: + inputs: + note: + description: "Anything to say about this release (optional)" + required: false + +permissions: + contents: read + pull-requests: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: What would this release? + id: what + run: | + git fetch origin main develop + AHEAD=$(git rev-list --count origin/main..origin/develop) + echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" + if [ "$AHEAD" = "0" ]; then + echo "::notice::develop has nothing main does not already have." + exit 0 + fi + + # The subjects, not the hashes. Somebody deciding whether to release + # wants to read what changed, and a list of shas is not that. + git log --no-merges --format='- %s' origin/main..origin/develop > /tmp/commits.txt + echo "$AHEAD commit(s) to promote:" + cat /tmp/commits.txt + + # Anyone whose work is in this release, so the release notes are not + # the first time they see their name. + git log --format='%an' origin/main..origin/develop \ + | sort -u | grep -v 'github-actions' > /tmp/authors.txt || true + + - name: Open the release pull request + if: steps.what.outputs.ahead != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NOTE: ${{ inputs.note }} + run: | + { + echo "Promotes **${{ steps.what.outputs.ahead }} commit(s)** from \`develop\` to \`main\`." + echo + if [ -n "$NOTE" ]; then echo "$NOTE"; echo; fi + echo "### What is in it" + echo + cat /tmp/commits.txt + echo + echo "### Contributors in this release" + echo + sed 's/^/- /' /tmp/authors.txt + echo + echo "### Before merging" + echo + echo "- [ ] Everything here has been tried on develop.posnic.io or locally" + echo "- [ ] Anything labelled \`QA failed\` has been fixed or reverted" + echo "- [ ] CI is green" + echo + echo "Merging this does **not** release anything. Tag main afterwards:" + echo + echo '```bash' + echo 'git checkout main && git pull' + echo 'git tag vX.Y.Z && git push origin vX.Y.Z' + echo '```' + echo + echo "That is what builds the installers and publishes the release." + } > /tmp/body.md + + if gh pr list --base main --head develop --state open --json number --jq 'length' | grep -q '^0$'; then + gh pr create --base main --head develop \ + --title "Release: promote develop to main" --body-file /tmp/body.md + else + # Reusing the open one keeps the discussion in a single place + # rather than starting a second thread about the same release. + NUM=$(gh pr list --base main --head develop --state open --json number --jq '.[0].number') + gh pr edit "$NUM" --body-file /tmp/body.md + echo "updated the open release pull request #$NUM" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ec4ec426..c2ac28958 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ -# Tagged public releases for Windows, macOS and Linux. +# Tagged public releases for macOS and Linux. +# Windows is signed on a card and published separately - see the matrix below. # # git tag v1.4.0 && git push origin v1.4.0 # @@ -70,11 +71,32 @@ jobs: strategy: fail-fast: false matrix: + # WINDOWS IS NOT BUILT HERE, on purpose. + # + # Its code signing key lives on a Certum card - CA/Browser Forum rules + # have required code signing keys to sit on certified hardware since + # June 2023, so there is no .pfx and no secret to configure - and a + # GitHub-hosted runner has no card reader. A Windows build produced here + # could only ever be UNSIGNED, and shipping that is worse than shipping + # nothing: every customer sees "Unknown publisher". + # + # It is built and published from a desk instead: + # npm run build (with POSNIC_SIGN_SHA1 set) + # npm run release:windows -- + # + # That command refuses to publish anything unsigned, untimestamped, or + # whose latest.yml does not match the installer beside it. include: - - { os: windows-latest, name: Windows, flag: '--win --x64' } - { os: macos-latest, name: macOS, flag: '--mac' } - { os: ubuntu-latest, name: Linux, flag: '--linux --x64' } runs-on: ${{ matrix.os }} + # secrets cannot appear in step-level `if:` expressions (GitHub refuses + # the whole file, silently, as a 0-second startup failure) - so their + # PRESENCE is computed here, where secrets are legal, and the steps + # branch on these strings instead. + env: + HAVE_MAC_CERT: ${{ secrets.MAC_CERT_P12 != '' }} + HAVE_NOTARY: ${{ secrets.ASC_API_KEY_P8 != '' }} steps: - uses: actions/checkout@v7 with: @@ -108,7 +130,7 @@ jobs: # MongoDB ships inside the installer and its binaries are not in git - # they are 90 MB of someone else's build output. A developer gets them - # from download-mongodb.bat; a runner has to fetch them itself, and + # from src/download-mongodb.bat; a runner has to fetch them itself, and # without this the packaged app has no database to start. # The database, for whichever machine this is. # @@ -122,8 +144,8 @@ jobs: shell: bash run: | set -euo pipefail - VERSION=$(grep -oE 'set MONGODB_VERSION=[0-9.]+' download-mongodb.bat | cut -d= -f2) - echo "MongoDB $VERSION, the version download-mongodb.bat pins" + VERSION=$(grep -oE 'set MONGODB_VERSION=[0-9.]+' src/download-mongodb.bat | cut -d= -f2) + echo "MongoDB $VERSION, the version src/download-mongodb.bat pins" fetch_tgz() { # url, destination bin directory curl -fsSL -o mongo.tgz "$1" @@ -179,22 +201,78 @@ jobs: - name: Prepare everything the installer bundles run: npm run prepare:bundle + # macOS signing + notarization (RELEASE_TRUST_PLAN §3). Everything here + # is DORMANT until the Apple secrets exist: without them the build is + # exactly what it was before - unsigned, loudly labelled as such - and + # with them electron-builder signs with the Developer ID certificate, + # submits to Apple's notary service and staples the ticket. Unlike the + # Windows key (hardware card, desk only), Apple credentials are files, + # so CI is the right place for them. + - name: Stage Apple notarization credentials + if: matrix.name == 'macOS' && env.HAVE_NOTARY == 'true' + env: + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + run: | + echo "$ASC_API_KEY_P8" > "$RUNNER_TEMP/asc_api_key.p8" + echo "APPLE_API_KEY=$RUNNER_TEMP/asc_api_key.p8" >> "$GITHUB_ENV" + # electron-builder would publish to the release by itself; it is turned # off here so that a half-finished matrix never leaves a release with # only one platform attached. - name: Build - run: npx electron-builder ${{ matrix.flag }} --publish never + run: > + npx electron-builder ${{ matrix.flag }} --publish never + ${{ matrix.name == 'macOS' && env.HAVE_NOTARY == 'true' && '--config.mac.notarize=true' || '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Windows does not include the Visual C++ runtime. A binary that needs it - # and cannot find one runs on every machine we test and dies with - # 0xC0000135 on a customer's clean install, before it can log anything. - # Checked here because that is the last point where the answer is still - # cheap. - - name: Native binaries can load on a clean Windows - if: matrix.name == 'Windows' - run: node scripts/check-native-runtime.js + CSC_LINK: ${{ matrix.name == 'macOS' && secrets.MAC_CERT_P12 || '' }} + CSC_KEY_PASSWORD: ${{ matrix.name == 'macOS' && secrets.MAC_CERT_PASSWORD || '' }} + APPLE_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.ASC_API_ISSUER }} + + # The gate that makes an un-notarized dmg impossible to publish once the + # credentials exist - the same promise scripts/release-windows.js makes + # for the exe: signed and accepted by the OS's own assessor, or no build. + - name: Verify macOS signature and notarization + if: matrix.name == 'macOS' && env.HAVE_MAC_CERT == 'true' + env: + APPLE_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.ASC_API_ISSUER }} + run: | + set -euo pipefail + APP=$(find dist/mac* -maxdepth 1 -name '*.app' | head -1) + codesign --verify --deep --strict --verbose=2 "$APP" + if [ "$HAVE_NOTARY" = "true" ]; then + spctl -a -vv "$APP" + # electron-builder notarizes the APP while packing - the dmg is + # born afterwards, signed but ticketless, and Gatekeeper's + # assessment of the container then says "Unnotarized Developer + # ID". The container earns its own ticket here, stapled so an + # offline install verifies too. + for DMG in dist/*.dmg; do + xcrun notarytool submit "$DMG" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait + xcrun stapler staple "$DMG" + spctl -a -t open -vv --context context:primary-signature "$DMG" + done + echo "macOS artifacts are signed, notarized and Gatekeeper-approved" + else + echo "::warning::signed but NOT notarized - ASC API key not configured yet (RELEASE_TRUST_PLAN Appendix A2)" + fi + + - name: Warn when the macOS build ships unsigned + if: matrix.name == 'macOS' && env.HAVE_MAC_CERT != 'true' + run: echo "::warning::macOS build is UNSIGNED - Apple secrets (MAC_CERT_P12 etc.) are not configured yet. See RELEASE_TRUST_PLAN.md §3." + + # The clean-Windows runtime check moved to scripts/release-windows.js + # along with the Windows build itself. Left here it would have been a step + # that never runs - `if: matrix.name == 'Windows'` against a matrix with + # no Windows in it - which reads as coverage and is not. + + # One CycloneDX document per downloadable package. Each file carries the + # package SHA-256, exact source commit, locked API graph, Electron runtime + # and the separately licensed MongoDB server bundled with the package. + - name: Generate artifact SBOMs + run: npm run release:sbom - name: Upload installers uses: actions/upload-artifact@v7 @@ -206,6 +284,12 @@ jobs: dist/*.zip dist/*.AppImage dist/*.deb + # Built on every Linux run (package.json declares the snap target) + # and then dropped here, so publish-snap.yml failed on every release + # trying to download a .snap the release never carried - a red X + # beside a store that was silently never updated. + dist/*.snap + dist/*.cdx.json # The update manifest, without which auto-update cannot work at all: # electron-updater fetches latest.yml - latest-mac.yml, latest-linux.yml # on the others - to learn what the newest version is. It was built on @@ -221,6 +305,11 @@ jobs: name: Publish release needs: build runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write env: ASSET_SIGNING_KEY: ${{ secrets.POSNIC_ASSET_SIGNING_KEY }} steps: @@ -254,8 +343,8 @@ jobs: run: | mkdir -p release find artifacts -type f \( -name '*.exe' -o -name '*.dmg' -o -name '*.zip' \ - -o -name '*.AppImage' -o -name '*.deb' -o -name 'latest*.yml' \ - -o -name '*.blockmap' \) -exec mv {} release/ \; + -o -name '*.AppImage' -o -name '*.deb' -o -name '*.snap' -o -name 'latest*.yml' \ + -o -name '*.blockmap' -o -name '*.cdx.json' \) -exec mv {} release/ \; # The machine-readable statement of what this release IS (version, # channel, schema + sync-protocol, oldest supported client). Fleet # tooling and the gateway read this instead of parsing file names. @@ -268,6 +357,95 @@ jobs: sha256sum $(ls | grep -vE 'latest.*\.yml$|\.blockmap$|release-manifest\.json$|asset-manifest\.json$') > SHA256SUMS.txt echo "Publishing:"; ls -lh + # Bind every package and package SBOM named in SHA256SUMS.txt to this + # repository, workflow, ref and commit. The action uses a short-lived + # GitHub OIDC identity; no long-lived signing key is stored in secrets. + - name: Attest release artifact provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: release/SHA256SUMS.txt + + # The notes serve two kinds of tag: the beta channel builds from here + # too (this workflow triggers on every v*), and a stable release must + # not greet customers with "this is an early public build". The beta + # warning is written only when the tag says beta; everything below it + # is true of both. + - name: Compose the release notes + env: + TAG: ${{ inputs.tag || github.ref_name }} + run: | + mkdir -p release + case "$TAG" in + *beta*|*alpha*|*rc*) + cat > release/NOTES.md <<'POSNIC_BETA' + ## Beta - please read before installing + + This is an early public build. It works, it is tested, and it is **not + finished**. Expect rough edges, and please report them. + POSNIC_BETA + ;; + *) + cat > release/NOTES.md <> release/NOTES.md <<'POSNIC_COMMON' + + ### About the installers + + - **Windows** is signed and timestamped. The installer names its + publisher, and no warning should appear. + - **macOS is signed and notarized** (Developer ID, checked by Apple's + notary service, ticket stapled) - Gatekeeper opens it like any app + from the App Store era. If you see a publisher warning, you are not + holding our build. + - **Linux**: install through the signed APT repository at + [packages.posnic.com](https://packages.posnic.com) and `apt` verifies + every package and every update from then on. The direct `.deb` and + AppImage remain, with checksums and detached GPG signatures beside them. + + Whatever you download, `SHA256SUMS.txt` on this page lists what the + bytes should be - and the release is provenance-attested, so GitHub can + prove it was built by this repository's own workflow. + + To check that for yourself, with the GitHub CLI installed: + + `gh attestation verify --repo Posnic/POS` + + That confirms the repository, workflow, ref and commit recorded for the + file. It is not a code signature, an independent audit, or proof that + the software is safe. It answers one question: did this file come out + of our build. + + Automatic updates check both the hash they were promised and, on + Windows and macOS, the code signature of what actually arrived - + an update that is not ours does not install. + + ### What to expect + + - Your data stays on your own computer. No account, and no internet needed. + - **Take your own backups.** *Backup Manager -> Backup Now*, and keep a copy on + another machine or a USB drive. This software holds your sales. + - Printing, scales and cash drawers are tested, but not on every model. Test + your hardware from *Config -> Device Setup* before a trading day. + - First launch takes a few minutes while the database is prepared. Later + launches take seconds. + + ### If something goes wrong + + Open an issue with what you did, what happened, and your log file attached - + `%APPDATA%\posnic\app.log` on Windows. For an urgent problem where a shop + cannot trade: **+91 94941 11161**, answered any hour. + + Free and open source under AGPL-3.0-only. Posnic Cloud (sync, off-site backups, + remote dashboard) is a separate paid service, and nothing here needs it. + POSNIC_COMMON + echo "--- release notes ---"; cat release/NOTES.md + - uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag || github.ref_name }} @@ -283,44 +461,4 @@ jobs: # should be offered to installed copies - which is what verifying the # update path requires. prerelease: ${{ vars.POSNIC_PRERELEASE != 'no' }} - body: | - ## Beta - please read before installing - - This is an early public build. It works, it is tested, and it is **not - finished**. Expect rough edges, and please report them. - - ### The installers are not signed yet - - A code signing certificate is on the way. Until it arrives: - - - **Windows** will say *"Windows protected your PC"* and name an unknown - publisher. Choose **More info -> Run anyway**. - - **macOS** will refuse to open it. Right-click the app -> **Open**, or use - *System Settings -> Privacy & Security -> Open Anyway*. - - That warning is accurate. You are trusting the download rather than a - certificate, so **check `SHA256SUMS.txt` against your file before installing**, - and download only from this releases page. - - Automatic updates are not signature-verified in this build either. Posnic - checks the download against the hash it was told to expect, but nothing yet - proves that hash came from us. - - ### What to expect - - - Your data stays on your own computer. No account, and no internet needed. - - **Take your own backups.** *Backup Manager -> Backup Now*, and keep a copy on - another machine or a USB drive. This is a beta holding your sales. - - Printing, scales and cash drawers are tested, but not on every model. Test - your hardware from *Config -> Device Setup* before a trading day. - - First launch takes a few minutes while the database is prepared. Later - launches take seconds. - - ### If something goes wrong - - Open an issue with what you did, what happened, and your log file attached - - `%APPDATA%\posnic\app.log` on Windows. For an urgent problem where a shop - cannot trade: **+91 94941 11161**, answered any hour. - - Free and open source under AGPL-3.0-only. Posnic Cloud (sync, off-site backups, - remote dashboard) is a separate paid service, and nothing here needs it. + body_path: release/NOTES.md diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..6947c578a --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,50 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [develop] + schedule: + - cron: '31 4 * * 3' + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Supply-chain security analysis + if: github.event.repository.private == false + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + checks: read + contents: read + id-token: write + issues: read + pull-requests: read + security-events: write + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Retain the SARIF result for diagnosis + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: openssf-scorecard-sarif + path: results.sarif + retention-days: 5 + + - name: Upload findings to code scanning + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: results.sarif diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml new file mode 100644 index 000000000..25ffaf03e --- /dev/null +++ b/.github/workflows/sync-main-to-develop.yml @@ -0,0 +1,83 @@ +# Keep develop from drifting behind main. +# +# The normal direction is develop -> main: work lands on develop, gets tested, +# and the owner promotes it. main only moves ahead of develop when something +# skipped that path - a hotfix applied directly, or the merge commit a release +# creates. Left alone, develop then silently lacks a fix that is already live, +# and the next contributor branches from a tree that is missing it. +# +# This opens a pull request rather than pushing. Both branches are restricted to +# the owner on purpose, and a workflow that force-pushed past that would make +# the protection a decoration. The owner stays the only thing that merges; +# this only notices and asks. +name: Sync main to develop + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-develop + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Is develop actually behind? + id: check + run: | + git fetch origin main develop + # Commits on main that develop does not have. Zero is the normal + # case - after a release develop already contains everything. + BEHIND=$(git rev-list --count origin/develop..origin/main) + echo "behind=$BEHIND" >> "$GITHUB_OUTPUT" + echo "develop is $BEHIND commit(s) behind main" + + - name: Open or update the sync pull request + if: steps.check.outputs.behind != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="sync/main-to-develop" + + # A real merge, not a reset: develop may carry work main has never + # seen, and resetting it to main would delete that silently. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" origin/develop + + if git merge --no-edit origin/main; then + CONFLICT="" + else + # Conflicts are left in the branch deliberately. A person has to + # resolve them, and hiding that by aborting would leave develop + # quietly behind with nothing to show for it. + git merge --abort || true + git checkout -B "$BRANCH" origin/main + CONFLICT=" (main had conflicts with develop - this branch is main, so review carefully)" + fi + + git push -f origin "$BRANCH" + + BODY="main has moved ahead of develop by ${{ steps.check.outputs.behind }} commit(s)${CONFLICT}. + + That normally means a hotfix went straight to main, or this is the merge a release created. Either way develop should pick it up, or the next contributor branches from a tree that is missing a fix which is already live. + + Nothing here is new work - it is main's own commits." + + if gh pr list --head "$BRANCH" --state open --json number --jq 'length' | grep -q '^0$'; then + gh pr create --base develop --head "$BRANCH" \ + --title "Sync main into develop" --body "$BODY" --label "area: contributor" + else + echo "a sync pull request is already open" + fi diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml new file mode 100644 index 000000000..f0cabb484 --- /dev/null +++ b/.github/workflows/translations.yml @@ -0,0 +1,89 @@ +# Checks a translation pull request the way a maintainer would, but instantly. +# +# A translation PR needs almost none of the repository's CI - no Electron, no +# MongoDB, no packaging. It needs three questions answered, and answered fast +# enough that a first-time contributor is not left guessing: +# +# is the file still valid JSON +# did the encoding survive the round trip +# did anything outside the language files change +# +# The encoding question is the one a human reviewer cannot answer. Nine Tamil +# strings shipped to the sale screen as UTF-8-saved-as-cp1252 and were live for +# months, because mojibake is invisible to anyone who does not read the script. +name: Translations + +on: + pull_request: + paths: + - 'languages/**' + - 'frontend/gulpfile.js/config.js' + - 'tests/tools/i18n-coverage.js' + +# A contributor pushing three times in a row should not queue three runs. +concurrency: + group: translations-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + # To ask the API which files the PR touches, rather than trying to work it + # out from a shallow clone that does not contain both ends of the diff. + pull-requests: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + + # No npm install. These checks read files; pulling a dependency tree to + # do that would make the run slower than the contribution. + - name: Language files are valid + run: node tests/tools/check-translations.js + + - name: Coverage + run: node tests/tools/i18n-coverage.js + + # ADVISORY ONLY. This step must never fail a run, and it has to be written + # that way rather than intended that way. + # + # It used to read the diff with git, from a checkout that is both shallow + # and - on a pull_request event - the MERGE commit, so the head sha was + # simply not in local history. `git diff BASE HEAD` said "bad object", the + # HEAD~1 fallback said "unknown revision" on a depth-1 clone, and `bash -e` + # turned an advisory note into exit 128. Every translation PR would have + # gone red with a git error, which is exactly the experience the rest of + # this file exists to avoid. + # + # The API knows the file list without any history, so ask it. + - name: Nothing outside the language files changed + continue-on-error: true + env: + BASE: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + # A translation PR that also edits application code is not a + # translation PR. Saying so here is quicker and kinder than a + # reviewer noticing on the third read. + git fetch --no-tags --depth=1 origin "$BASE" >/dev/null 2>&1 || true + CHANGED=$(git diff --name-only "$BASE" HEAD || git diff --name-only HEAD~1 HEAD) + CHANGED=$(gh pr diff "$PR" --name-only) || { + echo "::notice::Could not read the file list; skipping this check." + exit 0 + } + echo "Changed:"; echo "$CHANGED" | sed 's/^/ /' + UNEXPECTED=$(echo "$CHANGED" | grep -v -E '^(languages/|docs/|\.github/)' \ + | grep -v -E '^(frontend/gulpfile\.js/config\.js|tests/tools/[a-z-]+\.js|tests/i18n\.test\.js)$' \ + || true) + if [ -n "$UNEXPECTED" ]; then + echo "" + echo "::warning::This PR changes files outside languages/. That may be" + echo "fine, but it needs a maintainer rather than a translation review:" + echo "$UNEXPECTED" | sed 's/^/ /' + fi diff --git a/.gitignore b/.gitignore index 78da4af2e..6845fced7 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,12 @@ builds/brand-seed/ *-debug.jpg screenshot-*.png asset-signing-key.private.pem +.demo-review/ + +# a dev till pointed at the wrong anchor once wrote its database here +src/mongodb/ + +# Translator worksheets (tests/tools/i18n-coverage.js --worksheet). A working +# file on its way to a person and back; the translations belong in +# frontend/languages/.json once merged, not in a half-filled sheet. +*-to-translate.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..f841cf21a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +npx lint-staged diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 000000000..ad557e522 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,30 @@ +cff-version: 1.2.0 +message: >- + If you use Posnic POS in research, cite the exact release or commit you + evaluated together with these project metadata. +title: Posnic POS +type: software +abstract: >- + Posnic POS is zero-price, offline-first open source POS and billing software + for retail shops and restaurants. Posnic's own source is AGPL-3.0-only. + Release packages include separately licensed components, including MongoDB + Community Server under SSPL-1.0. Its primary API and database run on the shop + computer or on a server you control, with optional external payment, cloud, + and integration dependencies. +authors: + - name: Posnic Innovations Private Limited +repository-code: https://github.com/Posnic/POS +url: https://posnic.io/ +license: AGPL-3.0-only +version: 1.6.1 +keywords: + - point of sale + - POS software + - open source POS + - offline POS + - online/offline POS + - offline-first + - retail + - restaurant + - inventory management + - billing software diff --git a/README.md b/README.md index 4a1c85a82..4e62680dd 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@
-Posnic +Posnic # Posnic -**Point of sale that never goes down.** +**Free open source POS and billing software with public source, local checkout +and online/offline workflows.** -A complete, offline-first POS for shops, restaurants and pharmacies. -Your data lives on your own computer. No internet required, ever. +An offline-first POS for retail shops and restaurants. The primary API and +database run on the shop computer or on a server you control; electronic +payments, optional cloud services, downloads and integrations can still need a +network. + +Posnic's own source is AGPL-3.0-only. Release packages also bundle separately +licensed components, including MongoDB Community Server under SSPL-1.0. Review +the [package notices](THIRD-PARTY-NOTICES.md) and +[reproduced package evidence](https://posnic.com/assets/posnic-package-license-evidence.json) +before making a package-level licence statement. [![CI](https://github.com/Posnic/POS/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Posnic/POS/actions/workflows/ci.yml) [![Release](https://github.com/Posnic/POS/actions/workflows/release.yml/badge.svg)](https://github.com/Posnic/POS/actions/workflows/release.yml) @@ -15,29 +24,75 @@ Your data lives on your own computer. No internet required, ever. [![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest) [![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests) [![Coverage](https://img.shields.io/badge/coverage-66%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests) -[![API](https://img.shields.io/badge/REST%20API-571%20endpoints-blue)](docs/API.md) -[![Licence](https://img.shields.io/badge/licence-AGPL--3.0-blue)](LICENSE) +[![API](https://img.shields.io/badge/REST%20API-608%20endpoints-blue)](docs/API.md) +[![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE) +[![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md) [![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest) ### [⬇ Download for Windows](https://github.com/Posnic/POS/releases/latest) · [macOS](https://github.com/Posnic/POS/releases/latest) · [Linux](https://github.com/Posnic/POS/releases/latest) -[User guide](docs/USER_GUIDE.md) · [Developer guide](docs/DEVELOPMENT.md) · [Architecture](docs/ARCHITECTURE.md) · [API](docs/API.md) · [Discussions](https://github.com/Posnic/POS/discussions) +### [▶ Try the live demo](https://demo.posnic.io) — nothing to install, resets on the hour + +Log in as `admin` / `admin`, `manager` / `manager` or `cashier` / `cashier` — +the login page has one-tap buttons for all three. It is a real supermarket +shop with a week of sample trading, running the same code as this repository; +ring up sales, break things freely, and the whole shop is restored on the +hour. Outbound email, SMS and password changes are switched off there. + +[Website](https://posnic.io/) · [Product facts](https://posnic.com/posnic-facts) · [Package evidence](https://posnic.com/assets/posnic-package-license-evidence.json) · [CodeMeta metadata](codemeta.json) · [Citation metadata](CITATION.cff) · [Roadmap](docs/ROADMAP.md) · [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) · [User guide](docs/USER_GUIDE.md) · [Developer guide](docs/DEVELOPMENT.md) · [Architecture](docs/ARCHITECTURE.md) · [API](docs/API.md) · [Discussions](https://github.com/Posnic/POS/discussions)
--- -## Why Posnic +## Posnic in use + +![A completed cash sale in Posnic v1.3.0](docs/images/offline-sale-v1-3-0.png) -Most point-of-sale software stops working when the internet does. Posnic runs -its database and its API on the till itself, so a dropped connection is not an -outage — it is nothing at all. A shop can unplug the router and keep selling. +This synthetic cash sale was completed and reopened during the bounded +[offline workflow verification](https://posnic.com/posnic-facts). The published +test notes state what was blocked, what passed and what the result does not +prove. -- **Free forever, and complete.** Not a trial, not a crippled edition. A shop - can run its entire business on it without paying anyone. -- **Your data stays yours.** It is on your machine. No account, no signup, no - telemetry. See [PRIVACY.md](PRIVACY.md). -- **Open source under AGPL-3.0-only.** Read it, change it, self-host it, fork it. +## Why Posnic + +Posnic runs its primary database and API on the till itself. In a bounded +v1.3.0 Windows test, one synthetic cash sale completed and reopened while +external hosts were blocked inside Electron. This does not prove an +operating-system-wide outage, complete shift, payment-terminal path, power-loss +recovery or every workflow. Review the +[versioned evidence and limitations](https://posnic.com/posnic-facts). + +- **Free local edition.** The v1.3.0 desktop packages have no trial clock and + have a zero software price. Posnic's own source is AGPL-3.0-only; packaged + dependencies retain their own licences. Hardware, support, backups, optional + services and downtime can still create operating costs. +- **Local data path.** The local edition needs no Posnic account, and its + published privacy policy says it sends no analytics or telemetry to Posnic. + See [PRIVACY.md](docs/PRIVACY.md). +- **Posnic source under AGPL-3.0-only.** Read it, change it, self-host it and + fork it under the licence terms. Review each bundled component separately. + +### Using Posnic in a real business or pilot? + +Share a bounded +[deployment evidence report](https://github.com/Posnic/POS/issues/new?template=deployment_evidence.yml), +including failures, workarounds, or why a trial stopped. The form records the +exact version, relationship, observation window, workflows and limitations while +letting you refuse marketing reuse. Read the +[adoption evidence policy](docs/ADOPTION_EVIDENCE.md) before posting private or +production information. + +### Reviewing Posnic independently? + +Use the public [review brief](https://posnic.com/assets/posnic-independent-review-brief.txt) +and [24-control protocol](https://posnic.com/assets/posnic-independent-review-protocol.csv) +with the exact release, package filename and SHA-256 you tested. Review access +does not require payment, positive coverage, a backlink or advance approval. +Publish failures, conflicts and untested areas beside successful results. An +independent evaluator can submit a bounded public result through the +[deployment evidence form](https://github.com/Posnic/POS/issues/new?template=deployment_evidence.yml) +without granting marketing reuse. ## Features @@ -48,17 +103,38 @@ outage — it is nothing at all. A shop can unplug the router and keep selling. | **Customers** | Customer accounts, categories with their own pricing, outstanding balances | | **Tax** | GST invoices, IGST and CGST/SGST, HSN codes, GST reports for filing | | **Restaurants** | Kitchen order tickets, table management, kiosk and customer displays | -| **Hardware** | Thermal printers, barcode scanners, cash drawers, weighing scales, second displays | +| **Hardware** | Documented paths for thermal printers, barcode scanners, cash drawers, weighing scales and second displays; verify the exact device in the [hardware matrix](docs/HARDWARE_MATRIX.md) | | **Reports** | Sales, purchases, inventory, expenses, profit, staff activity — all exportable | | **Branches** | Multiple outlets, per-branch stock, staff roles and permissions | ## Install -Download the installer for your platform from +Posnic installs two ways, both free and both the same AGPL-3.0 software. +**Most shops want the desktop app.** + +| | **Desktop** | **Your own server** | +|---|---|---| +| Install | Download and run | One command on Ubuntu | +| Used from | That computer | A browser on any till, tablet or phone on the network | +| Data lives | That computer | Your server | +| Needs internet | No | No — your own network is enough | +| Somebody maintains it | No | **You** — updates, backups, certificate | +| Sync between shops | Posnic Cloud | Posnic Cloud | + +A server is not a better desktop; it is a machine somebody has to look after. If +one person rings up sales on one computer, the desktop app is the right answer +and always will be. + +### Desktop + +Download the package for your platform from **[the latest release](https://github.com/Posnic/POS/releases/latest)**, run it, -and follow the wizard. Nothing else to install — the database ships inside. +and follow the wizard. Current v1.6.1 packages include MongoDB Community Server +under its separate SSPL-1.0 licence, so no separate database install is needed +for the bundled setup. See [third-party notices](THIRD-PARTY-NOTICES.md). -Verify your download against `SHA256SUMS.txt` in the release. +Verify your download against `SHA256SUMS.txt`. For releases that provide an +artifact-bound SBOM and provenance, follow the [release verification guide](docs/VERIFY_RELEASE.md). > **Windows** may warn that the publisher is unrecognised: *More info* → *Run > anyway*. **macOS**: *System Settings → Privacy & Security → Open Anyway*. @@ -67,6 +143,35 @@ Verify your download against `SHA256SUMS.txt` in the release. First launch takes a few minutes while it sets up its database. After that, seconds. Full walkthrough in the **[user guide](docs/USER_GUIDE.md)**. +### Your own server + +Ubuntu 24.04, 2 GB of memory, 8 GB of disk. A 2 GB virtual machine from any +provider runs a single shop comfortably. + +```bash +curl -fsSL https://raw.githubusercontent.com/Posnic/POS/main/scripts/install-server.sh -o install-server.sh +less install-server.sh # read it first - you are about to run it as root +sudo bash install-server.sh +``` + +It installs Node.js 22, MongoDB 8 and Posnic into `/opt/posnic`, generates that +machine's own secrets, and runs it under systemd so it survives a reboot. When +it finishes it prints the address to open. Re-running it updates Posnic and +leaves your secrets and your data alone. + +Three things it cannot do for you, none of them optional on a shop taking real +money: **get a certificate** (without one, passwords cross the network in the +clear), **keep port 27017 off the internet**, and **restore a backup once** to +prove it is a backup rather than a file. + +**Self-hosting does not include sync between tills or branches.** That is +[Posnic Cloud](https://posnic.com/pricing.html). A self-hosted Posnic is one +database several people use at once — which is what most single-shop setups +actually want — not several databases kept in step. + +Full guide, including installing by hand on other systems: +**[docs/SELF_HOSTING.md](docs/SELF_HOSTING.md)**. + ## Build from source ```bash @@ -77,9 +182,10 @@ npm --prefix api install npm start ``` -Needs Node 22.12+. You do **not** need to install MongoDB — Posnic brings its own -and picks a port derived from the app name so it never collides with a database -you already run. +Needs Node 22.12+. The build tooling fetches MongoDB Community Server 7.0.14 and +the desktop process chooses a port derived from the app name to reduce collision +risk. Review [the architecture](docs/ARCHITECTURE.md) and package licences before +building or distributing an artifact. ```bash npm run build # Windows installer @@ -87,66 +193,82 @@ npm run build:mac npm run build:linux ``` -See the **[developer guide](docs/DEVELOPMENT.md)** for tests, linting and +New contributors should start with the +**[contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md)**. See the +**[developer guide](docs/DEVELOPMENT.md)** for deeper tests, linting and conventions. ## Editions -The desktop application is free and open source. **Posnic Cloud** is a paid -service for shops that want more than one till. +The desktop packages have a zero software price and no trial clock. Posnic's own +source is AGPL-3.0-only, while bundled components keep their separate licences. +**Posnic Cloud** is a paid service for shops that want more than one till. | | Posnic (this repo) | Posnic Cloud | |---|---|---| -| Full POS, offline, unlimited items and sales | ✅ | ✅ | +| Selected local workflows and local operational records | ✅ | ✅ | | Local database and backups | ✅ | ✅ | -| Hardware: printers, scanners, drawers, scales | ✅ | ✅ | +| Documented printer, scanner, drawer and scale paths | ✅ | ✅ | | GST invoicing and reports | ✅ | ✅ | +| Runs on your own server, used from a browser | ✅ | ✅ | | Sync across tills and branches | | ✅ | | Off-site backups, remote dashboard | | ✅ | | Installer under your own brand | | ✅ | +Both ways of running Posnic — [desktop and your own server](#install) — sit in +the left column. Neither is a trial and neither expires. + **We do not move features from the left column to the right.** What is free today stays free. Cloud has to earn its price by being useful, not by making -the free edition worse. This is written down in [GOVERNANCE.md](GOVERNANCE.md). +the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE.md). ## Documentation | | | |---|---| | [User guide](docs/USER_GUIDE.md) | Running a shop with Posnic, first sale to closing the till | +| [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules | | [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues | | [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite | -| [REST API](docs/API.md) | 571 endpoints, generated from the routes | +| [REST API](docs/API.md) | 608 endpoints, generated from the routes | | [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked | +| [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built | | [Backups](docs/BACKUP_POLICY.md) | What is backed up, when, and what it does not protect you from | | [Disaster recovery](docs/DISASTER_RECOVERY.md) | Getting back to working, with RPO and RTO as numbers | | [Release runbook](docs/RELEASE_RUNBOOK.md) | How a release goes out, and four ways to take one back | +| [Release verification](docs/VERIFY_RELEASE.md) | Match a package to its checksum, CycloneDX inventory, provenance and component licences | | [Support lifecycle](docs/SUPPORT_LIFECYCLE.md) | Which versions get fixes, and for how long | | [Incident response](docs/INCIDENT_RESPONSE.md) | Who decides, who is told, and when | -| [Terms of use](TERMS_OF_USE.md) | Customer terms for Posnic Cloud | +| [Terms of use](docs/TERMS_OF_USE.md) | Customer terms for Posnic Cloud | | [Subprocessors](docs/SUBPROCESSORS.md) | Who else can touch your data. For the local edition: nobody | | [Cloud operations](docs/CLOUD_OPERATIONS.md) | What the paid service is made of, and what is still to be decided | | [Data processing addendum](docs/DATA_PROCESSING_ADDENDUM.md) | For customers who need a written DPA | -| [Contributing](CONTRIBUTING.md) | How to get a change merged | -| [Support](SUPPORT.md) | Where to ask, and what happens to your issue | -| [Governance](GOVERNANCE.md) | Who decides what, and what we have promised | -| [Privacy](PRIVACY.md) | What the app collects, and what it does not | -| [Security](SECURITY.md) | What Posnic protects, what it cannot, and reporting a vulnerability | -| [Third-party notices](THIRD-PARTY-NOTICES.md) | Other people's software that ships inside the installer | -| [Code of conduct](CODE_OF_CONDUCT.md) | How we treat each other | +| [Contributing](.github/CONTRIBUTING.md) | How to get a change merged | +| [Public roadmap](docs/ROADMAP.md) | Current priorities, evidence gaps and structured ways to help | +| [Citation metadata](CITATION.cff) | Human and tool-readable citation identity for exact releases or commits | +| [Adoption evidence](docs/ADOPTION_EVIDENCE.md) | How real deployment reports are scoped, reviewed, cited, corrected and kept privacy-safe | +| [Support](.github/SUPPORT.md) | Where to ask, and what happens to your issue | +| [Governance](docs/GOVERNANCE.md) | Who decides what, and what we have promised | +| [Privacy](docs/PRIVACY.md) | What the app collects, and what it does not | +| [Security](.github/SECURITY.md) | What Posnic protects, what it cannot, and reporting a vulnerability | +| [Third-party notices](THIRD-PARTY-NOTICES.md) | Separately licensed software included in release packages | +| [CodeMeta](codemeta.json) | Machine-readable product, publisher, source, licence and platform identity | +| [Code of conduct](.github/CODE_OF_CONDUCT.md) | How we treat each other | ## Contributing Bug reports, fixes, translations, hardware quirks from real shops, documentation -— all welcome, and none of it requires permission to start. +— all welcome, and none of it requires permission to start. The +[contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) gives the local setup, +test commands and public feature-ticket map. Good places to begin are listed in the [developer guide](docs/DEVELOPMENT.md#good-first-issues): 19 known-failing tests, 21 real latent bugs the linter found, and two very large files that want splitting. -Read [CONTRIBUTING.md](CONTRIBUTING.md) first. Security issues go to -[SECURITY.md](SECURITY.md), privately — never a public issue. +Read [CONTRIBUTING.md](.github/CONTRIBUTING.md) first. Security issues go to +[SECURITY.md](.github/SECURITY.md), privately — never a public issue. ## Buy the team a chai ☕ @@ -157,7 +279,7 @@ little back is what keeps the next release coming. | | | |---|---| | ☕ **[Buy us a chai](https://github.com/sponsors/Posnic)** | one-off or monthly, from a dollar up | -| ☁️ **[Posnic Cloud](https://posnic.com/pricing.html)** | sync, off-site backups, remote dashboards — the paid service that funds this one | +| ☁️ **[Posnic Cloud](https://posnic.com/pricing)** | sync, off-site backups, remote dashboards — the paid service that funds this one | | 🏢 **Commercial licence** | keep your modifications private — **info@posnic.com** | Sponsors are named in releases unless they would rather not be. @@ -169,27 +291,29 @@ Sponsors are named in releases unless they would rather not be. | | | |---|---| | Sales and licensing | **info@posnic.com** | -| Support | [SUPPORT.md](SUPPORT.md) · [Discussions](https://github.com/Posnic/POS/discussions) | -| Security | **security@posnic.com** — privately, never a public issue ([SECURITY.md](SECURITY.md)) | -| Web | [posnic.com](https://posnic.com) | +| Support | [SUPPORT.md](.github/SUPPORT.md) · [Discussions](https://github.com/Posnic/POS/discussions) | +| Security | **security@posnic.com** — privately, never a public issue ([SECURITY.md](.github/SECURITY.md)) | +| Web | [posnic.io](https://posnic.io/) · [posnic.com](https://posnic.com) | Paid setup, migration from an existing till, hardware selection, custom reporting and white-labelled installers are all available. The software stays -free either way — see [GOVERNANCE.md](GOVERNANCE.md) for what we have promised +free either way — see [GOVERNANCE.md](docs/GOVERNANCE.md) for what we have promised never to move behind a paywall. -## Licence +## Source and package licences -[GNU AGPL-3.0-only](LICENSE). Use it, change it, self-host it, including for clients. -If you run a modified version as a hosted service, the AGPL requires you to -publish your modifications. +Posnic's own source is [GNU AGPL-3.0-only](LICENSE). Use, change, self-host and +redistribute that source under the licence terms. Release packages also include +separately licensed components; read [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) +and the licence material shipped with the exact artifact. This summary is not +legal advice. The **Posnic name and logo are trademarks** and are not covered by the AGPL — -see [GOVERNANCE.md § Trademark](GOVERNANCE.md#trademark-and-reserved-rights). +see [GOVERNANCE.md § Trademark](docs/GOVERNANCE.md#trademark-and-reserved-rights). Companies needing to keep modifications private can buy a commercial licence: **info@posnic.com**. Such a licence covers the code Posnic owns. Contributors keep the copyright in what they write and there is no CLA, so community code can only be relicensed with its author's agreement — a deliberate limit on our own power, set out in [GOVERNANCE.md § Contributor -copyright](GOVERNANCE.md#contributor-copyright). +copyright](docs/GOVERNANCE.md#contributor-copyright). diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d089b281b..8ea3b365f 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,10 +1,10 @@ # Third-party notices -Posnic is distributed under the GNU Affero General Public License v3.0 -(see [LICENSE](LICENSE)). The Windows installer also carries software written by -other people, under their own licences. Those licences are listed here, and the -obligation to name them is why this file exists — several of them require that -their notice travel with any copy. +Posnic's own source is distributed under GNU AGPL-3.0-only (see +[LICENSE](LICENSE)). Windows, macOS and Linux release packages also carry +software written by other people under their own licences. Those components +and known package notices are listed here. The root Posnic licence does not +replace a bundled component's licence. This covers what ships **inside the installer**. Node packages pulled in at build time carry their own licences in `node_modules`; `npm ls --omit=dev` will list @@ -14,24 +14,26 @@ them for a given build. ## MongoDB Community Server -**Shipped as:** `resources/mongodb/bin/mongod.exe` -**Version:** 7.0.x +**Shipped as:** `resources/mongodb/bin/mongod.exe` on Windows and +`resources/mongodb/bin/mongod` on macOS and Linux +**Version:** 7.0.14 (exact bundled release matching installer build pin) **Copyright:** © MongoDB, Inc. **Licence:** Server Side Public License, Version 1 (SSPL-1.0) -**Source and licence text:** +**Source:** +**Official licensing information:** + Posnic keeps a shop's data in a MongoDB instance running on the shop's own computer. The server binary is redistributed unmodified. -The SSPL is not an OSI-approved open source licence, and it is not the same -licence as Posnic's own. It applies to `mongod.exe` alone: it does not extend to -Posnic's source code, which is separate work that talks to MongoDB over its wire -protocol. Its principal condition concerns offering MongoDB itself to third -parties as a service, which Posnic does not do — it runs a local database for the -shop that installed it. +MongoDB states that SSPL is not an OSI-approved open-source licence. Posnic's +own source and the MongoDB binary are recorded as separately licensed +components. Review the exact licences, package contents and intended use or +distribution rather than assigning one licence to the complete bundle. This +notice records technical provenance and is not legal advice. If you would rather not receive it, the installer can be built without a bundled -MongoDB and pointed at one you already run; see `download-mongodb.bat` and +MongoDB and pointed at one you already run; see `download-mongodb.bat` and `MONGODB_URI`. ## Node.js @@ -71,7 +73,7 @@ listed in `LICENSES.chromium.html` beside the installed application) ## Microsoft Visual C++ Runtime **Shipped as:** `resources/mongodb/bin/msvcp140.dll`, -`vcruntime140.dll`, `vcruntime140_1.dll` +`vcruntime140.dll`, `vcruntime140_1.dll` (Windows package only) **Copyright:** © Microsoft Corporation **Licence:** Redistributed under the Microsoft Visual Studio redistributable terms, as files MongoDB requires to run. @@ -175,6 +177,21 @@ page loads are Font Awesome's. If that holds up under a proper check, most of these sets can be deleted outright and the question goes away rather than being answered. +### Flag icons + +**Shipped as:** `frontend/static/images/flags/1x1/` and `4x3/` - one SVG per +language the app offers (in, us, lk, np, sa, fr, es, pt, id, th, de, tz, nl, +it), and nothing else. The 238 other countries the stylesheet knows are not +bundled. + +| Set | Licence | Source | +| --- | --- | --- | +| flag-icons (formerly flag-icon-css) | MIT | `flag-icons` 7.5.0, `npm view flag-icons license`, 2 September 2026 | + +The eight added on 2 September 2026 and the four on 4 September (de, tz, nl, +it) were copied unmodified from that package for the language menu; `in.svg` and `us.svg` were already here from the +same project's earlier name. MIT needs its notice carried, which this file does. + ### What has been removed `flag-icon-css-master` — 528 files — is gone. Nothing referenced that path, and diff --git a/api/.gitignore b/api/.gitignore index 49c1834ec..72a3f39a7 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -171,3 +171,6 @@ test-install.sh *.clean.js *-refactored.controller.js + +# local dev database + machine-only secrets (never committed) +.local-db/ diff --git a/api/app.js b/api/app.js index 386c6f423..29147161a 100644 --- a/api/app.js +++ b/api/app.js @@ -130,16 +130,52 @@ const cspDirectives = { 'http://127.0.0.1:5555', ], }; -app.use( - helmet({ - contentSecurityPolicy: { - directives: cspDirectives, - }, - referrerPolicy: { - policy: 'strict-origin-when-cross-origin', - }, - }) -); + +/* + * upgrade-insecure-requests comes in with helmet's default directives. On + * the https production origin it is right; on a plain-http origin it + * rewrites every asset fetch to https:// and the page loads + * nothing. localhost is exempt (browsers treat it as trustworthy), which is + * why this only ever bites when the local copy is opened from another + * device - the owner's phone on the LAN (http://192.168.1.11:5055) got raw + * HTML with every script and stylesheet red in the network tab. + */ +if (process.env.NODE_ENV !== 'production') { + /* null, not delete: helmet merges these onto its defaults (useDefaults), + so a missing key silently comes back - null is the documented way to + switch a default directive off. */ + cspDirectives['upgrade-insecure-requests'] = null; +} + +/* + * Two policies, chosen per request: the locked-down one, and - only while + * the shop's own Google Analytics feature is ON (a Settings toggle plus the + * shop's own measurement id) - one that admits the Google domains. An + * unconfigured shop never carries the wider policy, so the PRIVACY.md + * promise is enforced by the header itself, not by convention. + */ +const { getAnalytics } = require('./src/services/analytics-config'); +const gaCspDirectives = { + ...cspDirectives, + 'script-src': [...cspDirectives['script-src'], 'https://www.googletagmanager.com'], + 'connect-src': [ + ...cspDirectives['connect-src'], + 'https://*.google-analytics.com', + 'https://*.analytics.google.com', + 'https://*.googletagmanager.com', + ], +}; +const helmetOptions = (directives) => ({ + contentSecurityPolicy: { directives }, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, +}); +const helmetBase = helmet(helmetOptions(cspDirectives)); +const helmetGa = helmet(helmetOptions(gaCspDirectives)); +app.use((req, res, next) => { + getAnalytics() + .then((a) => (a.enabled ? helmetGa : helmetBase)(req, res, next)) + .catch(() => helmetBase(req, res, next)); +}); /* * Health, for a supervisor rather than a person. Registered here, before the @@ -174,13 +210,29 @@ app.use( * tenant-free by design - the login page and the update machinery read it * before any authentication exists. */ -app.get('/api/runtime-info', (req, res) => { +app.get('/api/runtime-info', async (req, res) => { const { buildRuntimeInfo } = require('./src/utils/runtime-info'); - return res.status(200).json(buildRuntimeInfo()); + const info = buildRuntimeInfo(); + /* the login page injects the shop's own GA pre-auth from here - the id is + not a secret (it sits in the page source of every GA-carrying site) */ + try { + info.analytics = await getAnalytics(); + } catch (e) { + info.analytics = { enabled: false, id: '' }; + } + info.demo = require('./src/config/demo-mode').isDemoMode(); + return res.status(200).json(info); }); -app.get('/runtime-info', (req, res) => { +app.get('/runtime-info', async (req, res) => { const { buildRuntimeInfo } = require('./src/utils/runtime-info'); - return res.status(200).json(buildRuntimeInfo()); + const info = buildRuntimeInfo(); + try { + info.analytics = await getAnalytics(); + } catch (e) { + info.analytics = { enabled: false, id: '' }; + } + info.demo = require('./src/config/demo-mode').isDemoMode(); + return res.status(200).json(info); }); app.get('/api/healthz', (req, res) => { @@ -331,6 +383,8 @@ app.use((err, req, res, next) => { next(err); // Pass other errors through }); +const { filterGuard } = require('./src/middleware/filter-guard'); + // Sanitization function to prevent NoSQL injection const sanitize = (obj) => { if (!obj || typeof obj !== 'object') return obj; @@ -369,7 +423,12 @@ app.use((req, res, next) => { req.body = sanitize({ ...req.body }); } - next(); + /* The gap the sanitiser above cannot see into: a filter arriving as a JSON + STRING is one ordinary-looking value to that walk, so its operators + survive to be parsed and spread into a live query. See + src/middleware/filter-guard.js - it is its own file so the behaviour can + be tested, which an inline closure in this file could not be. */ + return filterGuard(req, res, next); }); // XSS protection middleware @@ -624,17 +683,31 @@ const UPDATED_ASSETS = process.env.POSNIC_ASSET_DIR; * to the new hashed URLs, so it is the one thing that may never be stale. */ const HASHED_ASSET = /\.[0-9a-f]{8}\.(js|css)$/; +const STATIC_MEDIA = /\.(png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|otf|mp3|wav)(\?.*)?$/i; function assetCacheHeaders(res, filePath) { if (HASHED_ASSET.test(filePath)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache'); + } else if (STATIC_MEDIA.test(filePath)) { + /* + * Fonts and images went out with NO Cache-Control (this function only + * labelled the hashed bundles and html), so Cloudflare stamped its 4h + * default - Lighthouse: "use efficient cache lifetimes, 2,718 KiB". + * These names are not hashed, so not immutable - but a week is honest + * for artwork and font files that change a few times a year, and the + * service worker's versioned cache still refreshes them per release. + */ + res.setHeader('Cache-Control', 'public, max-age=604800'); } } if (UPDATED_ASSETS && fs.existsSync(UPDATED_ASSETS)) { console.log('[assets] serving updated assets from ' + UPDATED_ASSETS); - app.use('/', express.static(UPDATED_ASSETS, { fallthrough: true, setHeaders: assetCacheHeaders })); + app.use( + '/', + express.static(UPDATED_ASSETS, { fallthrough: true, setHeaders: assetCacheHeaders }) + ); } // Serve transformed frontend assets (used for login page, etc.) @@ -688,9 +761,15 @@ app.get('/print/:token', (req, res) => { let store; try { + /* packaged: resources/api beside resources/print-document-store.js */ store = require('../print-document-store'); } catch (e) { - return res.status(503).end(); + try { + /* dev checkout: the desktop shell lives in src/ */ + store = require('../src/print-document-store'); + } catch (e2) { + return res.status(503).end(); + } } const html = store.take(req.params.token); @@ -703,7 +782,10 @@ app.get('/print/:token', (req, res) => { }); // The compiled stylesheets reference ../fonts, which resolves to /fonts. -app.use('/fonts', express.static(path.join(BUILT_STATIC, 'fonts'), { fallthrough: true, maxAge: STATIC_MAX_AGE })); +app.use( + '/fonts', + express.static(path.join(BUILT_STATIC, 'fonts'), { fallthrough: true, maxAge: STATIC_MAX_AGE }) +); // Serving backend-specific static files (exports, uploads, etc.) app.use(express.static(path.join(__dirname, 'public'))); @@ -1052,9 +1134,10 @@ app.get(['/push/key', '/api/push/key'], sseProtect, async (req, res) => { }); app.post(['/push/subscribe', '/api/push/subscribe'], sseProtect, async (req, res) => { try { - const result = req.db && req.user - ? await pushInfra.subscribe(req.db, req.user._id, req.body && req.body.subscription) - : { ok: false }; + const result = + req.db && req.user + ? await pushInfra.subscribe(req.db, req.user._id, req.body && req.body.subscription) + : { ok: false }; if (!result.ok) return res.status(400).json({ type: 'error', message: 'Invalid subscription' }); res.json({ type: 'success', data: null, message: 'Subscribed' }); } catch (e) { @@ -1079,8 +1162,12 @@ app.get(['/webhooks', '/api/webhooks'], sseProtect, requireBranchWrite, async (r res.json({ type: 'success', data: rows.map((r) => ({ - id: r._id, url: r.url, events: r.events, - description: r.description, active: r.active, createdAt: r.createdAt, + id: r._id, + url: r.url, + events: r.events, + description: r.description, + active: r.active, + createdAt: r.createdAt, })), }); } catch (e) { @@ -1091,21 +1178,100 @@ app.post(['/webhooks', '/api/webhooks'], sseProtect, requireBranchWrite, async ( try { const result = await webhookInfra.addSubscription(req.db, req.body || {}); if (!result.ok) return res.status(400).json({ type: 'error', message: result.reason }); - res.json({ type: 'success', data: { id: result.id, secret: result.secret }, - message: 'Webhook registered. Store the secret now - it is not shown again.' }); + res.json({ + type: 'success', + data: { id: result.id, secret: result.secret }, + message: 'Webhook registered. Store the secret now - it is not shown again.', + }); } catch (e) { res.status(500).json({ type: 'error', message: 'Could not register webhook' }); } }); -app.delete(['/webhooks/:id', '/api/webhooks/:id'], sseProtect, requireBranchWrite, async (req, res) => { - try { - const result = await webhookInfra.removeSubscription(req.db, req.params.id); - res.json({ type: result.ok ? 'success' : 'error', data: null, - message: result.ok ? 'Webhook removed' : 'Not found' }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Could not remove webhook' }); +app.delete( + ['/webhooks/:id', '/api/webhooks/:id'], + sseProtect, + requireBranchWrite, + async (req, res) => { + try { + const result = await webhookInfra.removeSubscription(req.db, req.params.id); + res.json({ + type: result.ok ? 'success' : 'error', + data: null, + message: result.ok ? 'Webhook removed' : 'Not found', + }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Could not remove webhook' }); + } } -}); +); +/* + * Report email (owner ask): the client renders the exact PDF the user + * saw on screen and posts it here; the server only addresses and sends + * it through the same transport chain as the purchase-order mailer. + * Branch-write holders: mailing a report sends shop data outward. + */ +app.post( + ['/reports/email', '/api/reports/email'], + sseProtect, + requireBranchWrite, + express.json({ limit: '12mb' }), + async (req, res) => { + try { + const to = String(req.body?.to || '').trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { + return res.status(400).json({ type: 'error', message: 'Enter a valid email address' }); + } + const b64 = String(req.body?.pdf_base64 || ''); + if (!b64 || b64.length > 12 * 1024 * 1024) { + return res.status(400).json({ type: 'error', message: 'Report PDF missing or too large' }); + } + const filename = + String(req.body?.filename || 'report') + .replace(/[^a-z0-9-_]/gi, '') + .slice(0, 60) || 'report'; + const title = String(req.body?.title || 'Report').slice(0, 120); + // Owner rule: the shop's own SMTP first, the platform chain otherwise. + const { resolveShopTransport } = require('./src/utils/email'); + let branchDoc = null; + try { + const bid = req.user && req.user.branch_id; + if (bid && req.db) { + branchDoc = await req.db + .collection('branches') + .findOne({ _id: new (require('mongodb').ObjectId)(String(bid)) }); + } + } catch (e) { + /* platform chain covers it */ + } + const resolved = resolveShopTransport(branchDoc); + const transporter = resolved.transporter; + const shopName = (req.user && req.user.branch_name) || 'Posnic POS'; + const info = await transporter.sendMail({ + from: `${shopName} <${resolved.from}>`, + to, + subject: `${title} - ${shopName}`, + text: `Please find attached: ${title}.`, + attachments: [{ filename: `${filename}.pdf`, content: Buffer.from(b64, 'base64') }], + }); + /* The dev fallback transport prints to console instead of delivering - + say so rather than claiming a send that never left the box. */ + if (!resolved.shopOwned && transporter.options && transporter.options.jsonTransport) { + return res.status(503).json({ + type: 'error', + message: 'Email is not configured on this server - the PDF was generated but not sent', + }); + } + return res.json({ + type: 'success', + data: { to, messageId: info.messageId || '' }, + message: 'Report emailed', + }); + } catch (e) { + console.error('Error in reports/email:', e); + return res.status(500).json({ type: 'error', message: 'Could not email the report' }); + } + } +); /* * Scoped API tokens (integration platform step 2) - minted with an explicit * ACL subset, hashed at rest, plaintext shown exactly once. Branch-write @@ -1118,8 +1284,13 @@ app.get(['/api-tokens', '/api/api-tokens'], sseProtect, requireBranchWrite, asyn res.json({ type: 'success', data: rows.map((r) => ({ - id: r._id, name: r.name, hint: r.token_hint, access: r.access, - active: r.active, createdAt: r.createdAt, last_used_at: r.last_used_at, + id: r._id, + name: r.name, + hint: r.token_hint, + access: r.access, + active: r.active, + createdAt: r.createdAt, + last_used_at: r.last_used_at, })), }); } catch (e) { @@ -1143,23 +1314,36 @@ app.post(['/api-tokens', '/api/api-tokens'], sseProtect, requireBranchWrite, asy res.status(500).json({ type: 'error', message: 'Could not create token' }); } }); -app.delete(['/api-tokens/:id', '/api/api-tokens/:id'], sseProtect, requireBranchWrite, async (req, res) => { - try { - const result = await apiTokens.revokeToken(req.db, req.params.id); - res.json({ type: result.ok ? 'success' : 'error', data: null, - message: result.ok ? 'Token revoked' : 'Not found' }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Could not revoke token' }); +app.delete( + ['/api-tokens/:id', '/api/api-tokens/:id'], + sseProtect, + requireBranchWrite, + async (req, res) => { + try { + const result = await apiTokens.revokeToken(req.db, req.params.id); + res.json({ + type: result.ok ? 'success' : 'error', + data: null, + message: result.ok ? 'Token revoked' : 'Not found', + }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Could not revoke token' }); + } } -}); +); -app.get(['/webhooks/deliveries', '/api/webhooks/deliveries'], sseProtect, requireBranchWrite, async (req, res) => { - try { - res.json({ type: 'success', data: await webhookInfra.recentDeliveries(req.db) }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Could not list deliveries' }); +app.get( + ['/webhooks/deliveries', '/api/webhooks/deliveries'], + sseProtect, + requireBranchWrite, + async (req, res) => { + try { + res.json({ type: 'success', data: await webhookInfra.recentDeliveries(req.db) }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Could not list deliveries' }); + } } -}); +); /* * WhatsApp connector lane (I6): the signed connector (I5) drains the @@ -1174,68 +1358,95 @@ const requireCustomerWrite = (req, res, next) => { return res.status(403).json({ type: 'error', message: 'This token cannot message customers' }); }; -app.post(['/connector/whatsapp/claim', '/api/connector/whatsapp/claim'], sseProtect, requireCustomerWrite, async (req, res) => { - try { - if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); - const rows = await waOutbox.claim(req.db, req.user.license, { limit: (req.body || {}).limit }); - res.json({ type: 'success', data: rows }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Claim failed' }); +app.post( + ['/connector/whatsapp/claim', '/api/connector/whatsapp/claim'], + sseProtect, + requireCustomerWrite, + async (req, res) => { + try { + if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); + const rows = await waOutbox.claim(req.db, req.user.license, { + limit: (req.body || {}).limit, + }); + res.json({ type: 'success', data: rows }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Claim failed' }); + } } -}); +); -app.post(['/connector/whatsapp/result', '/api/connector/whatsapp/result'], sseProtect, requireCustomerWrite, async (req, res) => { - try { - if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); - const { id, ok, error } = req.body || {}; - const r = await waOutbox.report(req.db, req.user.license, id, { ok: ok === true, error }); - res.json({ type: 'success', data: r }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Report failed' }); +app.post( + ['/connector/whatsapp/result', '/api/connector/whatsapp/result'], + sseProtect, + requireCustomerWrite, + async (req, res) => { + try { + if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); + const { id, ok, error } = req.body || {}; + const r = await waOutbox.report(req.db, req.user.license, id, { ok: ok === true, error }); + res.json({ type: 'success', data: r }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Report failed' }); + } } -}); +); -app.get(['/connector/whatsapp/state', '/api/connector/whatsapp/state'], sseProtect, requireCustomerWrite, async (req, res) => { - try { - if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); - // The connector's view: every branch's link state, including the - // 'init_requested' rows that tell it a screen is waiting for a QR. - const rows = await req.db.collection('whatsapp_connector_state').find({}).toArray(); - res.json({ - type: 'success', - data: rows.map((r) => ({ - branch_id: r.branch_id ? String(r.branch_id) : null, - device_id: r.device_id || '', - status: r.status || 'unknown', - updated_date: r.updated_date, - })), - }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'Could not read state' }); +app.get( + ['/connector/whatsapp/state', '/api/connector/whatsapp/state'], + sseProtect, + requireCustomerWrite, + async (req, res) => { + try { + if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); + // The connector's view: every branch's link state, including the + // 'init_requested' rows that tell it a screen is waiting for a QR. + const rows = await req.db.collection('whatsapp_connector_state').find({}).toArray(); + res.json({ + type: 'success', + data: rows.map((r) => ({ + branch_id: r.branch_id ? String(r.branch_id) : null, + device_id: r.device_id || '', + status: r.status || 'unknown', + updated_date: r.updated_date, + })), + }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'Could not read state' }); + } } -}); +); -app.post(['/connector/whatsapp/state', '/api/connector/whatsapp/state'], sseProtect, requireCustomerWrite, async (req, res) => { - try { - if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); - const { branch_id, device_id, status, qr } = req.body || {}; - await waOutbox.recordState(req.db, req.user.license, { branch_id, device_id, status, qr }); - res.json({ type: 'success' }); - } catch (e) { - res.status(500).json({ type: 'error', message: 'State update failed' }); +app.post( + ['/connector/whatsapp/state', '/api/connector/whatsapp/state'], + sseProtect, + requireCustomerWrite, + async (req, res) => { + try { + if (!req.db) return res.status(503).json({ type: 'error', message: 'No shop in context' }); + const { branch_id, device_id, status, qr } = req.body || {}; + await waOutbox.recordState(req.db, req.user.license, { branch_id, device_id, status, qr }); + res.json({ type: 'success' }); + } catch (e) { + res.status(500).json({ type: 'error', message: 'State update failed' }); + } } -}); +); app.post(['/push/test', '/api/push/test'], sseProtect, async (req, res) => { try { - const result = req.db && req.user - ? await pushInfra.sendToUser(req.db, req.user._id, { - title: 'Posnic', - body: 'Notifications are working on this device.', - url: '/dashboard.html#/dashboard', - }) - : { sent: 0 }; - res.json({ type: 'success', data: result, message: result.sent ? 'Sent' : 'No subscriptions on this device yet' }); + const result = + req.db && req.user + ? await pushInfra.sendToUser(req.db, req.user._id, { + title: 'Posnic', + body: 'Notifications are working on this device.', + url: '/dashboard.html#/dashboard', + }) + : { sent: 0 }; + res.json({ + type: 'success', + data: result, + message: result.sent ? 'Sent' : 'No subscriptions on this device yet', + }); } catch (e) { res.status(500).json({ type: 'error', message: 'Send failed' }); } @@ -1262,15 +1473,30 @@ app.use('/suppliers', suppliersRoutes); // Serve frontend static files const frontendPath = path.join(__dirname, '..', 'frontend'); app.use('/static', express.static(path.join(frontendPath, 'static'), { maxAge: STATIC_MAX_AGE })); -app.use('/images', express.static(path.join(frontendPath, 'static', 'images'), { maxAge: STATIC_MAX_AGE })); -app.use('/fonts', express.static(path.join(frontendPath, 'static', 'fonts'), { maxAge: STATIC_MAX_AGE })); -app.use('/style', express.static(path.join(frontendPath, 'public', 'style'), { setHeaders: assetCacheHeaders })); -app.use('/script', express.static(path.join(frontendPath, 'public', 'script'), { setHeaders: assetCacheHeaders })); +app.use( + '/images', + express.static(path.join(frontendPath, 'static', 'images'), { maxAge: STATIC_MAX_AGE }) +); +app.use( + '/fonts', + express.static(path.join(frontendPath, 'static', 'fonts'), { maxAge: STATIC_MAX_AGE }) +); +app.use( + '/style', + express.static(path.join(frontendPath, 'public', 'style'), { setHeaders: assetCacheHeaders }) +); +app.use( + '/script', + express.static(path.join(frontendPath, 'public', 'script'), { setHeaders: assetCacheHeaders }) +); // Serve static files from /public/static for pages loaded from /public/ app.use('/public/static', express.static(path.join(frontendPath, 'static'))); app.use('/public/images', express.static(path.join(frontendPath, 'static', 'images'))); app.use('/public/fonts', express.static(path.join(frontendPath, 'static', 'fonts'))); -app.use('/public', express.static(path.join(frontendPath, 'public'), { setHeaders: assetCacheHeaders })); +app.use( + '/public', + express.static(path.join(frontendPath, 'public'), { setHeaders: assetCacheHeaders }) +); app.use(express.static(path.join(frontendPath, 'public'), { setHeaders: assetCacheHeaders })); // Also mount API routes at root for backward compatibility diff --git a/api/package-lock.json b/api/package-lock.json index 4dc82af51..9fd520b58 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,19 +1,19 @@ { "name": "Api_v2_express", - "version": "2.0.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Api_v2_express", - "version": "2.0.0", + "version": "1.6.1", "license": "AGPL-3.0-only", "dependencies": { - "@aws-sdk/client-s3": "^3.1106.0", - "@getbrevo/brevo": "^6.0.2", - "axios": "^1.19.0", + "@aws-sdk/client-s3": "^3.1121.0", + "@getbrevo/brevo": "^6.0.3", + "axios": "^1.20.0", "bcryptjs": "^3.0.3", - "bson": "^7.3.1", + "bson": "^7.3.2", "compression": "^1.8.1", "connect-mongo": "^6.0.0", "cookie-parser": "^1.4.7", @@ -27,25 +27,26 @@ "express-validator": "^7.3.2", "helmet": "^8.3.0", "hpp": "^0.2.3", - "html-to-text": "^10.0.0", + "html-to-text": "^10.0.1", "http-status": "^2.1.0", - "joi": "^18.2.3", + "joi": "^18.2.5", "jsonwebtoken": "^9.0.3", "lodash": "^4.18.1", "moment": "^2.30.1", "moment-timezone": "^0.6.3", - "mongodb": "^7.5.0", - "mongoose": "^9.9.1", + "mongodb": "^7.6.0", + "mongoose": "^9.9.4", "mongoose-paginate-v2": "^1.9.5", - "morgan": "^1.11.0", - "multer": "^2.2.0", - "nodemailer": "^9.0.5", - "pdfkit": "^0.19.1", + "morgan": "^1.12.0", + "multer": "^2.3.0", + "nodemailer": "^9.0.6", + "pdfkit": "^0.20.1", "pug": "^3.0.4", "qrcode": "^1.5.4", "razorpay": "^2.9.8", "sib-api-v3-sdk": "^8.5.0", - "uuid": "^14.0.1", + "unzipper": "^0.12.5", + "uuid": "^14.0.2", "validator": "^13.15.35", "web-push": "^3.6.7", "whatsapp-web.js": "^1.34.7", @@ -53,13 +54,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@redocly/cli": "^2.46.0", - "@types/node": "^26.2.0", - "eslint": "^10.8.1", + "@redocly/cli": "^2.49.0", + "@types/node": "^26.4.0", + "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "glob": "^13.0.6", - "globals": "^17.9.0", - "jest": "^30.4.2", + "globals": "^17.11.0", + "jest": "^30.5.0", + "mongodb-memory-server": "^11.2.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", "swagger-ui-express": "^5.0.1" @@ -69,15 +71,15 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", - "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -85,21 +87,21 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1106.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1106.0.tgz", - "integrity": "sha512-hUTlnyRRGlVdfvJLL3hCEnMm7CmunSzc/lxFVRX8g1fjJTMUVbyQCfhfMhp7dZ7JBftLU6OYresu3Hje4nvkJw==", + "version": "3.1121.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1121.0.tgz", + "integrity": "sha512-hBnoqaVBeWdkgXcJElMXA2yUZWkBCBntu2qmN+tfqmzC+j4LzJC3ox8qIgS2WdMS1cb8UwyBogUVrkRXybNm0A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/checksums": "^3.1000.26", - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/credential-provider-node": "^3.972.78", - "@aws-sdk/middleware-sdk-s3": "^3.972.72", - "@aws-sdk/signature-v4-multi-region": "^3.996.43", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -107,17 +109,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", - "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", - "@aws-sdk/xml-builder": "^3.972.38", + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.31.1", + "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -126,15 +128,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", - "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -142,17 +144,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", - "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -160,23 +162,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", - "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-login": "^3.972.75", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -184,16 +186,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", - "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -201,21 +203,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.79", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", - "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-ini": "^3.973.13", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -223,15 +225,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", - "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -239,17 +241,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", - "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/token-providers": "3.1108.0", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -257,16 +259,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", - "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -274,16 +276,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", - "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -291,18 +293,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", - "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/fetch-http-handler": "^5.6.13", - "@smithy/node-http-handler": "^4.9.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -310,14 +312,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", - "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -325,16 +327,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1108.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", - "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", - "@smithy/core": "^3.31.1", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -342,12 +344,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", - "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -355,12 +357,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", - "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -442,14 +444,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -590,12 +592,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -859,18 +861,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -878,9 +880,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1136,9 +1138,9 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@getbrevo/brevo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-6.0.2.tgz", - "integrity": "sha512-yxyacx4wkuWcCbFDdFE3RxklaHSGXEP3d0gZaxPy+9aBquvl4ksr7IQgbRCcF8crx5aRvpStVBLeh5iCbWHJrg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-6.0.3.tgz", + "integrity": "sha512-yWBLKoHC8P5HLZ8z6iRuqDYLpCRKPTUETnZOLZzNDWGFYL78y4SHOTxQVrKH5ROX7f4q+SQ19LD3GWtJlIuNXA==", "engines": { "node": ">=18.0.0" } @@ -1388,9 +1390,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1422,17 +1424,17 @@ } }, "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.0.tgz", + "integrity": "sha512-BI1DpOedrJqbrYVi9yNhDWGjqphR/+gsM4STmg2+VaeXm7851hvpBDyKOZGMXATTrGEnFuEseQvLCtrrRmG0GQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1440,18 +1442,18 @@ } }, "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.0.tgz", + "integrity": "sha512-DLjRME+NY//j+UDTSfWnjoP0srrdR3DrJRy7yFZktGIwzpN2iVy2vMo0jziZ5c2Ij7bOwlJRXKVWtxZusazOJg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -1459,20 +1461,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", + "jest-changed-files": "30.5.0", + "jest-config": "30.5.0", + "jest-haste-map": "30.5.0", + "jest-message-util": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-resolve-dependencies": "30.5.0", + "jest-runner": "30.5.0", + "jest-runtime": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", + "jest-watcher": "30.5.0", + "pretty-format": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1488,9 +1490,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", "engines": { @@ -1498,70 +1500,70 @@ } }, "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "30.4.1" + "jest-mock": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.0.tgz", + "integrity": "sha512-jEmgmgJEobJ3zEhDOGp1VAJ6JkoVelpS8uZ1ae1Ul/5lP78UKJKmmU0lciJwd6JdnqOXHaaS/QCKwbf1dHI9MA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" + "expect": "30.5.0", + "jest-snapshot": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", "dev": true, "license": "MIT", "engines": { @@ -1569,62 +1571,78 @@ } }, "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.0.tgz", + "integrity": "sha512-h7eJx534czwL8lQMYB0hwLT4/HquO8EX/RtYL7RNUHyUyWWVciYjoudN4Ns5JmNvn2/jh0Vm9UstZjEzJJ5EsQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" + "@jest/environment": "30.5.0", + "@jest/expect": "30.5.0", + "@jest/types": "30.5.0", + "jest-mock": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.4.0" + "jest-regex-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.0.tgz", + "integrity": "sha512-FEAuusWm+PUOn9ydjaHhpOpyPmS6IbGE04HaKTuUI4zd8eqYZiXiNLoCsdCog/l2XnNj53E9pFy64UltcvfJKg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", + "@jridgewell/trace-mapping": "^0.3.31", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", + "jest-worker": "30.5.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1641,82 +1659,10 @@ } } }, - "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { @@ -1727,13 +1673,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.0.tgz", + "integrity": "sha512-iWQtIsi2dRsO2oWzVceOeynuRJiYTW8gsDVp5wFQ02ipHluQsNgBpasWSHiawVxukIlsGLdCtCSbIEK7fPtpvQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1743,14 +1689,15 @@ } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.0.tgz", + "integrity": "sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -1758,14 +1705,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.0.tgz", + "integrity": "sha512-9IlPqUzUMkVDmoDqSSrVVLroVotgN3hTUPWPwq24XWXhh1Zpg915RXZ5pgRJ/7j4YE/kng5nqjSn4p3S68SZJA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/types": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1774,15 +1721,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.0.tgz", + "integrity": "sha512-TXlvSDIVv482b83hD8A8WtxDJEmGF47f60W6jRXOQL0Isfs3hKtk4rZIm9R/jLzAibg8+c56y+Q00BQs8R7Xcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", + "@jest/test-result": "30.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", + "jest-haste-map": "30.5.0", "slash": "^3.0.0" }, "engines": { @@ -1790,23 +1737,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.0.tgz", + "integrity": "sha512-n1cYhoByyULEIXi64wbT4Lq91qeT1E6bwpM//sprFXhw955qaiHTdAmy1c1rNFGB6fCf1J+nxDUSf3RGwgZP5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", + "@jest/types": "30.5.0", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1816,14 +1763,14 @@ } }, "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -1867,9 +1814,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1894,22 +1841,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@noble/ciphers": { @@ -1936,6 +1886,324 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2048,9 +2316,9 @@ } }, "node_modules/@redocly/cli": { - "version": "2.46.0", - "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.46.0.tgz", - "integrity": "sha512-Hx4dIYwFhMkE6fZXDl0TwuVsvE8INX7dzEOgAcEhl0mhTkHC95o/rhUTlVLbNRg4mWHQ27jbdgKXPZBJJANWYA==", + "version": "2.49.0", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.49.0.tgz", + "integrity": "sha512-87UuARXYrPIiHhd/UriamL4BApuPgB0E91coBtoPG6PK4giTwiuo3dI4RoyMGhsC7TwgAnL8PJuhCwGymgwbVA==", "dev": true, "license": "MIT", "bin": { @@ -2087,9 +2355,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -2114,12 +2382,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", - "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.17.0", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2127,13 +2395,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", - "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.32.0", - "@smithy/types": "^4.17.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2141,13 +2409,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", - "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.32.0", - "@smithy/types": "^4.17.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2155,13 +2423,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", - "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.32.0", - "@smithy/types": "^4.17.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2169,13 +2437,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", - "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.32.0", - "@smithy/types": "^4.17.0", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -2183,9 +2451,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", - "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2215,9 +2483,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2319,9 +2587,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2378,9 +2646,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "dev": true, "license": "ISC" }, @@ -2490,6 +2758,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2504,6 +2775,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2518,6 +2792,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2532,6 +2809,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2546,6 +2826,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2560,6 +2843,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2574,6 +2860,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2588,6 +2877,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2602,6 +2894,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2616,6 +2911,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2957,6 +3255,16 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, + "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", @@ -2964,9 +3272,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -3061,16 +3369,16 @@ } }, "node_modules/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.0.tgz", + "integrity": "sha512-PrhPHlKC+MsLnuNzgIH/y1dkz1f6cSfKWaQeaG8WxLMuG44dYWQ8E9uRrsBbAGCU/3+BEFYPN4d6G3Zc5Y+waA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.4.1", + "@jest/transform": "30.5.0", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3083,9 +3391,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -3096,16 +3404,16 @@ "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3143,20 +3451,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", + "babel-plugin-jest-hoist": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/babel-walk": { @@ -3276,9 +3584,9 @@ ] }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3326,6 +3634,7 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", "engines": { "node": ">=0.6" } @@ -3334,6 +3643,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" @@ -3365,9 +3675,10 @@ } }, "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" }, "node_modules/bn.js": { "version": "4.12.5", @@ -3448,25 +3759,10 @@ "base64-js": "^1.1.2" } }, - "node_modules/browserify-zlib": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", - "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", - "license": "MIT", - "dependencies": { - "pako": "~1.0.5" - } - }, - "node_modules/browserify-zlib/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -3484,11 +3780,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -3508,9 +3804,9 @@ } }, "node_modules/bson": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", - "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz", + "integrity": "sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==", "license": "Apache-2.0", "engines": { "node": ">=20.19.0" @@ -3562,6 +3858,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", "engines": { "node": ">=0.10" } @@ -3639,9 +3936,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3663,6 +3960,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", "dependencies": { "traverse": ">=0.3.0 <0.4" }, @@ -3782,9 +4080,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, @@ -3860,6 +4158,13 @@ "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "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/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4212,9 +4517,10 @@ }, "node_modules/deepmerge": { "version": "4.3.1", - "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -4269,6 +4575,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4439,9 +4755,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "dev": true, "license": "ISC" }, @@ -4526,6 +4842,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4598,9 +4921,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", "workspaces": [ @@ -4978,6 +5301,60 @@ "node": ">=8.3.0" } }, + "node_modules/exceljs/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/exceljs/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/exceljs/node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/exceljs/node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/exceljs/node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", @@ -5039,18 +5416,18 @@ } }, "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.0.tgz", + "integrity": "sha512-8fiMWcEjPU7B9nErC4FtFcCzf2tC6I75Qf7m8wzBAWC2taZmcno3yAFEjIQL34SwoGZNgPf63UDiJLyh4SMPaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "@jest/expect-utils": "30.5.0", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5259,6 +5636,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5300,6 +5683,50 @@ "node": ">= 0.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-cache-dir/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/find-cache-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/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -5528,6 +5955,7 @@ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", "deprecated": "This package is no longer supported.", + "license": "ISC", "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", @@ -5709,9 +6137,9 @@ } }, "node_modules/globals": { - "version": "17.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", - "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", "dev": true, "license": "MIT", "engines": { @@ -5854,13 +6282,13 @@ "license": "MIT" }, "node_modules/html-to-text": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", - "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz", + "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==", "license": "MIT", "dependencies": { "@selderee/plugin-htmlparser2": "~0.12.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "dom-serializer": "^2.0.0", "htmlparser2": "^10.1.0", "selderee": "~0.12.0" @@ -6358,16 +6786,16 @@ } }, "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.0.tgz", + "integrity": "sha512-HeFeOUEKh5gjnp1rjuSCse8Dhj0Y3KA8lsZ3azr4Wnq1nCxwMBu35MDc9mp8iblZxmeAz6wV4P241tyUB7J2Ew==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", + "@jest/core": "30.5.0", + "@jest/types": "30.5.0", "import-local": "^3.2.0", - "jest-cli": "30.4.2" + "jest-cli": "30.5.0" }, "bin": { "jest": "bin/jest.js" @@ -6385,14 +6813,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.0.tgz", + "integrity": "sha512-dq1x8JiEnHkJDxxOrF6UJDivBRAQMwAa5tzr+VX3um0SfyMFseUPQUbe51wLwggfoa5h/EmOtSpdJxxkzSlNRQ==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "p-limit": "^3.1.0" }, "engines": { @@ -6416,29 +6844,29 @@ } }, "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.0.tgz", + "integrity": "sha512-T3v7uM4wwCu+RQicjsAWCgoL3CiyuX3THSKwv2uMb9N2bMUgb+HfwDWEUma85vOGbvQNQ/YfoCXF1OCZot0ZEw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/expect": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-each": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-runtime": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", "p-limit": "^3.1.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -6464,21 +6892,21 @@ } }, "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.0.tgz", + "integrity": "sha512-QHZMiy32x2K+NzJ1AuuoCAVc1Y5co0VXib3R7kD9MWcWFflzsD1eJN0wR+mkNlM+ts7C+Bjz0oOoFE5IiHylCg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/core": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-config": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "yargs": "^17.7.2" }, "bin": { @@ -6540,9 +6968,9 @@ } }, "node_modules/jest-cli/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6569,33 +6997,33 @@ } }, "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.0.tgz", + "integrity": "sha512-gYQl2FqYgiVpyuB7DutBIbJRWaq5VcHdzOXJJ51HNa8J5JrsZehIlzNFW0/9s5uvvcbzM5/LTUizYSbsFErv+w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.0", + "@jest/types": "30.5.0", + "babel-jest": "30.5.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-circus": "30.5.0", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-runner": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "parse-json": "^5.2.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6619,98 +7047,26 @@ } } }, - "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/jest-config/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.0.tgz", + "integrity": "sha512-QjCfDMwdPFvLxTQmS4/Dswx3PUCiqmSXVLGljMC3SU7YG1qHVoR6b86IH/O2G9k9OMyKXz2vS2Q60VnAozNDwA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "pretty-format": "30.4.1" + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { @@ -6721,70 +7077,86 @@ } }, "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.0.tgz", + "integrity": "sha512-NiMFNhRygJEFqYNt8pnkxppUF6CR486GEpt9rSU6lPBf7KeccaOL0zbjxG8fgJgD//6e7zYdssnlt6j+1kI31A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.0", "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" + "jest-util": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.0.tgz", + "integrity": "sha512-bTc79ywKLz0ogbT3JIYuEhgWV4Ffd/cJe06co4v8CyRtlmju8x5gokMlGFR8ARWhmk5SnbDRxmf50XWnlZVhDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" + "jest-mock": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.0.tgz", + "integrity": "sha512-0FStogBslBVOEqTOJr4oXMtFitmrWp9WscG6Gbns88i0YAuMXijCT2G5VMfg/HCR4QAnL+OF2C2ednag+HlDuA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.0", + "jest-worker": "30.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" }, - "optionalDependencies": { - "fsevents": "^2.3.3" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6795,50 +7167,50 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.0.tgz", + "integrity": "sha512-Mq11ceAkNR250Iv45RoOwuG9fb4kYbJ02qoyL7A0nCI8FV5+aG/THmcEUx5uR2dNSa4KOtz+xBKrJ8cZPhPpuQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.0.tgz", + "integrity": "sha512-EfaYMC9f9ds7fahB/LYFTgd1Z2RS9Vpm2e46gazij0onkpoQG7Daq+MLm8/gQVqWwRVjL/RNDggbFx9MsrJEmQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" + "jest-diff": "30.5.0", + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "picomatch": "^4.0.3", - "pretty-format": "30.4.1", + "pretty-format": "30.5.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6847,9 +7219,9 @@ } }, "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6860,42 +7232,25 @@ } }, "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-util": "30.4.1" + "jest-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, "node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { @@ -6903,68 +7258,67 @@ } }, "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.0.tgz", + "integrity": "sha512-NFvQWJ4G7e2kN5712iG+12Xr325NRFGAIhovrwLfgq5Oqd4bFHITw4CzcFkZGp1GTCqemC4v1l8/yZzedKZkjw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-util": "30.5.0", + "jest-validate": "30.5.0", "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "unrs-resolver": "^1.12.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.0.tgz", + "integrity": "sha512-TnSBAp3wGOnqXBmLT3OXtJkugw6Vj2KU0IPde2EJmbGns/QMoNlkauJ7jZ8KYaxONSpx0o4pUvi+u1Q/LSk3Pg==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.0.tgz", + "integrity": "sha512-Q6Yt+1LvXvEstvru6sQLT7OQYC77VNl6dK0KEvBkeOHgThmXDqNp3Ox9TglDWFHU85pemqTNdKwxfnmO3vgrdA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.0", + "@jest/environment": "30.5.0", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.0", + "jest-haste-map": "30.5.0", + "jest-leak-detector": "30.5.0", + "jest-message-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-runtime": "30.5.0", + "jest-util": "30.5.0", + "jest-watcher": "30.5.0", + "jest-worker": "30.5.0", + "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6987,32 +7341,33 @@ } }, "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.0.tgz", + "integrity": "sha512-VTRz0sRIw2EISeHigx1O+CMuwoG4+RKJjF8dp8okzFaDNQEcv5Mw0h5QW8VJXgb2CRF4gZIjSEBb2jdzXPagFQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/globals": "30.5.0", + "@jest/source-map": "30.5.0", + "@jest/test-result": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", + "cjs-module-lexer": "^2.2.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.0", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.0", + "jest-snapshot": "30.5.0", + "jest-util": "30.5.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -7020,82 +7375,10 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.0.tgz", + "integrity": "sha512-pZWETdcqmKve9MDTE/AX6RaeAbRhzKhhAXGozAW8Pg2FfOSdUrQ/7D+VdwE3fLQsqjpITXJVQvYVZoMEzrUl0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7104,20 +7387,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.0", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.0", + "@jest/transform": "30.5.0", + "@jest/types": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.4.1", + "expect": "30.5.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", + "jest-diff": "30.5.0", + "jest-matcher-utils": "30.5.0", + "jest-message-util": "30.5.0", + "jest-util": "30.5.0", + "pretty-format": "30.5.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -7126,13 +7409,13 @@ } }, "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -7144,9 +7427,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -7157,18 +7440,18 @@ } }, "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.0.tgz", + "integrity": "sha512-N/hsPYKgBSzBeVZ2RHCs3yvBbTZNPX7Be8q33zhyo/yeFneBL1swzly31LYU4LJ3zJM9e8TxSM8IYLo2SDJZYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.4.1" + "pretty-format": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7188,19 +7471,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.0.tgz", + "integrity": "sha512-ujjnEoL4Uu+Swu3WRwYenWMB9JMEiD2T3OypnveMJ64S/1r/5G8eC4OQ1wEOgYWQqMi+mT+buzccflCHr/XJ9Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/test-result": "30.5.0", + "@jest/types": "30.5.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "string-length": "^4.0.2" }, "engines": { @@ -7208,15 +7491,15 @@ } }, "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.0.tgz", + "integrity": "sha512-7kFk/607EoynNHLJa20daivkElM+c9PrCLduYy6AlMkYrXbh5TmVtW1BLXE05Y8baAFsJHMoC3xs2QRRTotwLw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", + "jest-util": "30.5.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -7251,9 +7534,9 @@ } }, "node_modules/joi": { - "version": "18.2.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", - "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "version": "18.2.5", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.5.tgz", + "integrity": "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -7268,12 +7551,6 @@ "node": ">= 20" } }, - "node_modules/js-md5": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", - "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", - "license": "MIT" - }, "node_modules/js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", @@ -7366,7 +7643,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", - "optional": true, "dependencies": { "universalify": "^2.0.0" }, @@ -7609,7 +7885,8 @@ "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" }, "node_modules/locate-path": { "version": "5.0.0", @@ -7746,16 +8023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7889,8 +8156,9 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -7919,9 +8187,9 @@ } }, "node_modules/mongodb": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", - "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.6.0.tgz", + "integrity": "sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.4.11", @@ -7977,10 +8245,88 @@ "node": ">=20.19.0" } }, + "node_modules/mongodb-memory-server": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "11.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "^7.2.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.8", + "tslib": "^2.8.1", + "yauzl": "^3.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/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/mongodb-memory-server-core/node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/mongodb-memory-server-core/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/mongoose": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.9.1.tgz", - "integrity": "sha512-hI00baDVPjV5VfYA3j+oLuPvLfmCQICWot9zJZgkuDMXnWEYhIx9xCPEOt2IC3lDJz6ajsz2bpgO24E+xsP6wg==", + "version": "9.9.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.9.4.tgz", + "integrity": "sha512-Gc7buf0ExrOZ3t8MD8tVzTek7Qr5d+tEwj4GRFmHCtTZvpaDb38EVsXgCkYacPL9ICAftgS+FGe+dQHLRLHhcw==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -8033,10 +8379,56 @@ "node": ">=4.0.0" } }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", + "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/morgan": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", - "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", + "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -8089,9 +8481,9 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -8186,6 +8578,26 @@ "node": ">= 0.4.0" } }, + "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/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -8232,13 +8644,12 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "devOptional": true, "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -8252,9 +8663,9 @@ "license": "LGPL-3.0-or-later" }, "node_modules/nodemailer": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", - "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz", + "integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -8622,17 +9033,17 @@ } }, "node_modules/pdfkit": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", - "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", + "integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==", "license": "MIT", "dependencies": { - "@noble/ciphers": "^1.0.0", - "@noble/hashes": "^1.6.0", + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", "fontkit": "^2.0.4", - "js-md5": "^0.8.3", "linebreak": "^1.1.0", - "png-js": "^1.1.0" + "png-js": "^2.0.0" } }, "node_modules/peberminta": { @@ -8693,11 +9104,11 @@ } }, "node_modules/png-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", - "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", "dependencies": { - "browserify-zlib": "^0.2.0" + "fflate": "^0.8.2" } }, "node_modules/pngjs": { @@ -8736,16 +9147,16 @@ } }, "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9056,9 +9467,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -9120,22 +9531,6 @@ "axios": "^1.18.1" } }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "dev": true, - "license": "MIT" - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9266,6 +9661,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -9601,23 +9997,12 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, "license": "BSD-3-Clause", + "optional": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -9962,37 +10347,133 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=8" + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10021,13 +10502,6 @@ "node": ">=14.14" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10078,6 +10552,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", "engines": { "node": "*" } @@ -10212,7 +10687,6 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 10.0.0" } @@ -10265,53 +10739,36 @@ } }, "node_modules/unzipper": { - "version": "0.10.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", - "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "license": "MIT", "dependencies": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", + "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fstream": "^1.0.12", + "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", - "listenercount": "~1.0.1", - "readable-stream": "~2.3.6", - "setimmediate": "~1.0.4" - } - }, - "node_modules/unzipper/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "node-int64": "^0.4.0" } }, - "node_modules/unzipper/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/unzipper/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -10355,9 +10812,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", - "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -10408,16 +10865,6 @@ "node": ">=0.10.0" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -10511,13 +10958,6 @@ "node": ">= 14" } }, - "node_modules/whatsapp-web.js/node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT", - "optional": true - }, "node_modules/whatsapp-web.js/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", diff --git a/api/package.json b/api/package.json index 71690e914..9b6e7668d 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "Api_v2_express", - "version": "2.0.0", + "version": "1.6.1", "description": "Posnic API v2 - Express.js Backend (1:1 PHP Replica)", "main": "server.js", "scripts": { @@ -25,7 +25,8 @@ "test:api": "node scripts/test-local.js tests/api --forceExit --runInBand", "audit:money": "node scripts/audit-money.js", "test:all": "jest --detectOpenHandles --forceExit", - "test:coverage:all": "jest --coverage --detectOpenHandles --forceExit" + "test:coverage:all": "jest --coverage --detectOpenHandles --forceExit", + "local": "node scripts/local-dev.js" }, "keywords": [ "posnic", @@ -38,11 +39,11 @@ "author": "Posnic Team", "license": "AGPL-3.0-only", "dependencies": { - "@aws-sdk/client-s3": "^3.1106.0", - "@getbrevo/brevo": "^6.0.2", - "axios": "^1.19.0", + "@aws-sdk/client-s3": "^3.1121.0", + "@getbrevo/brevo": "^6.0.3", + "axios": "^1.20.0", "bcryptjs": "^3.0.3", - "bson": "^7.3.1", + "bson": "^7.3.2", "compression": "^1.8.1", "connect-mongo": "^6.0.0", "cookie-parser": "^1.4.7", @@ -56,25 +57,26 @@ "express-validator": "^7.3.2", "helmet": "^8.3.0", "hpp": "^0.2.3", - "html-to-text": "^10.0.0", + "html-to-text": "^10.0.1", "http-status": "^2.1.0", - "joi": "^18.2.3", + "joi": "^18.2.5", "jsonwebtoken": "^9.0.3", "lodash": "^4.18.1", "moment": "^2.30.1", "moment-timezone": "^0.6.3", - "mongodb": "^7.5.0", - "mongoose": "^9.9.1", + "mongodb": "^7.6.0", + "mongoose": "^9.9.4", "mongoose-paginate-v2": "^1.9.5", - "morgan": "^1.11.0", - "multer": "^2.2.0", - "nodemailer": "^9.0.5", - "pdfkit": "^0.19.1", + "morgan": "^1.12.0", + "multer": "^2.3.0", + "nodemailer": "^9.0.6", + "pdfkit": "^0.20.1", "pug": "^3.0.4", "qrcode": "^1.5.4", "razorpay": "^2.9.8", "sib-api-v3-sdk": "^8.5.0", - "uuid": "^14.0.1", + "unzipper": "^0.12.5", + "uuid": "^14.0.2", "validator": "^13.15.35", "web-push": "^3.6.7", "whatsapp-web.js": "^1.34.7", @@ -82,13 +84,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@redocly/cli": "^2.46.0", - "@types/node": "^26.2.0", - "eslint": "^10.8.1", + "@redocly/cli": "^2.49.0", + "@types/node": "^26.4.0", + "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "glob": "^13.0.6", - "globals": "^17.9.0", - "jest": "^30.4.2", + "globals": "^17.11.0", + "jest": "^30.5.0", + "mongodb-memory-server": "^11.2.0", "nodemon": "^3.1.14", "prettier": "^3.9.6", "swagger-ui-express": "^5.0.1" @@ -108,6 +111,7 @@ "exceljs": { "uuid": "^11.1.1" }, - "deepmerge-ts": "^8.0.0" + "deepmerge-ts": "^8.0.0", + "qs": "^6.16.0" } } diff --git a/api/scripts/audit-module-keys.js b/api/scripts/audit-module-keys.js old mode 100644 new mode 100755 diff --git a/api/scripts/audit-money.js b/api/scripts/audit-money.js old mode 100644 new mode 100755 diff --git a/api/scripts/check-ci-database.js b/api/scripts/check-ci-database.js old mode 100644 new mode 100755 diff --git a/api/scripts/known-failures.js b/api/scripts/known-failures.js old mode 100644 new mode 100755 diff --git a/api/scripts/local-dev.js b/api/scripts/local-dev.js new file mode 100755 index 000000000..fde9bb63a --- /dev/null +++ b/api/scripts/local-dev.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +'use strict'; + +/* + * A whole Posnic on this machine, with nothing shared with production. + * + * Owner, before the vendor-stack upgrade: "create one local mongodb and + * install all local... i dont want existing customer affected." + * + * Starts an embedded MongoDB (downloaded once into the mongodb-memory-server + * cache) with its files under api/.local-db so the data SURVIVES restarts - + * an in-memory database would make every test session start from nothing. + * Then boots the API against it on a local port. The frontend is served by + * the same API process out of frontend/public, so one URL is the whole app. + * + * Nothing here reads production credentials: the URI is localhost, the JWT + * secret is a throwaway, and the tenant is whatever you seed. + */ +const path = require('path'); +const fs = require('fs'); + +const DB_DIR = path.join(__dirname, '..', '.local-db'); +const PORT = process.env.LOCAL_PORT || 5055; +const DB_PORT = process.env.LOCAL_DB_PORT || 27055; + +async function main() { + fs.mkdirSync(DB_DIR, { recursive: true }); + + /* + * A previous run that was killed (rather than shut down) leaves + * mongod.lock behind and the next start dies on DBPathInUse. Clear a + * stale lock when no mongod is actually holding the port. + */ + const lock = path.join(DB_DIR, 'mongod.lock'); + if (fs.existsSync(lock)) { + try { + fs.unlinkSync(lock); + console.log('[local] cleared a stale database lock from a previous run'); + } catch (e) { + console.error('[local] a previous MongoDB is still running and holding ' + DB_DIR); + console.error('[local] stop it first: powershell "Get-Process *mongo* | Stop-Process -Force"'); + process.exit(1); + } + } + + const { MongoMemoryServer } = require('mongodb-memory-server'); + console.log('[local] starting embedded MongoDB (first run downloads it)…'); + const mongo = await MongoMemoryServer.create({ + instance: { port: Number(DB_PORT), dbName: 'PosnicPro', dbPath: DB_DIR, storageEngine: 'wiredTiger' }, + }); + const uri = mongo.getUri('PosnicPro'); + console.log('[local] mongodb ready on port ' + DB_PORT + ' (data kept in api/.local-db)'); + + /* + * Secrets: generated once for THIS machine and kept beside the local + * database. The API refuses to boot without them by design (a shipped + * default would be shared by every installation), and these never leave + * the box - .local-db is gitignored. + */ + const crypto = require('crypto'); + const secretsFile = path.join(DB_DIR, 'local-secrets.json'); + let secrets; + if (fs.existsSync(secretsFile)) { + secrets = JSON.parse(fs.readFileSync(secretsFile, 'utf8')); + } else { + const rand = () => crypto.randomBytes(32).toString('hex'); + secrets = { + SESSION_SECRET: rand(), + ENCRYPTION_KEY: rand(), + ENCRYPTION_IV: rand(), + JWT_SECRET: rand(), + KIOSK_API_KEY: rand(), + POSNIC_KEY: rand(), + POSNIC_SECRET: rand(), + }; + fs.writeFileSync(secretsFile, JSON.stringify(secrets, null, 2)); + console.log('[local] generated local-only secrets (api/.local-db/local-secrets.json)'); + } + for (const [k, v] of Object.entries(secrets)) { + if (!process.env[k]) process.env[k] = v; + } + + process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + process.env.MONGODB_URI = uri; + process.env.PORT = String(PORT); + process.env.JWT_EXPIRE = process.env.JWT_EXPIRE || '30d'; + + console.log('[local] booting the API…'); + require(path.join(__dirname, '..', 'server.js')); + + /* + * A shop to log into. Installed once through the real installation + * endpoint - the same path a genuine tenant takes - so the local copy + * exercises production code rather than a special test fixture. + */ + const LOGIN = { user: 'local@posnic.test', pass: 'Local@12345' }; + setTimeout(async () => { + try { + const base = 'http://localhost:' + PORT; + /* + * Install exactly ONCE per local database. Re-running install/add on + * an existing license does NOT upsert - it creates a NEW branch id + * every time, orphaning the previous branch's items and settings + * (that is how the local shop kept "losing" its catalogue between + * restarts). Until the endpoint is made idempotent server-side, a + * marker beside the data files is the guard. + */ + const installedMarker = path.join(DB_DIR, 'installed.marker'); + if (!fs.existsSync(installedMarker)) { + const res = await fetch(base + '/api/install/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key: process.env.POSNIC_KEY, + secret: process.env.POSNIC_SECRET, + register_companyname: 'Local Test Shop', + register_username: LOGIN.user, + register_useremail: LOGIN.user, + register_userphone: '9000000000', + register_userpassword: LOGIN.pass, + /* a real, stable ObjectId so restarts reuse the same shop */ + register_license: 'local'.split('').map(function (c) { return c.charCodeAt(0).toString(16); }).join('').padEnd(24, '0'), + register_firstname: 'Local', + register_lastname: 'Tester', + /* the installer builds a default customer from these */ + register_country: 'India', + register_countryid: '101', + register_state: 'Tamil Nadu', + register_stateid: '4035', + register_currency: 'INR', + register_timezone: 'Asia/Kolkata', + /* register_demo, not demo_data. The install service reads only + the former, so this script has been creating an EMPTY local shop + all along - 'demo_data' was silently ignored. */ + register_demo: 'yes', + businessType: 'supermarket', + }), + }).then((r) => r.json()).catch((e) => ({ error: e.message })); + console.log('[local] shop install: ' + (res && (res.message || res.type || res.error || 'done'))); + if (res && res.type !== 'error' && !res.error) { + fs.writeFileSync(installedMarker, new Date().toISOString()); + } + } + } catch (e) { + console.log('[local] shop install skipped: ' + e.message); + } + console.log('\n' + '='.repeat(66)); + console.log(' POSNIC (LOCAL) -> http://localhost:' + PORT + '/login.html'); + console.log(' login: ' + LOGIN.user + ' password: ' + LOGIN.pass); + console.log(' database: local only (api/.local-db) - production untouched'); + console.log('='.repeat(66) + '\n'); + }, 3000); + + const shutdown = async () => { + console.log('\n[local] stopping…'); + await mongo.stop().catch(() => {}); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error('[local] failed to start:', err && err.message); + process.exit(1); +}); diff --git a/api/scripts/retro-link-variants.js b/api/scripts/retro-link-variants.js old mode 100644 new mode 100755 diff --git a/api/scripts/ring-reload.sh b/api/scripts/ring-reload.sh old mode 100644 new mode 100755 index a3cba652a..2dc4d796b --- a/api/scripts/ring-reload.sh +++ b/api/scripts/ring-reload.sh @@ -44,6 +44,15 @@ ADMIN_DIR="$HOME/apps/admin" API_DIR="$(pwd)" RINGS_FILE="$ADMIN_DIR/provisioning/rings.json" +# A planned reload is not an outage. The watchdog honors ~/maintenance and +# stays quiet while it exists - without this, every deploy mailed the owner +# an ALERT + recovered pair the moment its blip crossed a watchdog tick. +# The trap clears it on ANY exit: after a FAILED deploy the processes are +# genuinely down and the very next tick should alert as loudly as ever. +MAINTENANCE_FLAG="$HOME/maintenance" +touch "$MAINTENANCE_FLAG" +trap 'rm -f "$MAINTENANCE_FLAG"' EXIT + cd "$APP_DIR" online_names() { @@ -145,10 +154,19 @@ smoke() { # deploy, and a heavy shipping day drained the fixture to zero - every # deploy then failed its own gate for a reason unrelated to the code. # Top the fixture back up before proving the ring. Never a gate itself. + # The SAME tenant the smoke below uses, or the two halves top up one shop + # while selling from another. It comes from the sourced admin .env so the + # target can be changed without a public repo commit if the owner rotates + # the test shop. if [ -f "$API_DIR/scripts/smoke-restock.js" ]; then - node "$API_DIR/scripts/smoke-restock.js" || true + SMOKE_WRITE_TENANT="${SMOKE_WRITE_TENANT:-}" \ + node "$API_DIR/scripts/smoke-restock.js" || true fi - SMOKE_QUIET=true SMOKE_WRITE_TENANT="${SMOKE_WRITE_TENANT:-tech}" \ + # The billing write test deliberately has no code default: it creates and + # deletes a real sale, so the target must be an operator-owned test shop. + # If SMOKE_WRITE_TENANT is absent, smoke.js reports the write check as + # skipped instead of guessing a customer shop. + SMOKE_QUIET=true SMOKE_WRITE_TENANT="${SMOKE_WRITE_TENANT:-}" \ node provisioning/smoke.js --quiet ) } diff --git a/api/scripts/smoke-restock.js b/api/scripts/smoke-restock.js old mode 100644 new mode 100755 index d87c3606e..f42b30eaa --- a/api/scripts/smoke-restock.js +++ b/api/scripts/smoke-restock.js @@ -40,6 +40,49 @@ async function main() { const client = new MongoClient(uri); await client.connect(); try { + /* + * THE WRITE SHOP'S OWN FIXTURE, FIRST. + * + * The billing smoke sells whatever item db.items.find().limit(1) returns + * in SMOKE_WRITE_TENANT's database - not the legacy fixture below. When + * the write shop moved off the old sbala shop, this helper kept topping + * up an item nobody was selling any more while the item actually being + * drained crept toward zero, one unit per deploy, with the freeze + * arriving weeks later on a commit that had nothing to do with it. + * + * The tenant db is derived exactly the way the provisioner names them: + * posnic_t_. + */ + const writeTenant = String(process.env.SMOKE_WRITE_TENANT || '').trim(); + if (writeTenant) { + const dbName = 'posnic_t_' + writeTenant.replace(/-/g, ''); + const items = client.db(dbName).collection('items'); + const first = await items + .find({}) + .project({ name: 1, available_quantity: 1 }) + .limit(1) + .toArray() + .catch(() => []); + if (first.length) { + const qty = Number(first[0].available_quantity) || 0; + if (qty < RESTOCK_BELOW) { + await items.updateOne( + { _id: first[0]._id }, + { $set: { available_quantity: RESTOCK_TO } } + ); + console.log( + `[smoke-restock] "${first[0].name}" in ${dbName} topped up ${qty} -> ${RESTOCK_TO}` + ); + } else { + console.log( + `[smoke-restock] "${first[0].name}" in ${dbName} has ${qty} - no top-up needed` + ); + } + } + } + + /* The legacy fixture, kept for as long as the old shop exists: pointing + SMOKE_WRITE_TENANT back at it must not reopen the drain. */ const { databases } = await client.db().admin().listDatabases({ nameOnly: true }); for (const { name } of databases) { if (['admin', 'local', 'config'].includes(name)) continue; diff --git a/api/scripts/test-local.js b/api/scripts/test-local.js old mode 100644 new mode 100755 diff --git a/api/shard.js b/api/shard.js index a77ce0d55..1bb9a0549 100644 --- a/api/shard.js +++ b/api/shard.js @@ -103,11 +103,79 @@ let lastMiss = 0; let controlClient = null; +/* + * Which shops this shard is responsible for. + * + * Unset means every provisioned shop, which is what a single-machine estate + * wants and what shipped. SHARD_INSTANCE narrows it to the shops assigned to + * one machine - necessary the moment a second machine runs a shard, because + * otherwise both would try to open every shop in the fleet and each would fail + * on the databases that live on the other one. + */ +const SHARD_INSTANCE = String(process.env.SHARD_INSTANCE || '').trim(); + +/* + * A registry from a FILE instead of the control database. + * + * The demo estate needs this. Reading the control registry means holding the + * control credential and TENANT_SECRET_KEY, which unseals every shop's secrets + * across the whole fleet - and the demo box is a public machine whose logins + * are printed on its own login page. Putting the fleet master key there to + * serve shops that contain nothing would be a poor trade. + * + * The file carries its own plaintext secrets, which is the same posture the + * single-shop demo already has: its JWT_SECRET sits in a 0600 .env beside it. + * The difference from the control path is only WHERE the shop list comes from; + * every tenant still gets its own scope and its own keys, and currentSecret + * still refuses to fall through to the environment. + * + * Unset - which is every production shard - and nothing here runs. + */ +const REGISTRY_FILE = String(process.env.SHARD_REGISTRY_FILE || '').trim(); + +/* Lower-cased once: node lower-cases incoming header names, and comparing + against a mixed-case env value would simply never match. */ +const SHOP_HEADER = String(process.env.SHARD_SHOP_HEADER || '').trim().toLowerCase(); + +function loadRegistryFromFile() { + const raw = JSON.parse(require('fs').readFileSync(REGISTRY_FILE, 'utf8')); + const rows = Array.isArray(raw) ? raw : raw.shops || []; + const next = new Map(); + + for (const r of rows) { + if (!r || !r.host || !r.tenantDb) continue; + /* Same rule as the control path: a shop whose keys are missing is not + served, because serving it would sign tokens nothing can verify. */ + const secrets = r.secrets && typeof r.secrets === 'object' ? r.secrets : null; + if (!secrets || !secrets.JWT_SECRET) { + console.error(`[shard] ${r.host}: no JWT_SECRET in the registry file; not served`); + continue; + } + const connection = mongoose.connection.useDb(r.tenantDb, { useCache: true }); + next.set(String(r.host).toLowerCase(), { + subdomain: r.subdomain || r.host, + tenantDb: r.tenantDb, + suspended: !!r.suspended, + connection, + db: connection.db, + secrets, + }); + } + + byHost.clear(); + for (const [k, v] of next) byHost.set(k, v); + lastLoad = Date.now(); + console.log(`[shard] serving ${next.size} hostname(s) from ${REGISTRY_FILE}`); +} + async function loadRegistry() { + if (REGISTRY_FILE) return loadRegistryFromFile(); + const query = { provisioned: true, subdomain: { $exists: true, $nin: [null, ''] } }; + if (SHARD_INSTANCE) query.instance = SHARD_INSTANCE; const tenants = await controlClient .db(CONTROL_DB) .collection('tenants') - .find({ provisioned: true, subdomain: { $exists: true, $nin: [null, ''] } }) + .find(query) .project({ subdomain: 1, tenantDb: 1, secrets: 1, suspended: 1, webDomain: 1 }) .toArray(); @@ -153,7 +221,8 @@ async function loadRegistry() { byHost.clear(); for (const [k, v] of next) byHost.set(k, v); lastLoad = Date.now(); - console.log(`[shard] serving ${next.size} hostname(s) across ${tenants.length} shop(s)`); + console.log(`[shard] serving ${next.size} hostname(s) across ${tenants.length} shop(s)` + + (SHARD_INSTANCE ? ` on ${SHARD_INSTANCE}` : '')); } /** The shop a request belongs to, or null. */ @@ -183,7 +252,8 @@ async function resolve(hostHeader) { async function main() { if (!CONTROL_URI) throw new Error('CONTROL_URI or MONGODB_URI must be set'); - if (!CONTROL_DB) throw new Error('CONTROL_DB must be set'); + /* Only the control path needs a control database to read. */ + if (!REGISTRY_FILE && !CONTROL_DB) throw new Error('CONTROL_DB must be set'); /* * Declared before anything is served. @@ -196,8 +266,10 @@ async function main() { enableMultiTenant(true); await mongoose.connect(CONTROL_URI, { maxPoolSize: POOL_SIZE }); - controlClient = new MongoClient(CONTROL_URI, { maxPoolSize: 5 }); - await controlClient.connect(); + if (!REGISTRY_FILE) { + controlClient = new MongoClient(CONTROL_URI, { maxPoolSize: 5 }); + await controlClient.connect(); + } await loadRegistry(); setInterval(() => { @@ -205,7 +277,21 @@ async function main() { }, RELOAD_MS).unref(); const server = http.createServer((req, res) => { - resolve(req.headers.host) + /* + * Which header names the shop. + * + * Host, normally - a shop is its own hostname and that is the whole + * addressing scheme. The demo estate is the exception: fifty shops share + * ONE public hostname and are chosen by a cookie, so the front end has to + * name the shop some other way. + * + * Rewriting Host would do it, and would also put an internal name into + * every absolute URL the application generates - links to a hostname that + * does not resolve for anybody. A header of its own costs nothing and + * cannot leak into a page. + */ + const named = SHOP_HEADER ? req.headers[SHOP_HEADER] : null; + resolve(named || req.headers.host) .then((tenant) => { if (!tenant) { res.writeHead(404, { 'content-type': 'text/plain' }); diff --git a/api/src/config/demo-mode.js b/api/src/config/demo-mode.js new file mode 100644 index 000000000..dd9c2a42b --- /dev/null +++ b/api/src/config/demo-mode.js @@ -0,0 +1,36 @@ +'use strict'; + +/* + * The public demo's collar (DEMO_SHOP_PLAN §4). + * + * One env flag, read here and nowhere else. Everything it changes is + * enforced SERVER-side - hiding a button is a courtesy, not a control - + * and every install without the flag behaves byte-identically to today: + * this ships to every till and every tenant, inert. + * + * What it collars, and why: + * - outbound email / SMS / WhatsApp: the demo holds published logins; + * an open relay with a web UI is what spammers dream about at night. + * - password and user changes: admin/admin, manager/manager and + * cashier/cashier are permanent fixtures - the next visitor needs + * them exactly as printed on the login page. + * + * Data entry stays OPEN on purpose. Making sales, adding items, breaking + * the layout - that IS the product tour, and the hourly restore makes the + * shop whole again. + */ +const isDemoMode = () => + ['1', 'true', 'yes', 'on'].includes(String(process.env.DEMO_MODE || '').toLowerCase()); + +const DEMO_BLOCKED_MESSAGE = + 'This is the public demo, so this action is switched off. Everything here resets on the hour - explore freely.'; + +/* Route middleware for the endpoints a demo must refuse. 403 with the reason + in plain words: the person who taps "change password" in a demo deserves + to know it is the demo saying no, not a bug. */ +const demoGuard = (req, res, next) => { + if (!isDemoMode()) return next(); + return res.status(403).json({ type: 'error', message: DEMO_BLOCKED_MESSAGE }); +}; + +module.exports = { isDemoMode, demoGuard, DEMO_BLOCKED_MESSAGE }; diff --git a/api/src/config/index.js b/api/src/config/index.js index 7119dc8a9..dd23985ec 100644 --- a/api/src/config/index.js +++ b/api/src/config/index.js @@ -47,7 +47,7 @@ const envVarsSchema = Joi.object() .default(15 * 60 * 1000), // 15 minutes RATE_LIMIT_MAX: Joi.alternatives().try(Joi.number(), Joi.string()).default(100), - // Email (optional – allow empty to avoid hard-failing when SMTP is not configured) + // Email (optional - allow empty to avoid hard-failing when SMTP is not configured) EMAIL_HOST: Joi.string().optional().allow('').description('SMTP host'), EMAIL_PORT: Joi.alternatives() .try(Joi.number(), Joi.string()) diff --git a/api/src/constants/roles.constants.js b/api/src/constants/roles.constants.js index f14a4c4f6..f3b5fef92 100644 --- a/api/src/constants/roles.constants.js +++ b/api/src/constants/roles.constants.js @@ -15,7 +15,7 @@ * `role.pos` in Phase 2. * * These DEFAULT_ROLES are seeded per tenant as `is_system` roles (clone to make - * a custom role). They are the STARTING presets — a shop can edit them. + * a custom role). They are the STARTING presets - a shop can edit them. */ // access-triple helpers @@ -71,6 +71,8 @@ const POS_PERMISSIONS = { REGISTER_CLOSE: 'register_close', CASH_IN_OUT: 'cash_in_out', CASH_DROP: 'cash_drop', + // Owner ask: quick sale grantable per cashier; deny only when unticked. + QUICK_SALE: 'quick_sale', }; const pos = (o = {}) => ({ @@ -87,6 +89,8 @@ const pos = (o = {}) => ({ register_close: !!o.register_close, cash_in_out: !!o.cash_in_out, cash_drop: !!o.cash_drop, + // Default allowed: selling fast is the norm; deny is the explicit act. + quick_sale: o.quick_sale !== false, }); const POS_FULL = { discount_apply: true, @@ -101,6 +105,7 @@ const POS_FULL = { register_close: true, cash_in_out: true, cash_drop: true, + quick_sale: true, }; const ROLE_KEYS = { diff --git a/api/src/constants/users.constants.js b/api/src/constants/users.constants.js index 953aceae4..1d1b60a69 100644 --- a/api/src/constants/users.constants.js +++ b/api/src/constants/users.constants.js @@ -92,17 +92,26 @@ const HTTP_STATUS = { INTERNAL_ERROR: 500, }; -// Languages +// Languages the app can show. Mirrors LANGUAGES in frontend/gulpfile.js/config.js, +// which is where a language is actually added; this is the server-side allow +// list for a user's stored preference. const LANGUAGES = { ENGLISH: 'en', - SPANISH: 'es', - FRENCH: 'fr', - GERMAN: 'de', - HINDI: 'hi', TAMIL: 'ta', - TELUGU: 'te', - KANNADA: 'kn', + HINDI: 'hi', MALAYALAM: 'ml', + KANNADA: 'kn', + TELUGU: 'te', + SINHALA: 'si', + NEPALI: 'ne', + ARABIC: 'ar', + FRENCH: 'fr', + SPANISH: 'es', + PORTUGUESE: 'pt', + INDONESIAN: 'id', + THAI: 'th', + // Kept for records that already hold it; the app does not offer it. + GERMAN: 'de', }; // Theme preferences diff --git a/api/src/controllers/activity-logs.controller.js b/api/src/controllers/activity-logs.controller.js index 416d30eff..95046ccb3 100644 --- a/api/src/controllers/activity-logs.controller.js +++ b/api/src/controllers/activity-logs.controller.js @@ -1,4 +1,5 @@ const activityLogger = require('../utils/activityLogger'); +const { clientIp } = require('../utils/client-ip'); const catchAsync = require('../utils/catchAsync'); const { AppError } = require('../utils/appError'); const mongoose = require('mongoose'); @@ -156,7 +157,7 @@ exports.createActivityLog = catchAsync(async (req, res, next) => { entity: req.body.entity, entityId: req.body.entityId, details: req.body.details, - ipAddress: req.ip, + ipAddress: clientIp(req), userAgent: req.get('user-agent'), ...(scope || {}), }); diff --git a/api/src/controllers/auth-utils.controller.js b/api/src/controllers/auth-utils.controller.js index e017f7997..860c94285 100644 --- a/api/src/controllers/auth-utils.controller.js +++ b/api/src/controllers/auth-utils.controller.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const { clientIp } = require('../utils/client-ip'); const { promisify } = require('util'); const { authCookieOptions } = require('../utils/auth-cookie'); /* Per request. A signing key read once at module load is the process's key, @@ -119,7 +120,7 @@ const createAndSendToken = async (user, statusCode, res, req) => { login_time: loginTime, logout_time: null, is_active: true, - ip_address: req.ip || '127.0.0.1', + ip_address: clientIp(req), created_date: loginTime, }; diff --git a/api/src/controllers/base.controller.js b/api/src/controllers/base.controller.js index 0bdf780d0..d931ef802 100644 --- a/api/src/controllers/base.controller.js +++ b/api/src/controllers/base.controller.js @@ -256,7 +256,7 @@ class BaseController { const process = (item) => { if (!item || typeof item !== 'object') return item; - // Preserve Date and ObjectId instances — spreading them creates empty {} + // Preserve Date and ObjectId instances - spreading them creates empty {} if (item instanceof Date) return item; if (item._bsontype) return item; diff --git a/api/src/controllers/branches.controller.js b/api/src/controllers/branches.controller.js index 1cebebf08..66311fbaf 100644 --- a/api/src/controllers/branches.controller.js +++ b/api/src/controllers/branches.controller.js @@ -4,6 +4,12 @@ const BranchModel = BranchModule.BranchModel; const branchesService = require('../services/branch.service'); const User = require('../models/user.model'); const { ObjectId } = require('mongodb'); +const { redactBranchSecrets, stripBranchSecrets } = require('../services/settings-groups'); +const SettingsRepository = require('../repositories/settings.repository'); +const dataSharing = require('../services/data-sharing'); +const catalogueCopy = require('../services/catalogue-copy'); + +const settingsRepository = new SettingsRepository(); class BranchesController extends BaseController { constructor() { @@ -51,7 +57,9 @@ class BranchesController extends BaseController { if (result.status === true) { if (result.data && result.data.list) { - result.data.list = this.mongoIDFilter(result.data.list); + /* Credentials never belong in a LIST - unstripped this hands out + every branch's mail and SMS passwords in one response. */ + result.data.list = stripBranchSecrets(this.mongoIDFilter(result.data.list)); } return this.success(res, result.data, result.message, 200); } else { @@ -100,7 +108,11 @@ class BranchesController extends BaseController { return this.error(res, 'Branch Not found', 404); } - return this.success(res, result.data, result.message || 'Branch retrieved successfully'); + return this.success( + res, + stripBranchSecrets(result.data), + result.message || 'Branch retrieved successfully' + ); } catch (error) { console.error('Error in getOne branch:', error); return this.error(res, 'Failed to retrieve branch', 500); @@ -131,7 +143,122 @@ class BranchesController extends BaseController { return this.error(res, result.message, 404, result.data); } - return this.success(res, result.data, result.message, 200); + /* + * S6 (D4): start a new shop from an existing one. + * + * The design says this replaces the hardcoded defaults in createBranch. + * It OVERLAYS them instead, deliberately: those defaults also seed the + * print templates and currency, and a source branch that happens to + * lack one would otherwise leave the new shop with nothing there. The + * defaults stay the floor, the copy states the preferences on top, and + * a create without the option behaves exactly as it always has. + * + * A failure here never fails the create - the branch exists and can be + * configured by hand; losing it because a copy went wrong would be far + * worse. The reason is reported alongside the branch instead. + */ + const copyFrom = req.body?.copy_settings_from; + if (copyFrom && result.data?._id) { + const groups = + Array.isArray(req.body.copy_groups) && req.body.copy_groups.length + ? req.body.copy_groups + : ['features', 'preferences', 'documents']; + try { + const copied = await settingsRepository.copyGroups(groups, copyFrom, result.data._id, { + licenseId: req.user?.license || req.user?.license_id || null, + }); + result.data.settings_copied = copied.status ? copied.data.copied : null; + if (!copied.status) result.data.settings_copy_error = copied.message; + } catch (e) { + console.error('[branch] settings copy skipped:', e && e.message); + result.data.settings_copy_error = e.message; + } + } + + /* + * Owner ask #85, the "inventory copy" half. + * + * Not a sharing switch, deliberately - see services/catalogue-copy.js. + * Stock lives on the item document and each branch owns its own items, + * so a shared item read would show N copies of every product with N + * different counts, and selling the wrong row would decrement another + * shop's stock. A new shop wants the CATALOGUE, with its own counts + * starting at zero, and that is a copy. + * + * Like the settings copy, a failure never fails the create: the branch + * exists and products can be added by hand, and losing a just-created + * shop over a catalogue copy would be far worse. + */ + const itemsFrom = req.body?.copy_items_from; + if (itemsFrom && result.data?._id) { + try { + const BaseModel = require('../models/base.model'); + const db = await BaseModel.getDb(); + const copied = await catalogueCopy.copyCatalogue(db, { + sourceBranchId: itemsFrom, + targetBranchId: result.data._id, + targetBranchName: result.data.name || req.body?.name || '', + licenseId: req.user?.license || req.user?.license_id || null, + userId: req.user?._id || null, + userName: req.user?.username || '', + }); + result.data.items_copied = copied.status ? copied.data : null; + if (!copied.status) result.data.items_copy_error = copied.message; + } catch (e) { + console.error('[branch] catalogue copy skipped:', e && e.message); + result.data.items_copy_error = e.message; + } + } + + /* + * Owner ask #85: sharing is decided when a second shop appears. + * + * "Whenever new branch created mandatory information needs to be auto + * filled... we should auto selected as true based on standard values." + * The default customer, supplier and tax are already seeded by + * createBranch; this is the other half - who the new shop can SEE. + * + * ONLY IF THE ACCOUNT HAS NOT ALREADY DECIDED. Creating a third shop + * must not silently re-impose a default over a rule someone deliberately + * set when they created the second. `accountGroup` answers exactly that + * question - `set` names the keys the account itself decides - so an + * existing choice is left alone key by key rather than wholesale. + * + * Written at ACCOUNT level, because "can this shop see that shop's + * customers" is not a fact about one shop. A branch can still override + * it afterwards; that is what S5 inheritance is for. + * + * Like the settings copy above, a failure here never fails the create. + */ + if (result.data?._id) { + try { + const ctx = { + licenseId: req.user?.license || req.user?.license_id || null, + branchId: result.data._id, + }; + const current = await settingsRepository.accountGroup('sharing', ctx); + const decided = (current.status && current.data?.set) || []; + const seed = {}; + for (const [key, fallback] of Object.entries(dataSharing.CREATE_DEFAULTS)) { + if (decided.includes(key)) continue; + seed[key] = + req.body?.[key] === undefined ? fallback : dataSharing.truthy(req.body[key]); + } + if (Object.keys(seed).length) { + const wrote = await settingsRepository.saveGroup('sharing', seed, ctx, { + level: 'account', + }); + result.data.sharing_defaults = wrote.status ? seed : null; + if (!wrote.status) result.data.sharing_error = wrote.message; + } + dataSharing.invalidate(ctx.licenseId); + } catch (e) { + console.error('[branch] sharing defaults skipped:', e && e.message); + result.data.sharing_error = e.message; + } + } + + return this.success(res, stripBranchSecrets(result.data), result.message, 200); } catch (error) { console.error('Error in add branch:', error); return this.error(res, 'Failed to create branch: ' + error.message, 500); @@ -163,7 +290,7 @@ class BranchesController extends BaseController { return this.error(res, result.message, 404); } - return this.success(res, result.data, result.message); + return this.success(res, stripBranchSecrets(result.data), result.message); } catch (error) { console.error('Error in edit branch:', error); return this.error(res, 'Failed to update branch: ' + error.message, 500); @@ -494,10 +621,15 @@ class BranchesController extends BaseController { }); } + /* S4: this returns the whole branch document, and the settings screen + reads its email and SMS cards from it - so the SMTP password, the SMS + gateway password and two API keys were being handed to the browser of + anyone who could open Settings. They leave as a configured/not map; + the values only ever travel inwards now. */ return res.status(200).json({ type: 'success', message: 'Get store details successfully', - data: result.data, + data: redactBranchSecrets(result.data), }); } catch (error) { console.error('Error in getOneStore:', error); @@ -535,7 +667,7 @@ class BranchesController extends BaseController { return this.error(res, 'Branch not found', 404); } - return this.success(res, branch, 'Branch details retrieved successfully'); + return this.success(res, stripBranchSecrets(branch), 'Branch details retrieved successfully'); } catch (error) { console.error('Error in getBranchDetails:', error); return this.error(res, error.message, 500); diff --git a/api/src/controllers/categories.controller.js b/api/src/controllers/categories.controller.js index bfc439668..d61889f04 100644 --- a/api/src/controllers/categories.controller.js +++ b/api/src/controllers/categories.controller.js @@ -241,6 +241,63 @@ class CategoriesController extends BaseController { * Get all categories with pagination * GET /categories */ + /** + * GET /categories/getDataChanges + * + * The route for this has been live and calling a method that was never + * written, so the endpoint answered 500 to anything that asked. Nothing in + * the frontend calls it today, which is the only reason it went unnoticed - + * a sync client picking it up would have found it broken. + * + * Thin by design: the service and repository already implement the query, + * exactly as customer-categories does. All this adds is the branch the + * changes are scoped to, resolved the same way every other method here + * resolves it rather than trusting a query parameter. + */ + getDataChanges = asyncHandler(async (req, res) => { + try { + const from = req.query.from || ''; + if (!from) { + return res.status(400).json({ + type: 'error', + message: 'A "from" date is required', + data: null, + }); + } + + const { branch_id: branchId } = await this.resolveBranchContext(req); + if (!branchId) { + return res.status(400).json({ + type: 'error', + message: 'Branch could not be resolved', + data: null, + }); + } + + const result = await this.service.getDataChanges(from, branchId); + if (!result.status) { + return res.status(200).json({ + type: 'error', + message: result.message || 'Not valid Input', + data: result.data || null, + }); + } + + return res.status(200).json({ + type: 'success', + message: 'Changes Retrieved', + data: result.data, + }); + } catch (error) { + console.error('Error in getDataChanges:', error); + return res.status(500).json({ + type: 'error', + message: error.message, + data: null, + }); + } + }); + getAll = asyncHandler(async (req, res) => { if (!this.checkPermission('category', 'read', req.user)) { return this.error(res, 'Unauthorized', 403); @@ -311,11 +368,49 @@ class CategoriesController extends BaseController { const result = await this.service.getAllCategories(filters, { page, limit }); if (result.status) { + // The number a retailer actually scans this list for: how many items + // live under each category. One pass over the page's categories - + // items carrying category_id group under the id, legacy name-only + // items group under the name, so the two sets cannot double count. + let itemCounts = null; + try { + const rows = result.data.data || []; + const ids = rows + .map((c) => c._id) + .filter(Boolean) + .map((v) => new Types.ObjectId(String(v))); + const names = rows.map((c) => c.name).filter(Boolean); + if (ids.length || names.length) { + const itemsCol = await new BaseModel('items').getCollection('items'); + const groups = await itemsCol + .aggregate([ + { + $match: { + $or: [{ category_id: { $in: ids } }, { category_name: { $in: names } }], + }, + }, + { $project: { key: { $ifNull: ['$category_id', '$category_name'] } } }, + { $group: { _id: '$key', n: { $sum: 1 } } }, + ]) + .toArray(); + itemCounts = {}; + groups.forEach((g) => { + itemCounts[String(g._id)] = g.n; + }); + } + } catch (e) { + // the count is a garnish - the list must never fail over it + itemCounts = null; + } + const list = (result.data.data || []).map((category) => ({ ...category, image: this.normalizeCategoryImage(category.image, req), discount_amount: category.discount_amount || DEFAULTS.DISCOUNT_AMOUNT, discount_percentage: category.discount_percentage || DEFAULTS.DISCOUNT_PERCENTAGE, + items_count: itemCounts + ? (itemCounts[String(category._id)] || 0) + (itemCounts[String(category.name)] || 0) + : null, })); return this.success( @@ -406,7 +501,7 @@ class CategoriesController extends BaseController { // Always derive branch_name from the RESOLVED branch_id so the two can never // disagree (PHP: branch_name => self::$currentBranchName, always the current // branch). The candidate-based branch_name from resolveBranchContext can belong - // to a DIFFERENT branch (e.g. branch_access[0]) — that bug stamped every + // to a DIFFERENT branch (e.g. branch_access[0]) - that bug stamped every // category with the first branch's name ("posnic") regardless of the actual // branch_id. The branches collection's only name field is `branch_name`. let finalBranchName = ''; @@ -440,6 +535,19 @@ class CategoriesController extends BaseController { branch_name: finalBranchName, // PHP: self::$currentBranchName }; + // Category tiles (CATEGORY_TILE_INHERITANCE_DESIGN.md): a colour and + // shape items without their own can inherit at render time. + if (req.body.tile_color !== undefined) { + const tileColor = String(req.body.tile_color || '').trim(); + categoryData.tile_color = /^#[0-9a-fA-F]{6}$/.test(tileColor) ? tileColor : ''; + } + if (req.body.tile_shape !== undefined) { + const tileShape = String(req.body.tile_shape || '').trim(); + categoryData.tile_shape = ['square', 'rounded', 'circle', 'diamond'].includes(tileShape) + ? tileShape + : ''; + } + const license = req.tenantContext?.licenseId || req.user?.license || req.user?.license_id; if (!license) { return this.error(res, 'License context is required', 400); @@ -494,6 +602,19 @@ class CategoriesController extends BaseController { if (req.body.is_active !== undefined) updateData.is_active = req.body.is_active; if (req.body.sort_order !== undefined) updateData.sort_order = parseInt(req.body.sort_order); + // Category tiles (CATEGORY_TILE_INHERITANCE_DESIGN.md): a colour and + // shape items without their own can inherit at render time. + if (req.body.tile_color !== undefined) { + const tileColor = String(req.body.tile_color || '').trim(); + updateData.tile_color = /^#[0-9a-fA-F]{6}$/.test(tileColor) ? tileColor : ''; + } + if (req.body.tile_shape !== undefined) { + const tileShape = String(req.body.tile_shape || '').trim(); + updateData.tile_shape = ['square', 'rounded', 'circle', 'diamond'].includes(tileShape) + ? tileShape + : ''; + } + // Handle discount fields with mutual exclusivity if (req.body.discount_amount !== undefined && req.body.discount_percentage === undefined) { updateData.discount_amount = parseFloat(req.body.discount_amount) || 0; diff --git a/api/src/controllers/customerCategory.controller.js b/api/src/controllers/customerCategory.controller.js index 7b2fa222b..34152fb19 100644 --- a/api/src/controllers/customerCategory.controller.js +++ b/api/src/controllers/customerCategory.controller.js @@ -33,8 +33,17 @@ class CustomerCategoryController extends BaseController { const result = await model.categoryPage(filters, options); if (result.status === true) { - // Apply MongoIDFilter to convert ObjectIds to strings - result.data.list = this.MongoIDFilter(result.data.list); + /* + * this.MongoIDFilter never existed - not here, not on the base - so + * this line has thrown into the catch and answered 500 on every call + * since it was written. Found by the phantom-helper sweep, which was + * itself written because three demo handlers shipped the same way. + * The ObjectId-to-string job it named is real, so it is done inline: + * one field, no invented helper to go phantom again. + */ + result.data.list = (result.data.list || []).map((row) => + row && row._id ? { ...row, _id: String(row._id) } : row + ); return this.success(res, result.data, result.message); } else { return this.error(res, 'Details Not Found', 404, result.data); diff --git a/api/src/controllers/customers.controller.js b/api/src/controllers/customers.controller.js index 50fb413e0..d70ecf191 100644 --- a/api/src/controllers/customers.controller.js +++ b/api/src/controllers/customers.controller.js @@ -242,10 +242,7 @@ class CustomerController extends BaseController { if (branchId && !branchName) { try { const BaseModel = require('../models/base.model'); - const branchesCollection = await BaseModel.prototype.getCollection.call( - { collectionName: 'branches' }, - 'branches' - ); + const branchesCollection = await new BaseModel('branches').getCollection('branches'); const branchDoc = await branchesCollection.findOne({ _id: new ObjectId(branchId), license: req.tenantContext?.licenseId, @@ -660,10 +657,7 @@ class CustomerController extends BaseController { let branchCity = ''; try { - const branchesCollection = await BaseModel.prototype.getCollection.call( - { collectionName: 'branches' }, - 'branches' - ); + const branchesCollection = await new BaseModel('branches').getCollection('branches'); const branchDoc = await branchesCollection.findOne({ _id: new ObjectId(branchId), license: req.tenantContext?.licenseId, @@ -945,10 +939,7 @@ class CustomerController extends BaseController { } // Get transaction collection - const transactionCollection = await BaseModel.prototype.getCollection.call( - { collectionName: 'transaction' }, - 'transaction' - ); + const transactionCollection = await new BaseModel('transaction').getCollection('transaction'); // Get paginated transactions const skip = (page - 1) * limit; @@ -1084,10 +1075,7 @@ class CustomerController extends BaseController { } // Get customer details - const customerCollection = await BaseModel.prototype.getCollection.call( - { collectionName: 'customers' }, - 'customers' - ); + const customerCollection = await new BaseModel('customers').getCollection('customers'); const customer = await customerCollection.findOne({ _id: new ObjectId(id), @@ -1103,10 +1091,7 @@ class CustomerController extends BaseController { } // Get transaction collection - const transactionCollection = await BaseModel.prototype.getCollection.call( - { collectionName: 'transaction' }, - 'transaction' - ); + const transactionCollection = await new BaseModel('transaction').getCollection('transaction'); // Create transaction document const transactionDate = date ? new Date(date) : new Date(); @@ -1203,6 +1188,120 @@ class CustomerController extends BaseController { } }); + /** + * DELETE /customers/deleteTransaction + * + * The route for this has been live and calling a method that was never + * written, so the trash icon on a customer's transaction list returned 500 + * every time. Express did not catch it at startup because the route wraps + * the call in an arrow function - the wrapper IS a function, so the server + * boots clean and the TypeError waits for someone to press delete. + * + * WHY THIS IS SAFE TO WRITE, when reversing a money record usually is not: + * the balance here is DERIVED, not maintained. `transaction` above computes + * it as sum(in) - sum(out) over every transaction for the customer and $sets + * the result. So a delete does not need bespoke reversal arithmetic that + * could drift from the original - it removes the row and re-runs the exact + * same derivation. The aggregate below is a copy of that one deliberately; + * if the definition of balance ever changes, both must change together. + * + * A transaction that belongs to a SALE is refused. The UI already shows a + * link icon rather than a trash for those, but the server must not trust + * that: deleting one would leave the sale believing it had been paid while + * the customer's ledger says otherwise, and nothing would flag the gap. + */ + deleteTransaction = asyncHandler(async (req, res) => { + try { + const { id, customer_id } = req.body || {}; + if (!id || !customer_id) { + return res.status(400).json({ + type: 'error', + message: 'Transaction id and customer id are required', + data: null, + }); + } + + const transactionCollection = await new BaseModel('transaction').getCollection('transaction'); + + /* Scoped by customer AND license, never by id alone: an id on its own + would let one customer's row be deleted from another's screen. */ + const scope = { + _id: new ObjectId(id), + customer_id: new ObjectId(customer_id), + license: BaseModel.license, + }; + + const existing = await transactionCollection.findOne(scope); + if (!existing) { + return res.status(404).json({ + type: 'error', + message: 'Transaction not found', + data: null, + }); + } + + if (existing.sale_id) { + return res.status(400).json({ + type: 'error', + message: 'This transaction belongs to a sale. Cancel the sale instead.', + data: null, + }); + } + + await transactionCollection.deleteOne(scope); + + // Recalculate customer balance from all transactions - the same + // derivation the add path uses, so the two can never disagree. + const aggregateResult = await transactionCollection + .aggregate([ + { + $match: { + customer_id: new ObjectId(customer_id), + license: BaseModel.license, + }, + }, + { + $group: { + _id: null, + totalIn: { + $sum: { $cond: [{ $eq: ['$type', 'in'] }, '$amount', 0] }, + }, + totalOut: { + $sum: { $cond: [{ $eq: ['$type', 'out'] }, '$amount', 0] }, + }, + }, + }, + { + $addFields: { + balance: { $subtract: ['$totalIn', '$totalOut'] }, + }, + }, + ]) + .toArray(); + + const newBalance = aggregateResult.length > 0 ? aggregateResult[0].balance : 0; + + const customerCollection = await new BaseModel('customers').getCollection('customers'); + await customerCollection.updateOne( + { _id: new ObjectId(customer_id), license: BaseModel.license }, + { $set: { balance: newBalance, updated_date: new Date() } } + ); + + return res.status(200).json({ + type: 'success', + message: 'Transaction deleted successfully', + data: newBalance, + }); + } catch (error) { + console.error('Error in deleteTransaction:', error); + return res.status(500).json({ + type: 'error', + message: error.message, + data: null, + }); + } + }); + /** * PHP: uploadTransactionImage() * Upload transaction image diff --git a/api/src/controllers/easy-tables.controller.js b/api/src/controllers/easy-tables.controller.js index b18ebeb70..583094f54 100644 --- a/api/src/controllers/easy-tables.controller.js +++ b/api/src/controllers/easy-tables.controller.js @@ -65,28 +65,13 @@ const FORBIDDEN_FIELD = /pass(word|wd)?|secret|token|apikey|api_key|hash|salt|ot * seconds per request and at worst is a way to read documents the query was * never scoped to. $function and $accumulator are the same class. * - * Checked recursively: these are just as dangerous nested inside $and or $or - * as they are at the top level, and only checking the top would be a guard - * that looks present. + * This rule was written here first and was needed in nine other places: the + * list endpoints take their filter as a JSON string, which the app-level '$' + * sanitiser cannot see inside. It now lives in utils/mongo-guard.js and is + * enforced for every query parameter in app.js, so this file uses the shared + * one rather than keeping the second copy that would only be fixed here. */ -const CODE_OPERATORS = new Set(['$where', '$function', '$accumulator', '$expr']); - -function findCodeOperator(value, depth = 0) { - if (depth > 8 || !value || typeof value !== 'object') return null; - if (Array.isArray(value)) { - for (const v of value) { - const hit = findCodeOperator(v, depth + 1); - if (hit) return hit; - } - return null; - } - for (const [key, v] of Object.entries(value)) { - if (CODE_OPERATORS.has(key)) return key; - const hit = findCodeOperator(v, depth + 1); - if (hit) return hit; - } - return null; -} +const { findCodeOperator } = require('../utils/mongo-guard'); class EasyTableController extends BaseController { constructor() { diff --git a/api/src/controllers/invoices.controller.js b/api/src/controllers/invoices.controller.js new file mode 100644 index 000000000..3ed12646a --- /dev/null +++ b/api/src/controllers/invoices.controller.js @@ -0,0 +1,275 @@ +'use strict'; + +/* + * Invoices (INVOICING_MODULE_DESIGN). ACL: the sale permission, like quotes - + * an invoice is a sales document. Reads need sale.read, anything that changes + * an invoice needs sale.write. + * + * Money is never touched here. Issuing books the SALE (services/invoice- + * booking); recording a payment pays that sale down (services/invoice-sync); + * the invoice repeats what the sale then says. + */ + +const InvoiceRepository = require('../repositories/invoice.repository'); +const QuoteRepository = require('../repositories/quote.repository'); +const invoiceSync = require('../services/invoice-sync'); +const invoiceBooking = require('../services/invoice-booking'); + +const repository = new InvoiceRepository(); +const quotes = new QuoteRepository(); + +function contextOf(req) { + const user = req.user || {}; + return { + branchId: + req.tenantContext?.branchId || + user.branch_id || + (Array.isArray(user.branch_access) && user.branch_access[0]?.branch_id) || + null, + branchName: req.tenantContext?.branchName || user.branch_name || '', + licenseId: req.tenantContext?.licenseId || user.license || null, + userId: user._id || null, + userName: user.username || user.email || '', + }; +} + +function can(req, perm) { + return req.user?.access?.sale?.[perm] !== false; +} + +const fail = (res, message, code = 400) => + res.status(code).json({ type: 'error', message, data: null }); +const ok = (res, data, message, meta) => + res.json({ type: 'success', message, data, ...(meta ? { meta } : {}) }); + +module.exports = { + async create(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.upsertInvoice(req.body || {}, '', contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices create:', error); + return fail(res, error.message, 500); + } + }, + + async update(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.upsertInvoice(req.body || {}, req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices update:', error); + return fail(res, error.message, 500); + } + }, + + /* + * Quote -> invoice. The invoice is created from the quote's own numbers as + * a draft (a proforma), then the quote is stamped `invoiced` so it cannot + * be converted twice. If the stamp is refused - the quote was invoiced + * meanwhile - the invoice just created is removed again rather than left + * as a second bill for one promise. + */ + async fromQuote(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const ctx = contextOf(req); + const q = await quotes.getQuote(req.params.quoteId, ctx); + if (!q.status) return fail(res, q.message, 404); + const quote = q.data; + if (quote.status === 'invoiced' && quote.invoice_id) { + return ok( + res, + { id: String(quote.invoice_id), invoice_id: quote.invoice_number || '', existing: true }, + 'This quote already has an invoice' + ); + } + if (!['open', 'draft', 'sent', 'accepted'].includes(quote.status)) { + return fail(res, 'Only an open or accepted quote can become an invoice'); + } + const created = await repository.createFromQuote(quote, ctx); + if (!created.status) return fail(res, created.message); + const stamped = await quotes.transition( + String(quote._id), + 'invoice', + { invoice_id: created.data.id, invoice_number: created.data.invoice_id }, + ctx + ); + if (!stamped.status) { + await repository.deleteInvoice(created.data.id, ctx); + return fail(res, stamped.message); + } + return ok( + res, + created.data, + 'Invoice ' + created.data.invoice_id + ' created from the quote' + ); + } catch (error) { + console.error('Error in invoices fromQuote:', error); + return fail(res, error.message, 500); + } + }, + + async share(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const { s3Config, uploadObject } = require('../utils/s3'); + if (!s3Config().bucket) { + return fail( + res, + 'Invoice links are not configured on this server - PDF, Email and WhatsApp text still work', + 503 + ); + } + const b64 = String((req.body && req.body.pdf_base64) || ''); + if (!b64) return fail(res, 'A rendered PDF is required', 400); + if (b64.length > 14 * 1024 * 1024) return fail(res, 'PDF too large', 400); + const ctx = contextOf(req); + const found = await repository.getInvoice(req.params.id, ctx); + if (!found.status) return fail(res, found.message, 404); + const doc = found.data; + const rev = ((doc.share && doc.share.rev) || 0) + 1; + const crypto = require('crypto'); + /* b/: bills, beside the sales' i/ and the quotes' q/. + 12 url-safe chars = 72 random bits; the key is the secret. */ + const key = `${process.env.SHARE_LINK_PREFIX || ''}b/${crypto.randomBytes(9).toString('base64url')}`; + const up = await uploadObject({ + key, + body: Buffer.from(b64, 'base64'), + contentType: 'application/pdf', + contentDisposition: `inline; filename="invoice-${String(doc.invoice_id || 'invoice').replace(/[^\w.-]/g, '_')}.pdf"`, + }); + const rec = await repository.recordShare(req.params.id, { key, url: up.Location, rev }, ctx); + if (!rec.status) return fail(res, rec.message); + return ok(res, { url: up.Location, rev }, 'Invoice link ready'); + } catch (error) { + console.error('Error in invoices share:', error); + return fail(res, error.message, 500); + } + }, + + async list(req, res) { + try { + if (!can(req, 'read')) return fail(res, 'Unauthorized access', 403); + const r = await repository.listInvoices( + { + status: req.query.status, + limit: req.query.limit, + page: req.query.page, + search: req.query.search, + field: req.query.field, + exact: req.query.exact, + from: req.query.from, + to: req.query.to, + sort: req.query.sort, + }, + contextOf(req) + ); + return r.status ? ok(res, r.data, r.message, r.meta) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices list:', error); + return fail(res, error.message, 500); + } + }, + + async summary(req, res) { + try { + if (!can(req, 'read')) return fail(res, 'Unauthorized access', 403); + const r = await repository.summary(contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices summary:', error); + return fail(res, error.message, 500); + } + }, + + async getById(req, res) { + try { + if (!can(req, 'read')) return fail(res, 'Unauthorized access', 403); + const r = await repository.getInvoice(req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message, 404); + } catch (error) { + console.error('Error in invoices getById:', error); + return fail(res, error.message, 500); + } + }, + + /* Issue: the draft becomes the sale. The server books it; nobody is sent + to a till screen. Replay-safe. */ + async issue(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await invoiceBooking.issueInvoice(req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices issue:', error); + return fail(res, error.message, 500); + } + }, + + /* + * cancel changes the document alone (drafts only). sync re-reads the sale: + * the invoice is rewritten FROM the sale, never from what a client claims. + */ + async transition(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const ctx = contextOf(req); + const action = String(req.body?.action || ''); + if (action === 'sync') { + const found = await repository.getInvoice(req.params.id, ctx); + if (!found.status) return fail(res, found.message, 404); + if (!found.data.sale_id) return fail(res, 'This invoice has not been issued yet'); + const r = await invoiceSync.syncSale(found.data.sale_id, { invoiceId: req.params.id }); + return r.synced ? ok(res, r.data, r.message) : fail(res, r.message); + } + const r = await repository.transition(req.params.id, action, req.body || {}, ctx); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices transition:', error); + return fail(res, error.message, 500); + } + }, + + /* + * Record a payment, in full or in part. A draft is issued first - the + * customer paying IS the moment the bill is real - so nobody has to press + * two buttons to record one thing. + */ + async payment(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const ctx = contextOf(req); + let found = await repository.getInvoice(req.params.id, ctx); + if (!found.status) return fail(res, found.message, 404); + let inv = found.data; + if (inv.status === 'cancelled') return fail(res, 'This invoice was cancelled'); + if (inv.status === 'paid') return fail(res, 'This invoice is already paid'); + if (!inv.sale_id) { + const issued = await invoiceBooking.issueInvoice(req.params.id, ctx); + if (!issued.status) return fail(res, issued.message); + found = await repository.getInvoice(req.params.id, ctx); + if (!found.status) return fail(res, found.message, 404); + inv = found.data; + } + const r = await invoiceSync.recordPayment(inv, req.body || {}, ctx); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices payment:', error); + return fail(res, error.message, 500); + } + }, + + async remove(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.deleteInvoice(req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in invoices remove:', error); + return fail(res, error.message, 500); + } + }, +}; diff --git a/api/src/controllers/items.controller.js b/api/src/controllers/items.controller.js index eab16c4c0..e3f935f45 100644 --- a/api/src/controllers/items.controller.js +++ b/api/src/controllers/items.controller.js @@ -7,6 +7,8 @@ const { ERROR_MESSAGES, SUCCESS_MESSAGES } = require('../constants/items.constan const sessionFilterUtil = require('../utils/session-filter.util'); const { toObjectId } = require('../utils/tenant-context'); const { isKioskConfigured } = require('../utils/kiosk'); +const { parseFilterParam } = require('../utils/mongo-guard'); +const { scanItems } = require('../services/gst-readiness'); class ItemsController extends BaseController { constructor() { @@ -126,7 +128,12 @@ class ItemsController extends BaseController { return requestContext; } + /* Kept for any caller that still wants the lenient shape, but it goes + through the same guard - a helper that parses client filters must not be + the one place a code operator can still get through. */ parseFilters(rawFilters) { + const guarded = parseFilterParam(rawFilters); + if (guarded.rejected) return {}; if (!rawFilters) { return {}; } @@ -220,20 +227,41 @@ class ItemsController extends BaseController { const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 100) : 5; const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1; - // Preserve existing flexible filter parsing semantics - let filters = {}; - if (req.query.filters) { - if (typeof req.query.filters === 'string') { - const parsedFilters = safeJsonParse(req.query.filters, null); - if (parsedFilters && typeof parsedFilters === 'object' && !Array.isArray(parsedFilters)) { - filters = parsedFilters; - } - } else if (typeof req.query.filters === 'object' && !Array.isArray(req.query.filters)) { - filters = req.query.filters; - } + /* + * Preserve existing flexible filter parsing semantics, minus the + * operators that execute code. + * + * app.js strips '$' keys from req.query, but this filter arrives as a + * JSON STRING - one ordinary-looking value the sanitiser copies across + * untouched, then parsed back here with its operators intact and spread + * into a live query. See utils/mongo-guard.js for why the line is drawn + * at code execution rather than at '$': the real query operators are in + * use ($ne keeps KOT out of Sales History, $gte/$lte drive date windows) + * and removing those would un-filter lists rather than harden them. + */ + const { filters, rejected } = parseFilterParam(req.query.filters); + if (rejected) { + return this.sendError(res, `Filter operator "${rejected}" is not allowed`, 400); } - const options = { limit, page, sort: { _id: -1 } }; + /* + * Sort, whitelisted (owner: high/low margin, recent, low stock, cost, + * price). Only these names reach the query; { $margin } is the + * repository's computed-sort marker, not a Mongo operator. + */ + const ITEM_SORTS = { + recent: { updated_date: -1, _id: -1 }, + name: { name: 1 }, + margin_desc: { $margin: -1 }, + margin_asc: { $margin: 1 }, + price_desc: { selling_price: -1, _id: -1 }, + price_asc: { selling_price: 1, _id: -1 }, + cost_desc: { company_price: -1, _id: -1 }, + cost_asc: { company_price: 1, _id: -1 }, + stock_asc: { available_quantity: 1, _id: -1 }, + stock_desc: { available_quantity: -1, _id: -1 }, + }; + const options = { limit, page, sort: ITEM_SORTS[req.query.sort] || { _id: -1 } }; // Use the service layer (ItemService → ItemRepository → LegacyItemModel) const branchId = this.model?.branchId || null; @@ -294,7 +322,12 @@ class ItemsController extends BaseController { ? parseInt(notificationRaw, 10) : null; - const filters = this.parseFilters(req.query.filters); + /* Same string-shaped filter, same guard - the low-stock list spreads it + into a query exactly as the main list does. */ + const { filters, rejected } = parseFilterParam(req.query.filters); + if (rejected) { + return this.sendError(res, `Filter operator "${rejected}" is not allowed`, 400); + } const branchId = this.model?.branchId || null; const licenseId = this.model?.licenseId || null; @@ -862,6 +895,55 @@ class ItemsController extends BaseController { } } + /* + * The sale screen's inline editors: when the cashier chooses "update + * the item too", only the changed money fields land on the record. + * Item write ACL - the same right the full edit form needs. + */ + async quickPatch(req, res) { + try { + if (req.user?.access?.item?.write === false) { + return this.error(res, 'Unauthorized access', 403); + } + const { id } = req.body || {}; + if (!id) return this.error(res, 'Item id is required', 400); + const result = await this.service.quickPatch(id, req.body); + if (!result || result.status !== true) { + return this.error(res, (result && result.message) || 'Could not update the item', 400); + } + return this.success(res, null, 'Item updated'); + } catch (error) { + console.error('Error in quickPatch:', error); + return this.error(res, error.message, 500); + } + } + + /* + * GST 2.0 readiness (HSN_GST2_RATE_REFRESH_DESIGN increment 2): a + * read-only checklist of items whose tax rate needs a human look - + * those on a withdrawn slab, and those disagreeing with the bundled + * HSN reference where that reference still names a live slab. Report + * ACL, because it is a report; it writes nothing. + */ + async gstReadiness(req, res) { + try { + if (req.user?.access?.report?.read !== true) { + return this.error(res, 'Unauthorized access', 403); + } + this.setRequestContext(req); + const { branchId, licenseId } = req.itemContext || {}; + if (!branchId || !licenseId) { + return this.error(res, 'Branch context is required', 400); + } + const items = await this.service.listForGstReadiness({ branchId, licenseId }); + const result = scanItems(items || []); + return this.success(res, result, 'GST readiness scan complete'); + } catch (error) { + console.error('Error in gstReadiness:', error); + return this.error(res, error.message, 500); + } + } + async updateItemQuantity(req, res) { try { const { id, value } = req.body; @@ -1140,6 +1222,188 @@ class ItemsController extends BaseController { } } + /* + * GET /api/items/export/jsonld + * + * The catalogue in a format somebody else's software can read, rather than + * the 18-column CSV that exportItems produces. See PRODUCT_EXPORT_FORMATS.md + * for why schema.org and not GDSN or UBL. + * + * Read-only, and gated on the same item:read the list is gated on - this + * exposes nothing a user cannot already see, only in a different shape. + */ + /* + * DELETE the demo data, as opposed to hiding it. + * + * Guarded by the item DELETE permission, not read or write: this destroys + * records, and the person who may edit a price is not automatically the + * person who may clear a catalogue. + */ + /* + * Put the sample data back. + * + * Guarded by item WRITE rather than delete: this creates records. The + * service refuses when demo data is already present, so a double click + * cannot give a shop two of everything. + */ + async reseedDemoData(req, res) { + try { + if (req.user?.access?.item?.write === false) { + return this.sendError(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + await this.ensureContext(req); + const InstallService = require('../services/install.service'); + const installer = new InstallService(); + const result = await installer.reseedDemoData({ + branchId: this.model?.branchId || null, + licenseId: this.model?.licenseId || null, + user: req.user, + /* Optional. Absent means "put back whatever this shop had", which is + what the Demo Data switch asks for; a value means the shop has + chosen a different trade from the list on its Demo Data page. */ + businessType: req.body?.businessType, + }); + if (!result.status) return this.sendError(res, result.message, 400); + return this.success(res, result.data, result.message); + } catch (error) { + console.error('Error in reseedDemoData:', error); + return this.sendError(res, error.message, 500); + } + } + + /* + * The packs a shop can choose from. + * + * Served rather than hard-coded in the page, so the chooser cannot drift + * from what the server will actually install. A list typed into the + * frontend is correct until somebody adds a pack, and then it is a menu + * that silently omits an option nobody knows exists. + * + * Read permission, not write: this says what is available, not what is + * installed, and a cashier looking at the Demo Data page should see the + * page rather than an error. + */ + async listDemoPacks(req, res) { + try { + if (req.user?.access?.item?.read === false) { + return this.sendError(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + await this.ensureContext(req); + const { listDemoPacks } = require('../../utils/demoData'); + const packs = listDemoPacks(); + + /* + * Plus the website's per-currency trades, when this shop's currency has + * the zip. The probe is cached ten minutes and can only ADD rows - a + * posnic.com hiccup costs the extra trades, never the page. + */ + try { + const demoDatasetSvc = require('../services/demo-dataset'); + const BaseModelC = require('../models/base.model'); + const dbc = await BaseModelC.getDb(); + const { ObjectId: OIDC } = require('mongodb'); + const br = await dbc + .collection('branches') + .findOne( + { _id: new OIDC(String(this.model.branchId)) }, + { projection: { currency_value: 1, country: 1 } } + ); + const cur = + br && Array.isArray(br.currency_value) && br.currency_value[0] + ? br.currency_value[0].currency_text + : null; + if (cur) { + const extra = await demoDatasetSvc.listDatasetPacks(cur); + for (const row of extra) { + if (!packs.some((x) => x.key === row.key)) packs.push(row); + } + } + } catch (e) { + /* the built-in list stands on its own */ + } + + /* Which one this shop is on now, so the chooser opens on the right + answer instead of on the first row of the list. */ + let current = null; + try { + const BaseModel = require('../models/base.model'); + const db = await BaseModel.getDb(); + const { ObjectId } = require('mongodb'); + const row = await db.collection('items').findOne( + { + demo_pack: { $exists: true }, + license: new ObjectId(String(this.model.licenseId)), + }, + { projection: { demo_pack: 1 } } + ); + current = (row && row.demo_pack) || null; + } catch (e) { + /* A shop with no samples has no current pack, which is a fact about + the shop and not a failure to report. */ + current = null; + } + + return this.success(res, { packs, current }, 'success'); + } catch (error) { + console.error('Error in listDemoPacks:', error); + return this.sendError(res, error.message, 500); + } + } + + async purgeDemoData(req, res) { + try { + if (req.user?.access?.item?.delete === false) { + return this.sendError(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + await this.ensureContext(req); + const result = await this.service.purgeDemoData({ + branchId: this.model?.branchId || null, + licenseId: this.model?.licenseId || null, + user: req.user, + }); + if (!result.status) return this.sendError(res, result.message, 400); + return this.success(res, result.data, result.message); + } catch (error) { + console.error('Error in purgeDemoData:', error); + return this.sendError(res, error.message, 500); + } + } + + async exportCatalogueJsonLd(req, res) { + try { + if (req.user?.access?.item?.read === false) { + return this.sendError(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + await this.ensureContext(req); + + const branchId = this.model?.branchId || null; + const licenseId = this.model?.licenseId || null; + + /* + * ISO 4217, not the symbol. schema.org wants "INR"; the shop stores a + * sign (Rs) for display and the code separately. A symbol in + * priceCurrency is invalid, and "$" would not even identify a currency - + * it is used by a dozen of them. + */ + const currency = String(req.query.currency || '') + .trim() + .toUpperCase(); + const result = await this.service.exportCatalogueJsonLd({ + branchId, + licenseId, + currency: /^[A-Z]{3}$/.test(currency) ? currency : null, + }); + + if (!result.status) return this.sendError(res, result.message, 400); + + res.setHeader('Content-Type', 'application/ld+json; charset=utf-8'); + return res.status(200).send(JSON.stringify(result.data)); + } catch (error) { + console.error('Error in exportCatalogueJsonLd:', error); + return this.sendError(res, error.message, 500); + } + } + async exportItems(req, res) { try { const userAccess = req.user?.access?.item?.read; diff --git a/api/src/controllers/quotes.controller.js b/api/src/controllers/quotes.controller.js new file mode 100644 index 000000000..833655139 --- /dev/null +++ b/api/src/controllers/quotes.controller.js @@ -0,0 +1,173 @@ +'use strict'; + +/* + * Quotes (LS2). ACL: the sale permission - a quote is a sales document. + * Reads need sale.read, anything that changes a quote needs sale.write. + * Stock and payments are never touched here - see the repository header. + */ + +const QuoteRepository = require('../repositories/quote.repository'); + +const repository = new QuoteRepository(); + +function contextOf(req) { + const user = req.user || {}; + return { + branchId: + req.tenantContext?.branchId || + user.branch_id || + (Array.isArray(user.branch_access) && user.branch_access[0]?.branch_id) || + null, + branchName: req.tenantContext?.branchName || user.branch_name || '', + licenseId: req.tenantContext?.licenseId || user.license || null, + userId: user._id || null, + userName: user.username || user.email || '', + }; +} + +function can(req, perm) { + return req.user?.access?.sale?.[perm] !== false; +} + +const fail = (res, message, code = 400) => + res.status(code).json({ type: 'error', message, data: null }); +// `meta` rides alongside data (paging totals); omitted when there is none, +// so every existing caller sees exactly the payload it saw before. +const ok = (res, data, message, meta) => + res.json({ type: 'success', message, data, ...(meta ? { meta } : {}) }); + +module.exports = { + async create(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.upsertQuote(req.body || {}, '', contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in quotes create:', error); + return fail(res, error.message, 500); + } + }, + + async update(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.upsertQuote(req.body || {}, req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in quotes update:', error); + return fail(res, error.message, 500); + } + }, + + /* + * Share: the till renders the professional PDF (the same document the + * user saw) and posts it here; it lands in S3 under an unguessable + * random key (same convention as invoice links) and the quote records + * the newest revision - an edited quote re-shares as a new file, so an + * old link never silently shows different numbers. + */ + async share(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const { s3Config, uploadObject } = require('../utils/s3'); + if (!s3Config().bucket) { + return fail( + res, + 'Quote links are not configured on this server - PDF, Email and WhatsApp text still work', + 503 + ); + } + const b64 = String((req.body && req.body.pdf_base64) || ''); + if (!b64) return fail(res, 'A rendered PDF is required', 400); + if (b64.length > 14 * 1024 * 1024) return fail(res, 'PDF too large', 400); + const ctx = contextOf(req); + const found = await repository.getQuote(req.params.id, ctx); + if (!found.status) return fail(res, found.message, 404); + const doc = found.data; + const rev = ((doc.share && doc.share.rev) || 0) + 1; + const crypto = require('crypto'); + /* + * q/, matching the invoices' i/ (owner: short links, + * nothing brandable in them). 12 url-safe chars = 72 random bits; + * the rev still rides the recorded share, not the path. Old + * quotes/... keys stay served. + */ + const key = `${process.env.SHARE_LINK_PREFIX || ''}q/${crypto.randomBytes(9).toString('base64url')}`; + const up = await uploadObject({ + key, + body: Buffer.from(b64, 'base64'), + contentType: 'application/pdf', + contentDisposition: `inline; filename="quote-${String(doc.quote_id || 'quote').replace(/[^\w.-]/g, '_')}.pdf"`, + }); + const rec = await repository.recordShare(req.params.id, { key, url: up.Location, rev }, ctx); + if (!rec.status) return fail(res, rec.message); + return ok(res, { url: up.Location, rev }, 'Quote link ready'); + } catch (error) { + console.error('Error in quotes share:', error); + return fail(res, error.message, 500); + } + }, + + async list(req, res) { + try { + if (!can(req, 'read')) return fail(res, 'Unauthorized access', 403); + const r = await repository.listQuotes( + { + status: req.query.status, + limit: req.query.limit, + page: req.query.page, + search: req.query.search, + // which column to search, whether to anchor it, and the date window + field: req.query.field, + exact: req.query.exact, + from: req.query.from, + to: req.query.to, + sort: req.query.sort, + }, + contextOf(req) + ); + return r.status ? ok(res, r.data, r.message, r.meta) : fail(res, r.message); + } catch (error) { + console.error('Error in quotes list:', error); + return fail(res, error.message, 500); + } + }, + + async getById(req, res) { + try { + if (!can(req, 'read')) return fail(res, 'Unauthorized access', 403); + const r = await repository.getQuote(req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message, 404); + } catch (error) { + console.error('Error in quotes getById:', error); + return fail(res, error.message, 500); + } + }, + + async transition(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.transition( + req.params.id, + req.body?.action, + req.body || {}, + contextOf(req) + ); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in quotes transition:', error); + return fail(res, error.message, 500); + } + }, + + async remove(req, res) { + try { + if (!can(req, 'write')) return fail(res, 'Unauthorized access', 403); + const r = await repository.deleteQuote(req.params.id, contextOf(req)); + return r.status ? ok(res, r.data, r.message) : fail(res, r.message); + } catch (error) { + console.error('Error in quotes remove:', error); + return fail(res, error.message, 500); + } + }, +}; diff --git a/api/src/controllers/receivings.controller.js b/api/src/controllers/receivings.controller.js index 06ee511fd..da07c2eb7 100644 --- a/api/src/controllers/receivings.controller.js +++ b/api/src/controllers/receivings.controller.js @@ -174,6 +174,57 @@ class ReceivingsController extends BaseController { } } + /* + * Void a purchase (G8): delete-level permission, reason mandatory, + * record kept, stock reversed, credit withdrawn. Never a delete. + */ + /* + * Receive goods in steps (international partial receiving): {all:true}, + * or {lines:[{item_id, qty}]}, plus {close_short:true} to cancel the + * remainder. Write access - receiving goods is the module's core act. + */ + async receive(req, res) { + try { + if (!this.checkPermission('receiving', 'write', req.user)) { + return this.error(res, 'Unauthorized', 403); + } + await this.ensureContext(req); + const result = await Receiving.receivePartial( + req.params.id, + { + all: req.body?.all === true, + lines: Array.isArray(req.body?.lines) ? req.body.lines : [], + close_short: req.body?.close_short === true, + }, + {} + ); + if (result.status === true) return this.success(res, result.data, result.message); + return this.error(res, result.message || 'Could not receive', 400); + } catch (error) { + console.error('Error in receive:', error); + return this.error(res, error.message, 500); + } + } + + async void(req, res) { + try { + if (!this.checkPermission('receiving', 'delete', req.user)) { + return this.error(res, 'Unauthorized - voiding needs delete access', 403); + } + await this.ensureContext(req); + const reason = String(req.body?.reason || '').trim(); + if (!reason) { + return this.error(res, 'A reason is required to void a purchase', 400); + } + const result = await Receiving.voidReceiving(req.params.id, reason, {}); + if (result.status === true) return this.success(res, null, result.message); + return this.error(res, result.message || 'Could not void this purchase', 400); + } catch (error) { + console.error('Error in void:', error); + return this.error(res, error.message, 500); + } + } + /** * PHP: edit() * Update an existing receiving order @@ -781,6 +832,74 @@ class ReceivingsController extends BaseController { * PHP: receivingReportTable() * Get receiving report table */ + /* + * Attach a document to a purchase: the supplier's own PO, an invoice + * scan, a delivery note. Owner: "we cant upload any file. supplier po or + * some other stuff." Files live under /uploads/attachments (multer wrote + * them before this runs); the receiving doc carries the metadata, so the + * purchase view can list and open them. + */ + async addAttachment(req, res) { + try { + if (req.user?.access?.receiving?.write === false) { + return this.error(res, 'Unauthorized', 403); + } + if (!req.file) { + return this.error(res, 'Attach a PDF or an image file.', 400); + } + const { ObjectId } = require('mongodb'); + const id = String(req.params.id || ''); + if (!ObjectId.isValid(id)) return this.error(res, 'Purchase not found', 404); + const meta = { + id: String(new ObjectId()), + name: String(req.file.originalname || 'attachment').slice(0, 120), + url: '/uploads/attachments/' + req.file.filename, + size: req.file.size || 0, + type: req.file.mimetype || '', + uploaded_by: (req.user && (req.user.username || req.user.email)) || '', + date: new Date(), + }; + const r = await req.db + .collection('receivings') + .updateOne({ _id: new ObjectId(id) }, { $push: { attachments: meta } }); + if (!r.matchedCount) { + /* A purchase ORDER id can arrive here too - the one purchases door + attaches to either record. */ + const po = await req.db + .collection('purchase_orders') + .updateOne({ _id: new ObjectId(id) }, { $push: { attachments: meta } }); + if (!po.matchedCount) return this.error(res, 'Purchase not found', 404); + } + return this.success(res, meta, 'Attached'); + } catch (error) { + console.error('Error in addAttachment:', error); + return this.error(res, error.message, 500); + } + } + + async removeAttachment(req, res) { + try { + if (req.user?.access?.receiving?.write === false) { + return this.error(res, 'Unauthorized', 403); + } + const { ObjectId } = require('mongodb'); + const id = String(req.params.id || ''); + const attId = String(req.params.attId || ''); + if (!ObjectId.isValid(id) || !attId) return this.error(res, 'Not found', 404); + const pull = { $pull: { attachments: { id: attId } } }; + const r = await req.db.collection('receivings').updateOne({ _id: new ObjectId(id) }, pull); + if (!r.modifiedCount) { + await req.db.collection('purchase_orders').updateOne({ _id: new ObjectId(id) }, pull); + } + /* The file itself stays on disk deliberately: a removed listing must + be recoverable by support, and disk is cheaper than a regret. */ + return this.success(res, { removed: attId }, 'Attachment removed'); + } catch (error) { + console.error('Error in removeAttachment:', error); + return this.error(res, error.message, 500); + } + } + async receivingReportTable(req, res) { try { if (!this.checkPermission('report', 'read', req.user)) { @@ -1334,26 +1453,44 @@ class ReceivingsController extends BaseController { await done; const pdfBuffer = Buffer.concat(chunks); - const { Email } = require('../utils/email'); - const transporter = new Email( - { email: to, name: receiving.supplier?.supplier_name }, - '' - ).newTransport(); + // Owner rule: the shop's own SMTP first, the platform chain otherwise. + const { resolveShopTransport } = require('../utils/email'); + const resolved = resolveShopTransport(branch); + const transporter = resolved.transporter; const shopName = branch.branch_name || 'Posnic POS'; const orderId = receiving.receiving_id || String(receiving._id); + const { brandFor, renderEmail, kvBlock } = require('../utils/email-layout'); + const brand = brandFor(branch); + const customMessage = String(req.body.message || '').trim(); const info = await transporter.sendMail({ - from: `${shopName} <${process.env.EMAIL_FROM || 'no-reply@posnic.local'}>`, + from: `${shopName} <${resolved.from}>`, to, subject: - String(req.body.subject || '').trim() || `Purchase order from ${shopName} (${orderId})`, - text: - String(req.body.message || '').trim() || - `Please find attached purchase order ${orderId} from ${shopName}.`, + String(req.body.subject || '').trim() || `Purchase order ${orderId} from ${shopName}`, + html: renderEmail({ + brand, + title: 'Purchase order', + preheader: `Purchase order ${orderId} from ${shopName} - PDF attached`, + greeting: receiving.supplier_name ? `Dear ${receiving.supplier_name},` : undefined, + bodyHtml: + '

' + + (customMessage + ? require('../utils/email-layout').esc(customMessage) + : 'Please find our purchase order attached as a PDF.') + + '

' + + kvBlock([ + ['Order #', orderId], + ['Items', receiving.number_of_items], + ['Order value', receiving.total_amount], + ]), + footerNote: 'Reply to this email to confirm availability and delivery.', + }), + text: customMessage || `Please find attached purchase order ${orderId} from ${shopName}.`, attachments: [{ filename: `${orderId}.pdf`, content: pdfBuffer }], }); /* The dev fallback transport prints to console instead of delivering - say so rather than claiming a send that never left the box. */ - if (transporter.options && transporter.options.jsonTransport) { + if (!resolved.shopOwned && transporter.options && transporter.options.jsonTransport) { return this.error( res, 'Email is not configured on this server - the PDF was generated but not sent', @@ -1424,7 +1561,12 @@ class ReceivingsController extends BaseController { branch_id: { $in: branchObjectIds }, // For supplier receiving summary, only include completed/partially returned // documents; exclude "Open" receivings from the report. - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }, { updated_date: { $gte: fromDate, $lte: toDate }, diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js index 48eebbd87..b881966d6 100644 --- a/api/src/controllers/sales.controller.js +++ b/api/src/controllers/sales.controller.js @@ -1,10 +1,31 @@ // src/controllers/sales_controller.js const BaseController = require('./base.controller'); +const { clientIp } = require('../utils/client-ip'); const { currentConnection } = require('../db/tenant-context'); const Sale = require('../models/sale.model'); const BaseModel = require('../models/base.model'); const mongoose = require('mongoose'); const salesService = require('../services/sale.service'); + +/* The branch doc carries the shop's own SMTP settings; owner rule is + theirs first, the platform chain otherwise. Absent config, bad ids + and read errors all fall through to the platform transport. */ +async function shopTransportOf(req) { + const { resolveShopTransport } = require('../utils/email'); + let branchDoc = null; + try { + const { ObjectId } = require('mongodb'); + const bid = req.user && req.user.branch_id; + if (bid && req.db && ObjectId.isValid(String(bid))) { + branchDoc = await req.db.collection('branches').findOne({ _id: new ObjectId(String(bid)) }); + } + } catch (e) { + /* platform chain covers it */ + } + /* The branch rides along: the mail LAYOUT needs the same document the + transport was resolved from (shop name, address, white-label). */ + return { ...resolveShopTransport(branchDoc), branch: branchDoc }; +} const ItemService = require('../services/item.service'); const LoyaltyService = require('../services/loyalty.service'); const loyaltyService = new LoyaltyService(); @@ -45,14 +66,43 @@ const path = require('path'); const fs = require('fs'); const { getRequestDeviceId } = require('../utils/device-id.util'); +/* + * The sale screen sends several of these fields as DISPLAY text, straight out + * of the totals row - "1,459.00", not 1459. Number.parseFloat stops at the + * thousands separator and returns 1, which made a 34-rupee discount look like + * 3400% of the bill and tripped the manager-approval cap on every discounted + * sale over a thousand rupees. The role's discount cap was therefore broken + * for exactly the bills big enough to need it. + * + * So: strip anything that is not part of a number, then work out which + * separator is the decimal one. The rightmost of '.' and ',' wins when both + * appear ("1,459.00" and "1.459,00" both parse); a lone comma is a thousands + * group only when every group is three digits, otherwise it is a decimal + * comma. NaN is preserved for genuinely non-numeric text because + * auditFirstNumber relies on it to fall through to the next candidate field. + */ +const parseAuditNumber = (value) => { + if (typeof value === 'number') return value; + let s = String(value === null || value === undefined ? '' : value).trim(); + if (!s) return NaN; + s = s.replace(/[^\d.,-]/g, ''); + const lastDot = s.lastIndexOf('.'); + const lastComma = s.lastIndexOf(','); + if (lastDot >= 0 && lastComma >= 0) { + s = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, ''); + } else if (lastComma >= 0) { + s = /^-?\d{1,3}(,\d{3})+$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.'); + } + return Number.parseFloat(s); +}; const auditNumber = (value) => { - const parsed = Number.parseFloat(value); + const parsed = parseAuditNumber(value); return Number.isFinite(parsed) ? parsed : 0; }; const auditFirstNumber = (...values) => { for (const value of values) { if (value === null || value === undefined || value === '' || value === 'undefined') continue; - const parsed = Number.parseFloat(value); + const parsed = parseAuditNumber(value); if (Number.isFinite(parsed)) return parsed; } return 0; @@ -195,7 +245,7 @@ const writeSaleAudit = async (req, action, entityId, description, changes) => { changes, branch: resolveBranchId(user, req.session) || BaseModel.currentBranch, license: user.license || user.licenseId || BaseModel.license, - ipAddress: req.ip, + ipAddress: clientIp(req), userAgent: typeof req.get === 'function' ? req.get('user-agent') : req.headers?.['user-agent'], }); @@ -497,7 +547,7 @@ class SalesController extends BaseController { if (r && !r.allowed) { return { blocked: true, - error: `Credit limit exceeded — limit ${r.limit}, already outstanding ${r.outstanding}, this sale would make it ${r.wouldBe}.`, + error: `Credit limit exceeded. Limit ${r.limit}, already outstanding ${r.outstanding}, this sale would make it ${r.wouldBe}.`, data: r, }; } @@ -540,12 +590,25 @@ class SalesController extends BaseController { // Then earn on what they actually paid (already net of the redemption). const amount = Number(sale.sales_total || sale.total || 0); if (amount > 0) { - await loyaltyService.earn(sale.customer_id, { + const earned = await loyaltyService.earn(sale.customer_id, { amount, saleId: sale._id || saleId, reference: sale.sales_id || '', ctx, }); + /* Hand the points back to the till. This runs before the response is + written, so attaching them here puts them in it - and the cashier + can tell the customer what they just earned while they are still + standing there, which is the only moment it is worth anything. + Absent or zero simply means the strip says nothing about loyalty. */ + const pts = Number(earned?.data?.points) || 0; + if (pts > 0) { + saleData.loyalty_earned = { + points: pts, + balance: Number(earned?.data?.balance) || 0, + tier: earned?.data?.tier || '', + }; + } // If this is the referred customer's first qualifying purchase, reward // both them and whoever referred them. await loyaltyService.grantReferralIfEligible(sale.customer_id, { @@ -984,6 +1047,23 @@ class SalesController extends BaseController { sortBy: 'created_date:desc,_id:desc', }; + /* + * Sort, whitelisted (owner: highest bill, most items, date). Only + * these names reach the query - a raw client-supplied field would + * sort by anything on the document. + */ + const SALE_SORTS = { + recent: 'created_date:desc,_id:desc', + date_desc: 'date:desc,_id:desc', + date_asc: 'date:asc,_id:asc', + total_desc: 'sales_total:desc,_id:desc', + total_asc: 'sales_total:asc,_id:desc', + items_desc: 'number_of_items:desc,_id:desc', + }; + if (SALE_SORTS[req.query.sort]) { + options.sortBy = SALE_SORTS[req.query.sort]; + } + const result = await salesService.listSales(filter, options, { SaleModel }); const docs = Array.isArray(result?.results) ? result.results : []; @@ -1113,7 +1193,7 @@ class SalesController extends BaseController { } // Fallback: body itself may be an array or an object like - // { "0": "id1", "1": "id2" } – treat all string values as IDs. + // { "0": "id1", "1": "id2" } - treat all string values as IDs. if (Array.isArray(body)) { return body; } @@ -1196,6 +1276,8 @@ class SalesController extends BaseController { { branchId, licenseId: BaseModel.license, + // type=hold: the sale screen's Parked tab - holds only, more rows. + holdOnly: req.query?.type === 'hold', }, { SaleModel } ); @@ -3148,7 +3230,82 @@ class SalesController extends BaseController { }), { net: 0, tax: 0, gross: 0 } ); - return this.success(res, { list, totals }, 'Tax summary retrieved successfully'); + + /* + * THE INPUT SIDE. Owner: "for GST we need to know how we already paid + * tax while purchase. so that we know exactly how much need to pay." + * That is the standard GST position (GSTR-3B shape): tax COLLECTED on + * outward supplies, minus the INPUT TAX CREDIT already paid to + * suppliers on inward supplies, is what the period actually owes. + * Purchases have carried per-line tax and tax_percentage since the + * receiving form gained exclusive tax - it was recorded and never + * accounted. Failure here degrades to an absent block, never a dead + * sales summary. + */ + const purchases = { list: [], totals: { net: 0, tax: 0, gross: 0 } }; + try { + const num = (expr) => ({ + $convert: { input: expr, to: 'double', onError: 0, onNull: 0 }, + }); + const rMatch = { + branch_id: { $in: branchIds }, + del_status: { $nin: [1, '1', true] }, + }; + if (Object.keys(dateFilter).length) rMatch.date = dateFilter; + const rrows = await req.db + .collection('receivings') + .aggregate([ + { $match: rMatch }, + { $unwind: '$items' }, + { + $group: { + _id: num('$items.tax_percentage'), + tax: { $sum: num('$items.tax') }, + net: { + $sum: { + $multiply: [num('$items.item_price'), num('$items.item_quantity')], + }, + }, + lines: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]) + .toArray(); + purchases.list = rrows.map((r) => ({ + rate: roundToTwo(r._id || 0), + net: roundToTwo(r.net || 0), + tax: roundToTwo(r.tax || 0), + gross: roundToTwo((r.net || 0) + (r.tax || 0)), + lines: r.lines || 0, + })); + purchases.totals = purchases.list.reduce( + (acc, r) => ({ + net: roundToTwo(acc.net + r.net), + tax: roundToTwo(acc.tax + r.tax), + gross: roundToTwo(acc.gross + r.gross), + }), + { net: 0, tax: 0, gross: 0 } + ); + } catch (purchErr) { + console.error('taxSummary: purchase side skipped:', purchErr.message); + } + + /* The position, stated the way it is owed. A period where credit + exceeds output does not go negative - the excess carries forward, + which is how GST actually works. */ + const gst = { + output_tax: totals.tax, + input_tax_credit: purchases.totals.tax, + net_payable: roundToTwo(Math.max(0, totals.tax - purchases.totals.tax)), + credit_carry_forward: roundToTwo(Math.max(0, purchases.totals.tax - totals.tax)), + }; + + return this.success( + res, + { list, totals, purchases, gst }, + 'Tax summary retrieved successfully' + ); } catch (error) { console.error('Error in taxSummaryReportTable:', error); return this.error(res, 'Unable to load the tax summary. Please try again later.', 500, { @@ -4970,38 +5127,55 @@ class SalesController extends BaseController { return this.error(res, ERROR_MESSAGES.SALE_NOT_FOUND, 404); } - // Send email receipt (basic implementation - enhance based on email service) - const nodemailer = require('nodemailer'); - - // Configure email transporter - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.gmail.com', - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASSWORD, - }, - }); - - // Email content + // Shop SMTP first, platform chain otherwise (owner rule). + const resolved = await shopTransportOf(req); + const transporter = resolved.transporter; + + // Email content - the shared layout, not a bare

(owner: "not + // just unprofessional few text"). + const { + brandFor, + renderEmail, + kvBlock, + itemsTable, + totalRow, + } = require('../utils/email-layout'); + const brand = brandFor(resolved.branch || { branch_name: req.user?.branch_name }); + const currency = (resolved.branch && resolved.branch.currency) || ''; + const lines = (Array.isArray(sale.items) ? sale.items : []).map((it) => ({ + name: it.item_name || it.name || '', + qty: it.item_quantity || it.quantity || '', + price: Number(it.item_price ?? it.price ?? it.unit_price) || 0, + total: Number(it.total_amount ?? it.total) || 0, + })); const emailContent = { - from: process.env.EMAIL_FROM || 'noreply@posnic.com', + from: `${brand.shopName || brand.name} <${resolved.from}>`, to: email, - subject: `Receipt - ${sale.sales_id}`, - html: ` -

Sale Receipt

-

Invoice #: ${sale.sales_id}

-

Date: ${new Date(sale.date).toLocaleDateString()}

-

Customer: ${sale.customer_name}

-

Total: ${sale.items_total}

-

Payment Mode: ${sale.payment_mode}

-

Thank you for your business!

- `, + subject: `Receipt ${sale.sales_id} from ${brand.shopName || brand.name}`, + html: renderEmail({ + brand, + title: 'Your receipt', + preheader: `Receipt ${sale.sales_id} - thank you for your purchase`, + greeting: sale.customer_name ? `Dear ${sale.customer_name},` : undefined, + bodyHtml: + kvBlock([ + ['Receipt #', sale.sales_id], + ['Date', new Date(sale.date).toLocaleDateString()], + ['Payment', sale.payment_mode], + ]) + + (lines.length ? itemsTable(lines, currency) : '') + + totalRow('Total', Number(sale.items_total) || sale.items_total, currency), + footerNote: 'Thank you for your business - we hope to see you again.', + }), }; try { await transporter.sendMail(emailContent); + /* The dev fallback transport prints to console instead of + delivering - say so rather than claiming a send. */ + if (!resolved.shopOwned && transporter.options && transporter.options.jsonTransport) { + return this.error(res, 'Email is not configured on this server - nothing was sent', 503); + } return this.success(res, { sent: true }, 'Receipt sent successfully'); } catch (emailError) { console.error('Email error:', emailError); @@ -5056,7 +5230,7 @@ class SalesController extends BaseController { return this.error(res, ERROR_MESSAGES.SALE_NOT_FOUND, 404); } - // Format print details — must match PHP getCustomerPrintDetails() exactly, + // Format print details - must match PHP getCustomerPrintDetails() exactly, // because the frontend (customer_mail.js) reads these precise field names // and calls .toFixed()/loops on them. A missing field (e.g. sales_sub_total) // throws in the browser and blanks the whole receipt below the header. @@ -5217,9 +5391,19 @@ class SalesController extends BaseController { const pdfBuffer = Buffer.concat(chunks); const crypto = require('crypto'); - const year = new Date().getFullYear(); - const licensePart = String(sale.license || 'shop').slice(-8); - const key = `invoices/${licensePart}/${year}/${crypto.randomBytes(16).toString('hex')}.pdf`; + /* + * i/, nothing else (owner: "link can make short"). The key IS + * the secret and the whole path a customer sees - 12 url-safe chars + * carry 72 random bits, and behind the short domain the link reads + * https://xbill.in/i/AbC9xYz12Qw3. No folders: the DB holds the key + * per sale, and a path that organises nothing for anyone reading it + * only makes the message longer. No .pdf either - the object's + * content type renders it. Old invoices/... keys stay served. + */ + /* One short domain for EVERY instance (owner): each non-production + * instance namespaces its keys with SHARE_LINK_PREFIX (demo: 'd/'), + * and CloudFront routes each prefix to that instance's bucket. */ + const key = `${process.env.SHARE_LINK_PREFIX || ''}i/${crypto.randomBytes(9).toString('base64url')}`; const { PutObjectCommand } = require('@aws-sdk/client-s3'); await getS3Client().send( @@ -5228,6 +5412,9 @@ class SalesController extends BaseController { Key: key, Body: pdfBuffer, ContentType: 'application/pdf', + // The short key has no extension, so the object names its own + // download - the browser renders inline and saves a real .pdf. + ContentDisposition: `inline; filename="invoice-${String(sale.sales_id || 'invoice').replace(/[^\w.-]/g, '_')}.pdf"`, }) ); @@ -5280,31 +5467,31 @@ class SalesController extends BaseController { doc.on('end', async () => { const pdfBuffer = Buffer.concat(chunks); - // Send email with PDF attachment - const nodemailer = require('nodemailer'); - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.gmail.com', - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASSWORD, - }, - }); + // Shop SMTP first, platform chain otherwise (owner rule). + const resolved = await shopTransportOf(req); + const transporter = resolved.transporter; try { + const { brandFor, renderEmail, kvBlock } = require('../utils/email-layout'); + const brand = brandFor(resolved.branch || { branch_name: req.user?.branch_name }); await transporter.sendMail({ - from: process.env.EMAIL_FROM || 'noreply@posnic.com', + from: `${brand.shopName || brand.name} <${resolved.from}>`, to: email, - subject: `Invoice - ${sale.sales_id}`, - html: ` -

Invoice

-

Dear ${sale.customer_name},

-

Please find attached your invoice.

-

Invoice #: ${sale.sales_id}

-

Amount: ${sale.items_total}

-

Thank you for your business!

- `, + subject: `Invoice ${sale.sales_id} from ${brand.shopName || brand.name}`, + html: renderEmail({ + brand, + title: 'Your invoice', + preheader: `Invoice ${sale.sales_id} attached as PDF`, + greeting: sale.customer_name ? `Dear ${sale.customer_name},` : undefined, + bodyHtml: + '

' + + 'Please find your invoice attached as a PDF.

' + + kvBlock([ + ['Invoice #', sale.sales_id], + ['Amount', sale.items_total], + ]), + footerNote: 'Thank you for your business!', + }), attachments: [ { filename: `invoice-${sale.sales_id}.pdf`, @@ -5314,6 +5501,13 @@ class SalesController extends BaseController { ], }); + if (!resolved.shopOwned && transporter.options && transporter.options.jsonTransport) { + return this.error( + res, + 'Email is not configured on this server - nothing was sent', + 503 + ); + } return this.success(res, { sent: true, email }, 'PDF invoice emailed successfully'); } catch (emailError) { console.error('Email error:', emailError); @@ -6080,6 +6274,55 @@ class SalesController extends BaseController { } } + /* + * The Tax Payable view (PURCHASE_TAX_PLAN P4): months of output vs input + * tax and the net owed per head, credits applied in the statutory order. + */ + async taxPayable(req, res) { + try { + if (req.user?.access?.report?.read !== true) { + return this.error(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + const data = { + starting_date: req.query.starting_date, + ending_date: req.query.ending_date, + branch_id: req.session?.branch_id, + license: req.user?.license, + }; + const response = await salesService.taxPayablePage(data); + if (response.status === true) { + return this.success(res, response.data, SUCCESS_MESSAGES.GET_SUCCESSFULLY); + } + return this.error(res, response.message || ERROR_MESSAGES.SALES_DETAILS_NOT_FOUND, 404); + } catch (error) { + console.error('Error in taxPayable:', error); + return this.error(res, error.message, 500); + } + } + + /* The purchase register beneath it - the accountant's working paper. */ + async taxPayableRegister(req, res) { + try { + if (req.user?.access?.report?.read !== true) { + return this.error(res, ERROR_MESSAGES.UNAUTHORIZED, 403); + } + const data = { + starting_date: req.query.starting_date, + ending_date: req.query.ending_date, + branch_id: req.session?.branch_id, + license: req.user?.license, + }; + const response = await salesService.taxPayableRegisterPage(data); + if (response.status === true) { + return this.success(res, response.data, SUCCESS_MESSAGES.GET_SUCCESSFULLY); + } + return this.error(res, response.message || ERROR_MESSAGES.SALES_DETAILS_NOT_FOUND, 404); + } catch (error) { + console.error('Error in taxPayableRegister:', error); + return this.error(res, error.message, 500); + } + } + /** * PHP: gstOneReportTableJson() * Get GST-1 report as JSON @@ -6138,8 +6381,8 @@ class SalesController extends BaseController { return this.error(res, ERROR_MESSAGES.VALID_EMAIL_REQUIRED, 400); } - const userAccess = req.user?.access?.report?.read; - if (userAccess !== true) { + // Standard check - the raw read lacked the owner-class bypass. + if (!this.checkPermission('report', 'read', req.user)) { return this.error(res, 'Unauthorized', 403); } @@ -6148,14 +6391,17 @@ class SalesController extends BaseController { } const SaleModel = this.model || Sale; + const resolvedBranch = await shopTransportOf(req); const response = await salesService.sendDailySalesMail(input, { SaleModel, + shopTransport: resolvedBranch, }); if (response.status) { return this.success(res, response.data, response.message || 'Mail sent'); } else { - return this.error(res, response.message || 'Mail failed', 502); + // 503 with the real reason - a mail-config gap is not a gateway fault. + return this.error(res, response.message || 'Email is not configured on this server', 503); } } catch (error) { console.error('Error in dailySalesMail:', error); @@ -6581,7 +6827,7 @@ class SalesController extends BaseController { /** * PHP: multiKitchenPrint() - * Multi-printer KOT polling — returns pending print_jobs per sale + * Multi-printer KOT polling - returns pending print_jobs per sale */ async multiKitchenPrint(req, res) { try { @@ -6641,8 +6887,9 @@ class SalesController extends BaseController { */ async getNewSale(req, res) { try { - const userAccess = req.user?.access?.sales?.write; - if (userAccess !== true) { + // The standard check carries the owner-class bypass the raw access + // read lacked - super admins 403d here on the unpaid path. + if (!this.checkPermission('sales', 'write', req.user)) { return this.error(res, ERROR_MESSAGES.UNAUTHORIZED, 403); } @@ -7331,3 +7578,10 @@ class SalesController extends BaseController { } module.exports = new SalesController(); +/* + * Exposed for tests: the discount-cap estimate decides whether a sale is + * refused for want of manager approval, and it is worth pinning directly + * rather than only through a full request. + */ +module.exports.estimateManualDiscountPct = estimateManualDiscountPct; +module.exports.auditNumber = auditNumber; diff --git a/api/src/controllers/settings-groups.controller.js b/api/src/controllers/settings-groups.controller.js new file mode 100644 index 000000000..014c398bd --- /dev/null +++ b/api/src/controllers/settings-groups.controller.js @@ -0,0 +1,191 @@ +'use strict'; + +/* + * One endpoint per settings group (SETTINGS_AND_BRANCH_SCOPE_DESIGN, D2/S3). + * + * The whole point: an endpoint that only knows its own group cannot be asked + * for a field that belongs to another one. The three bugs of 2026-08-20 were + * all the same shape - a single endpoint serving both the full settings form + * and small partial saves, demanding fields the caller never had: + * + * - a non-empty notification_value on every call (5c84111) + * - one $set built from the whole form, wiping unsent keys (5c84111) + * - default_customer / default_supplier required to upload a signature + * image (69bc0cd) + * + * None of those can be expressed here. + * + * SECRETS ARE WRITE-ONLY. GET /settings/secrets answers which credentials are + * configured, never their values. Today an SMTP password sits in the same + * document as roundOff and is handed to any client that reads settings; this + * is where that stops. + */ + +const SettingsRepository = require('../repositories/settings.repository'); +const { GROUPS } = require('../services/settings-groups'); +const dataSharing = require('../services/data-sharing'); + +const repository = new SettingsRepository(); + +const GROUP_NAMES = Object.keys(GROUPS); + +function contextOf(req) { + const user = req.user || {}; + return { + branchId: + req.tenantContext?.branchId || + req.session?.selectedBranchId || + req.session?.branch_id || + user.branch_id || + (Array.isArray(user.branch_access) && user.branch_access[0]?.branch_id) || + null, + licenseId: req.tenantContext?.licenseId || user.license || user.license_id || null, + }; +} + +/* Reading settings needs the settings permission; writing needs it too, and + secrets additionally require an owner-class account - a cashier has no + business setting the shop's outgoing mail password. */ +const canRead = (req) => req.user?.access?.setting?.read !== false; +const canWrite = (req) => req.user?.access?.setting?.write !== false; +const OWNER_TYPES = ['owner', 'admin', 'super_admin', 'manager', 'store_manager']; +const isOwnerClass = (req) => + OWNER_TYPES.includes(String(req.user?.usertype || req.user?.role || '').toLowerCase()); + +/* Which branches this user may act on. A copy touches TWO of them, and the + destination is the dangerous one - it is the branch being overwritten. An + account with no branch_access list is an unrestricted one (owner/admin), so + an empty list means "all", not "none". */ +const branchesAllowed = (req) => { + const list = req.user?.branch_access; + if (!Array.isArray(list) || !list.length) return null; // null = unrestricted + return new Set(list.map((b) => String(b?.branch_id || b))); +}; +const mayTouchBranch = (req, branchId) => { + const allowed = branchesAllowed(req); + return allowed === null || allowed.has(String(branchId)); +}; + +const fail = (res, message, code = 400, data = null) => + res.status(code).json({ type: 'error', message, data }); +const ok = (res, data, message) => res.json({ type: 'success', message, data }); + +/* Which secrets exist, never what they are. */ +const describeSecrets = (values) => { + const out = {}; + for (const key of GROUPS.secrets) { + const v = values[key]; + // a boolean flag is enough to render "configured / not configured" + out[key] = v !== undefined && v !== null && String(v) !== ''; + } + return out; +}; + +module.exports = { + async read(req, res) { + try { + if (!canRead(req)) return fail(res, 'Unauthorized access', 403); + const group = String(req.params.group || ''); + if (!GROUP_NAMES.includes(group)) return fail(res, 'Unknown settings group', 404); + if (group === 'secrets' && !isOwnerClass(req)) { + return fail(res, 'Unauthorized access', 403); + } + + /* ?level=account reads what the ACCOUNT itself decides, unresolved. + Editing a shop-wide rule must not start from one branch's override - + saving that back would push that branch's choice onto every other + shop without anyone asking for it. */ + const wantsAccount = String(req.query?.level || '') === 'account'; + const r = wantsAccount + ? await repository.accountGroup(group, contextOf(req)) + : await repository.resolveGroup(group, contextOf(req)); + if (!r.status) return fail(res, r.message); + + if (group === 'secrets') { + // values, and now `inherited` too, never leave for this group + return ok(res, { group, configured: describeSecrets(r.data.values) }, 'success'); + } + return ok(res, r.data, 'success'); + } catch (error) { + console.error('Error in settings-groups read:', error); + return fail(res, error.message, 500); + } + }, + + /* + * S6 (D4). Copy settings from one branch to another. + * + * Owner-class only, and both branches must be ones this account may act on - + * the destination especially, since that is the branch being overwritten. + * Secrets are refused by name rather than quietly dropped: a caller that + * asked to copy credentials should be told it did not happen. + */ + async copy(req, res) { + try { + if (!canWrite(req)) return fail(res, 'Unauthorized access', 403); + if (!isOwnerClass(req)) return fail(res, 'Unauthorized access', 403); + + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const from = body.from || contextOf(req).branchId; + const to = body.to; + if (!to) return fail(res, 'Pick a branch to copy to'); + for (const [label, id] of [ + ['source', from], + ['destination', to], + ]) { + if (!mayTouchBranch(req, id)) { + return fail(res, `You do not have access to the ${label} branch`, 403); + } + } + + const groups = Array.isArray(body.groups) ? body.groups : []; + const r = await repository.copyGroups(groups, from, to, { + licenseId: contextOf(req).licenseId, + }); + if (!r.status) return fail(res, r.message, 400, r.data); + return ok(res, r.data, 'Settings copied'); + } catch (error) { + console.error('Error in settings-groups copy:', error); + return fail(res, error.message, 500); + } + }, + + async write(req, res) { + try { + if (!canWrite(req)) return fail(res, 'Unauthorized access', 403); + const group = String(req.params.group || ''); + if (!GROUP_NAMES.includes(group)) return fail(res, 'Unknown settings group', 404); + /* + * `sharing` is owner-class to WRITE, like secrets - and for the same + * reason, though it is not a credential. It is the only group where a + * value decides what data a person can READ: switching it on makes every + * shop's customers visible from every till at once. Reading it is left + * open, because a screen has to be able to say why a list looks the way + * it does. + */ + if ((group === 'secrets' || group === 'sharing') && !isOwnerClass(req)) { + return fail(res, 'Unauthorized access', 403); + } + + const body = req.body && typeof req.body === 'object' ? req.body : {}; + /* `level` is how a value is set for every branch at once. It is not a + settings key, so it is pulled out before the payload is validated - + otherwise it would be refused as belonging to no group. */ + const { level, ...values } = body; + if (level && level !== 'account' && level !== 'branch') { + return fail(res, "level must be 'account' or 'branch'"); + } + + const r = await repository.saveGroup(group, values, contextOf(req), { level }); + if (!r.status) return fail(res, r.message, 400, r.data); + /* The read path caches this for thirty seconds. Whoever just flipped the + switch must not be told to wait for it - the delay exists for OTHER + processes serving the same account, not for the person at the screen. */ + if (group === 'sharing') dataSharing.invalidate(contextOf(req).licenseId); + return ok(res, r.data, 'Settings saved'); + } catch (error) { + console.error('Error in settings-groups write:', error); + return fail(res, error.message, 500); + } + }, +}; diff --git a/api/src/controllers/settings.controller.js b/api/src/controllers/settings.controller.js index 771b1fee3..2405ab7fc 100644 --- a/api/src/controllers/settings.controller.js +++ b/api/src/controllers/settings.controller.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const path = require('path'); const BaseController = require('./base.controller'); +const { clientIp } = require('../utils/client-ip'); const SettingModel = require('../models/setting.model'); const settingsService = require('../services/setting.service'); @@ -121,6 +122,12 @@ class SettingController extends BaseController { branchId, licenseId, user: req.user, + /* Carried so security-relevant writes can record where they came from. + Models have never seen a request, which is exactly why the change log + has no addresses in it and why a changed password could not be traced + to anybody. */ + ip: clientIp(req), + userAgent: (req.headers && req.headers['user-agent']) || '', }); return model; } @@ -282,14 +289,9 @@ class SettingController extends BaseController { const data = req.query?.data || this.parseNestedQueryParam(req.query, 'data') || {}; const customerId = data.customer || req.query.customer; - if (!customerId) { - return res.status(400).json({ - type: 'error', - message: 'Customer ID is required', - data: null, - }); - } - + // No early refusal: the model self-heals a missing id from the branch + // context (finds or creates the branch's Walk-in) and only a truly + // contextless call fails. The 400 here kept the heal dead code. const settingModel = this.createModelWithContext(req); const result = await settingModel.getDefaultCustomer(customerId); @@ -325,14 +327,7 @@ class SettingController extends BaseController { const data = req.query?.data || this.parseNestedQueryParam(req.query, 'data') || {}; const supplierId = data.supplier || req.query.supplier; - if (!supplierId) { - return res.status(400).json({ - type: 'error', - message: 'Supplier ID is required', - data: null, - }); - } - + // Same rule as the customer: heal in the model, never refuse here. const settingModel = this.createModelWithContext(req); const result = await settingModel.getDefaultSupplier(supplierId); @@ -365,14 +360,7 @@ class SettingController extends BaseController { const customerId = data.customer || req.query.customer || req.query.customer_id; const supplierId = data.supplier || req.query.supplier || req.query.supplier_id; - if (!customerId || !supplierId) { - return res.status(400).json({ - type: 'error', - message: 'Customer and supplier ids are required', - data: null, - }); - } - + // Both ids heal in the model from the branch context. const settingModel = this.createModelWithContext(req); const result = await settingModel.getDefaultCustomerSupplier(customerId, supplierId); @@ -512,6 +500,28 @@ class SettingController extends BaseController { } } + async updateStarterLocale(req, res) { + try { + const settingModel = this.createModelWithContext(req); + settingsService.setModel(settingModel); + + const result = await settingsService.updateStarterLocale(req.body); + if (result.status) { + return res + .status(200) + .json({ type: 'success', message: 'Starter settings updated', data: result.data }); + } + return res.status(404).json({ + type: 'error', + message: result.message || 'Starter settings not updated', + data: result.data, + }); + } catch (error) { + console.error('Error updating starter locale:', error); + return res.status(500).json({ type: 'error', message: error.message }); + } + } + /* * One branch's module switches (M4 branch selector). Any branch of THIS * license only - the model's license filter is the wall; a foreign id @@ -546,32 +556,35 @@ class SettingController extends BaseController { const data = req.body; - // Sales prefix validation (exact length check not in middleware) - if (data.sales_prefix && data.sales_prefix.length !== 3) { - return res.status(400).json({ - type: 'error', - message: 'Data Not Valid: sales_prefix required (exactly 3 chars)', - data: null, - }); - } - if (!data.receiving_prefix || data.receiving_prefix.length !== 3) { + /* + * Prefixes: 1-6 characters. The old exactly-3 rule rejected the + * form's own default ('S'), so every settings save on such shops + * failed with a 400 and the form reset - toggles never persisted + * (owner's "I enabled quote and I see reset form"). + */ + if (data.sales_prefix && (data.sales_prefix.length < 1 || data.sales_prefix.length > 6)) { return res.status(400).json({ type: 'error', - message: 'Data Not Valid: receiving_prefix required (exactly 3 chars)', + message: 'Data Not Valid: sales_prefix must be 1-6 characters', data: null, }); } if ( - data.notification_value === undefined || - data.notification_value === null || - data.notification_value === '' + data.receiving_prefix !== undefined && + (String(data.receiving_prefix).length < 1 || String(data.receiving_prefix).length > 6) ) { return res.status(400).json({ type: 'error', - message: 'Data Not Valid: notification_value required', + message: 'Data Not Valid: receiving_prefix must be 1-6 characters', data: null, }); } + /* + * notification_value is OPTIONAL. Requiring it non-empty broke every + * shop that simply has no notification range configured: the Features + * save and the signature save both 400'd on a field their forms don't + * own. Empty means "none" and partial payloads may omit it entirely. + */ const result = await settingsService.updateCommonSettings(data); @@ -782,7 +795,7 @@ class SettingController extends BaseController { return res.status(400).json({ type: 'error', message: 'Valid email required' }); } const settingModel = this.createModelWithContext(req); - const result = await settingModel.getForgotUserDetails(req.body.email); + const result = await settingModel.getForgotUserDetails(req.body.email, req); if (result.status) { return res .status(200) diff --git a/api/src/controllers/shadow-login.controller.js b/api/src/controllers/shadow-login.controller.js index c0dee5a43..82db49bad 100644 --- a/api/src/controllers/shadow-login.controller.js +++ b/api/src/controllers/shadow-login.controller.js @@ -39,6 +39,16 @@ const BaseModel = require('../models/base.model'); otherwise the console could mint a permanent key to any shop by accident. */ const MAX_LIFETIME_SEC = 120; +function clientIp(req) { + const forwardedFor = req.headers && req.headers['x-forwarded-for']; + const forwardedIp = Array.isArray(forwardedFor) + ? forwardedFor[0] + : String(forwardedFor || '') + .split(',')[0] + .trim(); + return req.ip || req.connection?.remoteAddress || forwardedIp || 'unknown'; +} + async function shadowLogin(req, res) { const token = req.query.token || req.body?.token; if (!token) return res.status(400).send('This link is missing its token.'); @@ -77,10 +87,30 @@ async function shadowLogin(req, res) { * only needed for as long as a token could still be replayed. */ const spent = db.collection('shadow_login_tokens'); + /* + * Who is behind this link: the shop's owner, or one of our staff. + * + * `claims.actor` is the answer web-api now signs into the token. It used to + * be inferred by exact-matching a human-readable reason string, which meant + * that rewording the sentence would silently reclassify every owner sign-in + * as support and stop recording any of them. The string is still accepted so + * tokens minted by an older web-api keep working. + */ + const shadowActor = + claims.actor === 'owner' || claims.reason === 'owner sign-in from posnic.com' + ? 'owner' + : 'support'; try { await spent.createIndex({ jti: 1 }, { unique: true, name: 'jti_once' }); await spent.createIndex({ usedAt: 1 }, { expireAfterSeconds: 24 * 60 * 60, name: 'jti_ttl' }); - await spent.insertOne({ jti: claims.jti, usedAt: new Date(), by: claims.by || null }); + await spent.insertOne({ + jti: claims.jti, + usedAt: new Date(), + by: claims.by || null, + reason: claims.reason || null, + actor: shadowActor, + from: claims.from || null, + }); } catch (e) { if (e && e.code === 11000) { return res.status(401).send('This link has already been used. Ask for a new one.'); @@ -160,7 +190,76 @@ async function shadowLogin(req, res) { * and dated. One day, because that is what a normal sign-in uses and a * support session has no business outliving it. */ - const payload = JSON.stringify({ token: authToken, user: user.email }); + /* + * The identity fields a NORMAL sign-in stores, not just the token. + * + * The page stored token + email and nothing else, and the frontend reads + * localStorage for the rest: usertype gates admin-only screens (the + * first-run welcome refused its bypass because usertype was null), + * username's ABSENCE makes the ajax layer bounce any failed call to + * login.html, and branchname/branch_id feed the header and the per-shop + * keys. Every shadow session - which is also how the owner opens shops + * from My Account - ran half-signed-in, and each missing field failed as + * its own separate mystery. + */ + /* + * Where to land after signing in. The diagnostic rig needs this: a page + * that only breaks on #/variants or one item's view can't be captured by + * a run that always lands on the dashboard, and a second browser launch + * loses the first one's storage when the harness kills Chrome before the + * profile flushes. One launch, straight to the route, is the only shape + * that always works. Strictly a hash ROUTE - a handful of safe characters, + * never a URL - so this can't become an open redirect or script injection. + */ + const nextRaw = String((req.query && req.query.next) || ''); + const nextRoute = /^[a-zA-Z0-9/_-]{1,80}$/.test(nextRaw) ? nextRaw : ''; + const firstBranch = + Array.isArray(user.branch_access) && user.branch_access[0] ? user.branch_access[0] : {}; + if (shadowActor === 'owner') { + const branchId = user.branch_id || firstBranch.branch_id; + const licenseId = user.license || user.license_id; + if (branchId && licenseId) { + const activity = await BaseModel.changeUserLog( + user._id, + user.username || user.name || user.email, + new Date(), + branchId, + firstBranch.branch_name || '', + licenseId, + { + userAgent: req.headers['user-agent'] || '', + ip: clientIp(req), + /* + * 'signup' when this is the shop being opened for somebody the + * moment they created it. That is not the same event as a customer + * choosing to come back, and the console needs to tell them apart or + * "opened the shop" ends up measuring our own redirect. + */ + source: claims.from === 'signup' ? 'signup' : 'owner_link', + } + ); + if (!activity || activity.status === false) { + // eslint-disable-next-line no-console + console.warn('shadow owner activity log failed:', activity && activity.message); + } + } else { + // eslint-disable-next-line no-console + console.warn('shadow owner activity log skipped: branch or license missing'); + } + } + const payload = JSON.stringify({ + next: nextRoute, + token: authToken, + user: user.email, + username: user.username || user.name || user.email, + usertype: user.usertype || '', + branch_id: user.branch_id + ? String(user.branch_id) + : firstBranch.branch_id + ? String(firstBranch.branch_id) + : '', + branch_name: firstBranch.branch_name || '', + }); res.set('content-type', 'text/html; charset=utf-8'); /* Never cached, never indexed. For the next two hours this page body is a working credential for someone else's shop. */ @@ -176,6 +275,12 @@ async function shadowLogin(req, res) { (function () { var d = ${payload}; try { localStorage.setItem('posnic_jwt_token', d.token); } catch (e) {} + try { + if (d.username) localStorage.setItem('username', d.username); + if (d.usertype) localStorage.setItem('usertype', d.usertype); + if (d.branch_id) localStorage.setItem('branch_id_set', d.branch_id); + if (d.branch_name) localStorage.setItem('branchname', d.branch_name); + } catch (e) {} /* The other half a real sign-in sets. Without it the app has the token and still redirects to login.html - see the comment above this template. */ try { @@ -184,8 +289,9 @@ async function shadowLogin(req, res) { document.cookie = 'loginuser=yes; expires=' + until.toGMTString() + '; path=/'; } catch (e) {} /* replace, not assign: the back button should not return to a page that - still holds the token in its source. */ - location.replace('/'); + still holds the token in its source. d.next is server-sanitised to a + bare hash route (letters, digits, slash, dash, underscore only). */ + location.replace(d.next ? '/dashboard.html#/' + d.next : '/'); })(); `); } diff --git a/api/src/controllers/users.controller.js b/api/src/controllers/users.controller.js index c37025964..337ee9ecc 100644 --- a/api/src/controllers/users.controller.js +++ b/api/src/controllers/users.controller.js @@ -1,4 +1,5 @@ const { redact } = require('../utils/redact'); +const { clientIp } = require('../utils/client-ip'); const { currentConnection } = require('../db/tenant-context'); const { searchPattern } = require('../utils/safe-search'); const BaseController = require('./base.controller'); @@ -13,6 +14,23 @@ const BaseModel = require('../models/base.model'); const { authCookieOptions } = require('../utils/auth-cookie'); const sessionFilterUtil = require('../utils/session-filter.util'); const { setActiveTenantContext } = require('../utils/tenant-context'); +const { recordAudit } = require('../utils/audit-trail'); + +/* + * What somebody sees when a sign-in fails. + * + * Shown for BOTH an unknown user and a wrong password, on purpose: telling + * them apart would let anybody enumerate which emails have accounts here. + * + * The old wording was "Invalid account. Please contact your branch manager." + * A sole shop owner locked out of their own till read that as an instruction + * to contact himself, and it said nothing about the thing that had actually + * happened - somebody had changed the password the day before. Naming that + * possibility costs nothing, leaks nothing, and is the first thing to try. + */ +const LOGIN_FAILED_MESSAGE = + 'Username or password is incorrect. If the password was changed recently, ' + + 'use the new one - otherwise ask an administrator to reset it.'; const persistActiveTenant = async (req, data, fallbackLicense) => { const context = setActiveTenantContext(req, { @@ -202,6 +220,40 @@ class UsersController extends BaseController { * The frontend posts { username, password } to /users/verify. * This method authenticates the user, checks rate limiting, and returns user data with JWT token. */ + /* + * Record a failed sign-in where support can read it. + * + * The client is told one thing; this keeps the detail. When a shop says "we + * cannot get in", the two questions are which account was tried and from + * where - and, if the account exists, whether its password was changed + * recently. That last field is what took a database session and a bcrypt + * comparison by hand to establish, the one time it mattered. + * + * Never throws: a login must not fail because its audit line could not be + * written. Never records the attempted password, not even hashed. + */ + async recordFailedLogin(req, loginId, reason, user = null) { + try { + const db = await BaseModel.getDb(); + await recordAudit(db, { + event: 'login_failed', + actor: { id: user ? String(user._id) : null, name: loginId }, + target: user ? { id: String(user._id), name: user.email || '', type: 'user' } : null, + ip: clientIp(req), + userAgent: (req.headers && req.headers['user-agent']) || '', + extra: { + reason, + attempted: loginId, + /* Only meaningful when the account exists. Equal to creation means + it has never been changed. */ + passwordChangedAt: user ? user.updated_date || user.updated_at || null : null, + }, + }); + } catch (err) { + console.error('[audit] failed login not recorded:', err.message); + } + } + async legacyVerifyLogin(req, res) { try { // 🔍 DEBUG - Check if this method is called @@ -233,11 +285,27 @@ class UsersController extends BaseController { ) .lean(); - // PHP: if (isset($recordsFiltered) && ... password_verify(...)) + /* + * ONE MESSAGE FOR BOTH FAILURES, DELIBERATELY. + * + * Saying "no such user" here would tell anybody who asks which email + * addresses have accounts on this shop, one guess at a time. So an + * unknown user and a wrong password answer identically, and the reason + * is recorded on the server instead, where support can see it and an + * attacker cannot. + * + * The wording changed after a real shop was locked out for five days. + * The old text - "Invalid account. Please contact your branch manager." + * - told a sole owner, who IS the branch manager, to contact himself, + * and never mentioned the thing that had actually happened: the + * password had been changed. It now names that possibility, for both + * cases, which gives the person at the till something to try. + */ if (!user) { + await this.recordFailedLogin(req, loginId, 'no_such_user'); return res.status(404).json({ type: 'error', - message: 'Invalid account. Please contact your branch manager.', + message: LOGIN_FAILED_MESSAGE, data: 'incorrect', }); } @@ -253,7 +321,11 @@ class UsersController extends BaseController { // If base64 fails, try without base64 (old format) if (!passwordValid) { - passwordValid = await bcrypt.compare(password, user.password); + /* String() here as well as above: bcrypt.compare on a non-string + behaves differently across versions, and `password` arrives + straight from the request body where it can be an object or an + array. The base64 branch already coerced; this one did not. */ + passwordValid = await bcrypt.compare(String(password), user.password); } } @@ -261,9 +333,14 @@ class UsersController extends BaseController { // Match legacy PHP behaviour for incorrect credentials: // response('error', $response['message'], 'incorrect', 404); + /* Same message as the unknown-user case above. The distinction is + recorded server-side, including whether this account's password was + changed recently, which is the single most useful fact when + somebody says "it worked yesterday". */ + await this.recordFailedLogin(req, loginId, 'bad_password', user); return res.status(404).json({ type: 'error', - message: 'Invalid account. Please contact your branch manager.', + message: LOGIN_FAILED_MESSAGE, data: 'incorrect', }); } @@ -571,7 +648,7 @@ class UsersController extends BaseController { session_id: req.sessionID, license: user.license, branch_id: branchId, - ip_address: req.ip || req.connection.remoteAddress, + ip_address: clientIp(req), user_agent: req.get('User-Agent'), updated_date: currentTime, }, @@ -596,7 +673,7 @@ class UsersController extends BaseController { updated_date: currentTime, license: user.license, branch_id: branchId, - ip_address: req.ip || req.connection.remoteAddress, + ip_address: clientIp(req), user_agent: req.get('User-Agent'), }; @@ -1011,11 +1088,7 @@ class UsersController extends BaseController { activeTenant.licenseId, { userAgent: req.headers['user-agent'] || '', - ip: - req.ip || - req.connection?.remoteAddress || - req.headers['x-forwarded-for']?.split(',')[0] || - 'unknown', + ip: clientIp(req), } ).catch((err) => console.warn('changeUserLog failed:', err.message)); @@ -1723,35 +1796,107 @@ class UsersController extends BaseController { } } + /** + * Validate that an extracted S3 key is a legitimate Posnic user-image filename. + * Must match format generated by uploadUserImage(): + * YYYY-MM-DDTHH-mm-ss-posnic_user-{randomId}.{extension} + * + * Examples accepted: + * - 2025-12-25T14-30-45-posnic_user-abc123.jpg ✓ + * - 2025-09-02T15-22-08-posnic_user-k9j8l7m6n5.png ✓ + * + * Examples rejected: + * - backups/2025-12-25T14-30-45-posnic_user-abc123.jpg ✗ (contains /) + * - user.svg ✗ (doesn't match pattern) + * - arbitrary.jpg ✗ (no -posnic_user- marker) + * - 2025-12-25T14-30-45-posnic_category-abc123.jpg ✗ (wrong marker) + * + * @param {string} key - The S3 key or bare filename to validate + * @returns {boolean} True if valid Posnic user-image filename, false otherwise + */ + isValidPosnicUserImageFilename(key) { + if (!key || typeof key !== 'string') return false; + if (key.includes('/') || key.includes('\\')) return false; + + // Match: YYYY-MM-DDTHH-mm-ss-posnic_user-{2-15 alphanumeric chars}.{extension} + const userImagePattern = + /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-posnic_user-[a-z0-9]{2,15}\.(gif|jpg|png|jpeg|bmp)$/i; + return userImagePattern.test(key); + } + /** * PHP: userImageDelete() * Delete user profile image */ async userImageDelete(req, res) { try { - const imageUrl = req.body.data; - const userId = req.body.id; + const requestedImageUrl = req.body.data; + const bodyUserId = req.body.id; + + // Resolve the target user ID from body or authenticated request + const userId = + (bodyUserId && String(bodyUserId).trim()) || (req.user && (req.user._id || req.user.id)); + + if (!userId) { + return this.error(res, 'No user identified', 401); + } + + /* + * The rule getUser already uses: your own picture is always yours to + * remove, anybody else's needs the user-management permission. Without + * it the id simply arrives in the body and is obeyed, which was + * survivable while this only reset a database field and is not now that + * it destroys the file in the bucket. + */ + const isSelf = String(req.user && (req.user._id || req.user.id)) === String(userId); + if (!isSelf && !this.checkPermission('user', 'write', req.user)) { + return this.error(res, 'Unauthorized', 403); + } + + const User = this.userModel; + const user = await User.findById(userId).select('image').lean(); + + if (!user) { + return this.error(res, 'User not found', 404); + } + + const storedImageUrl = user.image || 'user.svg'; // Handle empty/default image - idempotent operation - // If no image or already default, just return success - if (!imageUrl || imageUrl.trim() === '' || imageUrl.includes('user.svg')) { + if (!storedImageUrl || storedImageUrl.trim() === '' || storedImageUrl.includes('user.svg')) { return this.success(res, 'user.svg', 'Image was deleted'); } - // TODO: Implement S3 deletion logic when S3 is configured - // For now, just update the database record to point to default image - const updateData = { image: 'user.svg' }; - const User = this.userModel; + const storageType = process.env.STORAGE_TYPE || 'local'; - // If an explicit user id is provided (e.g. from Users module), update that user - if (userId && userId.trim() !== '') { - await User.findByIdAndUpdate(userId, updateData); - } else if (req.user && (req.user._id || req.user.id)) { - // Otherwise fall back to currently authenticated user - const currentUserId = req.user._id || req.user.id; - await User.findByIdAndUpdate(currentUserId, updateData); + /* + * Clearing the object out of the bucket is BEST EFFORT, the way + * categories.controller already treats category images. + * + * Two rules pulling in opposite directions. Never delete an object we + * cannot positively identify as this user's picture, so anything + * unrecognised is left where it is rather than guessed at. And never let + * the bucket decide whether somebody may clear their own photograph: a + * stale URL, a bucket renamed since, a key written by the old PHP app, + * or an IAM policy without DeleteObject would otherwise leave that + * person stuck with a picture they cannot remove. An orphaned file is + * the cheaper failure. + * + * So every branch below skips the delete and carries on to the database. + */ + if (storageType === 's3' && process.env.AWS_S3_BUCKET) { + try { + const key = this.resolveUserImageS3Key(storedImageUrl, requestedImageUrl); + if (key) { + await require('../utils/s3').deleteObject(key); + } + } catch (err) { + console.error('Error deleting user image from S3:', err); + } } + await User.findByIdAndUpdate(userId, { image: 'user.svg' }); + return this.success(res, 'user.svg', 'Image was deleted'); } catch (error) { console.error('Error in userImageDelete:', error); @@ -1759,6 +1904,71 @@ class UsersController extends BaseController { } } + /** + * The S3 key to remove for a stored user image, or null when the object + * cannot be positively identified as one of ours. Never throws for a reason + * that should merely skip the delete; the caller treats null as "leave it". + * + * @param {string} storedImageUrl the URL held in the user record + * @param {string} [requestedImageUrl] what the client believed it was + * @returns {string|null} + */ + resolveUserImageS3Key(storedImageUrl, requestedImageUrl) { + // The client is working from a stale record; do not act on its guess. + if (requestedImageUrl && requestedImageUrl !== storedImageUrl) { + console.warn( + '[userImageDelete] request does not match the stored image, leaving the object in place' + ); + return null; + } + + let parsedUrl; + try { + parsedUrl = new URL(storedImageUrl); + } catch (err) { + // A relative path, written by an older install, is not an S3 object. + console.warn('[userImageDelete] stored image is not an absolute URL, leaving it in place'); + return null; + } + + const key = parsedUrl.pathname.replace(/^\/+/, ''); + const bucket = process.env.AWS_S3_BUCKET; + const region = process.env.AWS_REGION; + const publicUrl = process.env.AWS_S3_PUBLIC_URL; + + const allowedHosts = []; + if (publicUrl) { + try { + allowedHosts.push(new URL(publicUrl).hostname); + } catch (err) { + // Ignore invalid public URL configuration and fall back to the bucket hosts. + } + } + if (bucket) { + allowedHosts.push(`${bucket}.s3.amazonaws.com`); + if (region) { + allowedHosts.push(`${bucket}.s3.${region}.amazonaws.com`); + } + } + + const prefix = 'uploads/user_images/'; + const isBareKey = this.isValidPosnicUserImageFilename(key); + const isPrefixedKey = + key.startsWith(prefix) && this.isValidPosnicUserImageFilename(key.substring(prefix.length)); + + if (!isBareKey && !isPrefixedKey) { + console.warn('[userImageDelete] key is not a Posnic user image, leaving it in place'); + return null; + } + + if (!allowedHosts.includes(parsedUrl.hostname)) { + console.warn('[userImageDelete] image is not on a known bucket host, leaving it in place'); + return null; + } + + return key; + } + /** * PHP: updatePrintSetting() * Update user print settings @@ -1882,11 +2092,7 @@ class UsersController extends BaseController { activeTenant.licenseId, { userAgent: req.headers['user-agent'] || '', - ip: - req.ip || - req.connection?.remoteAddress || - req.headers['x-forwarded-for']?.split(',')[0] || - 'unknown', + ip: clientIp(req), } ).catch((err) => console.warn('changeUserLog failed:', err.message)); @@ -2389,7 +2595,7 @@ class UsersController extends BaseController { return res.status(404).json({ type: 'error', - message: 'Invalid account. Please contact your branch manager.', + message: LOGIN_FAILED_MESSAGE, data: null, }); } @@ -2581,7 +2787,7 @@ class UsersController extends BaseController { return res.status(404).json({ type: 'error', - message: 'Invalid account. Please contact your branch manager.', + message: LOGIN_FAILED_MESSAGE, data: null, }); } @@ -2718,18 +2924,22 @@ class UsersController extends BaseController { }); } - // Temporary hardcoded values for testing - TODO: Move to .env const posnicKey = process.env.GUZZLE_KEY || process.env.POSNIC_KEY; const posnicSecret = process.env.GUZZLE_SECRET || process.env.POSNIC_SECRET; - // Allow overriding the SSO endpoint via env for local/dev flexibility + /* + * The website mints the token now, not a PHP script. + * + * This pointed at api.posnic.com/user.php on a machine that has since + * been deleted - and it had already stopped working before that, because + * that origin no longer answered HTTPS, so Cloudflare returned 522 and + * this button failed silently for anyone who pressed it. + * + * SSO_URL still overrides, for local work against a dev website. + */ const ssoUrlFromEnv = process.env.SSO_URL; - const isDev = process.env.NODE_ENV !== 'production'; - const domainName = ssoUrlFromEnv - ? ssoUrlFromEnv - : isDev - ? 'http://api.dev.posnic.com/user.php?action=ssoToken' - : 'https://api.posnic.com/user.php?action=ssoToken'; + const siteBase = (process.env.POSNIC_SITE_URL || 'https://posnic.com').replace(/\/+$/, ''); + const domainName = ssoUrlFromEnv || `${siteBase}/api/sso/token`; console.log('[ssoClientLogin] Calling SSO API:', domainName); console.log('[ssoClientLogin] User ID:', String(userRecords._id)); @@ -2740,21 +2950,37 @@ class UsersController extends BaseController { formData.append('id', String(userRecords._id)); formData.append('email', userRecords.email); - const response = await axios.post(domainName, formData, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - posnickey: posnicKey, - posnicsecret: posnicSecret, - posnicsso: 'sso', - }, - proxy: false, - }); + /* + * JSON, because the endpoint answering is now our own Node service + * rather than a PHP script expecting form_params. The headers are + * unchanged so an older website that still speaks the old shape keeps + * working during a deploy where the two sides move separately. + */ + const response = await axios.post( + domainName, + { id: String(userRecords._id), email: userRecords.email }, + { + headers: { + 'Content-Type': 'application/json', + posnickey: posnicKey, + posnicsecret: posnicSecret, + posnicsso: 'sso', + }, + proxy: false, + timeout: 10000, + } + ); console.log('[ssoClientLogin] SSO API response:', JSON.stringify(response.data, null, 2)); if (response.data?.data?.token) { - const domainPath = isDev ? 'http://dev.posnic.com' : 'https://www.posnic.com/'; - const path = domainPath + '/ssoauth.html?token=' + response.data.data.token; + /* One slash. The old line joined 'https://www.posnic.com/' to + '/ssoauth.html' and produced a double slash in every link it ever + made. And the page is a route now, not a .html file. */ + /* /api/... because posnic.com is fronted by CloudFront, which forwards + only that prefix to the service; a bare /ssoauth answers 403 from the + CDN, exactly as the old /ssoauth.html did. */ + const path = `${siteBase}/api/sso/auth?token=${encodeURIComponent(response.data.data.token)}`; console.log('[ssoClientLogin] Success! Returning path:', path); // Match PHP: type 'success', message 'valid', HTTP 200 diff --git a/api/src/controllers/variants-v2.controller.js b/api/src/controllers/variants-v2.controller.js index 1aef752a8..35dd5e2e2 100644 --- a/api/src/controllers/variants-v2.controller.js +++ b/api/src/controllers/variants-v2.controller.js @@ -3,6 +3,7 @@ const Variant = require('../models/variant.model'); const mongoose = require('mongoose'); const { validationResult } = require('express-validator'); const { createActivityLog } = require('../utils/activityLogger'); +const { normalizeVariantFields } = require('../helpers/variants.helper'); const escapeRegExp = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -34,19 +35,10 @@ const formatVariant = (variant = {}) => { ? variant.product_type : []; - const fields = rawFields - .map((entry) => { - if (!entry) return null; - if (typeof entry === 'string') { - const name = entry.trim(); - return name ? { name } : null; - } - if (typeof entry === 'object' && entry.name) { - return { name: String(entry.name).trim() }; - } - return null; - }) - .filter(Boolean); + /* De-duplicated on READ as well as write. Variants saved before the + write-side guard existed still hold repeated values, and those are + exactly the ones showing the same size twice in the item form. */ + const fields = normalizeVariantFields(rawFields); const product_type = fields.map((f) => f.name); @@ -234,11 +226,9 @@ class VariantControllerV2 { return sendError(res, 'Variant name is required', 400); } - const rawTypes = Array.isArray(product_type) ? product_type : []; - const fields = rawTypes - .map((val) => (val != null ? String(val).trim() : '')) - .filter(Boolean) - .map((val) => ({ name: val })); + /* Trims, drops blanks, and collapses duplicates - see the helper for + why a repeated value is silently dropped rather than refused. */ + const fields = normalizeVariantFields(product_type); if (!fields.length) { return sendError(res, 'At least one variant value is required', 400); @@ -304,11 +294,9 @@ class VariantControllerV2 { return sendError(res, 'Variant name is required', 400); } - const rawTypes = Array.isArray(product_type) ? product_type : []; - const fields = rawTypes - .map((val) => (val != null ? String(val).trim() : '')) - .filter(Boolean) - .map((val) => ({ name: val })); + /* Trims, drops blanks, and collapses duplicates - see the helper for + why a repeated value is silently dropped rather than refused. */ + const fields = normalizeVariantFields(product_type); if (!fields.length) { return sendError(res, 'At least one variant value is required', 400); diff --git a/api/src/controllers/whatsapp.controller.js b/api/src/controllers/whatsapp.controller.js index 06256984d..d9cbef350 100644 --- a/api/src/controllers/whatsapp.controller.js +++ b/api/src/controllers/whatsapp.controller.js @@ -610,6 +610,70 @@ class WhatsAppController extends BaseController { /** * Save WhatsApp template */ + /* + * Edit a template that already exists. + * + * The route for this has been live and calling a method that was never + * written, so every "Template updated" attempt returned a 500. Express did + * not catch it at startup because the route wraps the call in an arrow + * function - the wrapper IS a function, so registration succeeds and the + * TypeError only surfaces when someone actually presses save. + * + * Scoped the same way saveTemplate scopes: the lookup carries templateScope, + * so a template id from another shop finds nothing rather than being + * rewritten. Never trust the id alone. + */ + async updateTemplate(req, res) { + try { + const { template_id, name, message, template_type } = req.body; + + if (!template_id) { + return res.json({ type: 'error', message: 'Template id is required' }); + } + if (!name || !message) { + return res.json({ + type: 'error', + message: 'Template name and message are required', + }); + } + + const branchId = activeBranchId(req); + if (!branchId && !req.tenantContext) { + return res.json({ + type: 'error', + message: 'Branch ID not found. Please ensure you are logged in.', + }); + } + + const Template = require('../models/whatsapp-template.model'); + const template = await Template.findOneAndUpdate( + { _id: template_id, ...templateScope(req) }, + { + $set: { + name, + message, + template_type: template_type || 'general', + updated_at: new Date(), + }, + }, + { new: true } + ); + + if (!template) { + return res.json({ type: 'error', message: 'Template not found' }); + } + + return res.json({ + type: 'success', + message: 'Template updated successfully', + data: template, + }); + } catch (error) { + console.error('Error in updateTemplate:', error); + return res.json({ type: 'error', message: error.message }); + } + } + async saveTemplate(req, res) { try { const { name, message, template_type } = req.body; diff --git a/api/src/db/ensure-index.js b/api/src/db/ensure-index.js new file mode 100644 index 000000000..7d9b22819 --- /dev/null +++ b/api/src/db/ensure-index.js @@ -0,0 +1,68 @@ +'use strict'; + +/* + * Create an index once per DATABASE, not once per process. + * + * A shop's data lives in its own database (see db/tenant-connections.js) and + * one process serves many shops. So the obvious latch is wrong in a way that + * is invisible in testing and total in production: + * + * if (this.constructor._ensured) return; // <- per PROCESS + * + * The first shop to hit the endpoint after a restart sets that flag, and every + * other shop on the same process never gets the index at all. It looks like it + * worked, because it did - once, for whoever was first. + * + * Keying the latch by database name fixes it: each shop gets exactly one + * attempt, and a restart re-attempts for everyone. createIndex is itself + * idempotent, so the latch is only there to avoid the round trip. + * + * WHERE THIS SHOULD EVENTUALLY LIVE: db/migrations.js runs versioned, ledgered + * migrations against each tenant database before it serves traffic, which is + * the right home for schema shape. It is written but not yet wired - nothing + * calls runMigrations and the registry is empty - so index creation still + * happens lazily here. When migrations are wired, these move and this file + * goes away. + */ + +const ensured = new Set(); + +/* The database a collection belongs to, however the driver exposes it. */ +function databaseNameOf(collection) { + return ( + collection?.dbName || collection?.s?.db?.databaseName || collection?.conn?.name || 'default' + ); +} + +/** + * Ensure `keys` exists on `collection`, at most once per database per process. + * + * Best effort by design: an index build that fails - a shop mid-build, a + * permission quirk, a legacy duplicate blocking a unique index - must never + * fail the request that happened to trigger it. It simply is not latched, so + * the next request tries again. + * + * @returns {Promise} true if the index is now believed to exist + */ +async function ensureIndexOnce(collection, keys, options = {}) { + if (!collection || !keys) return false; + + const name = options.name || JSON.stringify(keys); + const key = `${databaseNameOf(collection)}::${name}`; + if (ensured.has(key)) return true; + + try { + await collection.createIndex(keys, options); + ensured.add(key); + return true; + } catch (e) { + return false; + } +} + +/* Tests only: forget what has been ensured. */ +function _reset() { + ensured.clear(); +} + +module.exports = { ensureIndexOnce, databaseNameOf, _reset }; diff --git a/api/src/fonts/DEJAVU-LICENSE b/api/src/fonts/DEJAVU-LICENSE new file mode 100644 index 000000000..df52c1709 --- /dev/null +++ b/api/src/fonts/DEJAVU-LICENSE @@ -0,0 +1,187 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +TeX Gyre DJV Math +----------------- +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. + +Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski +(on behalf of TeX users groups) are in public domain. + +Letters imported from Euler Fraktur from AMSfonts are (c) American +Mathematical Society (see below). +Bitstream Vera Fonts Copyright +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera +is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license (“Fonts”) and associated +documentation +files (the “Font Software”), to reproduce and distribute the Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, +and/or sell copies of the Font Software, and to permit persons to whom +the Font Software is furnished to do so, subject to the following +conditions: + +The above copyright and trademark notices and this permission notice +shall be +included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional +glyphs or characters may be added to the Fonts, only if the fonts are +renamed +to names not containing either the words “Bitstream” or the word “Vera”. + +This License becomes null and void to the extent applicable to Fonts or +Font Software +that has been modified and is distributed under the “Bitstream Vera” +names. + +The Font Software may be sold as part of a larger software package but +no copy +of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, +SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN +ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +Except as contained in this notice, the names of GNOME, the GNOME +Foundation, +and Bitstream Inc., shall not be used in advertising or otherwise to promote +the sale, use or other dealings in this Font Software without prior written +authorization from the GNOME Foundation or Bitstream Inc., respectively. +For further information, contact: fonts at gnome dot org. + +AMSFonts (v. 2.2) copyright + +The PostScript Type 1 implementation of the AMSFonts produced by and +previously distributed by Blue Sky Research and Y&Y, Inc. are now freely +available for general use. This has been accomplished through the +cooperation +of a consortium of scientific publishers with Blue Sky Research and Y&Y. +Members of this consortium include: + +Elsevier Science IBM Corporation Society for Industrial and Applied +Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) + +In order to assure the authenticity of these fonts, copyright will be +held by +the American Mathematical Society. This is not meant to restrict in any way +the legitimate use of the fonts, such as (but not limited to) electronic +distribution of documents containing these fonts, inclusion of these fonts +into other public domain or commercial font collections or computer +applications, use of the outline data to create derivative fonts and/or +faces, etc. However, the AMS does require that the AMS copyright notice be +removed from any derivative versions of the fonts which have been altered in +any way. In addition, to ensure the fidelity of TeX documents using Computer +Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, +has requested that any alterations which yield different font metrics be +given a different name. + +$Id$ diff --git a/api/src/fonts/DejaVuSansCondensed-Bold.ttf b/api/src/fonts/DejaVuSansCondensed-Bold.ttf new file mode 100644 index 000000000..22987c62d Binary files /dev/null and b/api/src/fonts/DejaVuSansCondensed-Bold.ttf differ diff --git a/api/src/fonts/DejaVuSansCondensed.ttf b/api/src/fonts/DejaVuSansCondensed.ttf new file mode 100644 index 000000000..3259bc21a Binary files /dev/null and b/api/src/fonts/DejaVuSansCondensed.ttf differ diff --git a/api/src/helpers/install.helper.js b/api/src/helpers/install.helper.js index 6cbdb4f02..b5592f56a 100644 --- a/api/src/helpers/install.helper.js +++ b/api/src/helpers/install.helper.js @@ -30,11 +30,16 @@ const toObjectId = (id) => { return null; }; +/* Every way a caller has said yes to demo data. Compared lower-cased and + trimmed, so 'Yes' and ' on ' are the same answer as 'yes'. */ +const TRUTHY_DEMO = new Set(['yes', 'on', 'true', '1', 'y']); + /** * Sanitize installation data * @param {Object} data - Raw installation data * @returns {Object} */ + const sanitizeInstallData = (data) => { return { register_companyname: (data.register_companyname || '').trim(), @@ -51,8 +56,30 @@ const sanitizeInstallData = (data) => { register_countryid: (data.register_countryid || '').trim(), register_state: (data.register_state || '').trim(), register_timezone: (data.register_timezone || '').trim(), - register_demo: data.register_demo || false, - businessType: (data.businessType || '').trim(), + /* + * The provisioner's words, not ours. + * + * Gateway sends `register_demo: 'yes'` and `business: 'retail'` + * (apps/provisioner/provision.js). This side checked for `'on'` and read + * `businessType`, so BOTH missed - every cloud shop was created with no + * demo data at all and, had it run, always the supermarket pack. + * + * Silent, because each miss has a plausible fallback: no demo data looks + * like a shop that asked for none, and a supermarket pack looks like a + * default somebody chose. A new customer opened their till and found one + * product in it. + * + * Normalised here rather than changed in Gateway: this is the side that + * consumes the value, it already normalises businessType for the pack + * lookup, and a provisioner mid-flight for live signups is the wrong + * thing to edit to fix a reader. + */ + register_demo: TRUTHY_DEMO.has( + String(data.register_demo == null ? '' : data.register_demo) + .trim() + .toLowerCase() + ), + businessType: (data.businessType || data.business || '').trim(), }; }; diff --git a/api/src/helpers/items.helper.js b/api/src/helpers/items.helper.js index b68facbb1..6bcb4473c 100644 --- a/api/src/helpers/items.helper.js +++ b/api/src/helpers/items.helper.js @@ -4,6 +4,7 @@ */ const { FIELD_LIMITS, ERROR_MESSAGES } = require('../constants/items.constants'); +const gtin = require('../utils/gtin'); /** * Sanitize item data before persistence @@ -48,6 +49,32 @@ const sanitizeItemData = (data = {}) => { } }); + /* + * The GTIN is derived here, not trusted. + * + * This is the one funnel every create and every edit passes through, so it is + * the only place the rule can be enforced once: a gtin is stored only when it + * validates, and gtin14 - the zero-padded comparison form - is computed from + * it rather than accepted from the caller. + * + * Anything that fails validation clears BOTH fields rather than being left + * alone. Leaving the old value would mean a shop that corrected a mistyped + * barcode still has the wrong global identifier attached, which is precisely + * the error a shared database cannot recover from. + * + * barcode_id is deliberately NOT used as a source. It may hold an in-store + * code, a supplier reference, or free text, and promoting that to a global + * identifier is how a public database gets poisoned. + */ + if (Object.prototype.hasOwnProperty.call(sanitized, 'gtin')) { + const parsed = gtin.parse(sanitized.gtin); + sanitized.gtin = parsed ? parsed.gtin : ''; + sanitized.gtin14 = parsed ? parsed.gtin14 : ''; + } else { + /* Never let a caller set the comparison form directly - it is derived. */ + delete sanitized.gtin14; + } + return sanitized; }; diff --git a/api/src/helpers/variants.helper.js b/api/src/helpers/variants.helper.js new file mode 100644 index 000000000..1fa73c7bd --- /dev/null +++ b/api/src/helpers/variants.helper.js @@ -0,0 +1,56 @@ +/** + * Variant Helper Functions + * + * A variant's values are a set of CHOICES - "Size: 38, 40, 42". The one thing + * a set cannot contain is the same member twice, and nothing enforced that: + * saving Size as 38, 40, 40 stored 40 twice, the item form then offered it + * twice, and both copies could be picked. That produced two items called + * "Shirt / 40" - same name, same axis, different prices and stock, and no way + * for anyone to tell which one the barcode meant. + * + * Collapsed rather than rejected on purpose. The second 40 carries no + * information, so dropping it loses nothing a person meant to say - and + * refusing the save would block real work over a slip we can obviously + * resolve. Errors are for what we cannot decide; this we can. + */ + +/** + * Turn whatever the form sent into a clean, duplicate-free field list. + * + * Compared case- and space-insensitively: "40 " and "40" are the same size, + * and "Red" and "red" are the same colour. Someone typing both meant one. + * + * The FIRST spelling wins, so the shop's own capitalisation survives - keeping + * the later one would let a stray lowercase entry silently rename a value + * that is already printed on labels. + * + * @param {Array} rawTypes - values as submitted (strings, or {name} objects) + * @returns {Array<{name: String}>} - trimmed, non-empty, de-duplicated + */ +const normalizeVariantFields = (rawTypes) => { + if (!Array.isArray(rawTypes)) return []; + + const seen = new Set(); + const fields = []; + + rawTypes.forEach((entry) => { + let name = ''; + if (entry != null && typeof entry === 'object') { + name = entry.name != null ? String(entry.name).trim() : ''; + } else if (entry != null) { + name = String(entry).trim(); + } + if (!name) return; + + const key = name.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + fields.push({ name }); + }); + + return fields; +}; + +module.exports = { + normalizeVariantFields, +}; diff --git a/api/src/json/country_currency.json b/api/src/json/country_currency.json new file mode 100644 index 000000000..7e9b0bff8 --- /dev/null +++ b/api/src/json/country_currency.json @@ -0,0 +1,259 @@ +{ +"_comment": "ISO 3166-1 alpha-2 country code -> ISO 4217 currency code. Source of truth for resolving a shop's currency from its country; currency.json supplies the symbol for a code. Added 2026-08-26 after 73 of 246 countries fell through name-prefix matching to INR.", +"AF": "AFN", +"AL": "ALL", +"DZ": "DZD", +"AS": "USD", +"AD": "EUR", +"AO": "AOA", +"AI": "XCD", +"AQ": "USD", +"AG": "XCD", +"AR": "ARS", +"AM": "AMD", +"AW": "AWG", +"AU": "AUD", +"AT": "EUR", +"AZ": "AZN", +"BS": "BSD", +"BH": "BHD", +"BD": "BDT", +"BB": "BBD", +"BY": "BYN", +"BE": "EUR", +"BZ": "BZD", +"BJ": "XOF", +"BM": "BMD", +"BT": "BTN", +"BO": "BOB", +"BQ": "USD", +"BA": "BAM", +"BW": "BWP", +"BV": "NOK", +"BR": "BRL", +"IO": "USD", +"BN": "BND", +"BG": "BGN", +"BF": "XOF", +"BI": "BIF", +"CV": "CVE", +"KH": "KHR", +"CM": "XAF", +"CA": "CAD", +"KY": "KYD", +"CF": "XAF", +"TD": "XAF", +"CL": "CLP", +"CN": "CNY", +"CX": "AUD", +"CC": "AUD", +"CO": "COP", +"KM": "KMF", +"CG": "XAF", +"CD": "CDF", +"CK": "NZD", +"CR": "CRC", +"CI": "XOF", +"HR": "EUR", +"CU": "CUP", +"CW": "ANG", +"CY": "EUR", +"CZ": "CZK", +"DK": "DKK", +"DJ": "DJF", +"DM": "XCD", +"DO": "DOP", +"EC": "USD", +"EG": "EGP", +"SV": "USD", +"GQ": "XAF", +"ER": "ERN", +"EE": "EUR", +"SZ": "SZL", +"ET": "ETB", +"FK": "FKP", +"FO": "DKK", +"FJ": "FJD", +"FI": "EUR", +"FR": "EUR", +"GF": "EUR", +"PF": "XPF", +"TF": "EUR", +"GA": "XAF", +"GM": "GMD", +"GE": "GEL", +"DE": "EUR", +"GH": "GHS", +"GI": "GIP", +"GR": "EUR", +"GL": "DKK", +"GD": "XCD", +"GP": "EUR", +"GU": "USD", +"GT": "GTQ", +"GG": "GBP", +"GN": "GNF", +"GW": "XOF", +"GY": "GYD", +"HT": "HTG", +"HM": "AUD", +"HN": "HNL", +"HK": "HKD", +"HU": "HUF", +"IS": "ISK", +"IN": "INR", +"ID": "IDR", +"IR": "IRR", +"IQ": "IQD", +"IE": "EUR", +"IM": "GBP", +"IL": "ILS", +"IT": "EUR", +"JM": "JMD", +"JP": "JPY", +"JE": "GBP", +"JO": "JOD", +"KZ": "KZT", +"KE": "KES", +"KI": "AUD", +"KP": "KPW", +"KR": "KRW", +"XK": "EUR", +"KW": "KWD", +"KG": "KGS", +"LA": "LAK", +"LV": "EUR", +"LB": "LBP", +"LS": "LSL", +"LR": "LRD", +"LY": "LYD", +"LI": "CHF", +"LT": "EUR", +"LU": "EUR", +"MO": "MOP", +"MK": "MKD", +"MG": "MGA", +"MW": "MWK", +"MY": "MYR", +"MV": "MVR", +"ML": "XOF", +"MT": "EUR", +"MH": "USD", +"MQ": "EUR", +"MR": "MRU", +"MU": "MUR", +"YT": "EUR", +"MX": "MXN", +"FM": "USD", +"MD": "MDL", +"MC": "EUR", +"MN": "MNT", +"ME": "EUR", +"MS": "XCD", +"MA": "MAD", +"MZ": "MZN", +"MM": "MMK", +"NA": "NAD", +"NR": "AUD", +"NP": "NPR", +"NL": "EUR", +"NC": "XPF", +"NZ": "NZD", +"NI": "NIO", +"NE": "XOF", +"NG": "NGN", +"NU": "NZD", +"NF": "AUD", +"MP": "USD", +"NO": "NOK", +"OM": "OMR", +"PK": "PKR", +"PW": "USD", +"PS": "ILS", +"PA": "PAB", +"PG": "PGK", +"PY": "PYG", +"PE": "PEN", +"PH": "PHP", +"PN": "NZD", +"PL": "PLN", +"PT": "EUR", +"PR": "USD", +"QA": "QAR", +"RE": "EUR", +"RO": "RON", +"RU": "RUB", +"RW": "RWF", +"SH": "SHP", +"KN": "XCD", +"LC": "XCD", +"PM": "EUR", +"VC": "XCD", +"WS": "WST", +"SM": "EUR", +"ST": "STN", +"SA": "SAR", +"SN": "XOF", +"RS": "RSD", +"SC": "SCR", +"SL": "SLE", +"SG": "SGD", +"SX": "ANG", +"SK": "EUR", +"SI": "EUR", +"SB": "SBD", +"SO": "SOS", +"ZA": "ZAR", +"GS": "GBP", +"SS": "SSP", +"ES": "EUR", +"LK": "LKR", +"SD": "SDG", +"SR": "SRD", +"SJ": "NOK", +"SE": "SEK", +"CH": "CHF", +"SY": "SYP", +"TW": "TWD", +"TJ": "TJS", +"TZ": "TZS", +"TH": "THB", +"TL": "USD", +"TG": "XOF", +"TK": "NZD", +"TO": "TOP", +"TT": "TTD", +"TN": "TND", +"TR": "TRY", +"TM": "TMT", +"TC": "USD", +"TV": "AUD", +"UG": "UGX", +"UA": "UAH", +"AE": "AED", +"GB": "GBP", +"US": "USD", +"UM": "USD", +"UY": "UYU", +"UZ": "UZS", +"VU": "VUV", +"VA": "EUR", +"VE": "VES", +"VN": "VND", +"VG": "USD", +"VI": "USD", +"WF": "XPF", +"EH": "MAD", +"YE": "YER", +"ZM": "ZMW", +"ZW": "ZWG", +"AX": "EUR", +"TP": "USD", +"XA": "AUD", +"XU": "GBP", +"XJ": "GBP", +"XM": "GBP", +"AN": "ANG", +"XG": "GBP", +"YU": "RSD" +} \ No newline at end of file diff --git a/api/src/json/country_tax_rates.json b/api/src/json/country_tax_rates.json new file mode 100644 index 000000000..b9cde52be --- /dev/null +++ b/api/src/json/country_tax_rates.json @@ -0,0 +1,355 @@ +{ + "_comment": "Consumption-tax rates per country, for provisioning a new shop. Read by api/src/services/country-tax.js. A rate here OVERRIDES the legacy `tax` array in countries.json; a country absent from this file, or present but not `verified`, falls back to that array, and a country whose only legacy row is a 0% placeholder gets NO tax at all. See country-tax.js for why that order.", + "_rules": [ + "No rate without sourceUrl and checkedAt. A rate that cannot be cited is not entered - set verified:false and leave the rates out.", + "Cite the country's own tax authority or finance ministry. Not blog posts, not aggregators, not recollection.", + "regime:'none' means PROVEN to have no consumption tax, with a source saying so. That is different from unverified, and the two must never be collapsed.", + "An incomplete but sourced entry beats a complete invented one. Record only the bands you can cite.", + "verified:true means the rate was read from the cited source. reviewer records a PERSON having checked it, and is null until one has - the two are separate claims and the file keeps them apart on purpose.", + "A country ABSENT from this file is not an oversight to fill in from memory. Australia, Canada, Saudi Arabia and Singapore were attempted and their sites refused automated reads or render the rate in JavaScript - they are deliberately absent, and fall back to countries.json." + ], + "_schema": { + "regime": "vat_credit | sales_tax | none", + "label": "what the country calls it, eg VAT, GST, Consumption tax", + "rates": "[{ category, name, value }] - category is one of standard, reduced, zero, exempt", + "verified": "boolean - whether the rates were read from the cited source", + "sourceUrl": "the tax authority page the rates were read from", + "sourceName": "the authority", + "checkedAt": "YYYY-MM-DD", + "reviewer": "person who checked it, or null if nobody has yet", + "provenance": "read-from-source (fetched and read during the change) | reviewed (a person checked it). verified gates USE; reviewer records a PERSON having signed off, and is null until one has." + }, + "GB": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 20, + "name": "VAT 20%" + }, + { + "category": "reduced", + "value": 5, + "name": "VAT 5%" + }, + { + "category": "zero", + "value": 0, + "name": "VAT 0%" + } + ], + "verified": true, + "sourceUrl": "https://www.gov.uk/vat-rates", + "sourceName": "GOV.UK (HM Revenue & Customs)", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "AE": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 5, + "name": "VAT 5%" + } + ], + "verified": true, + "sourceUrl": "https://tax.gov.ae/en/taxes/vat.aspx", + "sourceName": "UAE Federal Tax Authority", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "NZ": { + "regime": "vat_credit", + "label": "GST", + "rates": [ + { + "category": "standard", + "value": 15, + "name": "GST 15%" + } + ], + "verified": true, + "sourceUrl": "https://www.ird.govt.nz/gst", + "sourceName": "Inland Revenue (New Zealand)", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "ZA": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 15, + "name": "VAT 15%" + } + ], + "verified": true, + "sourceUrl": "https://www.sars.gov.za/types-of-tax/value-added-tax/", + "sourceName": "South African Revenue Service", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "JP": { + "regime": "vat_credit", + "label": "Consumption tax", + "rates": [ + { + "category": "standard", + "value": 10, + "name": "Consumption tax 10%" + }, + { + "category": "reduced", + "value": 8, + "name": "Consumption tax 8%" + } + ], + "verified": true, + "sourceUrl": "https://www.nta.go.jp/english/taxes/consumption_tax/01.htm", + "sourceName": "National Tax Agency (Japan)", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "CH": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 8.1, + "name": "VAT 8.1%" + }, + { + "category": "reduced", + "value": 2.6, + "name": "VAT 2.6%" + }, + { + "category": "reduced", + "value": 3.8, + "name": "VAT 3.8%" + } + ], + "verified": true, + "sourceUrl": "https://www.estv.admin.ch/estv/en/home/value-added-tax/vat-rates-switzerland.html", + "sourceName": "Federal Tax Administration (Switzerland)", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "NO": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 25, + "name": "VAT 25%" + }, + { + "category": "reduced", + "value": 15, + "name": "VAT 15%" + }, + { + "category": "reduced", + "value": 12, + "name": "VAT 12%" + } + ], + "verified": true, + "sourceUrl": "https://www.skatteetaten.no/en/rates/value-added-tax/", + "sourceName": "Norwegian Tax Administration", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "TH": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 7, + "name": "VAT 7%" + } + ], + "verified": true, + "sourceUrl": "https://www.rd.go.th/english/6043.html", + "sourceName": "Revenue Department (Thailand)", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "DE": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 19, + "name": "VAT 19%" + }, + { + "category": "reduced", + "value": 7, + "name": "VAT 7%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "FR": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 20, + "name": "VAT 20%" + }, + { + "category": "reduced", + "value": 10, + "name": "VAT 10%" + }, + { + "category": "reduced", + "value": 5.5, + "name": "VAT 5.5%" + }, + { + "category": "reduced", + "value": 2.1, + "name": "VAT 2.1%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "IE": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 23, + "name": "VAT 23%" + }, + { + "category": "reduced", + "value": 13.5, + "name": "VAT 13.5%" + }, + { + "category": "reduced", + "value": 9, + "name": "VAT 9%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "ES": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 21, + "name": "VAT 21%" + }, + { + "category": "reduced", + "value": 10, + "name": "VAT 10%" + }, + { + "category": "reduced", + "value": 4, + "name": "VAT 4%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "IT": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 22, + "name": "VAT 22%" + }, + { + "category": "reduced", + "value": 10, + "name": "VAT 10%" + }, + { + "category": "reduced", + "value": 5, + "name": "VAT 5%" + }, + { + "category": "reduced", + "value": 4, + "name": "VAT 4%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + }, + "NL": { + "regime": "vat_credit", + "label": "VAT", + "rates": [ + { + "category": "standard", + "value": 21, + "name": "VAT 21%" + }, + { + "category": "reduced", + "value": 9, + "name": "VAT 9%" + } + ], + "verified": true, + "sourceUrl": "https://europa.eu/youreurope/business/taxation/vat/vat-rules-rates/index_en.htm", + "sourceName": "European Commission - Your Europe", + "checkedAt": "2026-08-30", + "provenance": "read-from-source", + "reviewer": null + } +} diff --git a/api/src/json/currency.json b/api/src/json/currency.json index 6fb76eabd..73e37fd3a 100644 --- a/api/src/json/currency.json +++ b/api/src/json/currency.json @@ -1,1207 +1,1426 @@ { - "currency": [ - { - "id": 1, - "value": "Afghanistan Afghani / AFN or؋ ", - "text": "AFN", - "symbol": "؋" - }, - { - "id": 2, - "value": "Albania Lek / ALL or Lek", - "text": "ALL", - "symbol": "Lek" - }, - - { - "id": 3, - "value": "American Samoa US Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - - { - "id": 4, - "value": "Andorra Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 5, - "value": "Angola Kwanza / AOA or Kz", - "text": "AOA", - "symbol": "Kz" - }, - { - "id": 6, - "value": "Anguilla Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - - { - "id": 7, - "value": "Antigua and Barbuda Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 8, - "value": "Argentina Peso / ARS or $", - "text": "ARS", - "symbol": "$" - }, - - { - "id": 9, - "value": "Aruba Guilder / AWG or ƒ ", - "text": "AWG", - "symbol": "ƒ" - }, - { - "id": 10, - "value": "Australia Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 11, - "value": "Austria Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 12, - "value": "Azerbaijan Manat / AZN or ман", - "text": "AZN", - "symbol": "ман" - }, - { - "id": 13, - "value": "Bahamas Dollar / BSD or $", - "text": "BSD", - "symbol": "$" - }, - - { - "id": 14, - "value": "Barbados Dollar / BBD or $", - "text": "BBD", - "symbol": "$" - }, - - { - "id": 15, - "value": "Venezuela Bolivar / VEF or Bs", - "text": "VEF", - "symbol": "Bs" - }, - { - "id": 16, - "value": "Vietnam Dong / VND or ₫", - "text": "VND", - "symbol": "₫" - }, - - { - "id": 17, - "value": "Yemen Rial / YER or ﷼", - "text": "YER", - "symbol": "﷼" - }, - { - "id": 18, - "value": "Zambia Kwacha / ZMK or ZK", - "text": "ZMK", - "symbol": "ZK" - }, - { - "id": 19, - "value": "Zimbabwe Dollar / ZWD or Z$", - "text": "ZWD", - "symbol": "Z$" - }, - - { - "id": 20, - "value": "Belarus Ruble / BYR or p.", - "text": "BYR", - "symbol": "p." - }, - { - "id": 21, - "value": "Belgium Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 22, - "value": "Belize Dollar / BZD or BZ$", - "text": "BZD", - "symbol": "BZ$" - }, - - { - "id": 23, - "value": "Bermuda Dollar / BMD or $", - "text": "BMD", - "symbol": "$" - }, - { - "id": 24, - "value": "Vanuatu Vatu / VUV or Vt", - "text": "VUV", - "symbol": "Vt" - }, - { - "id": 25, - "value": "Vatican Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - - { - "id": 26, - "value": "Bolivia Boliviano / BOB or $b", - "text": "BOB", - "symbol": "$b" - }, - { - "id": 27, - "value": "Bosnia and Herzegovina Marka / BAM or KM", - "text": "BAM", - "symbol": "KM" - }, - { - "id": 28, - "value": "Botswana Pula / BWP or P", - "text": "BWP", - "symbol": "P" - }, - { - "id": 29, - "value": "Bouvet Island Krone / NOK or kr", - "text": "NOK", - "symbol": "kr" - }, - { - "id": 30, - "value": "Brazil Real / BRL or R$", - "text": "BRL", - "symbol": "R$" - }, - { - "id": 31, - "value": "British Indian Ocean Territory Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 32, - "value": "British Virgin Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 33, - "value": "Brunei Dollar / BND or $", - "text": "BND", - "symbol": "$" - }, - { - "id": 34, - "value": "Bulgaria Lev / BGN or лв", - "text": "BGN", - "symbol": "лв" - }, - { - "id": 35, - "value": "Uruguay Peso / UYU or $U", - "text": "UYU", - "symbol": "$U" - }, - { - "id": 36, - "value": "Uzbekistan Som / UZS or лв", - "text": "UZS", - "symbol": "лв" - }, - - { - "id": 37, - "value": "Cambodia Riels / KHR or ៛", - "text": "KHR", - "symbol": "៛" - }, - { - "id": 38, - "value": "Cameroon Franc / XAF or FCF", - "text": "XAF", - "symbol": "FCF" - }, - { - "id": 39, - "value": "Canada Dollar / CAD or $", - "text": "CAD", - "symbol": "$" - }, - { - "id": 40, - "value": "United States Minor Outlying Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - - { - "id": 41, - "value": "Cayman Islands Dollar / KYD or $", - "text": "KYD", - "symbol": "$" - }, - { - "id": 42, - "value": "Central African Republic Franc / XAF or FCF", - "text": "XAF", - "symbol": "FCF" - }, - - { - "id": 43, - "value": "China YuanRenminbi / CNY or ¥", - "text": "CNY", - "symbol": "¥" - }, - { - "id": 44, - "value": "United Kingdom Pound / GBP or £", - "text": "GBP", - "symbol": "£" - }, - { - "id": 45, - "value": "United States Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - - { - "id": 46, - "value": "Christmas Island Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 47, - "value": "Cocos Islands Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 48, - "value": "Colombia Peso / COP or $", - "text": "COP", - "symbol": "$" - }, - { - "id": 49, - "value": "Ukraine Hryvnia / UAH or ₴", - "text": "UAH", - "symbol": "₴" - }, - - { - "id": 50, - "value": "Cook Islands Dollar / NZD or $", - "text": "NZD", - "symbol": "$" - }, - { - "id": 51, - "value": "Costa Rica Colon / CRC or ₡", - "text": "CRC", - "symbol": "₡" - }, - { - "id": 52, - "value": "Croatia Kuna / HRK or kn", - "text": "HRK", - "symbol": "kn" - }, - { - "id": 53, - "value": "Cuba Peso / CUP or ₱", - "text": "CUP", - "symbol": "₱" - }, - - { - "id": 54, - "value": "Czech Republic Koruna / CZK or Kč", - "text": "CZK", - "symbol": "Kč" - }, - - { - "id": 55, - "value": "Denmark Krone / DKK or kr", - "text": "DKK", - "symbol": "kr" - }, - - { - "id": 56, - "value": "Turks and Caicos Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 57, - "value": "Tuvalu Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 58, - "value": "U.S. Virgin Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - - { - "id": 59, - "value": "Dominica Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 60, - "value": "Dominican Republic Peso / DOP or RD$", - "text": "DOP", - "symbol": "RD$" - }, - { - "id": 61, - "value": "East Timor Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 62, - "value": "Ecuador Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 63, - "value": "Egypt Pound / EGP or £", - "text": "EGP", - "symbol": "£" - }, - { - "id": 64, - "value": "El Salvador Colone / SVC or $", - "text": "SVC", - "symbol": "$" - }, - { - "id": 65, - "value": "Equatorial Guinea Franc / XAF or FCF", - "text": "XAF", - "symbol": "FCF" - }, - { - "id": 66, - "value": "Eritrea Nakfa / ERN or Nfk", - "text": "ERN", - "symbol": "Nfk" - }, - { - "id": 67, - "value": "Estonia Kroon / EEK or kr", - "text": "EEK", - "symbol": "kr" - }, - - { - "id": 68, - "value": "Turkmenistan Manat / TMM or m", - "text": "TMM", - "symbol": "m" - }, - - { - "id": 69, - "value": "Falkland Islands Pound / FKP or £", - "text": "FKP", - "symbol": "£" - }, - { - "id": 70, - "value": "Faroe Islands Krone / DKK or kr", - "text": "DKK", - "symbol": "kr" - }, - { - "id": 71, - "value": "Fiji Dollar / FJD or $", - "text": "FJD ", - "symbol": "$" - }, - { - "id": 72, - "value": "Finland Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 73, - "value": "France Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 74, - "value": "French Guiana Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 75, - "value": "Turkmenistan Manat / TMM or m", - "text": "TMM", - "symbol": "m" - }, - - { - "id": 76, - "value": "French Southern Territories Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 77, - "value": "Gabon Franc / XAF or FCF", - "text": "XAF", - "symbol": "FCF" - }, - { - "id": 78, - "value": "Gambia Dalasi / GMD or D", - "text": "GMD", - "symbol": "D" - }, - { - "id": 79, - "value": "Turkey Lira / TRY or YTL", - "text": "TRY", - "symbol": "YTL" - }, - - { - "id": 80, - "value": "Germany Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 81, - "value": "Ghana Cedi / GHC or ¢", - "text": "GHC", - "symbol": "¢" - }, - { - "id": 82, - "value": "Gibraltar Pound / GIP or £", - "text": "GIP", - "symbol": "£" - }, - { - "id": 83, - "value": "Greece Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 84, - "value": "Greenland Krone / DKK or kr", - "text": "DKK", - "symbol": "kr" - }, - { - "id": 85, - "value": "Grenada Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 86, - "value": "Guadeloupe Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 87, - "value": "Guam Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 88, - "value": "Guatemala Quetzal / GTQ or Q", - "text": "GTQ", - "symbol": "Q" - }, - { - "id": 89, - "value": "Tonga Paanga / TOP or T$", - "text": "TOP", - "symbol": "T$" - }, - { - "id": 90, - "value": "Trinidad and Tobago Dollar / TTD or TT$", - "text": "TTD", - "symbol": "TT$" - }, - - { - "id": 91, - "value": "Guyana Dollar / GYD or $", - "text": "GYD", - "symbol": "$" - }, - { - "id": 92, - "value": "Haiti Gourde / HTG or G", - "text": "HTG", - "symbol": "G" - }, - { - "id": 93, - "value": "Heard Island and McDonald Islands Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 94, - "value": "Honduras Lempira / HNL or L", - "text": "HNL", - "symbol": "L" - }, - { - "id": 95, - "value": "Hong Kong Dollar / HKD or $", - "text": "HKD", - "symbol": "$" - }, - { - "id": 96, - "value": "Hungary Forint / HUF or Ft", - "text": "HUF", - "symbol": "Ft" - }, - { - "id": 97, - "value": "Iceland Krona / ISK or kr", - "text": "ISK", - "symbol": "kr" - }, - { - "id": 98, - "value": "India Rupee / INR or ₹", - "text": "INR", - "symbol": "₹" - }, - { - "id": 99, - "value": "Indonesia Rupiah / IDR or Rp", - "text": "IDR", - "symbol": "Rp" - }, - { - "id": 100, - "value": "Iran Rial / IRR or ﷼", - "text": "IRR", - "symbol": "﷼" - }, - { - "id": 101, - "value": "Tokelau Dollar / NZD or $", - "text": "NZD", - "symbol": "$" - }, - - { - "id": 102, - "value": "Ireland Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 103, - "value": "Israel Shekel / ILS or ₪", - "text": "ILS", - "symbol": "₪" - }, - { - "id": 104, - "value": "Italy Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - - { - "id": 105, - "value": "Jamaica Dollar / JMD or $", - "text": "JMD", - "symbol": "$" - }, - { - "id": 106, - "value": "Japan Yen / JPY or ¥", - "text": "JPY", - "symbol": "¥" - }, - - { - "id": 107, - "value": "Kazakhstan Tenge / KZT or лв", - "text": "KZT", - "symbol": "лв" - }, - - { - "id": 108, - "value": "Kiribati Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 109, - "value": "Switzerland Franc / CHF or CHF", - "text": "CHF", - "symbol": "CHF" - }, - { - "id": 110, - "value": "Syria Pound / SYP or £", - "text": "SYP", - "symbol": "£" - }, - { - "id": 111, - "value": "Taiwan Dollar / TWD or NT$", - "text": "TWD", - "symbol": "NT$" - }, - - { - "id": 112, - "value": "Thailand Baht / THB or ฿", - "text": "THB", - "symbol": "฿" - }, - - { - "id": 113, - "value": "Kyrgyzstan Som / KGS or лв", - "text": "KGS", - "symbol": "лв" - }, - { - "id": 114, - "value": "Laos Kip / LAK or ₭", - "text": "LAK", - "symbol": "₭" - }, - { - "id": 115, - "value": "Latvia Lat / LVL or Ls", - "text": "LVL", - "symbol": "Ls" - }, - { - "id": 116, - "value": "Lebanon Pound / LBP or £", - "text": "LBP", - "symbol": "£" - }, - { - "id": 117, - "value": "Lesotho Loti / LSL or L", - "text": "LSL", - "symbol": "L" - }, - { - "id": 118, - "value": "Liberia Dollar / LRD or $", - "text": "LRD", - "symbol": "$" - }, - { - "id": 119, - "value": "Sweden Krona / SEK or kr", - "text": "SEK", - "symbol": "kr" - }, - - { - "id": 120, - "value": "Liechtenstein Franc / CHF or CHF", - "text": "CHF", - "symbol": "CHF" - }, - { - "id": 121, - "value": "Lithuania Litas / LTL or Lt", - "text": "LTL", - "symbol": "Lt" - }, - { - "id": 122, - "value": "Luxembourg Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 123, - "value": "Macao Pataca / MOP or MOP", - "text": "MOP", - "symbol": "MOP" - }, - { - "id": 124, - "value": "Macedonia Denar / MKD or ден", - "text": "MKD", - "symbol": "ден" - }, - { - "id": 125, - "value": "Svalbard and Jan Mayen Krone / NOK or kr", - "text": "NOK", - "symbol": "kr" - }, - - { - "id": 126, - "value": "Malawi Kwacha / MWK or MK", - "text": "MWK", - "symbol": "MK" - }, - { - "id": 127, - "value": "Malaysia Ringgit / MYR or RM", - "text": "MYR", - "symbol": "RM" - }, - { - "id": 128, - "value": "Maldives Rufiyaa / MVR or Rf", - "text": "MVR", - "symbol": "Rf" - }, - { - "id": 129, - "value": "Sri Lanka Rupee / LKR or ₨", - "text": "LKR", - "symbol": "₨" - }, - - { - "id": 130, - "value": "Surivalue Dollar / SRD or $", - "text": "SRD", - "symbol": "$" - }, - - { - "id": 131, - "value": "Marshall Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 132, - "value": "Martinique Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 133, - "value": "Mauritania Ouguiya / MRO or UM", - "text": "MRO", - "symbol": "UM" - }, - { - "id": 134, - "value": "Mauritius Rupee / MUR or ₨", - "text": "MUR", - "symbol": "₨" - }, - { - "id": 135, - "value": "Mayotte Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 136, - "value": "Mexico Peso / MXN or $", - "text": "MXN", - "symbol": "$" - }, - { - "id": 137, - "value": "Micronesia Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - - { - "id": 138, - "value": "Spain Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - - { - "id": 139, - "value": "Monaco Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 140, - "value": "Mongolia Tugrik / MNT or ₮", - "text": "MNT", - "symbol": "₮" - }, - { - "id": 141, - "value": "Montserrat Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 142, - "value": "South Korea Won / KRW or ₩", - "text": "KRW", - "symbol": "₩" - }, - - { - "id": 143, - "value": "Mozambique Meticail / MZN or MT", - "text": "MZN", - "symbol": "MT" - }, - { - "id": 144, - "value": "Myanmar Kyat / MMK or K", - "text": "MMK", - "symbol": "K" - }, - { - "id": 145, - "value": "Namibia Dollar / NAD or $", - "text": "NAD", - "symbol": "$" - }, - { - "id": 146, - "value": "Nauru Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 147, - "value": "Nepal Rupee / NPR or ₨", - "text": "NPR", - "symbol": "₨" - }, - { - "id": 148, - "value": "Netherlands Euro/ EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 149, - "value": "Netherlands Antilles Guilder / ANG or ƒ", - "text": "ANG", - "symbol": "ƒ" - }, - - { - "id": 150, - "value": "New Zealand Dollar / NZD or $", - "text": "NZD", - "symbol": "$" - }, - { - "id": 151, - "value": "Nicaragua Cordoba / NIO or C$", - "text": "NIO", - "symbol": "C$" - }, - { - "id": 152, - "value": "South Georgia and the South Sandwich Islands Pound / GBP or £", - "text": "GBP", - "symbol": "£" - }, - - { - "id": 153, - "value": "South Africa Rand / ZAR or R", - "text": "ZAR", - "symbol": "R" - }, - - { - "id": 154, - "value": "Nigeria Naira / NGN or ₦", - "text": "NGN", - "symbol": "₦" - }, - { - "id": 155, - "value": "Niue Dollar / NZD or $", - "text": "NZD", - "symbol": "$" - }, - { - "id": 156, - "value": "Norfolk Island Dollar / AUD or $", - "text": "AUD", - "symbol": "$" - }, - { - "id": 157, - "value": "North Korea Won / KPW or ₩", - "text": "KPW", - "symbol": "₩" - }, - { - "id": 158, - "value": "Northern Mariana Islands Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 159, - "value": "Norway Krone / NOK or kr", - "text": "NOK", - "symbol": "kr" - }, - { - "id": 160, - "value": "Oman Rial / OMR or ﷼", - "text": "OMR", - "symbol": "﷼" - }, - { - "id": 161, - "value": "Pakistan Rupee / PKR or ₨", - "text": "PKR", - "symbol": "₨" - }, - { - "id": 162, - "value": "Palau Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 163, - "value": "Palestinian Territory Shekel / ILS or ₪", - "text": "ILS", - "symbol": "₪" - }, - { - "id": 164, - "value": "Panama Balboa / PAB or B/.", - "text": "PAB", - "symbol": "B/." - }, - { - "id": 165, - "value": "Somalia Shilling / SOS or S", - "text": "SOS", - "symbol": "S" - }, - - { - "id": 166, - "value": "Paraguay Guarani / PYG or Gs", - "text": "PYG", - "symbol": "Gs" - }, - { - "id": 167, - "value": "Peru Sol / PEN or S/.", - "text": "PEN", - "symbol": "S/." - }, - { - "id": 168, - "value": "Philippines Peso / PHP or Php", - "text": "PHP", - "symbol": "Php" - }, - { - "id": 169, - "value": "Pitcairn Dollar / NZD or $", - "text": "NZD", - "symbol": "$" - }, - { - "id": 170, - "value": "Poland Zloty / PLN or zł", - "text": "PLN", - "symbol": "zł" - }, - { - "id": 171, - "value": "Portugal Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 172, - "value": "Puerto Rico Dollar / USD or $", - "text": "USD", - "symbol": "$" - }, - { - "id": 173, - "value": "Qatar Rial / QAR or ﷼", - "text": "QAR", - "symbol": "﷼" - }, - { - "id": 174, - "value": "Republic of the Congo Franc / XAF or FCF", - "text": "XAF", - "symbol": "FCF" - }, - { - "id": 175, - "value": "Reunion Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 176, - "value": "Romania Leu / RON or lei", - "text": "RON", - "symbol": "lei" - }, - { - "id": 177, - "value": "Russia Ruble / RUB or руб", - "text": "RUB", - "symbol": "руб" - }, - - { - "id": 178, - "value": "Solomon Islands Dollar / SBD or $", - "text": "SBD", - "symbol": "$" - }, - - { - "id": 179, - "value": "Saint Helena Pound / SHP or £", - "text": "SHP", - "symbol": "£" - }, - { - "id": 180, - "value": "Saint Kitts and Nevis Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 181, - "value": "Saint Lucia Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 182, - "value": "Saint Pierre and Miquelon Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 183, - "value": "Saint Vincent and the Grenadines Dollar / XCD or $", - "text": "XCD", - "symbol": "$" - }, - { - "id": 184, - "value": "Samoa Tala / WST or WS$", - "text": "WST", - "symbol": "WS$" - }, - { - "id": 185, - "value": "San Marino Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - { - "id": 186, - "value": "Sao Tome and Principe Dobra/ STD or Db", - "text": "STD", - "symbol": "Db" - }, - { - "id": 187, - "value": "Saudi Arabia Rial / SAR or ﷼", - "text": "SAR", - "symbol": "﷼" - }, - { - "id": 188, - "value": "Slovenia Euro / EUR or €", - "text": "EUR", - "symbol": "€" - }, - - { - "id": 189, - "value": "Serbia and Montenegro Dinar / RSD or Дин", - "text": "RSD", - "symbol": "Дин" - }, - { - "id": 190, - "value": "Seychelles Rupee / SCR or ₨", - "text": "SCR", - "symbol": "₨" - }, - { - "id": 191, - "value": "Sierra Leone Leone / SLL or Le", - "text": "SLL", - "symbol": "Le" - }, - { - "id": 192, - "value": "Singapore Dollar / SGD or $", - "text": "SGD", - "symbol": "$" - }, - { - "id": 193, - "value": "Slovakia Koruna / SKK or Sk", - "text": "SKK", - "symbol": "Sk" - } - - ] + "currency": [ + { + "id": 1, + "value": "Afghanistan Afghani / AFN or؋ ", + "text": "AFN", + "symbol": "؋" + }, + { + "id": 2, + "value": "Albania Lek / ALL or Lek", + "text": "ALL", + "symbol": "Lek" + }, + { + "id": 3, + "value": "American Samoa US Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 4, + "value": "Andorra Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 5, + "value": "Angola Kwanza / AOA or Kz", + "text": "AOA", + "symbol": "Kz" + }, + { + "id": 6, + "value": "Anguilla Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 7, + "value": "Antigua and Barbuda Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 8, + "value": "Argentina Peso / ARS or $", + "text": "ARS", + "symbol": "$" + }, + { + "id": 9, + "value": "Aruba Guilder / AWG or ƒ ", + "text": "AWG", + "symbol": "ƒ" + }, + { + "id": 10, + "value": "Australia Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 11, + "value": "Austria Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 12, + "value": "Azerbaijan Manat / AZN or ман", + "text": "AZN", + "symbol": "ман" + }, + { + "id": 13, + "value": "Bahamas Dollar / BSD or $", + "text": "BSD", + "symbol": "$" + }, + { + "id": 14, + "value": "Barbados Dollar / BBD or $", + "text": "BBD", + "symbol": "$" + }, + { + "id": 15, + "value": "Venezuela Bolivar / VEF or Bs", + "text": "VEF", + "symbol": "Bs" + }, + { + "id": 16, + "value": "Vietnam Dong / VND or ₫", + "text": "VND", + "symbol": "₫" + }, + { + "id": 17, + "value": "Yemen Rial / YER or ﷼", + "text": "YER", + "symbol": "﷼" + }, + { + "id": 18, + "value": "Zambia Kwacha / ZMK or ZK", + "text": "ZMK", + "symbol": "ZK" + }, + { + "id": 19, + "value": "Zimbabwe Dollar / ZWD or Z$", + "text": "ZWD", + "symbol": "Z$" + }, + { + "id": 20, + "value": "Belarus Ruble / BYR or p.", + "text": "BYR", + "symbol": "p." + }, + { + "id": 21, + "value": "Belgium Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 22, + "value": "Belize Dollar / BZD or BZ$", + "text": "BZD", + "symbol": "BZ$" + }, + { + "id": 23, + "value": "Bermuda Dollar / BMD or $", + "text": "BMD", + "symbol": "$" + }, + { + "id": 24, + "value": "Vanuatu Vatu / VUV or Vt", + "text": "VUV", + "symbol": "Vt" + }, + { + "id": 25, + "value": "Vatican Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 26, + "value": "Bolivia Boliviano / BOB or $b", + "text": "BOB", + "symbol": "$b" + }, + { + "id": 27, + "value": "Bosnia and Herzegovina Marka / BAM or KM", + "text": "BAM", + "symbol": "KM" + }, + { + "id": 28, + "value": "Botswana Pula / BWP or P", + "text": "BWP", + "symbol": "P" + }, + { + "id": 29, + "value": "Bouvet Island Krone / NOK or kr", + "text": "NOK", + "symbol": "kr" + }, + { + "id": 30, + "value": "Brazil Real / BRL or R$", + "text": "BRL", + "symbol": "R$" + }, + { + "id": 31, + "value": "British Indian Ocean Territory Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 32, + "value": "British Virgin Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 33, + "value": "Brunei Dollar / BND or $", + "text": "BND", + "symbol": "$" + }, + { + "id": 34, + "value": "Bulgaria Lev / BGN or лв", + "text": "BGN", + "symbol": "лв" + }, + { + "id": 35, + "value": "Uruguay Peso / UYU or $U", + "text": "UYU", + "symbol": "$U" + }, + { + "id": 36, + "value": "Uzbekistan Som / UZS or лв", + "text": "UZS", + "symbol": "лв" + }, + { + "id": 37, + "value": "Cambodia Riels / KHR or ៛", + "text": "KHR", + "symbol": "៛" + }, + { + "id": 38, + "value": "Cameroon Franc / XAF or FCF", + "text": "XAF", + "symbol": "FCF" + }, + { + "id": 39, + "value": "Canada Dollar / CAD or $", + "text": "CAD", + "symbol": "$" + }, + { + "id": 40, + "value": "United States Minor Outlying Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 41, + "value": "Cayman Islands Dollar / KYD or $", + "text": "KYD", + "symbol": "$" + }, + { + "id": 42, + "value": "Central African Republic Franc / XAF or FCF", + "text": "XAF", + "symbol": "FCF" + }, + { + "id": 43, + "value": "China YuanRenminbi / CNY or ¥", + "text": "CNY", + "symbol": "¥" + }, + { + "id": 44, + "value": "United Kingdom Pound / GBP or £", + "text": "GBP", + "symbol": "£" + }, + { + "id": 45, + "value": "United States Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 46, + "value": "Christmas Island Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 47, + "value": "Cocos Islands Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 48, + "value": "Colombia Peso / COP or $", + "text": "COP", + "symbol": "$" + }, + { + "id": 49, + "value": "Ukraine Hryvnia / UAH or ₴", + "text": "UAH", + "symbol": "₴" + }, + { + "id": 50, + "value": "Cook Islands Dollar / NZD or $", + "text": "NZD", + "symbol": "$" + }, + { + "id": 51, + "value": "Costa Rica Colon / CRC or ₡", + "text": "CRC", + "symbol": "₡" + }, + { + "id": 52, + "value": "Croatia Kuna / HRK or kn", + "text": "HRK", + "symbol": "kn" + }, + { + "id": 53, + "value": "Cuba Peso / CUP or ₱", + "text": "CUP", + "symbol": "₱" + }, + { + "id": 54, + "value": "Czech Republic Koruna / CZK or Kč", + "text": "CZK", + "symbol": "Kč" + }, + { + "id": 55, + "value": "Denmark Krone / DKK or kr", + "text": "DKK", + "symbol": "kr" + }, + { + "id": 56, + "value": "Turks and Caicos Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 57, + "value": "Tuvalu Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 58, + "value": "U.S. Virgin Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 59, + "value": "Dominica Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 60, + "value": "Dominican Republic Peso / DOP or RD$", + "text": "DOP", + "symbol": "RD$" + }, + { + "id": 61, + "value": "East Timor Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 62, + "value": "Ecuador Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 63, + "value": "Egypt Pound / EGP or £", + "text": "EGP", + "symbol": "£" + }, + { + "id": 64, + "value": "El Salvador Colone / SVC or $", + "text": "SVC", + "symbol": "$" + }, + { + "id": 65, + "value": "Equatorial Guinea Franc / XAF or FCF", + "text": "XAF", + "symbol": "FCF" + }, + { + "id": 66, + "value": "Eritrea Nakfa / ERN or Nfk", + "text": "ERN", + "symbol": "Nfk" + }, + { + "id": 67, + "value": "Estonia Kroon / EEK or kr", + "text": "EEK", + "symbol": "kr" + }, + { + "id": 68, + "value": "Turkmenistan Manat / TMM or m", + "text": "TMM", + "symbol": "m" + }, + { + "id": 69, + "value": "Falkland Islands Pound / FKP or £", + "text": "FKP", + "symbol": "£" + }, + { + "id": 70, + "value": "Faroe Islands Krone / DKK or kr", + "text": "DKK", + "symbol": "kr" + }, + { + "id": 71, + "value": "Fiji Dollar / FJD or $", + "text": "FJD ", + "symbol": "$" + }, + { + "id": 72, + "value": "Finland Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 73, + "value": "France Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 74, + "value": "French Guiana Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 75, + "value": "Turkmenistan Manat / TMM or m", + "text": "TMM", + "symbol": "m" + }, + { + "id": 76, + "value": "French Southern Territories Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 77, + "value": "Gabon Franc / XAF or FCF", + "text": "XAF", + "symbol": "FCF" + }, + { + "id": 78, + "value": "Gambia Dalasi / GMD or D", + "text": "GMD", + "symbol": "D" + }, + { + "id": 79, + "value": "Turkey Lira / TRY or YTL", + "text": "TRY", + "symbol": "YTL" + }, + { + "id": 80, + "value": "Germany Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 81, + "value": "Ghana Cedi / GHC or ¢", + "text": "GHC", + "symbol": "¢" + }, + { + "id": 82, + "value": "Gibraltar Pound / GIP or £", + "text": "GIP", + "symbol": "£" + }, + { + "id": 83, + "value": "Greece Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 84, + "value": "Greenland Krone / DKK or kr", + "text": "DKK", + "symbol": "kr" + }, + { + "id": 85, + "value": "Grenada Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 86, + "value": "Guadeloupe Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 87, + "value": "Guam Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 88, + "value": "Guatemala Quetzal / GTQ or Q", + "text": "GTQ", + "symbol": "Q" + }, + { + "id": 89, + "value": "Tonga Paanga / TOP or T$", + "text": "TOP", + "symbol": "T$" + }, + { + "id": 90, + "value": "Trinidad and Tobago Dollar / TTD or TT$", + "text": "TTD", + "symbol": "TT$" + }, + { + "id": 91, + "value": "Guyana Dollar / GYD or $", + "text": "GYD", + "symbol": "$" + }, + { + "id": 92, + "value": "Haiti Gourde / HTG or G", + "text": "HTG", + "symbol": "G" + }, + { + "id": 93, + "value": "Heard Island and McDonald Islands Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 94, + "value": "Honduras Lempira / HNL or L", + "text": "HNL", + "symbol": "L" + }, + { + "id": 95, + "value": "Hong Kong Dollar / HKD or $", + "text": "HKD", + "symbol": "$" + }, + { + "id": 96, + "value": "Hungary Forint / HUF or Ft", + "text": "HUF", + "symbol": "Ft" + }, + { + "id": 97, + "value": "Iceland Krona / ISK or kr", + "text": "ISK", + "symbol": "kr" + }, + { + "id": 98, + "value": "India Rupee / INR or ₹", + "text": "INR", + "symbol": "₹" + }, + { + "id": 99, + "value": "Indonesia Rupiah / IDR or Rp", + "text": "IDR", + "symbol": "Rp" + }, + { + "id": 100, + "value": "Iran Rial / IRR or ﷼", + "text": "IRR", + "symbol": "﷼" + }, + { + "id": 101, + "value": "Tokelau Dollar / NZD or $", + "text": "NZD", + "symbol": "$" + }, + { + "id": 102, + "value": "Ireland Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 103, + "value": "Israel Shekel / ILS or ₪", + "text": "ILS", + "symbol": "₪" + }, + { + "id": 104, + "value": "Italy Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 105, + "value": "Jamaica Dollar / JMD or $", + "text": "JMD", + "symbol": "$" + }, + { + "id": 106, + "value": "Japan Yen / JPY or ¥", + "text": "JPY", + "symbol": "¥" + }, + { + "id": 107, + "value": "Kazakhstan Tenge / KZT or лв", + "text": "KZT", + "symbol": "лв" + }, + { + "id": 108, + "value": "Kiribati Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 109, + "value": "Switzerland Franc / CHF or CHF", + "text": "CHF", + "symbol": "CHF" + }, + { + "id": 110, + "value": "Syria Pound / SYP or £", + "text": "SYP", + "symbol": "£" + }, + { + "id": 111, + "value": "Taiwan Dollar / TWD or NT$", + "text": "TWD", + "symbol": "NT$" + }, + { + "id": 112, + "value": "Thailand Baht / THB or ฿", + "text": "THB", + "symbol": "฿" + }, + { + "id": 113, + "value": "Kyrgyzstan Som / KGS or лв", + "text": "KGS", + "symbol": "лв" + }, + { + "id": 114, + "value": "Laos Kip / LAK or ₭", + "text": "LAK", + "symbol": "₭" + }, + { + "id": 115, + "value": "Latvia Lat / LVL or Ls", + "text": "LVL", + "symbol": "Ls" + }, + { + "id": 116, + "value": "Lebanon Pound / LBP or £", + "text": "LBP", + "symbol": "£" + }, + { + "id": 117, + "value": "Lesotho Loti / LSL or L", + "text": "LSL", + "symbol": "L" + }, + { + "id": 118, + "value": "Liberia Dollar / LRD or $", + "text": "LRD", + "symbol": "$" + }, + { + "id": 119, + "value": "Sweden Krona / SEK or kr", + "text": "SEK", + "symbol": "kr" + }, + { + "id": 120, + "value": "Liechtenstein Franc / CHF or CHF", + "text": "CHF", + "symbol": "CHF" + }, + { + "id": 121, + "value": "Lithuania Litas / LTL or Lt", + "text": "LTL", + "symbol": "Lt" + }, + { + "id": 122, + "value": "Luxembourg Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 123, + "value": "Macao Pataca / MOP or MOP", + "text": "MOP", + "symbol": "MOP" + }, + { + "id": 124, + "value": "Macedonia Denar / MKD or ден", + "text": "MKD", + "symbol": "ден" + }, + { + "id": 125, + "value": "Svalbard and Jan Mayen Krone / NOK or kr", + "text": "NOK", + "symbol": "kr" + }, + { + "id": 126, + "value": "Malawi Kwacha / MWK or MK", + "text": "MWK", + "symbol": "MK" + }, + { + "id": 127, + "value": "Malaysia Ringgit / MYR or RM", + "text": "MYR", + "symbol": "RM" + }, + { + "id": 128, + "value": "Maldives Rufiyaa / MVR or Rf", + "text": "MVR", + "symbol": "Rf" + }, + { + "id": 129, + "value": "Sri Lanka Rupee / LKR or ₨", + "text": "LKR", + "symbol": "₨" + }, + { + "id": 130, + "value": "Surivalue Dollar / SRD or $", + "text": "SRD", + "symbol": "$" + }, + { + "id": 131, + "value": "Marshall Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 132, + "value": "Martinique Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 133, + "value": "Mauritania Ouguiya / MRO or UM", + "text": "MRO", + "symbol": "UM" + }, + { + "id": 134, + "value": "Mauritius Rupee / MUR or ₨", + "text": "MUR", + "symbol": "₨" + }, + { + "id": 135, + "value": "Mayotte Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 136, + "value": "Mexico Peso / MXN or $", + "text": "MXN", + "symbol": "$" + }, + { + "id": 137, + "value": "Micronesia Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 138, + "value": "Spain Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 139, + "value": "Monaco Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 140, + "value": "Mongolia Tugrik / MNT or ₮", + "text": "MNT", + "symbol": "₮" + }, + { + "id": 141, + "value": "Montserrat Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 142, + "value": "South Korea Won / KRW or ₩", + "text": "KRW", + "symbol": "₩" + }, + { + "id": 143, + "value": "Mozambique Meticail / MZN or MT", + "text": "MZN", + "symbol": "MT" + }, + { + "id": 144, + "value": "Myanmar Kyat / MMK or K", + "text": "MMK", + "symbol": "K" + }, + { + "id": 145, + "value": "Namibia Dollar / NAD or $", + "text": "NAD", + "symbol": "$" + }, + { + "id": 146, + "value": "Nauru Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 147, + "value": "Nepal Rupee / NPR or ₨", + "text": "NPR", + "symbol": "₨" + }, + { + "id": 148, + "value": "Netherlands Euro/ EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 149, + "value": "Netherlands Antilles Guilder / ANG or ƒ", + "text": "ANG", + "symbol": "ƒ" + }, + { + "id": 150, + "value": "New Zealand Dollar / NZD or $", + "text": "NZD", + "symbol": "$" + }, + { + "id": 151, + "value": "Nicaragua Cordoba / NIO or C$", + "text": "NIO", + "symbol": "C$" + }, + { + "id": 152, + "value": "South Georgia and the South Sandwich Islands Pound / GBP or £", + "text": "GBP", + "symbol": "£" + }, + { + "id": 153, + "value": "South Africa Rand / ZAR or R", + "text": "ZAR", + "symbol": "R" + }, + { + "id": 154, + "value": "Nigeria Naira / NGN or ₦", + "text": "NGN", + "symbol": "₦" + }, + { + "id": 155, + "value": "Niue Dollar / NZD or $", + "text": "NZD", + "symbol": "$" + }, + { + "id": 156, + "value": "Norfolk Island Dollar / AUD or $", + "text": "AUD", + "symbol": "$" + }, + { + "id": 157, + "value": "North Korea Won / KPW or ₩", + "text": "KPW", + "symbol": "₩" + }, + { + "id": 158, + "value": "Northern Mariana Islands Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 159, + "value": "Norway Krone / NOK or kr", + "text": "NOK", + "symbol": "kr" + }, + { + "id": 160, + "value": "Oman Rial / OMR or ﷼", + "text": "OMR", + "symbol": "﷼" + }, + { + "id": 161, + "value": "Pakistan Rupee / PKR or ₨", + "text": "PKR", + "symbol": "₨" + }, + { + "id": 162, + "value": "Palau Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 163, + "value": "Palestinian Territory Shekel / ILS or ₪", + "text": "ILS", + "symbol": "₪" + }, + { + "id": 164, + "value": "Panama Balboa / PAB or B/.", + "text": "PAB", + "symbol": "B/." + }, + { + "id": 165, + "value": "Somalia Shilling / SOS or S", + "text": "SOS", + "symbol": "S" + }, + { + "id": 166, + "value": "Paraguay Guarani / PYG or Gs", + "text": "PYG", + "symbol": "Gs" + }, + { + "id": 167, + "value": "Peru Sol / PEN or S/.", + "text": "PEN", + "symbol": "S/." + }, + { + "id": 168, + "value": "Philippines Peso / PHP or Php", + "text": "PHP", + "symbol": "Php" + }, + { + "id": 169, + "value": "Pitcairn Dollar / NZD or $", + "text": "NZD", + "symbol": "$" + }, + { + "id": 170, + "value": "Poland Zloty / PLN or zł", + "text": "PLN", + "symbol": "zł" + }, + { + "id": 171, + "value": "Portugal Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 172, + "value": "Puerto Rico Dollar / USD or $", + "text": "USD", + "symbol": "$" + }, + { + "id": 173, + "value": "Qatar Rial / QAR or ﷼", + "text": "QAR", + "symbol": "﷼" + }, + { + "id": 174, + "value": "Republic of the Congo Franc / XAF or FCF", + "text": "XAF", + "symbol": "FCF" + }, + { + "id": 175, + "value": "Reunion Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 176, + "value": "Romania Leu / RON or lei", + "text": "RON", + "symbol": "lei" + }, + { + "id": 177, + "value": "Russia Ruble / RUB or руб", + "text": "RUB", + "symbol": "руб" + }, + { + "id": 178, + "value": "Solomon Islands Dollar / SBD or $", + "text": "SBD", + "symbol": "$" + }, + { + "id": 179, + "value": "Saint Helena Pound / SHP or £", + "text": "SHP", + "symbol": "£" + }, + { + "id": 180, + "value": "Saint Kitts and Nevis Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 181, + "value": "Saint Lucia Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 182, + "value": "Saint Pierre and Miquelon Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 183, + "value": "Saint Vincent and the Grenadines Dollar / XCD or $", + "text": "XCD", + "symbol": "$" + }, + { + "id": 184, + "value": "Samoa Tala / WST or WS$", + "text": "WST", + "symbol": "WS$" + }, + { + "id": 185, + "value": "San Marino Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 186, + "value": "Sao Tome and Principe Dobra/ STD or Db", + "text": "STD", + "symbol": "Db" + }, + { + "id": 187, + "value": "Saudi Arabia Rial / SAR or ﷼", + "text": "SAR", + "symbol": "﷼" + }, + { + "id": 188, + "value": "Slovenia Euro / EUR or €", + "text": "EUR", + "symbol": "€" + }, + { + "id": 189, + "value": "Serbia and Montenegro Dinar / RSD or Дин", + "text": "RSD", + "symbol": "Дин" + }, + { + "id": 190, + "value": "Seychelles Rupee / SCR or ₨", + "text": "SCR", + "symbol": "₨" + }, + { + "id": 191, + "value": "Sierra Leone Leone / SLL or Le", + "text": "SLL", + "symbol": "Le" + }, + { + "id": 192, + "value": "Singapore Dollar / SGD or $", + "text": "SGD", + "symbol": "$" + }, + { + "id": 193, + "value": "Slovakia Koruna / SKK or Sk", + "text": "SKK", + "symbol": "Sk" + }, + { + "id": 194, + "value": "Algeria Dinar / DZD or دج", + "text": "DZD", + "symbol": "دج" + }, + { + "id": 195, + "value": "Armenia Dram / AMD or ֏", + "text": "AMD", + "symbol": "֏" + }, + { + "id": 196, + "value": "Bahrain Dinar / BHD or .د.ب", + "text": "BHD", + "symbol": ".د.ب" + }, + { + "id": 197, + "value": "Bangladesh Taka / BDT or ৳", + "text": "BDT", + "symbol": "৳" + }, + { + "id": 198, + "value": "Belarus Ruble / BYN or Br", + "text": "BYN", + "symbol": "Br" + }, + { + "id": 199, + "value": "West African CFA Franc / XOF or CFA", + "text": "XOF", + "symbol": "CFA" + }, + { + "id": 200, + "value": "Bhutan Ngultrum / BTN or Nu.", + "text": "BTN", + "symbol": "Nu." + }, + { + "id": 201, + "value": "Burundi Franc / BIF or FBu", + "text": "BIF", + "symbol": "FBu" + }, + { + "id": 202, + "value": "Cape Verde Escudo / CVE or $", + "text": "CVE", + "symbol": "$" + }, + { + "id": 203, + "value": "Chile Peso / CLP or $", + "text": "CLP", + "symbol": "$" + }, + { + "id": 204, + "value": "Comoros Franc / KMF or CF", + "text": "KMF", + "symbol": "CF" + }, + { + "id": 205, + "value": "Congo Franc / CDF or FC", + "text": "CDF", + "symbol": "FC" + }, + { + "id": 206, + "value": "Djibouti Franc / DJF or Fdj", + "text": "DJF", + "symbol": "Fdj" + }, + { + "id": 207, + "value": "Swaziland Lilangeni / SZL or E", + "text": "SZL", + "symbol": "E" + }, + { + "id": 208, + "value": "Ethiopia Birr / ETB or Br", + "text": "ETB", + "symbol": "Br" + }, + { + "id": 209, + "value": "Fiji Islands Dollar / FJD or FJ$", + "text": "FJD", + "symbol": "FJ$" + }, + { + "id": 210, + "value": "CFP Franc / XPF or ₣", + "text": "XPF", + "symbol": "₣" + }, + { + "id": 211, + "value": "Georgia Lari / GEL or ₾", + "text": "GEL", + "symbol": "₾" + }, + { + "id": 212, + "value": "Ghana Cedi / GHS or GH₵", + "text": "GHS", + "symbol": "GH₵" + }, + { + "id": 213, + "value": "Guinea Franc / GNF or FG", + "text": "GNF", + "symbol": "FG" + }, + { + "id": 214, + "value": "Iraq Dinar / IQD or ع.د", + "text": "IQD", + "symbol": "ع.د" + }, + { + "id": 215, + "value": "Jordan Dinar / JOD or د.ا", + "text": "JOD", + "symbol": "د.ا" + }, + { + "id": 216, + "value": "Kenya Shilling / KES or KSh", + "text": "KES", + "symbol": "KSh" + }, + { + "id": 217, + "value": "Kuwait Dinar / KWD or د.ك", + "text": "KWD", + "symbol": "د.ك" + }, + { + "id": 218, + "value": "Libya Dinar / LYD or ل.د", + "text": "LYD", + "symbol": "ل.د" + }, + { + "id": 219, + "value": "Madagascar Ariary / MGA or Ar", + "text": "MGA", + "symbol": "Ar" + }, + { + "id": 220, + "value": "Mauritania Ouguiya / MRU or UM", + "text": "MRU", + "symbol": "UM" + }, + { + "id": 221, + "value": "Moldova Leu / MDL or L", + "text": "MDL", + "symbol": "L" + }, + { + "id": 222, + "value": "Morocco Dirham / MAD or د.م.", + "text": "MAD", + "symbol": "د.م." + }, + { + "id": 223, + "value": "Papua new Guinea Kina / PGK or K", + "text": "PGK", + "symbol": "K" + }, + { + "id": 224, + "value": "Rwanda Franc / RWF or FRw", + "text": "RWF", + "symbol": "FRw" + }, + { + "id": 225, + "value": "Sao Tome and Principe Dobra / STN or Db", + "text": "STN", + "symbol": "Db" + }, + { + "id": 226, + "value": "Sierra Leone Leone / SLE or Le", + "text": "SLE", + "symbol": "Le" + }, + { + "id": 227, + "value": "South Sudan Pound / SSP or £", + "text": "SSP", + "symbol": "£" + }, + { + "id": 228, + "value": "Sudan Pound / SDG or ج.س", + "text": "SDG", + "symbol": "ج.س" + }, + { + "id": 229, + "value": "Tajikistan Somoni / TJS or SM", + "text": "TJS", + "symbol": "SM" + }, + { + "id": 230, + "value": "Tanzania Shilling / TZS or TSh", + "text": "TZS", + "symbol": "TSh" + }, + { + "id": 231, + "value": "Tunisia Dinar / TND or د.ت", + "text": "TND", + "symbol": "د.ت" + }, + { + "id": 232, + "value": "Turkmenistan Manat / TMT or m", + "text": "TMT", + "symbol": "m" + }, + { + "id": 233, + "value": "Uganda Shilling / UGX or USh", + "text": "UGX", + "symbol": "USh" + }, + { + "id": 234, + "value": "United Arab Emirates Dirham / AED or د.إ", + "text": "AED", + "symbol": "د.إ" + }, + { + "id": 235, + "value": "Venezuela Bolivar / VES or Bs.", + "text": "VES", + "symbol": "Bs." + }, + { + "id": 236, + "value": "Zambia Kwacha / ZMW or ZK", + "text": "ZMW", + "symbol": "ZK" + }, + { + "id": 237, + "value": "Zimbabwe Gold / ZWG or ZiG", + "text": "ZWG", + "symbol": "ZiG" + } + ] } \ No newline at end of file diff --git a/api/src/json/gst_rates_2025.json b/api/src/json/gst_rates_2025.json new file mode 100644 index 000000000..bfba98cbf --- /dev/null +++ b/api/src/json/gst_rates_2025.json @@ -0,0 +1,817 @@ +{ + "source": { + "notification": "Notification No. 9/2025-Integrated Tax (Rate)", + "dated": "2025-09-17", + "effective_from": "2025-09-22", + "corrigendum": "2025-09-18", + "retrieved_from": "https://courier.cbic.gov.in/ECCS/advisory/2025/NOTIFICATION%20NO.%209_2025-INTEGRATED%20TAX%20(RATE)%20-1759486719.pdf", + "provenance_note": "Official CBIC courier domain; the file is the ICAI reprint of the notification. Rates are the IGST rates, which are the full GST rate a shop sets (CGST+SGST split that figure in half for intra-state sales).", + "schedules": { + "I": 5, + "II": 18, + "III": 40, + "IV": 3, + "V": 0.25, + "VI": 1.5, + "VII": 28 + } + }, + "rates": { + "01012100": 5.0, + "010129": 5.0, + "0202": 5.0, + "0203": 5.0, + "0204": 5.0, + "0205": 5.0, + "0206": 5.0, + "0207": 5.0, + "0303": 5.0, + "0304": 5.0, + "0305": 5.0, + "0306": 5.0, + "0307": 5.0, + "0308": 5.0, + "0402": 5.0, + "0403": 5.0, + "0404": 5.0, + "0405": 5.0, + "0406": 5.0, + "0408": 5.0, + "0409": 5.0, + "0410": 5.0, + "0502": 5.0, + "0504": 5.0, + "0505": 5.0, + "0508": 5.0, + "0510": 5.0, + "0511": 5.0, + "0713": 5.0, + "0714": 5.0, + "0801": 5.0, + "0802": 5.0, + "0804": 5.0, + "0805": 5.0, + "0806": 5.0, + "0811": 5.0, + "0812": 5.0, + "0813": 5.0, + "0814": 5.0, + "0901": 5.0, + "0902": 5.0, + "0903": 5.0, + "0904": 5.0, + "0905": 5.0, + "0906": 5.0, + "0907": 5.0, + "0908": 5.0, + "0909": 5.0, + "1001": 5.0, + "1002": 5.0, + "1003": 5.0, + "1004": 5.0, + "1005": 5.0, + "1006": 5.0, + "1007": 5.0, + "1008": 5.0, + "1101": 5.0, + "1102": 5.0, + "1103": 5.0, + "1104": 5.0, + "1105": 5.0, + "1106": 5.0, + "1107": 5.0, + "1108": 5.0, + "11090000": 5.0, + "1201": 5.0, + "1202": 5.0, + "1203": 5.0, + "1204": 5.0, + "1205": 5.0, + "1206": 5.0, + "1207": 5.0, + "1208": 5.0, + "1209": 5.0, + "12102000": 5.0, + "1211": 5.0, + "1212": 5.0, + "1301": 5.0, + "1302": 5.0, + "1401": 5.0, + "1501": 5.0, + "1502": 5.0, + "1503": 5.0, + "1504": 5.0, + "1505": 5.0, + "1506": 5.0, + "1507": 5.0, + "1508": 5.0, + "1509": 5.0, + "1510": 5.0, + "1511": 5.0, + "1512": 5.0, + "1513": 5.0, + "1514": 5.0, + "1515": 5.0, + "1516": 5.0, + "1517": 5.0, + "1518": 5.0, + "15200000": 5.0, + "1521": 5.0, + "1522": 5.0, + "1702": 18.0, + "21069020": 28.0, + "2202": 40.0, + "220210": 40.0, + "22029100": 40.0, + "220299": 40.0, + "22029990": 40.0, + "2207": 18.0, + "22071012": 18.0, + "2209": 18.0, + "2401": 28.0, + "2402": 28.0, + "2403": 28.0, + "24041100": 28.0, + "24041200": 18.0, + "24041900": 28.0, + "24049100": 18.0, + "25151220": 18.0, + "25151290": 18.0, + "25161200": 18.0, + "2523": 18.0, + "2601": 18.0, + "2602": 18.0, + "2603": 18.0, + "2604": 18.0, + "2605": 18.0, + "2606": 18.0, + "2607": 18.0, + "2608": 18.0, + "2609": 18.0, + "2610": 18.0, + "2619": 18.0, + "2620": 18.0, + "2621": 18.0, + "2701": 18.0, + "2702": 18.0, + "2703": 18.0, + "2706": 18.0, + "2707": 18.0, + "2708": 18.0, + "2710": 18.0, + "2711": 18.0, + "2712": 18.0, + "2713": 18.0, + "2714": 18.0, + "2715": 18.0, + "29061190": 18.0, + "3102": 18.0, + "3103": 18.0, + "3104": 18.0, + "3105": 18.0, + "3201": 18.0, + "3202": 18.0, + "3203": 18.0, + "3204": 18.0, + "3205": 18.0, + "3206": 18.0, + "3207": 18.0, + "3208": 18.0, + "3209": 18.0, + "3210": 18.0, + "32110000": 18.0, + "3212": 18.0, + "3213": 18.0, + "3214": 18.0, + "3215": 18.0, + "3301": 18.0, + "3302": 18.0, + "3303": 18.0, + "3304": 18.0, + "3305": 18.0, + "3306": 18.0, + "3307": 18.0, + "33074100": 18.0, + "3401": 18.0, + "3402": 18.0, + "3403": 18.0, + "3404": 18.0, + "3405": 18.0, + "3407": 18.0, + "3501": 18.0, + "3502": 18.0, + "3504": 18.0, + "3506": 18.0, + "3507": 18.0, + "3601": 18.0, + "3602": 18.0, + "3603": 18.0, + "3604": 18.0, + "3606": 18.0, + "3701": 18.0, + "3702": 18.0, + "3703": 18.0, + "3704": 18.0, + "3706": 18.0, + "3707": 18.0, + "3801": 18.0, + "3802": 18.0, + "38030000": 18.0, + "3804": 18.0, + "3805": 18.0, + "3806": 18.0, + "3807": 18.0, + "3808": 18.0, + "3809": 18.0, + "3810": 18.0, + "3811": 18.0, + "3812": 18.0, + "3813": 18.0, + "3814": 18.0, + "3815": 18.0, + "3816": 18.0, + "3817": 18.0, + "3818": 18.0, + "3819": 18.0, + "3820": 18.0, + "3821": 18.0, + "3823": 18.0, + "3824": 18.0, + "3825": 18.0, + "3826": 18.0, + "3827": 18.0, + "3901": 18.0, + "3902": 18.0, + "3903": 18.0, + "3904": 18.0, + "3905": 18.0, + "3906": 18.0, + "3907": 18.0, + "3908": 18.0, + "3909": 18.0, + "3910": 18.0, + "3911": 18.0, + "3912": 18.0, + "3913": 18.0, + "3914": 18.0, + "3915": 18.0, + "3916": 18.0, + "3917": 18.0, + "3918": 18.0, + "3919": 18.0, + "3920": 18.0, + "3921": 18.0, + "3922": 18.0, + "3923": 18.0, + "3924": 18.0, + "3925": 18.0, + "3926": 18.0, + "4002": 18.0, + "4003": 18.0, + "4004": 18.0, + "4005": 18.0, + "4006": 18.0, + "4007": 18.0, + "4008": 18.0, + "4009": 18.0, + "4010": 18.0, + "4011": 18.0, + "4012": 18.0, + "4013": 18.0, + "4014": 18.0, + "4015": 18.0, + "4016": 18.0, + "4017": 18.0, + "4201": 18.0, + "4202": 18.0, + "4203": 18.0, + "4205": 18.0, + "4206": 18.0, + "4301": 18.0, + "4302": 18.0, + "4303": 18.0, + "4304": 18.0, + "4403": 18.0, + "4407": 18.0, + "4408": 18.0, + "4409": 18.0, + "4410": 18.0, + "4411": 18.0, + "4412": 18.0, + "4413": 18.0, + "4414": 18.0, + "4418": 18.0, + "4421": 18.0, + "4501": 18.0, + "4702": 18.0, + "4802": 18.0, + "4803": 18.0, + "4804": 18.0, + "4805": 18.0, + "4806": 18.0, + "48062000": 18.0, + "48064010": 18.0, + "4807": 18.0, + "4808": 18.0, + "4809": 18.0, + "4810": 18.0, + "4811": 18.0, + "4812": 18.0, + "4813": 18.0, + "4814": 18.0, + "4816": 18.0, + "4818": 18.0, + "4820": 18.0, + "4821": 18.0, + "4822": 18.0, + "4823": 18.0, + "49060000": 18.0, + "4907": 18.0, + "4908": 18.0, + "4909": 18.0, + "4910": 18.0, + "4911": 18.0, + "56012200": 18.0, + "63053200": 18.0, + "6401": 18.0, + "6402": 18.0, + "6403": 18.0, + "6404": 18.0, + "6405": 18.0, + "6406": 18.0, + "6501": 18.0, + "6502": 18.0, + "65040000": 18.0, + "6505": 18.0, + "6506": 18.0, + "6507": 18.0, + "6702": 18.0, + "6703": 18.0, + "6704": 18.0, + "6801": 18.0, + "6802": 18.0, + "6803": 18.0, + "6804": 18.0, + "6805": 18.0, + "6806": 18.0, + "6807": 18.0, + "6808": 18.0, + "6809": 18.0, + "6810": 18.0, + "6811": 18.0, + "6812": 18.0, + "6813": 18.0, + "6814": 18.0, + "6815": 18.0, + "6901": 18.0, + "6902": 18.0, + "6903": 18.0, + "6904": 18.0, + "6905": 18.0, + "6906": 18.0, + "6907": 18.0, + "6909": 18.0, + "6910": 18.0, + "6914": 18.0, + "7002": 18.0, + "7003": 18.0, + "7004": 18.0, + "7005": 18.0, + "70060000": 18.0, + "7007": 18.0, + "7008": 18.0, + "7009": 18.0, + "7010": 18.0, + "7011": 18.0, + "7013": 18.0, + "7014": 18.0, + "7015": 18.0, + "7016": 18.0, + "7017": 18.0, + "7018": 18.0, + "7019": 18.0, + "7020": 18.0, + "7101": 3.0, + "7103": 0.25, + "7105": 3.0, + "7106": 3.0, + "7107": 3.0, + "7108": 3.0, + "7109": 3.0, + "7110": 3.0, + "7111": 3.0, + "7112": 3.0, + "7113": 3.0, + "7114": 3.0, + "7115": 3.0, + "7116": 3.0, + "7117": 3.0, + "7118": 3.0, + "7201": 18.0, + "7202": 18.0, + "7203": 18.0, + "7204": 18.0, + "7205": 18.0, + "7206": 18.0, + "7207": 18.0, + "7208": 18.0, + "7209": 18.0, + "7210": 18.0, + "7211": 18.0, + "7212": 18.0, + "7213": 18.0, + "7214": 18.0, + "7215": 18.0, + "7216": 18.0, + "7217": 18.0, + "7218": 18.0, + "7219": 18.0, + "7220": 18.0, + "7221": 18.0, + "7222": 18.0, + "7223": 18.0, + "7224": 18.0, + "7225": 18.0, + "7226": 18.0, + "7227": 18.0, + "7228": 18.0, + "7229": 18.0, + "7301": 18.0, + "7302": 18.0, + "7303": 18.0, + "7304": 18.0, + "7305": 18.0, + "7306": 18.0, + "7307": 18.0, + "7308": 18.0, + "7309": 18.0, + "7310": 18.0, + "7311": 18.0, + "7312": 18.0, + "7313": 18.0, + "7314": 18.0, + "7315": 18.0, + "7316": 18.0, + "7317": 18.0, + "7318": 18.0, + "7319": 18.0, + "7320": 18.0, + "7321": 18.0, + "7322": 18.0, + "7323": 18.0, + "73239410": 18.0, + "7324": 18.0, + "7325": 18.0, + "7326": 18.0, + "7401": 18.0, + "7402": 18.0, + "7403": 18.0, + "7404": 18.0, + "7405": 18.0, + "7406": 18.0, + "7407": 18.0, + "7408": 18.0, + "7409": 18.0, + "7410": 18.0, + "7411": 18.0, + "7412": 18.0, + "7413": 18.0, + "7415": 18.0, + "7418": 18.0, + "7419": 18.0, + "7501": 18.0, + "7502": 18.0, + "7503": 18.0, + "7504": 18.0, + "7505": 18.0, + "7506": 18.0, + "7507": 18.0, + "7508": 18.0, + "7601": 18.0, + "7602": 18.0, + "7603": 18.0, + "7604": 18.0, + "7605": 18.0, + "7606": 18.0, + "7607": 18.0, + "7608": 18.0, + "7609": 18.0, + "7610": 18.0, + "7611": 18.0, + "7612": 18.0, + "7613": 18.0, + "7614": 18.0, + "7615": 18.0, + "7616": 18.0, + "7801": 18.0, + "7802": 18.0, + "7804": 18.0, + "7806": 18.0, + "7901": 18.0, + "7902": 18.0, + "7903": 18.0, + "7904": 18.0, + "7905": 18.0, + "7907": 18.0, + "8001": 18.0, + "8002": 18.0, + "8003": 18.0, + "8007": 18.0, + "8101": 18.0, + "8102": 18.0, + "8103": 18.0, + "8104": 18.0, + "8105": 18.0, + "8106": 18.0, + "8107": 18.0, + "8108": 18.0, + "8109": 18.0, + "8110": 18.0, + "8111": 18.0, + "8112": 18.0, + "8113": 18.0, + "8202": 18.0, + "8203": 18.0, + "8204": 18.0, + "8205": 18.0, + "8206": 18.0, + "8207": 18.0, + "8208": 18.0, + "8209": 18.0, + "82100000": 18.0, + "8211": 18.0, + "8212": 18.0, + "82130000": 18.0, + "8214": 18.0, + "8215": 18.0, + "8301": 18.0, + "8302": 18.0, + "8303": 18.0, + "8304": 18.0, + "8305": 18.0, + "8307": 18.0, + "8308": 18.0, + "8309": 18.0, + "8310": 18.0, + "8311": 18.0, + "8401": 18.0, + "8402": 18.0, + "8403": 18.0, + "8404": 18.0, + "8405": 18.0, + "8406": 18.0, + "8407": 18.0, + "8408": 18.0, + "8409": 18.0, + "8410": 18.0, + "8411": 18.0, + "8412": 18.0, + "8413": 18.0, + "8414": 18.0, + "84142010": 18.0, + "84149012": 18.0, + "8415": 18.0, + "8416": 18.0, + "8417": 18.0, + "8418": 18.0, + "8420": 18.0, + "8421": 18.0, + "8422": 18.0, + "8423": 18.0, + "8424": 18.0, + "8425": 18.0, + "8426": 18.0, + "8427": 18.0, + "8428": 18.0, + "8429": 18.0, + "8430": 18.0, + "8431": 18.0, + "8433": 18.0, + "8434": 18.0, + "8435": 18.0, + "8437": 18.0, + "8438": 18.0, + "8439": 18.0, + "8440": 18.0, + "8441": 18.0, + "8442": 18.0, + "8443": 18.0, + "8444": 18.0, + "8445": 18.0, + "8446": 18.0, + "8447": 18.0, + "8448": 18.0, + "8449": 18.0, + "8450": 18.0, + "8451": 18.0, + "8453": 18.0, + "8454": 18.0, + "8455": 18.0, + "8456": 18.0, + "8457": 18.0, + "8458": 18.0, + "8459": 18.0, + "8460": 18.0, + "8461": 18.0, + "8462": 18.0, + "8463": 18.0, + "8464": 18.0, + "8465": 18.0, + "8466": 18.0, + "8467": 18.0, + "8468": 18.0, + "8470": 18.0, + "8471": 18.0, + "8472": 18.0, + "8473": 18.0, + "8474": 18.0, + "8475": 18.0, + "8476": 18.0, + "8477": 18.0, + "8478": 18.0, + "8479": 18.0, + "8480": 18.0, + "8481": 18.0, + "8482": 18.0, + "8483": 18.0, + "8484": 18.0, + "8485": 18.0, + "8486": 18.0, + "8487": 18.0, + "8501": 18.0, + "8502": 18.0, + "8503": 18.0, + "8504": 18.0, + "8505": 18.0, + "8506": 18.0, + "8507": 18.0, + "8508": 18.0, + "8509": 18.0, + "8510": 18.0, + "8511": 18.0, + "8512": 18.0, + "8513": 18.0, + "8514": 18.0, + "8515": 18.0, + "8516": 18.0, + "8517": 18.0, + "8518": 18.0, + "8519": 18.0, + "8521": 18.0, + "8522": 18.0, + "8523": 18.0, + "8524": 18.0, + "8525": 18.0, + "8526": 18.0, + "8527": 18.0, + "8528": 18.0, + "8529": 18.0, + "8530": 18.0, + "8531": 18.0, + "8532": 18.0, + "8533": 18.0, + "85340000": 18.0, + "8535": 18.0, + "8536": 18.0, + "8537": 18.0, + "8538": 18.0, + "8539": 18.0, + "8540": 18.0, + "8541": 18.0, + "8542": 18.0, + "8543": 18.0, + "8544": 18.0, + "8545": 18.0, + "8546": 18.0, + "8547": 18.0, + "85480000": 18.0, + "8549": 18.0, + "8601": 18.0, + "8602": 18.0, + "8603": 18.0, + "8604": 18.0, + "8605": 18.0, + "8606": 18.0, + "8607": 18.0, + "8608": 18.0, + "8609": 18.0, + "8701": 18.0, + "8702": 18.0, + "870331": 18.0, + "8704": 18.0, + "8705": 18.0, + "8706": 18.0, + "8707": 18.0, + "8708": 18.0, + "8709": 18.0, + "8714": 18.0, + "8715": 18.0, + "8716": 18.0, + "8801": 18.0, + "8802": 40.0, + "8804": 18.0, + "8805": 18.0, + "8807": 18.0, + "8903": 18.0, + "89080000": 18.0, + "9001": 18.0, + "9002": 18.0, + "9004": 18.0, + "9005": 18.0, + "9006": 18.0, + "9007": 18.0, + "9008": 18.0, + "9010": 18.0, + "9011": 18.0, + "9012": 18.0, + "9013": 18.0, + "9014": 18.0, + "9015": 18.0, + "9016": 18.0, + "9017": 18.0, + "9022": 18.0, + "9023": 18.0, + "9024": 18.0, + "9025": 18.0, + "9026": 18.0, + "9027": 18.0, + "9028": 18.0, + "9029": 18.0, + "9030": 18.0, + "9031": 18.0, + "9032": 18.0, + "9033": 18.0, + "9101": 18.0, + "9102": 18.0, + "9103": 18.0, + "9104": 18.0, + "9105": 18.0, + "9106": 18.0, + "9107": 18.0, + "9108": 18.0, + "9109": 18.0, + "9110": 18.0, + "9111": 18.0, + "9112": 18.0, + "9113": 18.0, + "9114": 18.0, + "9201": 18.0, + "9202": 18.0, + "9205": 18.0, + "92060000": 18.0, + "9207": 18.0, + "9208": 18.0, + "9209": 18.0, + "9301": 18.0, + "9303": 18.0, + "9304": 18.0, + "9305": 18.0, + "9306": 18.0, + "9307": 18.0, + "9402": 18.0, + "9403": 18.0, + "9404": 18.0, + "9405": 18.0, + "9406": 18.0, + "9503": 18.0, + "9504": 18.0, + "9505": 18.0, + "9506": 18.0, + "9508": 18.0, + "9602": 18.0, + "96040000": 18.0, + "9605": 18.0, + "96062100": 18.0, + "96062200": 18.0, + "9608": 18.0, + "96100000": 18.0, + "9611": 18.0, + "9612": 18.0, + "9613": 18.0, + "9616": 18.0, + "9617": 18.0, + "9618": 18.0, + "96200000": 18.0, + "9801": 18.0, + "9802": 18.0, + "9804": 18.0 + }, + "qualified": { + "0507": 5.0, + "0910": 5.0, + "1404": 5.0, + "4817": 18.0, + "4819": 18.0, + "7102": 1.5, + "7104": 1.5, + "8419": 18.0, + "8703": 40.0, + "870340": 40.0, + "870350": 40.0, + "870360": 40.0, + "870370": 40.0, + "8711": 40.0, + "9401": 18.0, + "9603": 18.0 + } +} \ No newline at end of file diff --git a/api/src/json/state_124);.json b/api/src/json/state_124);.json deleted file mode 100644 index e9a0618e3..000000000 --- a/api/src/json/state_124);.json +++ /dev/null @@ -1 +0,0 @@ -["Ajdabiya"] \ No newline at end of file diff --git a/api/src/json/states.json b/api/src/json/states.json index aef012af5..9e8acfd45 100644 --- a/api/src/json/states.json +++ b/api/src/json/states.json @@ -13557,7 +13557,7 @@ }, { "id": "2712", - "name": "Østfold", + "name": "\u00d8stfold", "country_id": "164" }, { @@ -20601,4 +20601,4 @@ "country_id": "246" } ] -} \ No newline at end of file +} diff --git a/api/src/json/tax_profiles.json b/api/src/json/tax_profiles.json index 404281e38..3b77f4248 100644 --- a/api/src/json/tax_profiles.json +++ b/api/src/json/tax_profiles.json @@ -2,12 +2,28 @@ "_comment": "Country tax profiles (TAX_INTERNATIONALIZATION_RESEARCH.md T0). Keyed by ISO sortname, which branch.sortname stores. _default covers every country without its own entry - single tax, exclusive display, generic wording. Profiles describe presentation and policy; rates stay in each shop's grouptax collection, seeded from countries.json.", "_default": { "label": "Tax", - "registration": { "label": "Tax No.", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "Tax No.", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "exclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "IN": { "label": "GST", @@ -18,158 +34,436 @@ }, "components": { "mode": "split_equal", - "intra": ["CGST", "SGST"], - "inter": ["IGST"], + "intra": [ + "CGST", + "SGST" + ], + "inter": [ + "IGST" + ], "placeOfSupply": "state" }, "display": "inclusive", - "rounding": { "granularity": "invoice", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": "HSN" }, - "reports": ["gstr", "tax-summary-by-rate"], - "counterpartyTypes": ["consumer", "regular", "composite", "unregistered"] + "rounding": { + "granularity": "invoice", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": "HSN" + }, + "reports": [ + "gstr", + "tax-summary-by-rate" + ], + "counterpartyTypes": [ + "consumer", + "regular", + "composite", + "unregistered" + ], + "regime": "vat_credit" }, "US": { "label": "Sales Tax", - "registration": { "label": "Seller's Permit", "regex": null, "onReceipt": false }, - "components": { "mode": "stacked" }, + "registration": { + "label": "Seller's Permit", + "regex": null, + "onReceipt": false + }, + "components": { + "mode": "stacked" + }, "display": "exclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": false, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": false, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "sales_tax" }, "GB": { "label": "VAT", - "registration": { "label": "VAT No.", "regex": "^GB[0-9]{9}([0-9]{3})?$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "VAT No.", + "regex": "^GB[0-9]{9}([0-9]{3})?$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "VAT Receipt", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "VAT Receipt", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "CA": { "label": "GST/HST", - "registration": { "label": "GST/HST No.", "regex": null, "onReceipt": true }, - "components": { "mode": "stacked", "separateLines": true }, + "registration": { + "label": "GST/HST No.", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "stacked", + "separateLines": true + }, "display": "exclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "AU": { "label": "GST", - "registration": { "label": "ABN", "regex": "^[0-9]{11}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "ABN", + "regex": "^[0-9]{11}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "invoice", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "invoice", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "NZ": { "label": "GST", - "registration": { "label": "GST No.", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "GST No.", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "DE": { "label": "USt.", - "registration": { "label": "USt-IdNr.", "regex": "^DE[0-9]{9}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "USt-IdNr.", + "regex": "^DE[0-9]{9}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "FR": { "label": "TVA", - "registration": { "label": "No. TVA", "regex": "^FR[0-9A-Z]{2}[0-9]{9}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "No. TVA", + "regex": "^FR[0-9A-Z]{2}[0-9]{9}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "JP": { "label": "Consumption Tax", - "registration": { "label": "Registration No.", "regex": "^T[0-9]{13}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "Registration No.", + "regex": "^T[0-9]{13}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "invoice", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Qualified Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "invoice", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Qualified Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "SG": { "label": "GST", - "registration": { "label": "GST Reg. No.", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "GST Reg. No.", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "AE": { "label": "VAT", - "registration": { "label": "TRN", "regex": "^[0-9]{15}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "TRN", + "regex": "^[0-9]{15}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "SA": { "label": "VAT", - "registration": { "label": "VAT No.", "regex": "^3[0-9]{13}3$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "VAT No.", + "regex": "^3[0-9]{13}3$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "ZA": { "label": "VAT", - "registration": { "label": "VAT No.", "regex": "^4[0-9]{9}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "VAT No.", + "regex": "^4[0-9]{9}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "MY": { "label": "SST", - "registration": { "label": "SST No.", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "SST No.", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "exclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "PH": { "label": "VAT", - "registration": { "label": "TIN", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "TIN", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Official Receipt", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Official Receipt", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "ID": { "label": "PPN", - "registration": { "label": "NPWP", "regex": null, "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "NPWP", + "regex": null, + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": null, "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": null, + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" }, "TH": { "label": "VAT", - "registration": { "label": "Tax ID", "regex": "^[0-9]{13}$", "onReceipt": true }, - "components": { "mode": "single" }, + "registration": { + "label": "Tax ID", + "regex": "^[0-9]{13}$", + "onReceipt": true + }, + "components": { + "mode": "single" + }, "display": "inclusive", - "rounding": { "granularity": "line", "mode": "half-up" }, - "receipt": { "breakdownPerRate": true, "wording": "Tax Invoice", "itemCode": null }, - "reports": ["tax-summary-by-rate"] + "rounding": { + "granularity": "line", + "mode": "half-up" + }, + "receipt": { + "breakdownPerRate": true, + "wording": "Tax Invoice", + "itemCode": null + }, + "reports": [ + "tax-summary-by-rate" + ], + "regime": "vat_credit" } } diff --git a/api/src/middleware/auth-rate-limit.js b/api/src/middleware/auth-rate-limit.js index 636cca89a..ef5c8aba4 100644 --- a/api/src/middleware/auth-rate-limit.js +++ b/api/src/middleware/auth-rate-limit.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); const { MongoRateLimitStore } = require('./rate-limit-store'); -const { perClientKey } = require('./rate-limit-key'); +const { perClientKey, perShopKey } = require('./rate-limit-key'); /* * Counters in the database, not in this process's memory. @@ -109,4 +109,23 @@ const registerLimiter = rateLimit({ legacyHeaders: false, }); -module.exports = { loginLimiter, passwordResetLimiter, registerLimiter }; +/* + * Invoice routes are also mounted at the legacy root path, where app.js's + * /api-wide limiter does not run. Keep a separate availability budget here so + * both route prefixes are protected without making a normal till workflow + * share a small credential-guessing budget. + */ +const invoiceLimiter = rateLimit({ + store: new MongoRateLimitStore({ prefix: 'invoice' }), + keyGenerator: perShopKey, + windowMs: 15 * 60 * 1000, + limit: 300, + message: { + status: false, + message: 'Too many invoice requests. Please wait a few minutes and try again.', + }, + standardHeaders: true, + legacyHeaders: false, +}); + +module.exports = { loginLimiter, passwordResetLimiter, registerLimiter, invoiceLimiter }; diff --git a/api/src/middleware/auth.js b/api/src/middleware/auth.js index e15f69679..ef25d7c1f 100644 --- a/api/src/middleware/auth.js +++ b/api/src/middleware/auth.js @@ -175,7 +175,26 @@ const auth = async (req, res, next) => { // GRANT ACCESS TO PROTECTED ROUTE return continueWithTenant(req, res, next, currentUser); } catch (error) { - next(new UnauthorizedError('Invalid token or user not found')); + /* + * Only a BAD TOKEN is a 401 here. This catch used to stamp every + * failure "Invalid token" - including the database timing out under + * load - and this middleware guards users/verify, the session + * heartbeat. So a demo-data install (a zip download and hundreds of + * inserts on a small shared instance) could make one heartbeat's user + * lookup fail, the client saw 401, and it did the only correct thing + * with a 401: signed the owner out, mid-install. An infrastructure + * hiccup is a 500 and a toast, never a sign-out; `protect` below has + * always drawn the line this way. + */ + if ( + error && + (error.name === 'JsonWebTokenError' || + error.name === 'TokenExpiredError' || + error.name === 'NotBeforeError') + ) { + return next(new UnauthorizedError('Invalid token or user not found')); + } + return next(error); } }; @@ -367,7 +386,7 @@ const isLoggedIn = async (req, res, next) => { }; /** - * Optional auth middleware — mirrors PHP session-exceptional behaviour. + * Optional auth middleware - mirrors PHP session-exceptional behaviour. * * PHP dispatcher.php starts a session on EVERY request. For routes listed in * $sessionExceptionalRequest the dispatcher simply skips the "is authenticated?" @@ -411,7 +430,7 @@ const optionalProtect = async (req, res, next) => { } } - // 3) No valid auth — continue anyway (session-exceptional) + // 3) No valid auth - continue anyway (session-exceptional) next(); } catch (_) { next(); diff --git a/api/src/middleware/filter-guard.js b/api/src/middleware/filter-guard.js new file mode 100644 index 000000000..ff012746a --- /dev/null +++ b/api/src/middleware/filter-guard.js @@ -0,0 +1,65 @@ +'use strict'; + +const { findCodeOperator } = require('../utils/mongo-guard'); + +/* + * The gap the app-level '$' sanitiser cannot see into. + * + * app.js walks req.query and req.body and drops any key beginning with '$'. + * That is right for ordinary parameters and genuinely load bearing. But the + * list endpoints take their filter as a JSON STRING: + * + * GET /api/items?filters={"$where":"sleep(5000)"} + * + * To that walk, `filters` is one string value with an ordinary name, so it is + * copied across untouched. The controller then JSON.parses it back into an + * object with its operators intact and spreads the result into a live query. + * The sanitiser is not defeated by cleverness - it simply never sees inside. + * + * Eight controllers parse a filter string this way (items, sales, customers, + * categories, customer-categories, customerCategory, branches, receivings), so + * this belongs in one place rather than eight, and it covers the ninth + * whenever somebody writes it. + * + * NOT a '$' strip, deliberately. These filters are a real query language and + * the app uses it: Sales History sends { sale_process: { $ne: 'KOT' } } to keep + * kitchen tickets out of the list, and date windows are $gte/$lte. Removing + * those would not harden anything - it would silently un-filter lists, and a + * list quietly showing rows it was told to hide is its own kind of bug. The + * line is drawn at CODE EXECUTION instead. + * + * Rejected loudly rather than emptied. A filter silently dropped reads as "no + * results", which sends somebody hunting for missing data instead of fixing + * the request that was refused. + */ +function filterGuard(req, res, next) { + const sources = [req.query, req.body]; + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + for (const [key, value] of Object.entries(source)) { + if (typeof value !== 'string') continue; + const head = value.trim()[0]; + /* Only things that could be an object or an array. Parsing every string + parameter on every request would be real work for no answer. */ + if (head !== '{' && head !== '[') continue; + let parsed; + try { + parsed = JSON.parse(value); + } catch (e) { + continue; /* not JSON - every endpoint here already tolerates that */ + } + const bad = findCodeOperator(parsed); + if (bad) { + console.warn(`Blocked code operator '${bad}' in parameter '${key}'`); + return res.status(400).json({ + type: 'error', + message: `Filter operator "${bad}" is not allowed`, + data: null, + }); + } + } + } + return next(); +} + +module.exports = { filterGuard }; diff --git a/api/src/middleware/settings.validation.js b/api/src/middleware/settings.validation.js index e683e06c0..8bd98f288 100644 --- a/api/src/middleware/settings.validation.js +++ b/api/src/middleware/settings.validation.js @@ -191,8 +191,20 @@ const validatePayment = [ * Validation for updateCommonSettings * PHP: setting.php lines 672-691 */ +/* + * This endpoint takes PARTIAL payloads - the quotation-defaults card and the + * signature upload each send a handful of their own keys and nothing else. + * Demanding fields they never carry made an image upload fail with "Default + * customer is required", which is a question the form never asked the user. + * + * So each rule is skipped when its key is absent (`.optional()`), and only + * validated when the caller actually sends it. A full settings-form save + * sends both keys and is checked exactly as before. Absent means "leave it + * alone"; present-but-empty is still refused. + */ const validateCommonSettings = [ body('default_customer') + .optional() .trim() .notEmpty() .withMessage('Default customer is required') @@ -205,6 +217,7 @@ const validateCommonSettings = [ ), body('default_supplier') + .optional() .trim() .notEmpty() .withMessage('Default supplier is required') diff --git a/api/src/middleware/upload.js b/api/src/middleware/upload.js index e4c75da64..911b95688 100644 --- a/api/src/middleware/upload.js +++ b/api/src/middleware/upload.js @@ -99,5 +99,34 @@ async function attachImageKey(req, res, next) { return next(); } +/* + * Document uploads: the supplier's own PO, an invoice scan, a delivery + * note - attached to a purchase (owner: "we cant upload any file. + * supplier po or some other stuff"). PDFs join the image types; the + * name is regenerated server-side so nothing a filename carries ever + * reaches the filesystem. + */ +const documentUpload = multer({ + storage: multer.diskStorage({ + destination: function (req, file, cb) { + const dir = path.join(uploadDir, 'attachments'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: function (req, file, cb) { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = (path.extname(file.originalname) || '').toLowerCase().slice(0, 8); + cb(null, 'doc-' + uniqueSuffix + ext); + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const ok = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg', 'image/webp']; + if (ok.includes(file.mimetype)) cb(null, true); + else cb(new Error('Only PDF and image files can be attached.'), false); + }, +}); + module.exports = upload; module.exports.attachImageKey = attachImageKey; +module.exports.documentUpload = documentUpload; diff --git a/api/src/models/audit.model.js b/api/src/models/audit.model.js index a64daf483..81c2527c7 100644 --- a/api/src/models/audit.model.js +++ b/api/src/models/audit.model.js @@ -5,7 +5,7 @@ // approval, role/permission change). Multi-tenant scoped by license + branch. // // Complements `data_change_log` (field-level CRUD diffs, written by -// BaseModel.changeLog) — this records the ACTION, not the row diff. +// BaseModel.changeLog) - this records the ACTION, not the row diff. // // Native-driver data-access class extending BaseModel (not Mongoose). All DB // operations are inherited; this class only declares the collection + fields. diff --git a/api/src/models/base.model.js b/api/src/models/base.model.js index 696553c95..83d68988b 100644 --- a/api/src/models/base.model.js +++ b/api/src/models/base.model.js @@ -159,7 +159,7 @@ class BaseModel { * optimisation - it is a bug fix. * * MONGODB_URI is set twice during startup. main.js sets a bare - * mongodb://localhost:/PosnicPro as a fallback, and server.js later + * mongodb://127.0.0.1:/PosnicPro as a fallback, and server.js later * replaces it with the credentialed URI that setup-mongodb.js validated. * Requiring the API's routes constructs repositories, each of which builds * a BaseModel, whose constructor starts connecting immediately - with @@ -1285,6 +1285,17 @@ class BaseModel { user_id: userId instanceof ObjectId ? userId : new ObjectId(String(userId)), user_name: userName || '', license: license instanceof ObjectId ? license : new ObjectId(String(license)), + /* + * How this session began, when the caller knows. + * + * 'signup' is the shop being opened FOR somebody the moment they + * created it, not somebody choosing to come back. Both are real + * sessions and both belong in the log, but only one of them answers + * "did this customer return", and the console cannot tell them apart + * from a timestamp. Absent on an ordinary sign-in, which is the vast + * majority of rows and needs no field. + */ + ...(reqInfo.source ? { source: String(reqInfo.source).slice(0, 32) } : {}), }; const insertResult = await collection.insertOne(document); diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index 18193e2af..cee818db8 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -11,6 +11,7 @@ const { Schema, Types } = require('mongoose'); * Not Found": a missing-data error for what was really a wrong-database one. */ const { defineModel } = require('../db/model-registry'); +const { featureToggleRepairs } = require('../services/settings-groups'); const BaseModel = require('./base.model'); const User = require('./user.model'); const CustomerModel = require('./customer.model'); @@ -619,6 +620,24 @@ class BranchModel { }; } + /* + * Self-healing read: a features key stored as the STRING 'true'/'false' + * (the group endpoint stored the welcome's checkbox map verbatim) reads + * as enabled through every `!== false` gate in the app. This endpoint + * is the whole legacy frontend's settings read, so the poisoned shops + * heal on their next open - answered fixed, and written back fixed. The + * write is best-effort: failing to repair must not fail the read. + */ + const stringToggleFixes = featureToggleRepairs(branch); + if (Object.keys(stringToggleFixes).length) { + Object.assign(branch, stringToggleFixes); + try { + await this.model.updateOne({ _id: branch._id }, { $set: stringToggleFixes }); + } catch (repairErr) { + console.error('branch toggle repair skipped:', repairErr.message); + } + } + // Simplify ObjectIds and Dates const simplified = BranchModel.simplifyDocument(branch); diff --git a/api/src/models/customer.model.js b/api/src/models/customer.model.js index 7fb4a880e..72ab28ab2 100644 --- a/api/src/models/customer.model.js +++ b/api/src/models/customer.model.js @@ -19,6 +19,11 @@ class CustomerModel extends BaseModel { // Branch Information branch_id: { type: 'ObjectId', select: true }, branch_name: { type: 'String', select: true }, + /* S7 (D5): the account-level relation, mirroring items. Seeded with the + owning branch so nothing moves; sharing a customer between shops is a + deliberate grant, never a migration side effect. select:false keeps it + out of list payloads, exactly as items do. */ + branch_access: { type: 'Array', select: false }, // Basic Information name: { type: 'String', select: true, required: true }, diff --git a/api/src/models/dashboard.model.js b/api/src/models/dashboard.model.js index dc3046364..f0654724f 100644 --- a/api/src/models/dashboard.model.js +++ b/api/src/models/dashboard.model.js @@ -576,7 +576,12 @@ class DashboardModel extends BaseModel { $gte: new Date(fromTimestamp || 0), $lte: new Date(toTimestamp || Date.now()), }, - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }); const [salesTotal, purchaseTotal] = await Promise.all([ @@ -626,7 +631,12 @@ class DashboardModel extends BaseModel { }); const purchaseMatch = this.getContextMatch({ ...inRange, - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }); const expenseMatch = this.getContextMatch({ ...inRange }); @@ -954,7 +964,12 @@ class DashboardModel extends BaseModel { const purchasecondition = this.getContextMatch({ date: dateRange, - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }); const return_condition = this.getContextMatch({ diff --git a/api/src/models/expense.model.js b/api/src/models/expense.model.js index c0448097d..763622be1 100644 --- a/api/src/models/expense.model.js +++ b/api/src/models/expense.model.js @@ -84,7 +84,7 @@ class ExpenseModel extends BaseModel { message: 'Expense added successfully', }; } else { - // Update existing expense — never modify created_date/created_by fields + // Update existing expense - never modify created_date/created_by fields delete expenseData.created_date; delete expenseData.created_by; delete expenseData.created_by_id; diff --git a/api/src/models/install.model.js b/api/src/models/install.model.js index de71e6f9c..d4b5c54f5 100644 --- a/api/src/models/install.model.js +++ b/api/src/models/install.model.js @@ -516,6 +516,21 @@ class InstallModel extends BaseModel { unitId, } = params; + /* + * Everything below is DEMO data, and is tagged as such. + * + * Tagged rather than remembered in a list: a list drifts the moment a + * shop edits or deletes one row, and then switching demo data off either + * misses records or removes the wrong ones. The tag travels with the + * record, so the answer is always on the record itself. + * + * DEMO_PACK names the pack. Today every shop receives the same mixed set + * - a restaurant gets a power amplifier - so it is recorded as what it + * is. Per-industry packs will change this value, not the mechanism. + */ + const DEMO_PACK = 'starter-mixed'; + const demoTag = { demo_pack: DEMO_PACK, demo_seeded_at: now }; + // Load demo data const installDocuments = JSON.parse( fs.readFileSync(path.join(__dirname, '../json/install_documents.json'), 'utf8') @@ -524,6 +539,7 @@ class InstallModel extends BaseModel { // Insert categories const categoryCollection = await this.getCollection('categories'); const categoryMultiData = installDocuments.documents[0].categories.map((cat) => ({ + ...demoTag, name: cat.name, discount_percentage: 0.0, discount_amount: 0, @@ -564,6 +580,7 @@ class InstallModel extends BaseModel { const category = categoryMap[itemValue.category_name]; if (category) { itemMultiData.push({ + ...demoTag, name: itemValue.name, category_id: category.id, category_name: category.name, diff --git a/api/src/models/item.model.js b/api/src/models/item.model.js index 47bdd4fd0..d36ab7f3b 100644 --- a/api/src/models/item.model.js +++ b/api/src/models/item.model.js @@ -44,6 +44,18 @@ const itemSchema = new mongoose.Schema( itemSchema.plugin(toJSON); itemSchema.plugin(paginate); +/* + * The Item List's whitelisted sorts (price, cost, stock, recency, name) each + * walk one of these; without them every re-sort scans the branch's whole + * catalogue. Margin sort is computed per query and cannot use an index - + * that one is documented as a scan in item.repository.findPage. + */ +itemSchema.index({ license: 1, branch_id: 1, selling_price: -1 }); +itemSchema.index({ license: 1, branch_id: 1, company_price: -1 }); +itemSchema.index({ license: 1, branch_id: 1, available_quantity: 1 }); +itemSchema.index({ license: 1, branch_id: 1, updated_date: -1 }); +itemSchema.index({ license: 1, branch_id: 1, name: 1 }); + const Item = defineModel('Item', itemSchema); // Attach the legacy BaseModel-based implementation so callers can import @@ -54,7 +66,7 @@ const Item = defineModel('Item', itemSchema); // ItemRepository. Most instance methods below are legacy-only and should // not be used by new code. // -// Usage examples (legacy only – new code should NOT use these directly): +// Usage examples (legacy only - new code should NOT use these directly): // const Item = require("../models/item.model"); // Mongoose // const LegacyItem = Item.LegacyItemModel; // Legacy class alias // const { LegacyItemModel } = require("../models/item.model"); @@ -67,6 +79,21 @@ class ItemModel { name: { type: 'String', select: true }, itemid: { type: 'String', select: true }, barcode_id: { type: 'String', select: true }, + /* + * The GLOBAL identifier, kept apart from barcode_id on purpose. + * + * barcode_id is whatever this shop prints or scans - it may be a GTIN, an + * in-store code, or a supplier reference somebody typed. gtin is only ever + * set when it validates (see utils/gtin.js), because a wrong GTIN is worse + * than a missing one: it claims to be a product it is not, and anyone + * matching against it inherits the error. + * + * gtin14 is the zero-padded comparison form. A UPC-A and the EAN-13 of the + * same product differ only by a leading zero, so matching on the raw string + * is how one product becomes two rows. + */ + gtin: { type: 'String', select: true }, + gtin14: { type: 'String', select: true }, image: { type: 'String', select: true }, multi_image: { type: 'Array', select: true }, category_id: { type: 'ObjectId', select: true }, @@ -115,8 +142,11 @@ class ItemModel { modifier_group_ids: { type: 'Array', select: true }, // IC1: deliberate ask-at-the-till pricing, set from the item form. open_price: { type: 'Boolean', select: true }, - // Loyverse study L2: the no-image sale-grid tile's colour. + // Loyverse study L2: the no-image sale-grid tile's colour and shape. tile_color: { type: 'String', select: true }, + tile_shape: { type: 'String', select: true }, + // Quick code (owner ask): "22 is hamam soap" - typed + Enter carts it. + plu_code: { type: 'String', select: true }, // Square study Q3: services - a second sellable kind, no stock. item_kind: { type: 'String', select: true }, service_unit: { type: 'String', select: true }, diff --git a/api/src/models/receiving.model.js b/api/src/models/receiving.model.js index c03a11878..0fc9cf249 100644 --- a/api/src/models/receiving.model.js +++ b/api/src/models/receiving.model.js @@ -1100,7 +1100,12 @@ receivingSchema.statics.supplierReceivingReportPage = async function (value, opt branch_id: { $in: branchIds }, // Supplier receiving summary should only include fully/partially // received documents; exclude Open receivings. - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }, { updated_date: { $gte: new Date(fromDate), $lte: new Date(toDate) }, @@ -1228,7 +1233,12 @@ receivingSchema.statics.receivingReportPage = async function (value, options = { const andConditions = [ { branch_id: { $in: branchIds }, - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, }, { date: { $gte: fromDate, $lte: toDate }, @@ -1338,7 +1348,12 @@ receivingSchema.statics.receivingsGraphicalReports = async function (value) { // Received/PartialReturn receivings aggregation const receivedCondition = { branch_id: { $in: branchIds }, - receiving_status: { $in: ['Received', 'PartialReturn'] }, + /* Deny-list, not allow-list. Legacy and imported rows carry other + spellings ('completed', absent entirely) and an allow-list of two + words silently dropped them from every purchase report and + dashboard number - the owner's "purchase not showing report". + Goods are IN unless the status says they never arrived. */ + receiving_status: { $nin: ['Open', 'Cancelled', 'FullReturn'] }, updated_date: { $gte: new Date(fromDate), $lte: new Date(toDate) }, }; if (licenseId) receivedCondition.license = licenseId; @@ -1597,6 +1612,267 @@ receivingSchema.statics.getReceivingOrder = async function (receiving_id) { * @param {String} id - Receiving ID (null for create, ID for update) * @returns {Promise} */ +/* + * Void a purchase (PURCHASE_TAX_PLAN G8). Never a delete: the record stays + * whole and gains an audit line; stock moved by a Received purchase is + * reversed through the same stocklog machinery that put it in; a voided + * purchase leaves the input-credit side of Tax Payable because the + * aggregations exclude Cancelled. An Open order never moved stock, so + * voiding it is bookkeeping only. + */ +receivingSchema.statics.voidReceiving = async function (id, reason, context = {}) { + try { + const baseModel = new BaseModel('receivings'); + const collection = await baseModel.getCollection('receivings'); + const itemsCollection = await baseModel.getCollection('items'); + const stockLogsCollection = await baseModel.getCollection('stocklogs'); + const license = context.license || BaseModel.license; + const loggedUser = context.userId || BaseModel.loggedUser; + const loggedUserName = context.userName || BaseModel.loggedUserName || ''; + const now = new Date(); + + const receiving = await collection.findOne({ + _id: new ObjectId(String(id)), + license: new ObjectId(String(license)), + }); + if (!receiving) return { status: false, message: 'Purchase not found' }; + if (receiving.receiving_status === 'Cancelled') { + return { status: false, message: 'This purchase is already voided' }; + } + + if (receiving.receiving_status === 'Received' || receiving.receiving_status === 'Partial') { + for (const line of receiving.items || []) { + if (!line.item_id || !ObjectId.isValid(String(line.item_id))) continue; + const itemId = new ObjectId(String(line.item_id)); + /* Reverse what actually went INTO stock - on a partial document + that is the cumulative qty_received, not the face quantity. */ + const qty = + line.qty_received !== undefined && line.qty_received !== null + ? parseFloat(line.qty_received) || 0 + : parseFloat(line.item_quantity || 0); + if (!qty) continue; + const itemDoc = await itemsCollection.findOne({ + _id: itemId, + license: new ObjectId(String(license)), + }); + if (!itemDoc) continue; + const tracks = itemDoc.track_inventory === true || itemDoc.track_inventory === 'true'; + if (!tracks) continue; + const currentQty = parseFloat(itemDoc.available_quantity || 0); + const newQty = currentQty - qty; + await itemsCollection.updateOne( + { _id: itemId, license: new ObjectId(String(license)) }, + { $set: { available_quantity: newQty } } + ); + await stockLogsCollection.insertOne({ + stocklog: true, + branch_id: receiving.branch_id, + view_item_id: itemId, + item_barcode_id: itemDoc.barcode_id || '', + item_name: line.item_name || itemDoc.name || '', + item_quantity: qty, + process: 'Void Purchase', + reference: receiving.receiving_id, + opening_balance: currentQty, + closing_balance: newQty, + count: -qty, + date: now, + action: 'Void', + changed_by_userid: loggedUser ? new ObjectId(String(loggedUser)) : null, + changed_by: loggedUserName, + license: new ObjectId(String(license)), + created_date: now, + updated_date: now, + }); + } + } + + await collection.updateOne( + { _id: receiving._id }, + { + $set: { + receiving_status: 'Cancelled', + voided_by: loggedUserName, + voided_by_id: loggedUser ? new ObjectId(String(loggedUser)) : null, + voided_at: now, + void_reason: String(reason || '').trim(), + updated_date: now, + updated_by: loggedUserName, + }, + } + ); + return { status: true, message: 'Purchase voided - stock reversed, record kept' }; + } catch (error) { + console.error('Error in voidReceiving:', error); + return { status: false, message: error.message }; + } +}; + +/* + * Receive goods against an Ordered or Partial purchase - all remaining, + * or entered per line (owner: "received 5 items but remaining will + * receive later"). Stock moves by exactly what arrives NOW; each event + * leaves a 'Receive Purchase' stocklog; the document's status settles to + * Partial or Received from its lines. close_short stops expecting the + * rest: the status closes at Received, the shortfall stays visible on the + * lines, and no stock moves for goods that never came. + */ +receivingSchema.statics.receivePartial = async function (id, payload = {}, context = {}) { + try { + const baseModel = new BaseModel('receivings'); + const collection = await baseModel.getCollection('receivings'); + const itemsCollection = await baseModel.getCollection('items'); + const stockLogsCollection = await baseModel.getCollection('stocklogs'); + const license = context.license || BaseModel.license; + const loggedUser = context.userId || BaseModel.loggedUser; + const loggedUserName = context.userName || BaseModel.loggedUserName || ''; + const now = new Date(); + + const receiving = await collection.findOne({ + _id: new ObjectId(String(id)), + license: new ObjectId(String(license)), + }); + if (!receiving) return { status: false, message: 'Purchase not found' }; + if (receiving.receiving_status === 'Cancelled') { + return { status: false, message: 'This purchase was voided - nothing can be received' }; + } + if (receiving.receiving_status === 'Received') { + return { status: false, message: 'This purchase is already fully received' }; + } + + const receivedOf = (line) => { + if (line.qty_received !== undefined && line.qty_received !== null) { + return parseFloat(line.qty_received) || 0; + } + return 0; + }; + + /* How much of each item is being received in THIS event. */ + const pool = {}; + if (payload.all === true) { + for (const line of receiving.items || []) { + if (!line.item_id) continue; + const key = String(line.item_id); + const remaining = (parseFloat(line.item_quantity) || 0) - receivedOf(line); + if (remaining > 0) pool[key] = (pool[key] || 0) + remaining; + } + } else { + for (const req of payload.lines || []) { + if (!req || !req.item_id) continue; + const qty = parseFloat(req.qty); + if (!qty || qty <= 0) continue; + pool[String(req.item_id)] = (pool[String(req.item_id)] || 0) + qty; + } + } + const receivingNow = Object.keys(pool).length > 0; + if (!receivingNow && payload.close_short !== true) { + return { status: false, message: 'Nothing to receive - enter a quantity' }; + } + + /* Apply to the lines, capped at each line's remainder, and total the + stock movement per item. */ + const stockDelta = {}; + const lines = (receiving.items || []).map((line) => ({ ...line })); + for (const line of lines) { + if (!line.item_id) continue; + const key = String(line.item_id); + const already = receivedOf(line); + const remaining = Math.max(0, (parseFloat(line.item_quantity) || 0) - already); + const take = Math.min(remaining, pool[key] || 0); + pool[key] = (pool[key] || 0) - take; + line.qty_received = Math.round((already + take) * 1000) / 1000; + if (take > 0) stockDelta[key] = (stockDelta[key] || 0) + take; + } + + /* Move the stock that arrived. The log is gated by stock_management, + the quantity is not - same contract as every other door. */ + const branchesCollection = await baseModel.getCollection('branches'); + const branchDoc = receiving.branch_id + ? await branchesCollection.findOne({ _id: new ObjectId(String(receiving.branch_id)) }) + : null; + const stockManagement = branchDoc?.stock_management === true; + const stockLogStatus = branchDoc?.stock_management_log !== false; + for (const key of Object.keys(stockDelta)) { + if (!ObjectId.isValid(key)) continue; + const delta = stockDelta[key]; + const itemDoc = await itemsCollection.findOne({ + _id: new ObjectId(key), + license: new ObjectId(String(license)), + }); + if (!itemDoc || !(itemDoc.track_inventory === true || itemDoc.track_inventory === 'true')) { + continue; + } + const currentQty = parseFloat(itemDoc.available_quantity || 0); + const newQty = currentQty + delta; + if (stockManagement) { + await stockLogsCollection.insertOne({ + stocklog: stockLogStatus, + branch_id: receiving.branch_id, + view_item_id: itemDoc._id, + item_barcode_id: itemDoc.barcode_id || '', + item_name: itemDoc.name || '', + item_quantity: delta, + process: 'Receive Purchase', + reference: receiving.receiving_id, + opening_balance: currentQty, + closing_balance: newQty, + count: delta, + date: now, + action: 'Add', + changed_by_userid: loggedUser ? new ObjectId(String(loggedUser)) : null, + changed_by: loggedUserName, + license: new ObjectId(String(license)), + created_date: now, + updated_date: now, + }); + } + await itemsCollection.updateOne( + { _id: itemDoc._id, license: new ObjectId(String(license)) }, + { $set: { available_quantity: newQty } } + ); + } + + /* Settle the document. */ + let anyReceived = false; + let allFull = true; + for (const line of lines) { + const ordered = parseFloat(line.item_quantity) || 0; + const got = receivedOf(line); + if (got > 0) anyReceived = true; + if (got < ordered) allFull = false; + } + let newStatus = allFull ? 'Received' : anyReceived ? 'Partial' : 'Open'; + const set = { + items: lines, + receiving_status: newStatus, + updated_date: now, + updated_by: loggedUserName, + }; + if (payload.close_short === true && newStatus !== 'Received') { + set.receiving_status = 'Received'; + set.closed_short = true; + set.closed_short_at = now; + set.closed_short_by = loggedUserName; + newStatus = 'Received'; + } + await collection.updateOne({ _id: receiving._id }, { $set: set }); + const movedTotal = Object.values(stockDelta).reduce((a, b) => a + b, 0); + return { + status: true, + message: + payload.close_short === true && movedTotal === 0 + ? 'Remaining quantities cancelled - the purchase is closed' + : newStatus === 'Received' + ? 'All goods received into stock' + : 'Received into stock - the rest stays expected', + data: { receiving_status: newStatus }, + }; + } catch (error) { + console.error('Error in receivePartial:', error); + return { status: false, message: error.message }; + } +}; + receivingSchema.statics.receivingInsertUpdate = async function (data, id) { try { const BaseModel = require('./base.model'); @@ -1796,7 +2072,24 @@ receivingSchema.statics.receivingInsertUpdate = async function (data, id) { let csgstValue = 0.0; if (indianGstSetting === 'gst_on') { - if (data.supplier_state !== currentBranchState) { + /* + * PURCHASE_TAX_PLAN P2: when the supplier's GSTIN is present and + * well-formed, its first two digits ARE the state code - a fact the + * invoice itself certifies - so the intra/inter decision stops + * depending on a hand-typed state name. The name comparison stays as + * the fallback for suppliers recorded without a GSTIN. + */ + let interState = data.supplier_state !== currentBranchState; + const gstin = String(data.supplier_gst_number || '').trim(); + if (/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin)) { + const supplierCode = gstin.slice(0, 2); + const states = require('../json/gst_state_code.json').gststate || []; + const branchRow = states.find( + (s) => String(s.value).toLowerCase() === String(currentBranchState).toLowerCase() + ); + if (branchRow) interState = supplierCode !== branchRow.id; + } + if (interState) { igstValue = parseFloat(item.gst || 0); } else { csgstValue = parseFloat(item.gst || 0) / 2; @@ -1943,6 +2236,63 @@ receivingSchema.statics.receivingInsertUpdate = async function (data, id) { updateData.source_po_id = new ObjectId(String(data.source_po_id)); } + /* + * PURCHASE_TAX_PLAN P2: the shop's tax decisions on this purchase. + * Presence-gated like source_po_id - the PO-receive door and older + * clients that do not send them change nothing, and an edit that omits + * them keeps what is stored. + * + * itc_eligible: whether this purchase's tax counts as input credit + * (composition suppliers and blocked credits switch it off). Default at + * READ time is true, so absent means eligible - the common case. + * + * invoice_total_declared: the total the SUPPLIER'S invoice states, + * including tax. Compared against what the lines add up to; a mismatch + * is recorded and shown, never blocked - the goods are already in the + * shop, and a visible flag beats a save that refuses (owner's call). + */ + /* The order's promised delivery date - the purchase-order module's one + field worth keeping when the two doors became one. Presence-gated. */ + if (data.expected_date !== undefined) { + const expected = String(data.expected_date || '').trim(); + updateData.expected_date = expected ? new Date(expected) : null; + } + if (data.itc_eligible !== undefined) { + updateData.itc_eligible = + data.itc_eligible === true || data.itc_eligible === 'true' || data.itc_eligible === 'on'; + } + /* Additional charges (freight, loading ...) ride on the purchase + TOTAL - never on item cost, never on the tax heads, so Tax Payable + and per-line costing stay untouched. Presence-gated like the rest: + a door that does not send them changes nothing. */ + let chargesTotal = 0; + if (data.additional_charges !== undefined) { + const cleaned = (Array.isArray(data.additional_charges) ? data.additional_charges : []) + .map((c) => ({ + label: String((c && c.label) || '').trim(), + amount: Math.round((parseFloat(c && c.amount) || 0) * 100) / 100, + })) + .filter((c) => c.amount > 0 || c.label); + chargesTotal = cleaned.reduce((sum, c) => sum + c.amount, 0); + updateData.additional_charges = cleaned; + updateData.additional_charges_total = Math.round(chargesTotal * 100) / 100; + updateData.total_amount = parseFloat((receivingTotalAmount + chargesTotal).toFixed(2)); + } + if (data.invoice_total_declared !== undefined && String(data.invoice_total_declared) !== '') { + const declared = parseFloat(data.invoice_total_declared); + if (!Number.isNaN(declared)) { + /* The supplier's printed total includes the charges. */ + const computedGrand = + parseFloat(receivingTotalAmount) + + chargesTotal + + ((data.exclusive_tax || '').trim() === 'on' + ? Math.round(receivingTaxAmount * 100) / 100 + : 0); + updateData.invoice_total_declared = Math.round(declared * 100) / 100; + updateData.invoice_total_mismatch = Math.abs(declared - computedGrand) > 0.5; + } + } + if (!id) { // INSERT new receiving const receivingData = { ...insertData, ...updateData }; @@ -2104,6 +2454,16 @@ receivingSchema.statics.receivingInsertUpdate = async function (data, id) { }; } + /* A voided purchase is a closed book: its stock reversal and audit + line are already written, and an edit would contradict both. */ + if (existingReceiving.receiving_status === 'Cancelled') { + return { + status: false, + data: null, + message: 'This purchase was voided - it can no longer be edited', + }; + } + // Preserve original date unless explicitly provided in update if (!data.date && existingReceiving.date) { updateData.date = existingReceiving.date; @@ -2125,71 +2485,127 @@ receivingSchema.statics.receivingInsertUpdate = async function (data, id) { itemCount: items.length, }); - // Update item quantities and create stock logs if status is 'Received' (PHP line 439-443) - if (data.status === 'Received') { - for (const itemUpdate of items) { - const itemId = new ObjectId(itemUpdate.item_id); - const itemQuantity = parseFloat(itemUpdate.item_quantity || 0); - const itemName = itemUpdate.item_name || ''; - + /* + * Stock moves on TRANSITIONS, never on saves. The legacy path (a + * faithful port of the PHP) re-added every line's full quantity each + * time a Received purchase was saved - invisible for years only + * because the edit form's status radio targeted ids that never + * existed and posted every edit back as Open. The ledger now reads + * the PREVIOUS state from the stored document: + * + * Ordered -> Received : count the received quantities in + * Received -> Received : apply per-line DELTAS (edited quantities, + * added lines, removed lines) + * Received -> Ordered : give back everything previously counted + * + * Every movement leaves one stocklog whose count carries its sign, + * so the stock register reads as the item's true history. + */ + const prevStatus = existingReceiving.receiving_status; + const nextStatus = (data.status || '').trim(); + /* What a line has actually put INTO stock: its cumulative + qty_received when the partial machinery has stamped one, else the + full quantity on a fully Received document, else nothing. */ + const receivedOf = (line, docStatus) => { + if (line.qty_received !== undefined && line.qty_received !== null) { + return parseFloat(line.qty_received) || 0; + } + return docStatus === 'Received' ? parseFloat(line.item_quantity) || 0 : 0; + }; + const countedBefore = {}; + for (const line of existingReceiving.items || []) { + if (!line.item_id) continue; + const key = String(line.item_id); + countedBefore[key] = (countedBefore[key] || 0) + receivedOf(line, prevStatus); + } + const countedAfter = {}; + const lineNames = {}; + if (nextStatus === 'Received') { + for (const line of items) { + if (!line.item_id) continue; + const key = String(line.item_id); + countedAfter[key] = (countedAfter[key] || 0) + (parseFloat(line.item_quantity) || 0); + if (line.item_name) lineNames[key] = line.item_name; + } + } else if (nextStatus === 'Partial') { + /* Editing a partially received document: what already arrived is a + FACT and rides along - matched per item, capped at the edited + ordered quantity. The edit cannot receive or un-receive; only + the Receive flow moves goods. */ + for (const line of items) { + if (!line.item_id) continue; + const key = String(line.item_id); + const already = countedBefore[key] || 0; + const ordered = parseFloat(line.item_quantity) || 0; + countedAfter[key] = Math.min(already, (countedAfter[key] || 0) + ordered); + if (line.item_name) lineNames[key] = line.item_name; + } + } + /* Stamp the received quantities onto the stored lines and settle the + document's status from them. */ + { + const pool = { ...countedAfter }; + let anyReceived = false; + let allFull = true; + for (const line of updateData.items || []) { + const key = String(line.item_id || ''); + const ordered = parseFloat(line.item_quantity) || 0; + const take = Math.min(ordered, pool[key] || 0); + pool[key] = (pool[key] || 0) - take; + line.qty_received = Math.round(take * 1000) / 1000; + if (take > 0) anyReceived = true; + if (take < ordered) allFull = false; + } + if (nextStatus === 'Partial') { + updateData.receiving_status = allFull ? 'Received' : anyReceived ? 'Partial' : 'Open'; + } + } + const touchedIds = new Set([...Object.keys(countedBefore), ...Object.keys(countedAfter)]); + if (touchedIds.size > 0) { + const stockLogsCollection = await baseModel.getCollection('stocklogs'); + for (const key of touchedIds) { + const delta = (countedAfter[key] || 0) - (countedBefore[key] || 0); + if (!delta || !ObjectId.isValid(key)) continue; const itemDoc = await itemsCollection.findOne({ - _id: itemId, + _id: new ObjectId(key), license: new ObjectId(license), }); - - console.log('[RECEIVING UPDATE DEBUG] Item check:', { - item_id: itemUpdate.item_id, - track_inventory: itemDoc?.track_inventory, - track_inventory_type: typeof itemDoc?.track_inventory, - }); - - // PHP checks: $documents['track_inventory'] === true (boolean or string 'true') - if (itemDoc && (itemDoc.track_inventory === true || itemDoc.track_inventory === 'true')) { - const availableQty = parseFloat(itemDoc.available_quantity || 0); - const newQty = itemQuantity + availableQty; - - // Create stock log for EDIT Receiving (PHP line 439-441) - // Only if stock_management is enabled - if (stockManagement) { - console.log( - '[RECEIVING UPDATE DEBUG] Creating stock log for item:', - itemUpdate.item_id - ); - - const stockLogData = { - stocklog: stockLogStatus, - branch_id: new ObjectId(currentBranch), - view_item_id: itemId, - item_barcode_id: itemDoc.barcode_id || '', - item_name: itemName || itemDoc.name || '', - item_quantity: itemQuantity, - process: 'Edit Receiving', - reference: data.alternative_id || '', - opening_balance: availableQty, - closing_balance: newQty, - count: itemQuantity, - date: receivingDate, - action: 'Add', - changed_by_userid: new ObjectId(loggedUser), - changed_by: loggedUserName, - license: new ObjectId(license), - created_date: receivingDate, - updated_date: receivingDate, - }; - - const stockLogsCollection = await baseModel.getCollection('stocklogs'); - await stockLogsCollection.insertOne(stockLogData); - console.log('[RECEIVING UPDATE] Stock log created successfully'); - } else { - console.log('[RECEIVING UPDATE DEBUG] Stock management disabled, skipping stock log'); - } - - // Update item quantity (PHP line 442) - await itemsCollection.updateOne( - { _id: itemId, license: new ObjectId(license) }, - { $set: { available_quantity: newQty } } - ); + if ( + !itemDoc || + !(itemDoc.track_inventory === true || itemDoc.track_inventory === 'true') + ) { + continue; } + const currentQty = parseFloat(itemDoc.available_quantity || 0); + const newQty = currentQty + delta; + if (stockManagement) { + await stockLogsCollection.insertOne({ + stocklog: stockLogStatus, + branch_id: new ObjectId(currentBranch), + view_item_id: itemDoc._id, + item_barcode_id: itemDoc.barcode_id || '', + item_name: lineNames[key] || itemDoc.name || '', + item_quantity: Math.abs(delta), + process: 'Edit Receiving', + reference: data.alternative_id || existingReceiving.receiving_id || '', + opening_balance: currentQty, + closing_balance: newQty, + count: delta, + date: receivingDate, + action: delta > 0 ? 'Add' : 'Deduct', + changed_by_userid: new ObjectId(loggedUser), + changed_by: loggedUserName, + license: new ObjectId(license), + created_date: receivingDate, + updated_date: receivingDate, + }); + } + // Quantities move even with stock_management off (the insert path + // has always behaved this way); only the LOG is optional. + await itemsCollection.updateOne( + { _id: itemDoc._id, license: new ObjectId(license) }, + { $set: { available_quantity: newQty } } + ); } } @@ -2676,6 +3092,7 @@ Receiving.receivingReportPage = receivingSchema.statics.receivingReportPage; Receiving.receivingsGraphicalReports = receivingSchema.statics.receivingsGraphicalReports; Receiving.getReceivingOrder = receivingSchema.statics.getReceivingOrder; Receiving.receivingInsertUpdate = receivingSchema.statics.receivingInsertUpdate; +Receiving.receivePartial = receivingSchema.statics.receivePartial; const { returnReceivingOrder } = require('./receiving-return.model'); Receiving.returnReceivingOrder = returnReceivingOrder; Receiving.deleteReceivingCollectionData = receivingSchema.statics.deleteReceivingCollectionData; diff --git a/api/src/models/sale.model.js b/api/src/models/sale.model.js index 5b347d86b..4d4ad7d15 100644 --- a/api/src/models/sale.model.js +++ b/api/src/models/sale.model.js @@ -116,7 +116,15 @@ const saleItemSchema = new mongoose.Schema( item_available_quantity: { type: Number, default: 0, - min: 0, + /* + * No `min`. This is a SNAPSHOT of the item's stock at sale time, not + * a quantity being sold - and stock legitimately goes negative on + * items whose `negative_stock` flag allows overselling. With min: 0 + * the whole sale was refused ("Path `item_available_quantity` (-1) is + * less than minimum allowed value (0)"), so one item already in the + * red blocked the cashier from taking money for the entire basket. + * A record of what stock WAS must never be able to fail validation. + */ }, item_id: { type: String, @@ -592,6 +600,23 @@ const saleSchema = new mongoose.Schema( trim: true, }, + // Permanent public invoice link: the S3 key IS the secret. One key for + // the sale's lifetime - createInvoiceLink answers the stored key on + // every later call. Without this schema entry the strict schema silently + // strips the $set and every share mints a fresh PDF. + invoice_key: { + type: String, + trim: true, + }, + + // The invoice this sale was recorded from (INVOICING_MODULE_DESIGN). + // Same lesson as invoice_key: the schema is strict, so an undeclared + // field is stripped without a word and the invoice never learns it was + // paid. Declared here, pinned by test, mirrored by services/invoice-sync. + source_invoice_id: { + type: mongoose.Schema.Types.ObjectId, + }, + // For strict PHP parity we do not require subtotal/total on the document; // they may be omitted entirely when saving via the legacy sales service. subtotal: { @@ -878,6 +903,14 @@ saleSchema.pre('save', async function () { // Validate that the original sale quantities are still available in stock // for the given sale id. Mirrors Api/src/model/sales_model.php::getSaleQtyDetailModel. // Static method for exporting sales data (ported from PHP exportSalesOrder) +/* + * Sales History's whitelisted sorts (bill total, item count, business date) + * each walk one of these; created_date already rides the default listing. + */ +saleSchema.index({ license: 1, branch_id: 1, sales_total: -1 }); +saleSchema.index({ license: 1, branch_id: 1, number_of_items: -1 }); +saleSchema.index({ license: 1, branch_id: 1, date: -1 }); + saleSchema.index( { license: 1, billing_transaction_id: 1 }, { @@ -950,6 +983,11 @@ class LegacySaleModel { sale_extra_discount: { type: 'Number', select: true }, extra_discount: { type: 'Number', select: true }, tip_amount: { type: 'Number', select: true }, + tip_in_total: { type: 'Boolean', select: true }, + source_quote_id: { type: 'ObjectId', select: true }, + quote_price_honoured: { type: 'Boolean', select: true }, + source_invoice_id: { type: 'ObjectId', select: true }, + charges: { type: 'Mixed', select: true }, discount_description: { type: 'String', select: true }, return_extra_discount: { type: 'Number', select: true }, extra_discount_type: { type: 'String', select: true }, @@ -1042,7 +1080,7 @@ Sale.LegacySaleModel = LegacySaleModel; * @param {Object} input - { email, data: { product_details, payment_details, tax_details, branch_details, dine_details, table_summary, extra_discount } } * @returns {Promise} */ -Sale.sendDailySalesMail = async function (input) { +Sale.sendDailySalesMail = async function (input, shopTransport = null) { try { const { BrevoClient } = require('@getbrevo/brevo'); const config = require('../config'); @@ -1199,6 +1237,36 @@ Sale.sendDailySalesMail = async function (input) { const apiKey = config.sendinblue_key || process.env.SENDINBLUE_KEY || process.env.BREVO_API_KEY || ''; + /* Owner rule: a shop that configured its own SMTP sends through it - + before Brevo, before the platform chain. */ + if (shopTransport && shopTransport.shopOwned) { + const subject = ((sales_type ? sales_type + ' ' : '') + 'sales report').trim(); + /* The layout every mail wears now - a report over shop SMTP was the + last plain-text straggler. */ + const { brandFor, renderEmail, kvBlock } = require('../utils/email-layout'); + const brand = brandFor(shopTransport.branch || { branch_name: branch.branch_name }); + await shopTransport.transporter.sendMail({ + from: `${branch.branch_name || 'Posnic POS'} <${shopTransport.from}>`, + to: input.email, + subject, + html: renderEmail({ + brand, + title: subject.charAt(0).toUpperCase() + subject.slice(1), + preheader: `Sales from ${from_date || '-'} to ${to_date || '-'}`, + bodyHtml: kvBlock([ + ['Period', `${from_date || '-'} to ${to_date || '-'}`], + ['Total quantity', qty_total], + ['Subtotal', `${currency} ${price_total.toFixed(2)}`], + ['Grand total', `${currency} ${amount_total.toFixed(2)}`], + ['Profit', `${currency} ${profit_total.toFixed(2)}`], + ['Tax total', `${currency} ${tax_total.toFixed(2)}`], + ['Tender total', `${currency} ${tender_total.toFixed(2)}`], + ]), + }), + }); + return { status: true, data: { sent: true }, message: 'Mail sent successfully' }; + } + if (apiKey) { // Preferred path: use Brevo transactional template const client = new BrevoClient({ apiKey }); @@ -1767,7 +1835,7 @@ Sale.getQrStatusModel = async function (id) { /** * PHP: kioskOrderModel($data) - * Process a kiosk order — calculate item totals, generate sales_id, + * Process a kiosk order - calculate item totals, generate sales_id, * find/create customer, insert sale document, return receipt data. * Ported from Api/src/model/sales_model.php lines 8297-8764. */ diff --git a/api/src/models/setting.model.js b/api/src/models/setting.model.js index e23ed00b2..0f9c21c18 100644 --- a/api/src/models/setting.model.js +++ b/api/src/models/setting.model.js @@ -3,6 +3,9 @@ const BaseModel = require('./base.model'); const { ObjectId } = require('mongodb'); const fs = require('fs'); const path = require('path'); +const { secretUpdate } = require('../services/settings-groups'); +const { recordAudit } = require('../utils/audit-trail'); +const { publicPageUrl } = require('../utils/public-url'); class SettingModel extends BaseModel { constructor() { @@ -28,10 +31,14 @@ class SettingModel extends BaseModel { this.cachedFallbackTax = null; } - setContext({ branchId = null, licenseId = null, user = null } = {}) { + setContext({ branchId = null, licenseId = null, user = null, ip = null, userAgent = null } = {}) { this.branchId = branchId; this.licenseId = licenseId; this.user = user; + /* Where the request came from. Only the audit trail uses these, and only + for the handful of events where "from where" is the whole question. */ + this.ip = ip; + this.userAgent = userAgent; this.branchName = null; // Will be loaded lazily when needed } @@ -125,15 +132,65 @@ class SettingModel extends BaseModel { * Get default customer details by ID */ async getDefaultCustomer(customerId) { - if (!customerId) { - return { - status: false, - data: null, - message: 'Customer ID is required', - }; - } - try { + /* + * Self-heal (owner ask): a branch without a configured default + * customer sells to Walk-In. Asked with no id, find - or create - + * this branch's Walk-in and repoint the branch doc, instead of + * failing every till on that branch with "Customer ID is required". + */ + if (!customerId && this.branchId) { + const customersHeal = await this.getCollection('customers'); + const healLicense = this.licenseId ? { license: this.normalizeId(this.licenseId) } : {}; + const branchIdNorm = this.normalizeId(this.branchId); + let walkin = await customersHeal.findOne({ + branch_id: branchIdNorm, + name: { $regex: /walk[- ]?in/i }, + ...healLicense, + }); + if (!walkin) { + const now = new Date(); + const seed = { + branch_id: branchIdNorm, + name: 'Walk-in Customer', + date: now, + phone: '', + // No email key: customers carries a unique sparse index on it. + address: '', + sortname: '', + country: '', + state: '', + city: '', + gst: 'disable', + gst_number: '', + gst_type: 'consumer', + created_date: now, + updated_date: now, + }; + if (this.licenseId) seed.license = this.normalizeId(this.licenseId); + const ins = await customersHeal.insertOne(seed); + walkin = { ...seed, _id: ins.insertedId }; + } + try { + const branches = await this.getCollection('branch'); + await branches.updateOne( + { _id: branchIdNorm }, + { $set: { default_customer: walkin._id } } + ); + } catch (e) { + /* pointer update is best-effort - the lookup below still answers */ + } + customerId = walkin._id; + } + + if (!customerId) { + return { + status: false, + data: null, + message: 'Customer ID is required', + }; + } + const customersCollection = await this.getCollection('customers'); const licenseFilter = this.licenseId ? { license: this.normalizeId(this.licenseId) } : {}; @@ -188,6 +245,58 @@ class SettingModel extends BaseModel { * Get default supplier details by ID */ async getDefaultSupplier(supplierId) { + /* + * Heal like the customer: with a branch context a missing id resolves + * to the branch's General Supplier - found or created, branch doc + * repointed. Contextless still refuses below. + */ + if (!supplierId && this.branchId) { + try { + const suppliers = await this.getCollection('suppliers'); + const healLicense = this.licenseId ? { license: this.normalizeId(this.licenseId) } : {}; + const branchIdNorm = this.normalizeId(this.branchId); + let general = await suppliers.findOne({ + branch_id: branchIdNorm, + name: { $regex: /general supplier/i }, + ...healLicense, + }); + if (!general) { + const now = new Date(); + const seed = { + branch_id: branchIdNorm, + name: 'General Supplier', + email: `anonymous-supplier-${branchIdNorm}@posnic.local`, + phone: '', + address: '', + sortname: '', + country: '', + state: '', + city: '', + gst: 'disable', + gst_number: '', + gst_type: 'consumer', + created_date: now, + updated_date: now, + }; + if (this.licenseId) seed.license = this.normalizeId(this.licenseId); + const ins = await suppliers.insertOne(seed); + general = { ...seed, _id: ins.insertedId }; + } + try { + const branches = await this.getCollection('branch'); + await branches.updateOne( + { _id: branchIdNorm }, + { $set: { default_supplier: general._id } } + ); + } catch (e) { + /* best-effort pointer */ + } + supplierId = general._id; + } catch (e) { + /* fall through to the plain refusal below */ + } + } + if (!supplierId) { return { status: false, @@ -244,13 +353,25 @@ class SettingModel extends BaseModel { } async getDefaultCustomerSupplier(customerId, supplierId) { - if (!customerId || !supplierId) { + /* + * Branch switch lands here with whatever the previous branch left in + * localStorage - possibly nothing. With a branch context BOTH defaults + * self-heal: the customer through the Walk-in heal, the supplier below. + */ + if (!customerId && this.branchId) { + const healed = await this.getDefaultCustomer(''); + if (healed.status && healed.data && healed.data.customer_id) { + customerId = healed.data.customer_id; + } + } + if ((!customerId || !supplierId) && !this.branchId) { return { status: false, data: null, message: 'Customer and supplier ids are required', }; } + // (supplier may be empty - with a branch context it self-heals below) try { const [customersCollection, suppliersCollection] = await Promise.all([ @@ -258,6 +379,54 @@ class SettingModel extends BaseModel { this.getCollection('suppliers'), ]); + /* + * Self-heal on branch switch (owner report): a branch without a + * default supplier gets its General Supplier found or created and + * the branch doc repointed - same medicine as the Walk-in customer. + */ + if (!supplierId && this.branchId) { + const healLicense = this.licenseId ? { license: this.normalizeId(this.licenseId) } : {}; + const branchIdNorm = this.normalizeId(this.branchId); + let general = await suppliersCollection.findOne({ + branch_id: branchIdNorm, + name: { $regex: /general supplier/i }, + ...healLicense, + }); + if (!general) { + const now = new Date(); + const seed = { + branch_id: branchIdNorm, + name: 'General Supplier', + // suppliers carries a unique index on email - unique per branch. + email: `anonymous-supplier-${branchIdNorm}@posnic.local`, + phone: '', + address: '', + sortname: '', + country: '', + state: '', + city: '', + gst: 'disable', + gst_number: '', + gst_type: 'consumer', + created_date: now, + updated_date: now, + }; + if (this.licenseId) seed.license = this.normalizeId(this.licenseId); + const ins = await suppliersCollection.insertOne(seed); + general = { ...seed, _id: ins.insertedId }; + } + try { + const branches = await this.getCollection('branch'); + await branches.updateOne( + { _id: branchIdNorm }, + { $set: { default_supplier: general._id } } + ); + } catch (e) { + /* pointer update is best-effort */ + } + supplierId = general._id; + } + const licenseFilter = this.licenseId ? { license: this.normalizeId(this.licenseId) } : {}; const [customer, supplier] = await Promise.all([ @@ -621,7 +790,10 @@ class SettingModel extends BaseModel { const module_recyclebin_enable = offOnly(data.module_recyclebin_enable); const module_themes_enable = offOnly(data.module_themes_enable); const module_cashbook_enable = offOnly(data.module_cashbook_enable); + const module_demo_data_enable = offOnly(data.module_demo_data_enable); const quick_sale_enable = offOnly(data.quick_sale_enable); + const quotes_enable = offOnly(data.quotes_enable); + const invoices_enable = offOnly(data.invoices_enable); /* * Two different forms save through here now: the Module On/Off tab @@ -769,7 +941,16 @@ class SettingModel extends BaseModel { module_recyclebin_enable: module_recyclebin_enable, module_themes_enable: module_themes_enable, module_cashbook_enable: module_cashbook_enable, + module_demo_data_enable: module_demo_data_enable, quick_sale_enable: quick_sale_enable, + quotes_enable: quotes_enable, + invoices_enable: invoices_enable, + ...(data.custom_charges_enable !== undefined + ? { + custom_charges_enable: + data.custom_charges_enable === true || data.custom_charges_enable === 'true', + } + : {}), }; // Update branch_name across all collections - for the branch actually @@ -801,6 +982,108 @@ class SettingModel extends BaseModel { } } + async updateStarterLocale(data = {}) { + try { + if (!this.branchId || !this.licenseId) { + throw new Error('Branch context is required'); + } + + const collection = await this.getCollection(); + const text = (value) => (value === undefined || value === null ? '' : String(value).trim()); + const currencySymbol = text(data.currencyText || data.currency_type) || '₹'; + const currencyName = text(data.currencyTextname || data.currency_setting) || currencySymbol; + const currencyValue = [ + { + currency_text: currencyName, + currency_sign: currencySymbol, + }, + ]; + + let sortname = ''; + try { + const countriesJsonPath = path.join( + __dirname, + '..', + '..', + '..', + 'api', + 'src', + 'json', + 'countries.json' + ); + const countriesData = JSON.parse(fs.readFileSync(countriesJsonPath, 'utf8')); + const countryMatch = countriesData.countries?.find( + (c) => c.value === text(data.setting_country) + ); + if (countryMatch?.sortname) { + sortname = countryMatch.sortname; + } + } catch (err) { + console.warn( + 'Could not load countries.json for starter locale sortname lookup:', + err.message + ); + } + + let cleanTimezone = text(data.time_zone) || 'Asia/Kolkata'; + const gmtOffsetMatch = cleanTimezone.match(/^([^(]+)\s*\(GMT[^)]+\)$/); + if (gmtOffsetMatch) { + cleanTimezone = gmtOffsetMatch[1].trim(); + } + + const updateData = { + country: text(data.setting_country), + country_id: text(data.country_id), + state: text(data.setting_state), + sortname, + currency: currencySymbol, + currency_text: text(data.currency_setting) || currencyName, + currency_type: currencySymbol, + currency_value: currencyValue, + time_zone: cleanTimezone, + client_dateformat: text(data.storedate) || 'dd/mm/yyyy', + server_dateformat: text(data.serverdate) || 'd/m/Y', + dateformat_text: text(data.dateText) || '01/01/2018 - dd/mm/yyyy', + }; + + const filter = { + _id: this.normalizeId(this.branchId), + license: this.normalizeId(this.licenseId), + }; + const result = await collection.updateOne(filter, { $set: updateData }); + if (result.matchedCount === 0) { + return { + status: false, + data: null, + message: 'Branch not found or license mismatch', + }; + } + + return { + status: true, + data: { + country: updateData.country, + state: updateData.state, + country_id: updateData.country_id, + currency_text: updateData.currency_text, + currency_type: updateData.currency_type, + time_zone: updateData.time_zone, + clientdate: updateData.client_dateformat, + serverdate: updateData.server_dateformat, + dateformat_text: updateData.dateformat_text, + }, + message: 'success', + }; + } catch (error) { + console.error('Error in updateStarterLocale:', error); + return { + status: false, + data: null, + message: error.message, + }; + } + } + toBoolean(value) { if (typeof value === 'boolean') return value; if (typeof value === 'string') { @@ -861,27 +1144,33 @@ class SettingModel extends BaseModel { // Update user printing_design using positional operator (matches PHP logic line 330-339) // Wrapped in try-catch: if user doesn't have printing_design for this branch, continue anyway - try { - await usersCollection.updateOne( - { - _id: this.normalizeId(this.user._id), - 'printing_design.branch_id': this.normalizeId(this.branchId), - }, - { - $set: { - 'printing_design.$.printing_design': data.print_type, - 'printing_design.$.printing_max_char': data.print_character, - 'printing_design.$.printing_size': data.print_size, - // Paper width in millimetres. Added alongside the others rather - // than replacing any: printing_size is the font size, this is - // the roll the receipt has to fit on. - 'printing_design.$.print_width': data.print_width, + // Skipped entirely for partial payloads that carry no printing fields. + if (data.print_type !== undefined) { + try { + await usersCollection.updateOne( + { + _id: this.normalizeId(this.user._id), + 'printing_design.branch_id': this.normalizeId(this.branchId), }, - } - ); - } catch (userPrintError) { - // Non-blocking: continue even if user printing update fails - console.warn('User printing_design update failed (non-critical):', userPrintError.message); + { + $set: { + 'printing_design.$.printing_design': data.print_type, + 'printing_design.$.printing_max_char': data.print_character, + 'printing_design.$.printing_size': data.print_size, + // Paper width in millimetres. Added alongside the others rather + // than replacing any: printing_size is the font size, this is + // the roll the receipt has to fit on. + 'printing_design.$.print_width': data.print_width, + }, + } + ); + } catch (userPrintError) { + // Non-blocking: continue even if user printing update fails + console.warn( + 'User printing_design update failed (non-critical):', + userPrintError.message + ); + } } // Default values - matches PHP lines 342-351 @@ -935,6 +1224,8 @@ class SettingModel extends BaseModel { const supplierCheckbox = this.toBoolean(data.supplier_checkbox); const taxCheckbox = this.toBoolean(data.tax_checkbox); const saleInlineEditor = this.toBoolean(data.sale_inline_editor); + // the double-click editor's own switch (replaces the old inline pencils) + const saleQuickEdit = data.sale_quick_edit_enable; const enableMultiPayment = this.toBoolean(data.enable_multi_payment); const tableOptions = this.toBoolean(data.table_options); const roundOff = this.toBoolean(data.roundOff); @@ -949,6 +1240,58 @@ class SettingModel extends BaseModel { discount_percentage: parseFloat(data.discount_percentage), discount_amount: parseFloat(data.discount_amount), sales_prefix: data.sales_prefix, + // Shop's own outgoing mail (owner rule: theirs first, ours as the + // cloud fallback). Password stored as given - it must be usable. + ...(data.email_smtp_host !== undefined + ? { email_smtp_host: String(data.email_smtp_host || '').trim() } + : {}), + ...(data.email_smtp_port !== undefined + ? { email_smtp_port: String(data.email_smtp_port || '').trim() } + : {}), + ...(data.email_smtp_secure !== undefined + ? { email_smtp_secure: String(data.email_smtp_secure) === 'true' } + : {}), + ...(data.email_smtp_username !== undefined + ? { email_smtp_username: String(data.email_smtp_username || '').trim() } + : {}), + /* S4: the password is never sent to the browser any more, so the form + loads with this field empty. Empty therefore means "keep the saved + one" - writing it through would blank the shop's mail the first + time anyone saved an unrelated setting. */ + ...secretUpdate('email_smtp_password', data.email_smtp_password), + ...(data.email_smtp_from !== undefined + ? { email_smtp_from: String(data.email_smtp_from || '').trim() } + : {}), + // Quotation defaults: prefilled into every NEW quote server-side, + // still editable per quote on its preview. Presence-gated so older + // tills that do not send them cannot wipe them. + ...(data.quote_default_payment_method !== undefined + ? { + quote_default_payment_method: String(data.quote_default_payment_method || '') + .trim() + .slice(0, 60), + } + : {}), + ...(data.quote_default_bank_details !== undefined + ? { + quote_default_bank_details: String(data.quote_default_bank_details || '') + .trim() + .slice(0, 500), + } + : {}), + ...(data.quote_default_terms !== undefined + ? { + quote_default_terms: String(data.quote_default_terms || '') + .trim() + .slice(0, 1500), + } + : {}), + ...(data.quote_default_signature !== undefined + ? { + // a small data-URL image; empty string removes it + quote_default_signature: String(data.quote_default_signature || '').slice(0, 400000), + } + : {}), indian_gst: data.indian_gst, receiving_prefix: data.receiving_prefix, branch_gstin_number: data.branch_gstin_number || '', @@ -982,6 +1325,9 @@ class SettingModel extends BaseModel { header_print: data.header_print, footer_print: data.footer_print, sale_inline_editor: saleInlineEditor, + ...(saleQuickEdit !== undefined + ? { sale_quick_edit_enable: String(saleQuickEdit) === 'true' } + : {}), enable_multi_payment: enableMultiPayment, table_options: tableOptions, hardware_weight_machine_enable: hardwareWeightMachineEnable, @@ -1020,7 +1366,17 @@ class SettingModel extends BaseModel { module_recyclebin_enable: offOnly, module_themes_enable: offOnly, module_cashbook_enable: offOnly, + module_demo_data_enable: offOnly, quick_sale_enable: offOnly, + /* Not a module: a record that the welcome has been shown. onOnly, + because absent must mean "not yet welcomed" - the opposite + default would mean nobody is ever welcomed and nothing looks + wrong. Presence-gated by the loop below, so a settings save + that does not mention it cannot set it either way. */ + first_run_done: onOnly, + first_run_decided: onOnly, + quotes_enable: offOnly, + invoices_enable: offOnly, pl_include_cashbook: offOnly, }; for (const [key, parse] of Object.entries(TOGGLES)) { @@ -1034,6 +1390,67 @@ class SettingModel extends BaseModel { updateFields.till_lock_idle_minutes = Math.min(idle, 120); } + /* + * Partial-save safety: this endpoint also takes small PATCH-style + * payloads (the quotation defaults card, the signature upload from the + * quote page). Any $set field whose SOURCE key was not sent is dropped + * here, so a partial payload can never wipe the rest of the shop's + * settings with undefined/false derived from absent controls. Full + * settings-form saves send every key, so they are unaffected. + */ + const SOURCE_OF = { + default_customer: 'default_customer', + default_supplier: 'default_supplier', + default_tax: 'default_tax', + notification_range: 'notification_value', + discount_percentage: 'discount_percentage', + discount_amount: 'discount_amount', + sales_prefix: 'sales_prefix', + indian_gst: 'indian_gst', + receiving_prefix: 'receiving_prefix', + branch_gstin_number: 'branch_gstin_number', + roundOff: 'roundOff', + receipt_barcode: 'receipt_barcode', + stock_management: 'stock_management', + stock_management_log: 'stock_log_management', + printall: 'printall', + sales_mail: 'sales_mail', + customer_print: 'customer_print', + print_url: 'print_url', + print_logoimg: 'print_logoimg', + print_sale_notes: 'print_sale_notes', + sales_sms: 'sales_sms', + auto_sms: 'auto_sms', + enable_sms_reminders: 'enable_sms_reminders', + enable_sms_auto_send: 'enable_sms_auto_send', + sms_auto_send_time: 'enable_sms_auto_send', + sms_retry_period: 'enable_sms_auto_send', + sms_max_retries: 'enable_sms_auto_send', + keyboard_view: 'keyboard_view', + whatsapp_receipt: 'whatsapp_receipt', + balance_view: 'balance_view', + customer_checkbox: 'customer_checkbox', + supplier_checkbox: 'supplier_checkbox', + tax_checkbox: 'tax_checkbox', + print_type: 'print_type', + printing_size: 'print_size', + print_width: 'print_width', + print_character: 'print_character', + header_print: 'header_print', + footer_print: 'footer_print', + sale_inline_editor: 'sale_inline_editor', + enable_multi_payment: 'enable_multi_payment', + table_options: 'table_options', + hardware_weight_machine_enable: 'hardware_weight_machine_enable', + enable_notification_reminders: 'enable_notification_reminders', + enable_email_reminders: 'enable_email_reminders', + }; + for (const [field, src] of Object.entries(SOURCE_OF)) { + if (data[src] === undefined && field in updateFields) { + delete updateFields[field]; + } + } + // Update branch collection (matches PHP $set logic line 389-434) await branchCollection.updateOne( { _id: this.normalizeId(this.branchId), license: this.normalizeId(this.licenseId) }, @@ -1089,7 +1506,11 @@ class SettingModel extends BaseModel { module_recyclebin_enable: { parse: offOnly, dflt: true }, module_themes_enable: { parse: offOnly, dflt: true }, module_cashbook_enable: { parse: offOnly, dflt: true }, + module_demo_data_enable: { parse: offOnly, dflt: true }, quick_sale_enable: { parse: offOnly, dflt: true }, + quotes_enable: { parse: offOnly, dflt: true }, + invoices_enable: { parse: offOnly, dflt: true }, + custom_charges_enable: { parse: (v) => v === true || v === 'true', dflt: false }, pl_include_cashbook: { parse: offOnly, dflt: true }, }; } @@ -1814,6 +2235,19 @@ class SettingModel extends BaseModel { } if (!oldPasswordValid) { + /* Recorded too. Somebody repeatedly failing to change a password is + either a person who has forgotten it or somebody working through a + list, and the address is what tells those apart. */ + await recordAudit(await this.getDB().catch(() => null), { + event: 'password_change_failed', + actor: { id: String(user._id), name: user.email || user.username || '' }, + target: { id: String(user._id), name: user.email || user.username || '', type: 'user' }, + ip: this.ip, + userAgent: this.userAgent, + branchId: this.branchId, + license: this.licenseId, + extra: { reason: 'current password did not match' }, + }); return { status: false, message: 'Current password is incorrect' }; } @@ -1841,6 +2275,26 @@ class SettingModel extends BaseModel { return { status: false, message: 'Failed to update password' }; } + /* + * The event that could not be answered. + * + * When a shop was locked out, the only evidence a password had changed + * was a timestamp on the user document - no actor, no address, nothing + * to tell the owner whether it was them last Tuesday or somebody else. + * The password itself is never written here; recordAudit redacts any + * field whose name looks like a secret regardless of what is passed. + */ + await recordAudit(await this.getDB().catch(() => null), { + event: 'password_changed', + actor: { id: String(user._id), name: user.email || user.username || '' }, + target: { id: String(user._id), name: user.email || user.username || '', type: 'user' }, + ip: this.ip, + userAgent: this.userAgent, + branchId: this.branchId, + license: this.licenseId, + extra: { method: 'self_service_change_password' }, + }); + return { status: true, data: result.modifiedCount, @@ -2274,15 +2728,17 @@ class SettingModel extends BaseModel { if (type === 'way2sms') { updateData = { ...updateData, - way2sms_api: data.way2sms_api?.trim() || '', + // the userid is an identifier; the key and password are credentials + // and are no longer sent back to the form, so empty means unchanged way2sms_userid: data.way2sms_userid?.trim() || '', - way2sms_password: data.way2sms_password?.trim() || '', + ...secretUpdate('way2sms_api', data.way2sms_api), + ...secretUpdate('way2sms_password', data.way2sms_password), }; } else if (type === 'textlocal') { updateData = { ...updateData, textlocal_sender: data.textlocal_sender?.trim() || '', - textlocal_api: data.textlocal_api?.trim() || '', + ...secretUpdate('textlocal_api', data.textlocal_api), }; } @@ -3040,14 +3496,33 @@ class SettingModel extends BaseModel { }); const taxProfiles = require('../services/tax-profiles'); const { code, profile } = taxProfiles.profileForBranch(branch || {}); + /* The regime rides along so the Tax Configuration page can say which + family the shop lives in without a second request; the shop's own + override (the tax settings group) is applied by resolveRegime. */ + const { resolveRegime } = require('../services/tax-regime'); + let taxGroup = {}; + try { + const SettingsRepository = require('../repositories/settings.repository'); + const r = await new SettingsRepository().resolveGroup('tax', { + licenseId: this.licenseId, + branchId: this.branchId, + }); + if (r && r.status && r.data && r.data.values) taxGroup = r.data.values; + } catch (e) { + /* decisions unavailable -> profile alone answers */ + } + const { regime } = resolveRegime(branch || {}, taxGroup); return { status: true, data: { code, + regime, + country: (branch && branch.country) || '', label: profile.label, registration: profile.registration, components: { mode: profile.components.mode }, display: profile.display, + decisions: taxGroup, }, message: 'success', }; @@ -3515,10 +3990,24 @@ class SettingModel extends BaseModel { } // Forgot Password - async getForgotUserDetails(email) { + /* + * @param {object} [req] the request, used ONLY to work out this shop's own + * public address for the link in the email. + */ + async getForgotUserDetails(email, req = null) { try { const usersCollection = await this.getCollection('users'); - const user = await usersCollection.findOne({ email: email }); + /* + * String(), because this value came from a request body and Mongo reads + * an object as operators. `{"email": {"$ne": null}}` posted to + * forgot-password would otherwise match the FIRST user in the shop and + * send a reset link for somebody else's account. + * + * The controller's regex happens to reject an object today - test() + * stringifies it to "[object Object]" - but that is the caller being + * careful, and this method is what actually touches the database. + */ + const user = await usersCollection.findOne({ email: String(email) }); if (!user) { return { @@ -3533,20 +4022,36 @@ class SettingModel extends BaseModel { const expireDate = new Date(Date.now() + 10 * 60 * 1000); await usersCollection.updateOne({ _id: user._id }, { $set: { expire_date: expireDate } }); - // Build reset link using userkey (matching PHP lines 700-711) - const serverName = process.env.SERVER_NAME || 'localhost'; - const cleanServerName = serverName.replace(/^api\./, ''); - const forgotPasswordId = encodeURIComponent(user.userkey); - - let basePath; - if (cleanServerName.includes('dev.posnic.io')) { - basePath = `http://pro.dev.posnic.io/forgotpassword.html?forgotpassword_Id=${forgotPasswordId}`; - } else if (cleanServerName.includes('localhost')) { - // For local development - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - basePath = `${frontendUrl}/forgotpassword.html?forgotpassword_Id=${forgotPasswordId}`; - } else { - basePath = `https://www.posnic.io/forgotpassword?forgotpassword_Id=${forgotPasswordId}`; + /* + * The link goes to THIS shop, not to a hardcoded address. + * + * This used to read SERVER_NAME, which is set on no process in the + * estate, fall through to the localhost branch, and mail every hosted + * shop a link to http://localhost:3000 - the recipient's own computer, + * on a port with nothing listening. Self-service recovery has therefore + * never once worked for a cloud shop, which is why a locked-out owner + * needed five days and a database session to get back into his till. + * + * publicPageUrl derives the address from the request, but accepts only a + * host on a domain we actually run: a link inside an email we send is + * exactly what password-reset poisoning targets. + */ + const basePath = publicPageUrl(req, 'forgotpassword.html', { + forgotpassword_Id: user.userkey, + }); + + if (!basePath) { + /* Refused rather than sent. A dead link and a poisoned link are both + worse than an error somebody can see and fix. */ + console.error( + '[forgot-password] no trustworthy public address for this shop - ' + + 'set PUBLIC_BASE_URL. No email sent.' + ); + return { + status: false, + data: null, + message: 'Password reset is not configured for this shop. Please contact support.', + }; } // Send email via Brevo (matching PHP lines 714-729) diff --git a/api/src/models/supplier-legacy.model.js b/api/src/models/supplier-legacy.model.js index 75399c28b..f0aa03948 100644 --- a/api/src/models/supplier-legacy.model.js +++ b/api/src/models/supplier-legacy.model.js @@ -18,6 +18,10 @@ class SupplierModel extends BaseModel { _id: { type: 'ObjectId', select: true, name: 'id' }, branch_id: { type: 'ObjectId', select: false }, branch_name: { type: 'String', select: false }, + /* S7 (D5): the account-level relation, same shape as items and customers. + Seeded with the owning branch so nothing moves; sharing a supplier + between shops is a deliberate grant. */ + branch_access: { type: 'Array', select: false }, name: { type: 'String', select: true }, email: { type: 'String', select: true }, phone: { type: 'String', select: true }, diff --git a/api/src/models/user.model.js b/api/src/models/user.model.js index 32df696d8..19dcfa756 100644 --- a/api/src/models/user.model.js +++ b/api/src/models/user.model.js @@ -375,7 +375,25 @@ const userSchema = new mongoose.Schema( preferredLanguage: { type: String, default: 'en', - enum: ['en', 'es', 'fr', 'de', 'hi', 'ta', 'te', 'kn', 'ml'], + /* The languages the app offers (frontend/gulpfile.js/config.js), plus + 'de' for records that already hold it. */ + enum: [ + 'en', + 'ta', + 'hi', + 'ml', + 'kn', + 'te', + 'si', + 'ne', + 'ar', + 'fr', + 'es', + 'pt', + 'id', + 'th', + 'de', + ], }, themePreference: { type: String, diff --git a/api/src/realtime/webhooks.js b/api/src/realtime/webhooks.js index 6f872844a..2e5724e79 100644 --- a/api/src/realtime/webhooks.js +++ b/api/src/realtime/webhooks.js @@ -38,6 +38,21 @@ const TIMEOUT_MS = 10_000; const DRAIN_EVERY_MS = 60_000; // how often lazy draining may run, per process const drainLast = new Map(); // dbName -> ts +/* Store only stable, non-sensitive failure categories. Raw exception messages can + * contain hostnames, query strings, credentials or payload fragments and do not + * belong in durable delivery records. */ +function classifyFailure(outcome) { + const status = Number(outcome && outcome.status) || 0; + if (status === 408) return { code: 'http_408', retryable: true }; + if (status === 429) return { code: 'http_429', retryable: true }; + if (status >= 500) return { code: 'http_5xx', retryable: true }; + if (status >= 400) return { code: 'http_4xx', retryable: false }; + if (outcome && ['AbortError', 'TimeoutError'].includes(outcome.errorName)) { + return { code: 'timeout', retryable: true }; + } + return { code: 'network_error', retryable: true }; +} + function sign(secret, body) { return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(body).digest('hex'); } @@ -100,10 +115,11 @@ async function attempt(db, delivery, sub) { }); outcome = { ok: res.status >= 200 && res.status < 300, status: res.status }; } catch (err) { - outcome = { ok: false, status: 0, error: err.message }; + outcome = { ok: false, status: 0, errorName: err && err.name }; } const attempts = (delivery.attempts || 0) + 1; + const failure = outcome.ok ? null : classifyFailure(outcome); if (outcome.ok) { await db.collection(DELIVERIES).updateOne( { _id: delivery._id }, @@ -116,7 +132,7 @@ async function attempt(db, delivery, sub) { }, } ); - } else if (attempts >= MAX_ATTEMPTS) { + } else if (!failure.retryable || attempts >= MAX_ATTEMPTS) { await db.collection(DELIVERIES).updateOne( { _id: delivery._id }, { @@ -124,8 +140,10 @@ async function attempt(db, delivery, sub) { status: 'dead', attempts, lastStatus: outcome.status, - lastError: outcome.error || '', + lastErrorCode: failure.code, + deadLetteredAt: new Date(), }, + $unset: { lastError: '', 'payload.at': '', 'payload.shop': '' }, } ); } else { @@ -137,8 +155,9 @@ async function attempt(db, delivery, sub) { attempts, nextAt: new Date(Date.now() + BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]), lastStatus: outcome.status, - lastError: outcome.error || '', + lastErrorCode: failure.code, }, + $unset: { lastError: '' }, } ); } @@ -206,12 +225,17 @@ async function drainDue(db, dbName) { for (const d of due) { const sub = subs.get(String(d.subscription_id)); if (!sub || !sub.active) { - await db - .collection(DELIVERIES) - .updateOne( - { _id: d._id }, - { $set: { status: 'dead', lastError: 'subscription removed' } } - ); + await db.collection(DELIVERIES).updateOne( + { _id: d._id }, + { + $set: { + status: 'dead', + lastErrorCode: 'subscription_removed', + deadLetteredAt: new Date(), + }, + $unset: { lastError: '', 'payload.at': '', 'payload.shop': '' }, + } + ); continue; } attempt(db, d, sub).catch(() => {}); @@ -236,7 +260,8 @@ async function recentDeliveries(db, limit = 50) { createdAt: 1, deliveredAt: 1, lastStatus: 1, - lastError: 1, + lastErrorCode: 1, + deadLetteredAt: 1, subscription_id: 1, }, } @@ -255,6 +280,7 @@ module.exports = { recentDeliveries, sign, urlAllowed, + classifyFailure, SUBS, DELIVERIES, MAX_ATTEMPTS, diff --git a/api/src/repositories/category.repository.js b/api/src/repositories/category.repository.js index 36e12d850..e974a395c 100644 --- a/api/src/repositories/category.repository.js +++ b/api/src/repositories/category.repository.js @@ -358,15 +358,15 @@ class CategoryRepository extends BaseModel { // not the intended multi-channel selector). is_deleted: { $ne: true }, $or: [ - // Case 1: Inventory not tracked — always allowed + // Case 1: Inventory not tracked - always allowed { track_inventory: false }, - // Case 2: Negative stock allowed — quantity >= 0 + // Case 2: Negative stock allowed - quantity >= 0 { $and: [{ negative_stock: true }, { available_quantity: { $gte: 0 } }], }, - // Case 3: Normal stock — quantity > 0 + // Case 3: Normal stock - quantity > 0 { $and: [ { diff --git a/api/src/repositories/customer.repository.js b/api/src/repositories/customer.repository.js index 697e7a379..2c3fc116c 100644 --- a/api/src/repositories/customer.repository.js +++ b/api/src/repositories/customer.repository.js @@ -1,6 +1,8 @@ // src/repositories/customer.repository.js const BaseModel = require('../models/base.model'); const { ObjectId } = require('mongodb'); +const { withBranchScope } = require('../services/branch-scope'); +const dataSharing = require('../services/data-sharing'); /** * Customer Repository @@ -50,12 +52,19 @@ class CustomerRepository extends BaseModel { */ async findById(id) { const collection = await this.getCollection(this.collectionName); - return await collection.findOne({ - _id: new ObjectId(id), - license: BaseModel.license, - ...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}), - is_deleted: { $ne: true }, - }); + return await collection.findOne( + withBranchScope( + { + _id: new ObjectId(id), + license: BaseModel.license, + is_deleted: { $ne: true }, + }, + await dataSharing.scopeBranch('customers', BaseModel.currentBranch, { + licenseId: BaseModel.license, + branchId: BaseModel.currentBranch, + }) + ) + ); } /** @@ -102,7 +111,7 @@ class CustomerRepository extends BaseModel { async search(searchTerm, options = {}) { const { page = 1, limit = 10, branchId = null } = options; - const query = { + let query = { license: BaseModel.license, is_deleted: { $ne: true }, $or: [ @@ -112,8 +121,18 @@ class CustomerRepository extends BaseModel { ], }; + /* withBranchScope, not `query.branch_id = ...`: this query already owns a + top-level $or for name/email/phone, and a second one would replace those + terms instead of adding to them - a search that quietly returns every + customer. The scope goes under $and. */ if (branchId) { - query.branch_id = new ObjectId(branchId); + query = withBranchScope( + query, + await dataSharing.scopeBranch('customers', branchId, { + licenseId: BaseModel.license, + branchId, + }) + ); } const collection = await this.getCollection(this.collectionName); @@ -147,6 +166,29 @@ class CustomerRepository extends BaseModel { is_deleted: false, }; + /* + * S7 step one (D5). Customers are branch-scoped here while every + * comparable product keeps them account-level: a customer who buys at one + * shop is the same person at the next, and today their loyalty, credit and + * consent fragment across branches. + * + * This writes the relation ITEMS already use - branch_access[] - alongside + * the existing branch_id, seeded with the branch that owns the record. + * Nothing moves and nothing reads it yet: with access listing only the + * owning branch, every customer stays exactly as visible as before. + * Sharing one is then a deliberate grant, not a migration side effect. + * + * The rule that matters for the phase after this: LINK, never merge. Two + * shops may hold same-name customers with different balances, and deciding + * they are one person is the owner's call, not a script's. + */ + if (!Array.isArray(document.branch_access)) { + const owning = document.branch_id || BaseModel.currentBranch || null; + document.branch_access = owning + ? [{ branch_id: owning, branch_name: document.branch_name || '' }] + : []; + } + const result = await collection.insertOne(document); return await this.findById(result.insertedId); } @@ -318,12 +360,19 @@ class CustomerRepository extends BaseModel { update.$set = { last_purchase: lastPurchaseDate }; } + /* The guard follows visibility: a sale at a branch the customer is shared + with should update that customer's totals. */ return collection.updateOne( - { - _id: id, - ...(BaseModel.license ? { license: BaseModel.license } : {}), - ...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}), - }, + withBranchScope( + { + _id: id, + ...(BaseModel.license ? { license: BaseModel.license } : {}), + }, + await dataSharing.scopeBranch('customers', BaseModel.currentBranch, { + licenseId: BaseModel.license, + branchId: BaseModel.currentBranch, + }) + ), update ); } diff --git a/api/src/repositories/expense.repository.js b/api/src/repositories/expense.repository.js index d9f64a70f..635cd98c7 100644 --- a/api/src/repositories/expense.repository.js +++ b/api/src/repositories/expense.repository.js @@ -133,7 +133,7 @@ class ExpenseRepository extends BaseModel { updated_by_id: updateData.updated_by_id || null, }; - // Only add fields that are provided — never modify created_date/created_by + // Only add fields that are provided - never modify created_date/created_by if (updateData.amount !== undefined) updateFields.amount = parseFloat(updateData.amount); if (updateData.type) updateFields.type = updateData.type; if (expenseDate) updateFields.date = expenseDate; diff --git a/api/src/repositories/install.repository.js b/api/src/repositories/install.repository.js index 40ebcf320..d861ee888 100644 --- a/api/src/repositories/install.repository.js +++ b/api/src/repositories/install.repository.js @@ -13,6 +13,25 @@ class InstallRepository extends BaseModel { super('branches'); } + /** + * How many branches this database already holds. + * + * The one question that separates "install a new shop" from "destroy an + * existing one". Every installed shop has a branch and no empty database + * does, so a non-zero answer means somebody's shop is on the other end of + * the call. + * + * Asked by processInstallation before it writes anything. On 28 August 2026 + * a live customer's five branches, 265 products and 87 sales were replaced + * because nothing asked it. + * + * @returns {Promise} + */ + async countExistingBranches() { + const branches = await this.getCollection('branches'); + return branches.countDocuments({}); + } + /** * Check if user already exists by username, email, or license * @param {Object} params @@ -86,6 +105,20 @@ class InstallRepository extends BaseModel { ); } + /** + * The units a branch already has, so the demo seed joins the shop's own + * master instead of duplicating it. + * @param {ObjectId} branchId + * @param {ObjectId} licenseId + * @returns {Promise} + */ + async findUnitsByBranch(branchId, licenseId) { + const unitCollection = await this.getCollection('unit'); + return unitCollection + .find({ branch_id: branchId, license: licenseId }, { projection: { _id: 1, value: 1 } }) + .toArray(); + } + /** * Insert a tax record * @param {Object} taxData - Tax data to insert @@ -138,8 +171,25 @@ class InstallRepository extends BaseModel { async insertCategories(categoriesData) { const categoryCollection = await this.getCollection('categories'); console.log(`🗄️ Repository: Inserting ${categoriesData.length} categories into DB...`); - const result = await categoryCollection.insertMany(categoriesData); - const ids = Object.values(result.insertedIds); + /* + * Upsert, never blind-insert. Categories carry a unique + * (name, branch_id) index, and a RESEED meets survivors: the purge + * keeps sold items and their categories, so the next pack's + * "Paper & Office" collides with the old pack's, insertMany threw + * E11000, and the whole demo install died half-done - the owner saw + * "restored 30 products" and a list of 11. An existing category is + * simply reused; only genuinely new ones are created. + */ + const ids = []; + for (const cat of categoriesData) { + const found = await categoryCollection.findOneAndUpdate( + { name: cat.name, branch_id: cat.branch_id }, + { $setOnInsert: cat }, + { upsert: true, returnDocument: 'after' } + ); + const doc = found && (found.value || found); + if (doc && doc._id) ids.push(doc._id); + } return ids; } diff --git a/api/src/repositories/invoice.repository.js b/api/src/repositories/invoice.repository.js new file mode 100644 index 000000000..2fdf04d9a --- /dev/null +++ b/api/src/repositories/invoice.repository.js @@ -0,0 +1,773 @@ +'use strict'; + +/* + * Invoices (INVOICING_MODULE_DESIGN): the bill a customer is asked to pay. + * + * The governing rule, stated where the code lives: + * + * AN INVOICE NEVER HOLDS MONEY. THE SALE DOES. + * + * An invoice is authored, shared and chased like a quote - lines, charges, + * discounts, an A4 sheet, a due date. What it is NOT is a second ledger. The + * accounting moment is still the sale: stock, tax, the customer's outstanding + * balance and every report read the `sales` collection and nothing else. + * + * The international model (Zoho, Odoo, QuickBooks, GST practice): a DRAFT is + * a proforma - editable, no stock, no tax. ISSUING it is the sale: the + * server books the sale record itself (services/invoice-booking), stock + * moves, the tax point is set, the numbers freeze. From then on the + * invoice's unpaid/partial/paid state is a MIRROR of that sale's + * payment_status, written by services/invoice-sync whenever the sale + * changes. Recording a payment - in full or in part - settles the SALE and + * the mirror follows. Nobody is walked through a till screen in between. + * + * So this repository writes only its own collection. It READS `branches` for + * the shop's invoice defaults, exactly as quotes read their own. A test pins + * that no write ever lands anywhere but `invoices`. + * + * Lifecycle: + * draft ──┬─ cancelled + * └─ (issue: the sale is booked) ─ unpaid ─ partial ─ paid + * `overdue` is not a state; it is a fact about an ISSUED, still-owed invoice + * past its due date, computed at read time so it can never go stale. A + * proforma is not a receivable, so a draft is never overdue. + */ + +const BaseModel = require('../models/base.model'); +const { ObjectId } = require('mongodb'); +const { ensureIndexOnce } = require('../db/ensure-index'); +const docMath = require('../services/document-math'); + +const STATUSES = Object.freeze(['draft', 'unpaid', 'partial', 'paid', 'cancelled']); +/* Still authoring: the numbers may change. Once the sale exists they may not - + a customer holds a document and the books hold a record, and they must + agree. */ +const EDITABLE = Object.freeze(['draft']); +/* Money is owed on these - issued, not yet paid off; what "overdue" applies to. */ +const OWED = Object.freeze(['unpaid', 'partial']); +/* A sale has been recorded against these. */ +const BOOKED = Object.freeze(['unpaid', 'partial', 'paid']); + +const DEFAULT_PREFIX = 'INV-'; +const DEFAULT_DUE_DAYS = 30; + +class InvoiceRepository extends BaseModel { + constructor() { + super('invoices'); + } + + static get STATUSES() { + return STATUSES; + } + + static get OWED() { + return OWED; + } + + _wall(context) { + const branchId = context && context.branchId; + const licenseId = context && context.licenseId; + if (!branchId || !ObjectId.isValid(String(branchId))) return null; + const wall = { branch_id: new ObjectId(String(branchId)) }; + if (licenseId && ObjectId.isValid(String(licenseId))) { + wall.license = new ObjectId(String(licenseId)); + } + return wall; + } + + /* + * The shop's invoice defaults, from the branch document. The settings + * group endpoint mirrors branch-level writes onto `branches`, so reading + * it here sees what the Invoices settings pane saved. A lookup that fails + * is a nicety missed, never a failed save - hence the empty object. + */ + async _defaults(wall) { + try { + const branches = await this.getCollection('branches'); + const b = await branches.findOne( + { _id: wall.branch_id }, + { + projection: { + invoice_prefix: 1, + invoice_due_days: 1, + invoice_terms: 1, + quote_default_payment_method: 1, + quote_default_bank_details: 1, + }, + } + ); + return b || {}; + } catch (e) { + return {}; + } + } + + static _prefixOf(defaults) { + const raw = String((defaults && defaults.invoice_prefix) || '').trim(); + return (raw || DEFAULT_PREFIX).slice(0, 12); + } + + static _dueDaysOf(defaults) { + const n = parseInt(defaults && defaults.invoice_due_days, 10); + if (!Number.isFinite(n) || n < 0) return DEFAULT_DUE_DAYS; + return Math.min(n, 365); + } + + /* + * Next invoice number for the branch: 000001, resilient to holes + * AND to a prefix change - the trailing digits are what count, whatever + * text sits before them, so renaming INV- to BILL- continues the sequence + * rather than restarting it at 1 beside the old numbers. + */ + async _nextInvoiceId(collection, wall, prefix) { + const rows = await collection + .find({ branch_id: wall.branch_id }, { projection: { invoice_id: 1 } }) + .toArray(); + let max = 0; + for (const r of rows) { + const m = String((r && r.invoice_id) || '').match(/(\d+)\s*$/); + const n = m ? Number(m[1]) : NaN; + if (Number.isFinite(n)) max = Math.max(max, n); + } + return prefix + String(max + 1).padStart(6, '0'); + } + + /* The list index - same reasoning as quote_list_by_branch: the wall plus + the sort, so listing is a walk and not a scan, per DATABASE. */ + async _ensureListIndex(collection) { + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, created_date: -1 }, + { name: 'invoice_list_by_branch' } + ); + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, due_date: 1 }, + { name: 'invoice_list_by_due' } + ); + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, total: -1 }, + { name: 'invoice_list_by_total' } + ); + } + + /* Overdue is a reading of the document, never a stored state. */ + static isOverdue(doc, now = new Date()) { + if (!doc || !OWED.includes(doc.status)) return false; + const due = doc.due_date ? new Date(doc.due_date) : null; + return !!(due && !Number.isNaN(due.getTime()) && due < now); + } + + static _decorate(doc, now = new Date()) { + if (!doc) return doc; + return { ...doc, is_overdue: InvoiceRepository.isOverdue(doc, now) }; + } + + /* Create (id empty) or update - draft/sent only; a booked invoice's numbers + belong to the sale that was recorded from it. */ + async upsertInvoice(data = {}, id = '', context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + + const parsed = docMath.normalizeLines(data.lines || data.items, 'invoice'); + if (parsed.error) return { status: false, data: null, message: parsed.error }; + + const money = docMath.computeTotals({ + lines: parsed.lines, + charges: docMath.normalizeCharges(data.charges), + discount: data.discount, + clientTotal: data.total, + clientTaxTotal: data.tax_total, + }); + + const now = new Date(); + const collection = await this.getCollection(this.collectionName); + + const doc = { + branch_id: wall.branch_id, + branch_name: String(context.branchName || '').trim(), + customer_id: + data.customer_id && ObjectId.isValid(String(data.customer_id)) + ? new ObjectId(String(data.customer_id)) + : null, + customer_name: String(data.customer_name || '').trim(), + customer_phone: String(data.customer_phone || '').trim(), + customer_address: String(data.customer_address || '') + .trim() + .slice(0, 300), + customer_gstin: String(data.customer_gstin || '') + .trim() + .slice(0, 20), + customer_email: String(data.customer_email || '') + .trim() + .slice(0, 120), + payment_method: String(data.payment_method || '') + .trim() + .slice(0, 60), + bank_details: String(data.bank_details || '') + .trim() + .slice(0, 500), + terms: String(data.terms || '') + .trim() + .slice(0, 1500), + /* A reference the CUSTOMER gave - their PO number, a job id - printed + on the document because their accounts department matches on it. */ + reference: String(data.reference || '') + .trim() + .slice(0, 60), + items: parsed.lines, + charges: money.charges, + discount: money.discount, + charges_total: money.charges_total, + custom_blocks: docMath.normalizeBlocks(data.custom_blocks), + layout: docMath.normalizeLayout(data.layout), + notes: String(data.notes || '') + .trim() + .slice(0, 2000), + subtotal: money.subtotal, + tax_total: money.tax_total, + total: money.total, + due_date: docMath.dateOrNull(data.due_date), + updated_date: now, + updated_by: String(context.userName || ''), + }; + if (wall.license) doc.license = wall.license; + + if (id) { + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + /* An edit keeps the balance honest: nothing is owed on a draft, so + the balance IS the total until a sale says otherwise. */ + doc.balance = money.total; + doc.paid_amount = 0; + const result = await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall, status: { $in: EDITABLE } }, + { $set: doc } + ); + if (!result.matchedCount) { + return { + status: false, + data: null, + message: 'This invoice can no longer be edited - it has been issued, or it is closed', + }; + } + return { status: true, data: { id: String(id) }, message: 'Invoice updated' }; + } + + /* A new invoice starts from the shop's invoice defaults wherever the + editor sent nothing: payment method and bank details are shared with + quotations (a shop has one bank account), the terms are the invoice's + own, and the due date follows the shop's credit days. */ + const defaults = await this._defaults(wall); + if (!doc.payment_method) + doc.payment_method = String(defaults.quote_default_payment_method || '') + .trim() + .slice(0, 60); + if (!doc.bank_details) + doc.bank_details = String(defaults.quote_default_bank_details || '') + .trim() + .slice(0, 500); + if (!doc.terms) + doc.terms = String(defaults.invoice_terms || '') + .trim() + .slice(0, 1500); + if (!doc.due_date) { + const days = InvoiceRepository._dueDaysOf(defaults); + doc.due_date = new Date(now.getTime() + days * 86400000); + } + + doc.invoice_id = await this._nextInvoiceId( + collection, + wall, + InvoiceRepository._prefixOf(defaults) + ); + doc.status = 'draft'; + doc.issue_date = now; + doc.paid_amount = 0; + doc.balance = money.total; + doc.sale_id = null; + doc.sale_number = ''; + /* Lineage: the quote this invoice grew from, if any. */ + doc.source_quote_id = + data.source_quote_id && ObjectId.isValid(String(data.source_quote_id)) + ? new ObjectId(String(data.source_quote_id)) + : null; + doc.source_quote_number = String(data.source_quote_number || '') + .trim() + .slice(0, 40); + doc.created_date = now; + doc.created_by = String(context.userName || ''); + const inserted = await collection.insertOne(doc); + return { + status: true, + data: { id: String(inserted.insertedId), invoice_id: doc.invoice_id }, + message: 'Invoice saved', + }; + } catch (error) { + console.error('Error in InvoiceRepository.upsertInvoice:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * Quote -> invoice. The quote's lines, charges and discount become the + * invoice's, at the quoted prices - the promise the quote made is what the + * invoice bills. Terms are the shop's INVOICE terms (a quotation's "valid + * till" wording has no place on a bill); everything else carries over. + */ + async createFromQuote(quote, context = {}) { + if (!quote || !Array.isArray(quote.items) || !quote.items.length) { + return { status: false, data: null, message: 'This quote has no lines to invoice' }; + } + const payload = { + lines: quote.items.map((l) => ({ + kind: l.kind, + item_id: l.item_id ? String(l.item_id) : '', + item_name: l.item_name, + description: l.description || '', + barcode_id: l.barcode_id || '', + qty: l.qty, + unit_price: l.unit_price, + discount: l.discount ? { type: l.discount.type, value: l.discount.value } : undefined, + tax_name: l.tax_name || '', + tax_value: l.tax_value || 0, + tax_type: l.tax_type || '', + })), + charges: (quote.charges || []).map((c) => ({ + name: c.name, + type: c.type, + value: c.value, + sign: c.sign, + })), + discount: quote.discount + ? { type: quote.discount.type, value: quote.discount.value } + : undefined, + customer_id: quote.customer_id ? String(quote.customer_id) : '', + customer_name: quote.customer_name || '', + customer_phone: quote.customer_phone || '', + customer_address: quote.customer_address || '', + customer_gstin: quote.customer_gstin || '', + customer_email: quote.customer_email || '', + payment_method: quote.payment_method || '', + bank_details: quote.bank_details || '', + custom_blocks: quote.custom_blocks || [], + layout: quote.layout || undefined, + notes: quote.notes || '', + tax_total: quote.tax_total, + total: quote.total, + source_quote_id: String(quote._id || ''), + source_quote_number: quote.quote_id || '', + }; + return this.upsertInvoice(payload, '', context); + } + + async listInvoices(params = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + const filter = { ...wall }; + const now = new Date(); + const status = String(params.status || ''); + if (STATUSES.includes(status)) { + filter.status = status; + } else if (status === 'overdue') { + /* the read-time fact, as a query: owed and past due */ + filter.status = { $in: OWED }; + filter.due_date = { $lt: now }; + } else if (status === 'owed') { + filter.status = { $in: OWED }; + } + + const term = String(params.search || '') + .trim() + .slice(0, 80); + if (term) { + const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rx = + String(params.exact) === 'true' ? new RegExp(`^${safe}$`, 'i') : new RegExp(safe, 'i'); + const field = String(params.field || 'all'); + if (field === 'invoice_id') filter.invoice_id = rx; + else if (field === 'customer_name') filter.customer_name = rx; + else filter.$or = [{ customer_name: rx }, { invoice_id: rx }]; + } + + /* Date range on created_date, same day-end rule as quotes: a date-only + "to" includes its whole day; a precise instant is kept as sent. */ + const range = {}; + const rawFrom = params.from ? String(params.from) : ''; + const rawTo = params.to ? String(params.to) : ''; + const from = rawFrom ? new Date(rawFrom) : null; + const to = rawTo ? new Date(rawTo) : null; + if (from && !Number.isNaN(from.getTime())) range.$gte = from; + if (to && !Number.isNaN(to.getTime())) { + if (/^\d{4}-\d{2}-\d{2}$/.test(rawTo.trim())) to.setHours(23, 59, 59, 999); + range.$lte = to; + } + if (Object.keys(range).length) filter.created_date = range; + + const limit = Math.min(Math.max(Number(params.limit) || 100, 1), 200); + const page = Math.max(Number(params.page) || 1, 1); + const collection = await this.getCollection(this.collectionName); + await this._ensureListIndex(collection); + + const SORTS = { + recent: { created_date: -1 }, + total_desc: { total: -1, _id: -1 }, + total_asc: { total: 1, _id: -1 }, + due_asc: { due_date: 1, _id: -1 }, + due_desc: { due_date: -1, _id: -1 }, + balance_desc: { balance: -1, _id: -1 }, + }; + const sort = SORTS[String(params.sort || '')] || { created_date: -1 }; + + /* A text search skips the count on purpose (see quotes): one extra row + answers "is there more" without a second regex pass. */ + const countable = !term; + let total = null; + let rows; + if (countable) { + total = await collection.countDocuments(filter); + rows = await collection + .find(filter) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit) + .toArray(); + } else { + rows = await collection + .find(filter) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit + 1) + .toArray(); + } + const hasMore = countable ? page * limit < total : rows.length > limit; + if (!countable && rows.length > limit) rows = rows.slice(0, limit); + + return { + status: true, + data: rows.map((r) => InvoiceRepository._decorate(r, now)), + meta: { + total, + page, + limit, + hasMore, + pages: total === null ? null : Math.max(1, Math.ceil(total / limit)), + }, + message: 'success', + }; + } catch (error) { + console.error('Error in InvoiceRepository.listInvoices:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* What the shop is owed, for the list's header strip: count and sum of + every owed invoice, and of the overdue subset. Two cheap aggregations + down the list index. */ + async summary(context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + const collection = await this.getCollection(this.collectionName); + const now = new Date(); + const rows = await collection + .aggregate([ + { $match: { ...wall, status: { $in: OWED } } }, + { + $group: { + _id: null, + owed_count: { $sum: 1 }, + owed_total: { $sum: { $ifNull: ['$balance', '$total'] } }, + overdue_count: { + $sum: { $cond: [{ $lt: ['$due_date', now] }, 1, 0] }, + }, + overdue_total: { + $sum: { + $cond: [{ $lt: ['$due_date', now] }, { $ifNull: ['$balance', '$total'] }, 0], + }, + }, + }, + }, + ]) + .toArray(); + const s = rows[0] || {}; + return { + status: true, + data: { + owed_count: s.owed_count || 0, + owed_total: docMath.round2(s.owed_total || 0), + overdue_count: s.overdue_count || 0, + overdue_total: docMath.round2(s.overdue_total || 0), + }, + message: 'success', + }; + } catch (error) { + console.error('Error in InvoiceRepository.summary:', error); + return { status: false, data: null, message: error.message }; + } + } + + async getInvoice(id, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const collection = await this.getCollection(this.collectionName); + const doc = await collection.findOne({ _id: new ObjectId(String(id)), ...wall }); + if (!doc) return { status: false, data: null, message: 'Invoice not found' }; + return { status: true, data: InvoiceRepository._decorate(doc), message: 'success' }; + } catch (error) { + console.error('Error in InvoiceRepository.getInvoice:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * The mirror. A snapshot of the sale's payment state (built by + * services/invoice-sync from the sale document) becomes the invoice's + * status and balance. Replay-safe: the same snapshot twice is the same + * document. A cancelled invoice is left alone - the sale that reached it + * is somebody else's problem to explain, and silently un-cancelling a + * document a customer was told is void would be worse. + */ + async applySaleSnapshot(id, snapshot = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const saleId = + snapshot.sale_id && ObjectId.isValid(String(snapshot.sale_id)) + ? new ObjectId(String(snapshot.sale_id)) + : null; + if (!saleId) return { status: false, data: null, message: 'A sale id is required' }; + + const collection = await this.getCollection(this.collectionName); + const doc = await collection.findOne({ _id: new ObjectId(String(id)), ...wall }); + if (!doc) return { status: false, data: null, message: 'Invoice not found' }; + if (doc.status === 'cancelled') { + return { status: false, data: null, message: 'This invoice was cancelled' }; + } + /* One invoice, one sale. A different sale arriving is refused, not + merged - two tills booking the same invoice is a mistake to surface. */ + if (doc.sale_id && String(doc.sale_id) !== String(saleId)) { + return { + status: false, + data: null, + message: 'This invoice was already recorded as sale ' + (doc.sale_number || ''), + }; + } + + /* What is owed is what the SALE says - a shop with round-off on books + 210 for a 209.85 document, and the customer pays the sale's figure. + The printed total stays the document's own; sale_total is kept beside + it so the sheet can show both when they differ. */ + const saleTotal = docMath.round2( + Number.isFinite(Number(snapshot.total)) && Number(snapshot.total) > 0 + ? Number(snapshot.total) + : Number(doc.total) || 0 + ); + const paid = Math.max( + 0, + Math.min(saleTotal, docMath.round2(Number(snapshot.paid_amount) || 0)) + ); + const balance = docMath.round2(Math.max(0, saleTotal - paid)); + const status = balance <= 0 ? 'paid' : paid > 0 ? 'partial' : 'unpaid'; + const now = new Date(); + const set = { + status, + sale_id: saleId, + sale_number: String(snapshot.sale_number || doc.sale_number || '').slice(0, 40), + sale_total: saleTotal, + paid_amount: paid, + balance, + sale_payment_status: String(snapshot.payment_status || '').slice(0, 20), + synced_date: now, + updated_date: now, + }; + if (!doc.sale_id) set.issued_date = now; + if (status === 'paid' && !doc.paid_date) set.paid_date = now; + if (status !== 'paid' && doc.paid_date) set.paid_date = null; + await collection.updateOne({ _id: doc._id }, { $set: set }); + return { + status: true, + data: { id: String(doc._id), status, paid_amount: paid, balance }, + message: 'Invoice ' + status, + }; + } catch (error) { + console.error('Error in InvoiceRepository.applySaleSnapshot:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * The one transition that needs no sale: cancel, while the document is a + * draft. Once issued, stock and tax have moved and a customer may hold the + * paper - the reversal is a return on the sale (a credit note is the + * follow-up), never a word written over a record that says otherwise. + */ + async transition(id, action, data = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const collection = await this.getCollection(this.collectionName); + const doc = await collection.findOne({ _id: new ObjectId(String(id)), ...wall }); + if (!doc) return { status: false, data: null, message: 'Invoice not found' }; + + if (action === 'cancel') { + if (doc.status === 'cancelled') { + return { status: false, data: null, message: 'This invoice is already cancelled' }; + } + if (doc.status !== 'draft') { + return { + status: false, + data: null, + message: + 'This invoice has been issued - reverse it with a return on sale ' + + (doc.sale_number || '') + + ' instead', + }; + } + await collection.updateOne( + { _id: doc._id }, + { + $set: { + status: 'cancelled', + cancelled_date: new Date(), + cancel_reason: String(data.reason || '') + .trim() + .slice(0, 200), + updated_date: new Date(), + }, + } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Invoice cancelled' }; + } + + return { status: false, data: null, message: 'Unknown action' }; + } catch (error) { + console.error('Error in InvoiceRepository.transition:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * The document's own memory of a payment recorded against it: who, when, + * how, which reference. The MONEY lives on the sale; this is the note on + * the paper. Appended, never totalled - the balance comes from the sale. + * + * This method was lost once, between two edits of the block above it, and + * the mirror's own tests did not notice because they mock this class. The + * contract test in invoice.repository.test.js now reads the callers. + */ + async recordPayment(id, entry = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const collection = await this.getCollection(this.collectionName); + const result = await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall }, + { + $push: { + payments: { + amount: docMath.round2(Number(entry.amount) || 0), + method: String(entry.method || '').slice(0, 40), + reference: String(entry.reference || '').slice(0, 80), + note: String(entry.note || '').slice(0, 200), + date: entry.date instanceof Date ? entry.date : new Date(), + by: String(entry.by || context.userName || '').slice(0, 80), + }, + }, + $set: { updated_date: new Date() }, + } + ); + if (!result.matchedCount) return { status: false, data: null, message: 'Invoice not found' }; + return { status: true, data: { id: String(id) }, message: 'Payment noted' }; + } catch (error) { + console.error('Error in InvoiceRepository.recordPayment:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* Share metadata: which S3 object currently represents this invoice, and + when it first left the shop. Sharing changes no status - a shared draft + is a proforma, a shared issued invoice is a bill - so `sent_date` is + a fact on the record, not a state of it. */ + async recordShare(id, share, context) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const collection = await this.getCollection(this.collectionName); + const result = await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall }, + { + $set: { + share: { + key: String(share.key || ''), + url: String(share.url || ''), + rev: Number(share.rev) || 1, + at: new Date(), + }, + updated_date: new Date(), + }, + } + ); + if (!result.matchedCount) return { status: false, data: null, message: 'Invoice not found' }; + await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall, sent_date: { $exists: false } }, + { $set: { sent_date: new Date() } } + ); + return { status: true, data: { rev: Number(share.rev) || 1 }, message: 'Share recorded' }; + } catch (error) { + console.error('Error in InvoiceRepository.recordShare:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* Delete: drafts only - an issued invoice has a sale behind it. */ + async deleteInvoice(id, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid invoice id' }; + } + const collection = await this.getCollection(this.collectionName); + const result = await collection.deleteOne({ + _id: new ObjectId(String(id)), + ...wall, + status: { $in: EDITABLE }, + }); + if (!result.deletedCount) { + return { + status: false, + data: null, + message: 'Only a draft can be deleted - an issued invoice is reversed on its sale', + }; + } + return { status: true, data: { id: String(id) }, message: 'Invoice deleted' }; + } catch (error) { + console.error('Error in InvoiceRepository.deleteInvoice:', error); + return { status: false, data: null, message: error.message }; + } + } +} + +module.exports = InvoiceRepository; +module.exports.BOOKED = BOOKED; diff --git a/api/src/repositories/item.repository.js b/api/src/repositories/item.repository.js index 379b945f9..77378380d 100644 --- a/api/src/repositories/item.repository.js +++ b/api/src/repositories/item.repository.js @@ -1,6 +1,7 @@ const { searchPattern } = require('../utils/safe-search'); // src/repositories/item.repository.js const BaseModel = require('../models/base.model'); +const demoData = require('../services/demo-data'); const Item = require('../models/item.model'); const Branch = require('../models/branch.model'); const { @@ -9,6 +10,11 @@ const { SUCCESS_MESSAGES, ERROR_MESSAGES, } = require('../constants/items.constants'); + +/* One definition of "not deleted", shared by every item query. It used to + live inside a single method, which is how the purchase picker ended up + without it. */ +const NOT_DELETED = Object.freeze({ del_status: { $nin: [1, '1', true] } }); const { ObjectId } = require('mongodb'); const { formatDate } = require('../utils/helpers'); const StockLogsRepository = require('./stock-log.repository'); @@ -85,7 +91,6 @@ class ItemRepository extends BaseModel { } static withoutTombstones(coll) { - const NOT_DELETED = { del_status: { $nin: [1, '1', true] } }; const merge = (f) => { if (!f || typeof f !== 'object' || Array.isArray(f)) return { ...NOT_DELETED }; /* A filter already using $or at the top level (the branch filters do) @@ -110,24 +115,20 @@ class ItemRepository extends BaseModel { } /** - * Find items with pagination and filters (equivalent to itemPage) + * The collection and the authoritative filter for one branch's item list. + * + * Shared by the flat list and the grouped one. Two copies of this would be + * two places to forget that client-supplied scope must be stripped - and the + * failure mode of forgetting is not an error, it is a query that quietly + * returns the wrong shop's items or none at all. * * @param {Object} params * @param {string|ObjectId} params.branchId - Branch context * @param {string|ObjectId} params.licenseId - License context - * @param {Object} [params.filters] - Additional filters - * @param {number} [params.page] - Page number (1-based) - * @param {number} [params.limit] - Page size - * @param {Object} [params.sort] - Sort object + * @param {Object} [params.filters] - Client-supplied business filters + * @returns {Promise<{collection: Object, filter: Object}>} */ - async findPage({ - branchId, - licenseId, - filters = {}, - page = 1, - limit = 5, - sort = { _id: -1 }, - } = {}) { + async listScope({ branchId, licenseId, filters = {} } = {}) { const collection = await this.getCollection(this.collectionName); const branchObjectId = this.toObjectId(branchId); @@ -143,10 +144,6 @@ class ItemRepository extends BaseModel { throw new Error('Active branch does not belong to the current license'); } - const effectiveLimit = parseInt(limit, 10) || 5; - const effectivePage = parseInt(page, 10) || 1; - const skip = (effectivePage - 1) * effectiveLimit; - const clientFilters = this.assignFilterObjects({ ...filters }, LegacyItemModel.fields); // Client-supplied scope is never part of the business filter. Leaving a // stale branch_name/branch_id alongside the authoritative scope can turn @@ -164,26 +161,107 @@ class ItemRepository extends BaseModel { delete clientFilters[key]; } + /* + * Demo products are hidden when the shop has switched Demo Data off. + * + * Resolved here, in the ONE place the item list builds its filter, so it + * cannot be applied to some reads and forgotten on others - a catalogue + * that hides sample items on the manage screen and shows them on the sale + * grid is worse than not hiding them at all. + * + * Nothing is written and nothing is deleted, so switching it back on + * restores everything instantly. See services/demo-data.js for why a + * demo item that has been sold must never simply be removed. + */ + const demoClause = await demoData.filter({ licenseId, branchId }); + const filter = { // branch_access is the canonical item-to-branch relation. branch_id and // branch_name are denormalized legacy fields and can be absent or stale // in production data after a branch rename. ...clientFilters, + ...demoClause, 'branch_access.branch_id': branchObjectId, license: licenseObjectId, }; - const [total, items] = await Promise.all([ - collection.countDocuments(filter), - collection + return { collection, filter }; + } + + /** + * Find items with pagination and filters (equivalent to itemPage) + * + * @param {Object} params + * @param {string|ObjectId} params.branchId - Branch context + * @param {string|ObjectId} params.licenseId - License context + * @param {Object} [params.filters] - Additional filters + * @param {number} [params.page] - Page number (1-based) + * @param {number} [params.limit] - Page size + * @param {Object} [params.sort] - Sort object + */ + async findPage({ + branchId, + licenseId, + filters = {}, + page = 1, + limit = 5, + sort = { _id: -1 }, + } = {}) { + const { collection, filter } = await this.listScope({ branchId, licenseId, filters }); + + const effectiveLimit = parseInt(limit, 10) || 5; + const effectivePage = parseInt(page, 10) || 1; + const skip = (effectivePage - 1) * effectiveLimit; + + /* + * Margin is COMPUTED (selling vs cost), so it cannot ride a find().sort() + * - the special { $margin: 1|-1 } marker switches to an aggregation that + * derives it per item. Items missing a selling price sort LAST in either + * direction: an uncomputable margin is not a low one, and surfacing it + * first would read as "these are your worst items" when nothing is known. + */ + const marginDir = sort && sort.$margin; + let itemsPromise; + if (marginDir === 1 || marginDir === -1) { + itemsPromise = collection + .aggregate([ + { $match: filter }, + { + $addFields: { + _margin: { + $cond: [ + { $gt: [{ $ifNull: ['$selling_price', 0] }, 0] }, + { + $divide: [ + { + $subtract: ['$selling_price', { $ifNull: ['$company_price', 0] }], + }, + '$selling_price', + ], + }, + marginDir === 1 ? Number.MAX_SAFE_INTEGER : -Number.MAX_SAFE_INTEGER, + ], + }, + }, + }, + { $sort: { _margin: marginDir, _id: -1 } }, + { $skip: skip }, + { $limit: effectiveLimit }, + { $project: BaseModel.getSelectFields(LegacyItemModel.fields) }, + ]) + .toArray(); + } else { + itemsPromise = collection .find(filter, { projection: BaseModel.getSelectFields(LegacyItemModel.fields), }) .sort(sort) .skip(skip) .limit(effectiveLimit) - .toArray(), - ]); + .toArray(); + } + + const [total, items] = await Promise.all([collection.countDocuments(filter), itemsPromise]); const list = items.map((doc) => BaseModel.simplifyFields(doc)); @@ -971,6 +1049,284 @@ class ItemRepository extends BaseModel { * (a tombstone would SYNC the non-event fleet-wide) - and their freshly * written 'Add Item' stock logs go with them. */ + /* + * Remove the demo data for good, and refuse to remove anything real. + * + * The switch hides; this destroys. So the whole value of it is in what it + * declines to touch: + * + * SOLD. A demo item exists to be rung up - that is how somebody finds out + * whether the till suits them - and a sale line stores item_id. Delete the + * item and the sale becomes a purchase of a product that does not exist. + * The sale is real even though the product was not, so nothing can put + * that right afterwards. + * + * RECEIVED. Same argument for a purchase or a stock receipt. + * + * EDITED. Changing the price on a sample and putting it on the shelf is + * how a small shop starts its real catalogue. By the time they press this + * button that row is THEIRS, whatever tag it still carries. + * + * What survives is reported by name, not counted. "Removed 128, kept 6" + * invites the question this function already knows the answer to, and a + * silent partial delete is worse than no delete at all. + * + * Soft, not hard: del_status is what the Recycle Bin reads, so this is still + * undoable by the shop that asked for it. A hard delete here would make + * "permanent" mean permanent in a way nobody asked for. + */ + async purgeDemoData({ branchId, licenseId, user } = {}) { + const items = await this.getCollection(this.collectionName); + const branch = this.toObjectId(branchId); + const license = this.toObjectId(licenseId); + + /* + * The sample sales and quotes go FIRST, and the order is the point. + * + * A demo sale references demo items. Remove the items while those sales + * still exist and every one of them is protected as "sold" - so the demo + * data would refuse to remove itself, held in place by its own samples. + * + * These are hard deletes, unlike the items. A sample sale is not a record + * of anything that happened, so keeping it in the Recycle Bin would only + * put invented money somewhere a shop can restore it from by accident. + */ + let salesRemoved = 0; + let quotesRemoved = 0; + let purchasesRemoved = 0; + let peopleRemoved = 0; + try { + const demoScope = { demo_pack: { $exists: true }, branch_id: branch, license }; + const salesCol = await this.getCollection('sales'); + salesRemoved = (await salesCol.deleteMany(demoScope)).deletedCount || 0; + const quotesCol = await this.getCollection('quotes'); + quotesRemoved = (await quotesCol.deleteMany(demoScope)).deletedCount || 0; + /* The sample purchases leave with the sample sales, or a Purchase + History full of DEMO rows survives the switch that promised to + remove them. */ + const receivingsCol = await this.getCollection('receivings'); + purchasesRemoved = (await receivingsCol.deleteMany(demoScope)).deletedCount || 0; + /* The sample people go with them. A demo customer left behind after the + samples are cleared is a stranger in the shop's own list, and nothing + on the row says where they came from. */ + const customersCol = await this.getCollection('customers'); + peopleRemoved += (await customersCol.deleteMany(demoScope)).deletedCount || 0; + const suppliersCol = await this.getCollection('suppliers'); + peopleRemoved += (await suppliersCol.deleteMany(demoScope)).deletedCount || 0; + } catch (e) { + /* Leaving the products behind is the safe half. Reported rather than + thrown, because a shop asking to clear samples should not be told the + whole thing failed when most of it worked. */ + console.error('purgeDemoData: sample sales/quotes:', e.message); + } + + const scope = { demo_pack: { $exists: true }, 'branch_access.branch_id': branch, license }; + const candidates = await items + .find(scope, { projection: { _id: 1, name: 1, demo_seeded_at: 1, updated_date: 1 } }) + .toArray(); + + if (!candidates.length) { + return { + status: true, + removed: 0, + salesRemoved, + quotesRemoved, + purchasesRemoved, + kept: [], + message: + salesRemoved || quotesRemoved || purchasesRemoved + ? `Removed ${salesRemoved} sample sale(s), ${quotesRemoved} sample quote(s) and ${purchasesRemoved} sample purchase(s).` + : 'There is no demo data to remove.', + }; + } + + /* Both shapes, because item_id is stored as a string in some collections + and an ObjectId in others. Matching only one silently finds nothing, + which here means deleting something that was sold. */ + const ids = candidates.map((c) => c._id); + const idPairs = ids.flatMap((id) => [id, String(id)]); + + const used = new Set(); + const noteUsed = (rows, pick) => { + for (const r of rows) { + for (const v of pick(r)) if (v) used.add(String(v)); + } + }; + + try { + const sales = await this.getCollection('sales'); + noteUsed( + await sales + .find({ 'items.item_id': { $in: idPairs } }, { projection: { 'items.item_id': 1 } }) + .toArray(), + (r) => (r.items || []).map((i) => i.item_id) + ); + } catch (e) { + /* Unreadable history is not permission to delete. Treat every candidate + as used rather than guess - the cost is that nothing is removed and + somebody asks why, which is recoverable. */ + console.error('purgeDemoData: could not read sales:', e.message); + return { + status: false, + removed: 0, + kept: [], + message: 'Could not check the sales history, so nothing was removed.', + }; + } + + try { + const receivings = await this.getCollection('receivings'); + noteUsed( + await receivings + .find({ 'items.item_id': { $in: idPairs } }, { projection: { 'items.item_id': 1 } }) + .toArray(), + (r) => (r.items || []).map((i) => i.item_id) + ); + } catch (e) { + console.error('purgeDemoData: could not read receivings:', e.message); + return { + status: false, + removed: 0, + kept: [], + message: 'Could not check the purchase history, so nothing was removed.', + }; + } + + const kept = []; + const removable = []; + for (const c of candidates) { + if (used.has(String(c._id))) { + kept.push({ name: c.name, why: 'sold or received' }); + continue; + } + const seeded = c.demo_seeded_at ? new Date(c.demo_seeded_at).getTime() : 0; + const touched = c.updated_date ? new Date(c.updated_date).getTime() : 0; + /* A second of slack: the seed writes created_date and updated_date in + the same pass, and clock resolution should not make every row look + edited. */ + if (seeded && touched && touched > seeded + 1000) { + kept.push({ name: c.name, why: 'you have edited it' }); + continue; + } + removable.push(c._id); + } + + let removed = 0; + if (removable.length) { + const now = new Date(); + const r = await items.updateMany( + { _id: { $in: removable }, license }, + { + $set: { + del_status: 1, + deleted_date: now, + updated_date: now, + deleted_by: (user && (user.name || user.username)) || 'System', + }, + } + ); + removed = r.modifiedCount || 0; + } + + /* + * Categories go only when nothing is left in them. A demo category still + * holding a product the shop kept is now their category, and emptying the + * shelf label out from under a product is its own small disaster. + */ + let categoriesRemoved = 0; + try { + const cats = await this.getCollection('categories'); + const demoCats = await cats + .find( + { demo_pack: { $exists: true }, branch_id: branch, license }, + { projection: { _id: 1 } } + ) + .toArray(); + for (const cat of demoCats) { + // eslint-disable-next-line no-await-in-loop + const remaining = await items.countDocuments({ + category_id: cat._id, + 'branch_access.branch_id': branch, + license, + /* LIVE products only. The purge itself soft-deletes the samples + into the Recycle Bin, so counting binned rows meant every demo + category was held in place by the very products this purge had + just removed - "switch off demo data" left the categories + standing, every time, and nothing said why. */ + del_status: { $nin: [1, '1', true] }, + }); + if (remaining === 0) { + // eslint-disable-next-line no-await-in-loop + await cats.deleteOne({ _id: cat._id, license }); + categoriesRemoved++; + } + } + } catch (e) { + /* The products are gone either way; a leftover empty category is untidy, + not harmful, and must not turn a successful removal into a failure. */ + console.error('purgeDemoData: category cleanup:', e.message); + } + + /* + * Units, by the category rule: a demo unit still measuring anything + * alive - a sample the shop sold or edited (kept above), an item of + * their own that adopted it - is now the shop's unit and stays. Only a + * unit measuring nothing leaves. Seed and purge are a pair: the seed + * writes these rows (install.service demo units), so the switch that + * promises "removed" must know how to remove them. + */ + let unitsRemoved = 0; + try { + const unitsCol = await this.getCollection('unit'); + const demoUnits = await unitsCol + .find( + { demo_pack: { $exists: true }, branch_id: branch, license }, + { projection: { _id: 1 } } + ) + .toArray(); + for (const u of demoUnits) { + // eslint-disable-next-line no-await-in-loop + const remaining = await items.countDocuments({ + /* unit_id is an ObjectId on rows the seed wrote and a string on + rows some editors write; matching one shape silently keeps or + orphans the other. */ + unit_id: { $in: [u._id, String(u._id)] }, + 'branch_access.branch_id': branch, + license, + del_status: { $nin: [1, '1', true] }, + }); + if (remaining === 0) { + // eslint-disable-next-line no-await-in-loop + await unitsCol.deleteOne({ _id: u._id, license }); + unitsRemoved++; + } + } + } catch (e) { + console.error('purgeDemoData: unit cleanup:', e.message); + } + + return { + status: true, + removed, + categoriesRemoved, + unitsRemoved, + salesRemoved, + purchasesRemoved, + quotesRemoved, + peopleRemoved, + kept, + message: kept.length + ? `Removed ${removed} sample product${removed === 1 ? '' : 's'}. Kept ${kept.length}: ` + + kept + .slice(0, 6) + .map((k) => `${k.name} (${k.why})`) + .join(', ') + + (kept.length > 6 ? ` and ${kept.length - 6} more` : '') + + '.' + : `Removed ${removed} sample product${removed === 1 ? '' : 's'}.`, + }; + } + async hardDeleteItems(ids, { licenseId } = {}) { const objectIds = (ids || []) .map((v) => (ObjectId.isValid(String(v)) ? new ObjectId(String(v)) : null)) @@ -1229,6 +1585,30 @@ class ItemRepository extends BaseModel { updateData.tile_color = /^#[0-9a-fA-F]{6}$/.test(tileColor) ? tileColor : ''; } + // Quick code (owner ask): digits only, up to 6, or empty clears. + if (data.plu_code !== undefined) { + const plu = String(data.plu_code || '').trim(); + updateData.plu_code = /^\d{1,6}$/.test(plu) ? plu : ''; + } + + // The tile's shape rides the same rules: a known shape or empty. + if (data.tile_shape !== undefined) { + const tileShape = String(data.tile_shape || '').trim(); + updateData.tile_shape = [ + 'square', + 'rounded', + 'circle', + 'diamond', + 'triangle', + 'pentagon', + 'hexagon', + 'star', + 'octagon', + ].includes(tileShape) + ? tileShape + : ''; + } + /* * Lightspeed study LS1 trio, all presence-gated: brand (a name, for * filtering and the future community catalog), tags (free keywords), @@ -2003,6 +2383,10 @@ class ItemRepository extends BaseModel { { barcode_id: regex }, { barcodes: regex }, ]; + // An all-digits query is how quick codes are typed. + if (/^\d{1,6}$/.test(String(query))) { + searchConditions.push({ plu_code: String(query) }); + } } // Stock availability logic @@ -2018,9 +2402,15 @@ class ItemRepository extends BaseModel { ], }; + /* Same two clauses as the sale grid, same reason: search is a doorway + to selling, and a hidden or deleted sample reachable by typing its + name is not hidden at all. */ + const demoClause = await demoData.filter({ licenseId, branchId }); const match = { $and: [ searchConditions.length ? { $or: searchConditions } : null, + Object.keys(demoClause).length ? demoClause : null, + { del_status: { $nin: [1, '1', true] } }, { 'branch_access.branch_id': branchObjectId }, { item_status: { $ne: 'instant' } }, stockCondition, @@ -2060,6 +2450,8 @@ class ItemRepository extends BaseModel { items_expiry_date: 1, item_kind: 1, tile_color: 1, + tile_shape: 1, + plu_code: 1, }, }, ]) @@ -2088,8 +2480,17 @@ class ItemRepository extends BaseModel { items_expiry_date: item.items_expiry_date != null ? String(item.items_expiry_date) : '', item_kind: item.item_kind || 'product', tile_color: item.tile_color || '', + tile_shape: item.tile_shape || '', + plu_code: item.plu_code || '', })); + // Exact quick-code hits lead the list - Enter carts them instantly. + if (/^\d{1,6}$/.test(String(query))) { + suggestions.sort( + (a, b) => (b.plu_code === String(query) ? 1 : 0) - (a.plu_code === String(query) ? 1 : 0) + ); + } + return { status: true, data: suggestions, @@ -2121,8 +2522,27 @@ class ItemRepository extends BaseModel { const licenseObjectId = licenseId && ObjectId.isValid(licenseId) ? new ObjectId(licenseId) : licenseId || null; + /* + * The demo filter and the deleted filter, HERE TOO. + * + * listScope's comment promised the demo clause lived in "the ONE place + * the item list builds its filter... so it cannot be applied to some + * reads and forgotten on others - a catalogue that hides sample items + * on the manage screen and shows them on the sale grid is worse than + * not hiding them at all." This query is the sale grid, it never went + * through listScope, and the exact failure that sentence names is the + * owner's report, verbatim: "i see nothing in item list but sales page + * is showing item... i hard refreshed page. not removed." + * + * del_status for the same reason: with Demo Data's off now a DELETION, + * a sale grid that ignores the deleted flag re-shells every purged + * sample the moment the purge lands. + */ + const demoClause = await demoData.filter({ licenseId, branchId }); const match = { $and: [ + ...(Object.keys(demoClause).length ? [demoClause] : []), + { del_status: { $nin: [1, '1', true] } }, { 'branch_access.branch_id': branchObjectId }, { item_status: { $ne: 'instant' } }, { sales_channel: true }, @@ -2174,6 +2594,7 @@ class ItemRepository extends BaseModel { track_inventory: item.track_inventory === true, // Tile colour (Loyverse study L2): the no-image tile's look. tile_color: item.tile_color || '', + tile_shape: item.tile_shape || '', })); return { @@ -2298,6 +2719,8 @@ class ItemRepository extends BaseModel { image: DEFAULTS.IMAGE, sort_order: 0, track_inventory: false, + // Instant lines sell any quantity - stock never blocks them. + negative_stock: true, sales_channel: true, ecommerce: false, item_status: ITEM_STATUS.INSTANT, @@ -2458,6 +2881,7 @@ class ItemRepository extends BaseModel { track_inventory: item.track_inventory !== false, item_kind: item.item_kind || 'product', tile_color: item.tile_color || '', + tile_shape: item.tile_shape || '', })); return { status: true, data: list, message: 'success' }; @@ -2491,6 +2915,12 @@ class ItemRepository extends BaseModel { { 'branch_access.branch_id': new ObjectId(String(branchId)) }, { item_status: { $ne: ITEM_STATUS.INSTANT } }, { supplier_id: new ObjectId(String(supplierId)) }, + /* A deleted product is not purchasable. Every other item query + carries NOT_DELETED; this one did not, so the purchase picker + happily offered items the catalogue had already removed - the + owner found it as "item list not showing but purchase shows + all items", which is the same fact seen from both ends. */ + NOT_DELETED, ]; if (licenseId && ObjectId.isValid(String(licenseId))) { conditions.push({ license: new ObjectId(String(licenseId)) }); @@ -3023,7 +3453,12 @@ class ItemRepository extends BaseModel { const licenseObjectId = licenseId && ObjectId.isValid(licenseId) ? new ObjectId(licenseId) : licenseId; + /* Same two clauses as the sale grid, same reason: a category tab is + just the shelf filtered, and it showed what the shelf hid. */ + const demoClause = await demoData.filter({ licenseId, branchId }); const conditions = [ + ...(Object.keys(demoClause).length ? [demoClause] : []), + { del_status: { $nin: [1, '1', true] } }, { item_status: { $ne: ITEM_STATUS.INSTANT } }, { $or: [{ available_quantity: { $gt: 0 } }, { negative_stock: true }], @@ -3477,6 +3912,71 @@ class ItemRepository extends BaseModel { } } + /* + * Targeted money-field patch from the sale screen's "update item too" + * choice: only the fields the cashier actually changed, nothing else on + * the item is touched. + */ + /* + * Every item this branch can sell, reduced to the four fields the GST + * readiness scan compares. Scoped by branch_access + license like the + * paged list above (branch_id/branch_name are stale legacy fields). + * Projected rather than fetched whole: this walks the entire catalogue. + */ + async listForGstReadiness({ branchId, licenseId } = {}) { + const collection = await this.getCollection(this.collectionName); + const filter = { + 'branch_access.branch_id': this.toObjectId(branchId), + license: this.toObjectId(licenseId), + }; + return collection + .find(filter, { + projection: { item_name: 1, name: 1, tax: 1, hsncode: 1, hsndescription: 1 }, + }) + .sort({ item_name: 1 }) + .toArray(); + } + + async quickPatch(id, fields) { + try { + const collection = await this.getCollection(this.collectionName); + const objectId = ObjectId.isValid(id) ? new ObjectId(id) : id; + const set = {}; + if (fields.selling_price !== undefined) { + const v = parseFloat(fields.selling_price); + if (Number.isFinite(v) && v >= 0) set.selling_price = Math.round(v * 100) / 100; + } + if (fields.tax !== undefined) { + const v = parseFloat(fields.tax); + if (Number.isFinite(v) && v >= 0 && v <= 100) set.tax = v; + } + if (fields.discount_percentage !== undefined) { + const v = parseFloat(fields.discount_percentage); + if (Number.isFinite(v) && v >= 0 && v <= 100) { + set.discount_percentage = v; + set.discount_amount = 0; + } + } + if (fields.discount_amount !== undefined) { + const v = parseFloat(fields.discount_amount); + if (Number.isFinite(v) && v >= 0) { + set.discount_amount = Math.round(v * 100) / 100; + set.discount_percentage = 0; + } + } + if (!Object.keys(set).length) { + return { status: false, message: 'Nothing to update' }; + } + set.updated_date = new Date(); + const result = await collection.updateOne({ _id: objectId }, { $set: set }); + if (!result.matchedCount) return { status: false, message: 'Item not found' }; + return { status: true, message: 'Item updated' }; + } catch (error) { + console.error('Error in ItemRepository.quickPatch:', error); + throw error; + } + } + async updateItemQuantity(id, value) { try { const collection = await this.getCollection(this.collectionName); @@ -4232,7 +4732,7 @@ class ItemRepository extends BaseModel { let taxDocuments = null; // Detect whether this import row is HSN-based. For HSN items we do - // not create/attach tax groups – they use HSN codes instead. + // not create/attach tax groups - they use HSN codes instead. const rawImportHsn = items.hsncode !== undefined && items.hsncode !== null ? items.hsncode @@ -4284,7 +4784,7 @@ class ItemRepository extends BaseModel { const taxRate = Number(items.tax); if (!hasHsnForTax && importTaxName) { - // Non-HSN item with explicit tax_name from CSV – treat as a + // Non-HSN item with explicit tax_name from CSV - treat as a // tax group name (e.g. "mugrt8"). // 1) Try existing tax group with this name @@ -4671,6 +5171,62 @@ class ItemRepository extends BaseModel { * an optional category and search so "select all N" matches exactly what the * filtered list showed, not just the 100 rows on the current page. */ + /* + * Every item in the catalogue, grouped into families, without holding the + * catalogue in memory. + * + * The export has to group variants together, and the obvious way to do that + * is to read everything and bucket it. This repository has already paid for + * that once: the catalogue copy called .toArray() and then built a second + * full array of the same rows, so two copies of a shop's entire item list + * were resident at once, on a process shared with every other shop. + * + * Sorting by variant_group_id means a family arrives contiguously, so the + * grouping can be done as the cursor advances - one family resident at a + * time. The caller gets each family as it completes and decides what to do + * with it. + * + * The sort is the load-bearing part. Without it a family is scattered + * through the stream and the "flush when the id changes" rule silently emits + * the same family several times, each with some of its variants. + */ + async streamCatalogue({ branchId, licenseId }, onGroup) { + const collection = await this.getCollection(this.collectionName); + + const filter = { + 'branch_access.branch_id': this.toObjectId(branchId), + license: this.toObjectId(licenseId), + }; + + const cursor = collection.find(filter).sort({ variant_group_id: 1, _id: 1 }); + + let current = null; + let pending = []; + let count = 0; + + const flush = async () => { + if (!pending.length) return; + await onGroup(pending); + count += pending.length; + pending = []; + }; + + for await (const row of cursor) { + const gid = row.variant_group_id ? String(row.variant_group_id) : ''; + /* Items with no family are each their own group, so an empty id must not + collapse them all into one enormous "family". */ + if (!gid || gid !== current) { + await flush(); + current = gid || null; + } + pending.push(row); + if (!gid) await flush(); + } + await flush(); + + return count; + } + _buildExportFilter(selection, context = {}) { const licenseId = context.licenseId || null; const licenseClause = licenseId diff --git a/api/src/repositories/quote.repository.js b/api/src/repositories/quote.repository.js new file mode 100644 index 000000000..796275455 --- /dev/null +++ b/api/src/repositories/quote.repository.js @@ -0,0 +1,671 @@ +'use strict'; + +/* + * Quotes (Lightspeed study LS2): a priced offer, printable and convertible, + * and NOTHING else. A quote never touches stock, never records a payment, + * never becomes revenue by itself - the sale it converts into does all of + * that through the ordinary sale path, which sends source_quote_id so the + * quote can be stamped converted. A test pins that the only collection this + * repository touches is its own. + */ + +const BaseModel = require('../models/base.model'); +const { ObjectId } = require('mongodb'); +const { ensureIndexOnce } = require('../db/ensure-index'); +const docMath = require('../services/document-math'); + +const STATUSES = Object.freeze([ + 'open', + 'draft', + 'sent', + 'accepted', + 'declined', + 'invoiced', + 'converted', + 'cancelled', +]); +/* draft and sent behave like open: still editable, still convertible. + accepted freezes edits (the promise is made); declined/converted/ + cancelled are history. invoiced (INVOICING_MODULE_DESIGN) means an + invoice now carries the numbers: the quote is frozen like accepted, and + it is the INVOICE that converts to the sale - which stamps the quote + converted in turn, so the chain reads quote -> invoice -> sale. */ +const EDITABLE = Object.freeze(['open', 'draft', 'sent']); + +class QuoteRepository extends BaseModel { + constructor() { + super('quotes'); + } + + _wall(context) { + const branchId = context && context.branchId; + const licenseId = context && context.licenseId; + if (!branchId || !ObjectId.isValid(String(branchId))) return null; + const wall = { branch_id: new ObjectId(String(branchId)) }; + if (licenseId && ObjectId.isValid(String(licenseId))) { + wall.license = new ObjectId(String(licenseId)); + } + return wall; + } + + /* Next quote number for the branch: QUO-000001 style, resilient to holes. */ + async _nextQuoteId(collection, wall) { + const rows = await collection + .find({ branch_id: wall.branch_id }, { projection: { quote_id: 1 } }) + .toArray(); + let max = 0; + for (const r of rows) { + const n = Number(String((r && r.quote_id) || '').replace(/^QUO-?/i, '')); + if (Number.isFinite(n)) max = Math.max(max, n); + } + return 'QUO-' + String(max + 1).padStart(6, '0'); + } + + /* + * The money arithmetic moved to services/document-math when invoices + * arrived: a quote becomes an invoice becomes a sale, and one copy of the + * rounding rules is the only way the three documents agree. These thin + * delegates keep the repository's own surface unchanged. + */ + static _round2(n) { + return docMath.round2(n); + } + + static _discountOf(raw, gross) { + return docMath.discountOf(raw, gross); + } + + _normalizeLines(rows) { + return docMath.normalizeLines(rows, 'quote'); + } + + _normalizeCharges(rows) { + return docMath.normalizeCharges(rows); + } + + _normalizeBlocks(rows) { + return docMath.normalizeBlocks(rows); + } + + _normalizeLayout(value) { + return docMath.normalizeLayout(value); + } + + _validUntil(value) { + return docMath.dateOrNull(value); + } + + /* Create (id empty) or update - open quotes only; history is not editable. */ + async upsertQuote(data = {}, id = '', context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + + const parsed = this._normalizeLines(data.lines || data.items); + if (parsed.error) return { status: false, data: null, message: parsed.error }; + + /* Money authority (QUOTATION_MODULE_DESIGN rule 4) lives in + document-math.computeTotals, shared with invoices. */ + const money = docMath.computeTotals({ + lines: parsed.lines, + charges: this._normalizeCharges(data.charges), + discount: data.discount, + clientTotal: data.total, + clientTaxTotal: data.tax_total, + }); + + const now = new Date(); + const collection = await this.getCollection(this.collectionName); + + const doc = { + branch_id: wall.branch_id, + branch_name: String(context.branchName || '').trim(), + customer_id: + data.customer_id && ObjectId.isValid(String(data.customer_id)) + ? new ObjectId(String(data.customer_id)) + : null, + customer_name: String(data.customer_name || '').trim(), + customer_phone: String(data.customer_phone || '').trim(), + // Professional quotation fields (owner spec): all presence-tolerant + // strings the preview edits in place. + customer_address: String(data.customer_address || '') + .trim() + .slice(0, 300), + customer_gstin: String(data.customer_gstin || '') + .trim() + .slice(0, 20), + customer_email: String(data.customer_email || '') + .trim() + .slice(0, 120), + payment_method: String(data.payment_method || '') + .trim() + .slice(0, 60), + bank_details: String(data.bank_details || '') + .trim() + .slice(0, 500), + terms: String(data.terms || '') + .trim() + .slice(0, 1500), + items: parsed.lines, + charges: money.charges, + discount: money.discount, + charges_total: money.charges_total, + custom_blocks: this._normalizeBlocks(data.custom_blocks), + layout: this._normalizeLayout(data.layout), + notes: String(data.notes || '') + .trim() + .slice(0, 2000), + subtotal: money.subtotal, + tax_total: money.tax_total, + total: money.total, + valid_until: this._validUntil(data.valid_until), + note: String(data.note || '') + .trim() + .slice(0, 500), + updated_date: now, + updated_by: String(context.userName || ''), + }; + if (wall.license) doc.license = wall.license; + + if (id) { + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid quote id' }; + } + const result = await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall, status: { $in: EDITABLE } }, + { $set: doc } + ); + if (!result.matchedCount) { + return { + status: false, + data: null, + message: 'This quote can no longer be edited - it is accepted, converted or closed', + }; + } + return { status: true, data: { id: String(id) }, message: 'Quote updated' }; + } + + /* A new quote starts from the shop's quotation defaults (settings) + wherever the sale screen sent nothing - each stays editable on the + quote's preview. A defaults lookup that fails is only a nicety + missed, never a failed save. */ + if (!doc.payment_method || !doc.bank_details || !doc.terms) { + try { + const branches = await this.getCollection('branches'); + const b = await branches.findOne( + { _id: wall.branch_id }, + { + projection: { + quote_default_payment_method: 1, + quote_default_bank_details: 1, + quote_default_terms: 1, + }, + } + ); + if (b) { + if (!doc.payment_method) + doc.payment_method = String(b.quote_default_payment_method || '') + .trim() + .slice(0, 60); + if (!doc.bank_details) + doc.bank_details = String(b.quote_default_bank_details || '') + .trim() + .slice(0, 500); + if (!doc.terms) + doc.terms = String(b.quote_default_terms || '') + .trim() + .slice(0, 1500); + } + } catch (e) { + /* defaults are a nicety */ + } + } + + doc.quote_id = await this._nextQuoteId(collection, wall); + doc.status = 'open'; + doc.converted_sale_id = null; + doc.created_date = now; + doc.created_by = String(context.userName || ''); + const inserted = await collection.insertOne(doc); + return { + status: true, + data: { id: String(inserted.insertedId), quote_id: doc.quote_id }, + message: 'Quote saved', + }; + } catch (error) { + console.error('Error in QuoteRepository.upsertQuote:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * Quote list: status filter, free-text search and paging all decided + * HERE. They used to be decided in the browser over whatever the first + * 100 rows happened to be, which was not merely slow - past 100 quotes a + * search for an older one returned "no quotes here yet" for a quote that + * plainly exists, and the pager only ever walked that same first 100. + * `meta` carries the true total so the pager knows how far the list goes. + */ + /* + * The index the quote list needs. + * + * Without it every list request is a full collection scan, and the cost is + * paid three times over: + * + * - the scan itself, on a filter that is always branch + license + * - AGAIN for countDocuments, which the pager needs and which runs the + * same filter a second time on every request + * - and the sort. Sorting created_date with no index behind it happens in + * memory, which Mongo caps at 32MB. Past that it does not get slower, it + * ERRORS - so the list would go from working to "Could not load quotes" + * at some unannounced number of quotes. + * + * `{ branch_id, license, created_date }` fixes all three: Mongo walks only + * this branch's quotes, already in the order the list wants them. + * + * It does NOT help the search regex - /term/i is unanchored and + * case-insensitive, so no index can serve it. But the wall runs first, so + * the regex is then tested against one branch's quotes rather than every + * quote in the database, which is where the real cost was. + * + * Best-effort and once per process, following the sales precedent: an index + * build that fails must never fail a page load. + */ + async _ensureListIndex(collection) { + /* Once per DATABASE, not once per process. Each shop has its own database + and one process serves many, so a static boolean would give the index to + whichever shop happened to ask first and to nobody else. */ + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, created_date: -1 }, + { name: 'quote_list_by_branch' } + ); + /* The whitelisted sorts (high amount, valid-till) walk these. */ + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, total: -1 }, + { name: 'quote_list_by_total' } + ); + await ensureIndexOnce( + collection, + { branch_id: 1, license: 1, valid_until: 1 }, + { name: 'quote_list_by_valid_until' } + ); + } + + async listQuotes(params = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + const filter = { ...wall }; + if (params.status && STATUSES.includes(String(params.status))) { + filter.status = String(params.status); + } + /* + * Free text, plus the two filters that actually get used on a long + * list: WHICH field, and WHEN. + * + * `field` narrows the search to one column instead of both. `exact` + * anchors it, and anchoring is not cosmetic: /^QUO-000012$/ can be + * served by an index where /QUO/ cannot. A shop that searches by quote + * number gets a seek instead of a scan of its whole history, which is + * the difference that matters once the list is long. + */ + const term = String(params.search || '') + .trim() + .slice(0, 80); + if (term) { + // user text becomes a regex: escape it, or a stray '(' is a 500 + const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rx = + String(params.exact) === 'true' ? new RegExp(`^${safe}$`, 'i') : new RegExp(safe, 'i'); + const field = String(params.field || 'all'); + if (field === 'quote_id') filter.quote_id = rx; + else if (field === 'customer_name') filter.customer_name = rx; + else filter.$or = [{ customer_name: rx }, { quote_id: rx }]; + } + + /* + * Date range on created_date. The list index is + * { branch_id, license, created_date }, so a range is a seek down that + * index and the sort it already needs comes free from the same walk. + * + * `to` is pushed to the END of its day. Picking 21 Aug means "including + * the 21st"; comparing against midnight would silently drop a day of + * quotes and read as data loss. + */ + const range = {}; + const rawFrom = params.from ? String(params.from) : ''; + const rawTo = params.to ? String(params.to) : ''; + const from = rawFrom ? new Date(rawFrom) : null; + const to = rawTo ? new Date(rawTo) : null; + if (from && !Number.isNaN(from.getTime())) range.$gte = from; + if (to && !Number.isNaN(to.getTime())) { + /* Only a DATE-ONLY value gets pushed to the end of its day. + "2026-08-21" means "including the 21st", and comparing it against + midnight would silently drop a day. But the filter bar now sends a + precise instant, and forcing 23:59 onto that would widen a range the + user deliberately narrowed - "up to 2pm" would quietly mean "up to + midnight". The presence of a time is what tells the two apart. */ + if (/^\d{4}-\d{2}-\d{2}$/.test(rawTo.trim())) to.setHours(23, 59, 59, 999); + range.$lte = to; + } + if (Object.keys(range).length) filter.created_date = range; + const limit = Math.min(Math.max(Number(params.limit) || 100, 1), 200); + const page = Math.max(Number(params.page) || 1, 1); + const collection = await this.getCollection(this.collectionName); + await this._ensureListIndex(collection); + + /* + * The exact total is the most expensive thing on this screen and the + * least useful, so it is only paid for when it is cheap. + * + * countDocuments runs the whole filter a SECOND time. Branch, status and + * a date range are all served by the list index, so counting them is a + * seek and worth having - real totals, real page numbers. An unanchored + * regex cannot use any index, so counting a text search means running + * that regex across every candidate row, twice per request. + * + * Nobody acts on "1,247 quotes". They act on "is mine here" and "is + * there more". The second needs ONE extra row, not a count: ask for + * limit + 1, and if it comes back there is a next page. One query + * instead of two, on exactly the case that would hurt. + */ + const countable = !term; + let total = null; + let rows; + + /* + * Sort, whitelisted (owner: high amount, valid-till, date). Only + * these names reach the query - a raw client field would sort by + * anything on the document. + */ + const QUOTE_SORTS = { + recent: { created_date: -1 }, + total_desc: { total: -1, _id: -1 }, + total_asc: { total: 1, _id: -1 }, + valid_asc: { valid_until: 1, _id: -1 }, + valid_desc: { valid_until: -1, _id: -1 }, + }; + const sort = QUOTE_SORTS[String(params.sort || '')] || { created_date: -1 }; + + if (countable) { + total = await collection.countDocuments(filter); + rows = await collection + .find(filter) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit) + .toArray(); + } else { + rows = await collection + .find(filter) + .sort(sort) + .skip((page - 1) * limit) + .limit(limit + 1) + .toArray(); + } + + // the probe row is proof of a next page, never something to display + const hasMore = countable ? page * limit < total : rows.length > limit; + if (!countable && rows.length > limit) rows = rows.slice(0, limit); + + return { + status: true, + data: rows, + meta: { + total, + page, + limit, + hasMore, + // pages is only honest when a total was actually measured + pages: total === null ? null : Math.max(1, Math.ceil(total / limit)), + }, + message: 'success', + }; + } catch (error) { + console.error('Error in QuoteRepository.listQuotes:', error); + return { status: false, data: null, message: error.message }; + } + } + + async getQuote(id, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid quote id' }; + } + const collection = await this.getCollection(this.collectionName); + const doc = await collection.findOne({ _id: new ObjectId(String(id)), ...wall }); + if (!doc) return { status: false, data: null, message: 'Quote not found' }; + return { status: true, data: doc, message: 'success' }; + } catch (error) { + console.error('Error in QuoteRepository.getQuote:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * Transitions: convert (open -> converted, stamping the sale that came + * from it) and cancel (open -> cancelled). Converting an already + * converted quote with the SAME sale id is a replay and succeeds; + * with a different sale id it is refused - one quote, one sale. + */ + async transition(id, action, data = {}, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid quote id' }; + } + const collection = await this.getCollection(this.collectionName); + const doc = await collection.findOne({ _id: new ObjectId(String(id)), ...wall }); + if (!doc) return { status: false, data: null, message: 'Quote not found' }; + + if (action === 'cancel') { + if (doc.status === 'converted' || doc.status === 'cancelled') { + return { status: false, data: null, message: 'This quote is already closed' }; + } + if (doc.status === 'invoiced') { + return { + status: false, + data: null, + message: 'This quote became an invoice - cancel the invoice instead', + }; + } + await collection.updateOne( + { _id: doc._id }, + { $set: { status: 'cancelled', updated_date: new Date() } } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Quote cancelled' }; + } + + /* + * The quote left the shop (emailed, WhatsApped, linked): open/draft + * become 'sent'. Idempotent and quiet - re-sending an already-sent + * quote succeeds without touching it, and history states refuse. + */ + if (action === 'send') { + if (doc.status === 'sent') { + return { status: true, data: { id: String(doc._id) }, message: 'Quote already sent' }; + } + if (!EDITABLE.includes(doc.status)) { + return { status: false, data: null, message: 'This quote is closed - nothing to send' }; + } + await collection.updateOne( + { _id: doc._id }, + { $set: { status: 'sent', sent_date: new Date(), updated_date: new Date() } } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Quote marked sent' }; + } + + /* The customer said yes / no. Accepting freezes edits - the numbers + are now a promise; converting is still allowed (that IS the point). */ + if (action === 'accept' || action === 'decline') { + if (!EDITABLE.includes(doc.status)) { + return { + status: false, + data: null, + message: 'Only an open quote can be accepted or declined', + }; + } + const status = action === 'accept' ? 'accepted' : 'declined'; + await collection.updateOne( + { _id: doc._id }, + { $set: { status, [action + 'ed_date']: new Date(), updated_date: new Date() } } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Quote ' + status }; + } + + /* + * The quote became an invoice (INVOICING_MODULE_DESIGN): the invoice + * repository creates the document and then stamps the quote here. One + * quote, one invoice - a replay with the same invoice id succeeds, a + * different one is refused. + */ + if (action === 'invoice') { + const invoiceId = + data.invoice_id && ObjectId.isValid(String(data.invoice_id)) + ? new ObjectId(String(data.invoice_id)) + : null; + if (!invoiceId) return { status: false, data: null, message: 'An invoice id is required' }; + if (doc.status === 'invoiced') { + const same = doc.invoice_id && String(doc.invoice_id) === String(invoiceId); + return same + ? { status: true, data: { id: String(doc._id) }, message: 'Quote already invoiced' } + : { status: false, data: null, message: 'This quote already has an invoice' }; + } + if (!EDITABLE.includes(doc.status) && doc.status !== 'accepted') { + return { + status: false, + data: null, + message: 'Only an open or accepted quote can become an invoice', + }; + } + await collection.updateOne( + { _id: doc._id }, + { + $set: { + status: 'invoiced', + invoice_id: invoiceId, + invoice_number: String(data.invoice_number || '').slice(0, 40), + invoiced_date: new Date(), + updated_date: new Date(), + }, + } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Quote invoiced' }; + } + + if (action === 'convert') { + const saleId = + data.sale_id && ObjectId.isValid(String(data.sale_id)) + ? new ObjectId(String(data.sale_id)) + : null; + if (doc.status === 'converted') { + const same = + saleId && doc.converted_sale_id && String(doc.converted_sale_id) === String(saleId); + return same + ? { status: true, data: { id: String(doc._id) }, message: 'Quote already converted' } + : { status: false, data: null, message: 'This quote was already converted to a sale' }; + } + /* invoiced joins the list: the invoice's own conversion stamps the + quote it came from, closing the chain. */ + if ( + !EDITABLE.includes(doc.status) && + doc.status !== 'accepted' && + doc.status !== 'invoiced' + ) { + return { + status: false, + data: null, + message: 'Only an open or accepted quote can be converted', + }; + } + await collection.updateOne( + { _id: doc._id }, + { + $set: { + status: 'converted', + converted_sale_id: saleId, + converted_date: new Date(), + updated_date: new Date(), + }, + } + ); + return { status: true, data: { id: String(doc._id) }, message: 'Quote converted' }; + } + + return { status: false, data: null, message: 'Unknown action' }; + } catch (error) { + console.error('Error in QuoteRepository.transition:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* Share metadata: which S3 object currently represents this quote. Any + status may share - sending a converted quote's record is legitimate. + Revisions are the caller's job; this only persists the newest one. */ + async recordShare(id, share, context) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid quote id' }; + } + const collection = await this.getCollection(this.collectionName); + const result = await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall }, + { + $set: { + share: { + key: String(share.key || ''), + url: String(share.url || ''), + rev: Number(share.rev) || 1, + at: new Date(), + }, + updated_date: new Date(), + }, + } + ); + if (!result.matchedCount) return { status: false, data: null, message: 'Quote not found' }; + /* A shared quote has been SENT - open/draft move on; anything else + (sent already, accepted, history) is left exactly as it is. */ + await collection.updateOne( + { _id: new ObjectId(String(id)), ...wall, status: { $in: ['open', 'draft'] } }, + { $set: { status: 'sent', sent_date: new Date() } } + ); + return { status: true, data: { rev: Number(share.rev) || 1 }, message: 'Share recorded' }; + } catch (error) { + console.error('Error in QuoteRepository.recordShare:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* Delete: open quotes only, enforced in the query itself. */ + async deleteQuote(id, context = {}) { + try { + const wall = this._wall(context); + if (!wall) return { status: false, data: null, message: 'Branch ID not found' }; + if (!ObjectId.isValid(String(id))) { + return { status: false, data: null, message: 'Invalid quote id' }; + } + const collection = await this.getCollection(this.collectionName); + const result = await collection.deleteOne({ + _id: new ObjectId(String(id)), + ...wall, + status: { $in: EDITABLE }, + }); + if (!result.deletedCount) { + return { status: false, data: null, message: 'Only an open quote can be deleted' }; + } + return { status: true, data: { id: String(id) }, message: 'Quote deleted' }; + } catch (error) { + console.error('Error in QuoteRepository.deleteQuote:', error); + return { status: false, data: null, message: error.message }; + } + } +} + +module.exports = QuoteRepository; diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 9f008c444..d51069ea3 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -3,6 +3,7 @@ const { currentConnection } = require('../db/tenant-context'); const { ObjectId } = require('mongodb'); const crypto = require('crypto'); const BaseModel = require('../models/base.model'); +const { ensureIndexOnce } = require('../db/ensure-index'); const { formatDate } = require('../utils/helpers'); const StockLogsRepository = require('./stock-log.repository'); const { PAYMENT_STATUS } = require('../constants'); @@ -12,6 +13,37 @@ const activeTenantFilter = () => ({ ...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}), }); +/* + * GST place-of-supply code for a B2B invoice. + * + * A GSTIN's first two digits ARE the state code (35AAAA...), so the + * customer's own number is the authoritative source and needs no table. + * The stored state NAME is the fallback for rows whose GSTIN is missing + * or malformed. Returns '' when neither resolves - better an empty field + * the shop can see and fix than a wrong state silently filed. + */ +let _gstStateByName = null; +const gstStateCode = (gstin, stateName) => { + const num = String(gstin || '').trim(); + if (/^\d{2}/.test(num)) return num.slice(0, 2); + const name = String(stateName || '') + .trim() + .toLowerCase(); + if (!name) return ''; + if (!_gstStateByName) { + _gstStateByName = new Map(); + try { + const list = require('../json/gst_state_code.json').gststate || []; + for (const row of list) { + _gstStateByName.set(String(row.value || '').toLowerCase(), String(row.id || '')); + } + } catch (e) { + /* file unreadable: GSTIN-derived codes still work */ + } + } + return _gstStateByName.get(name) || ''; +}; + const round = (value, decimals = 2) => { const num = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(num)) return 0; @@ -2910,6 +2942,23 @@ class SalesRepository { } } + /* + * The item/category details tables send these rows to the client RAW, and + * the client renders row.items_total.toFixed(2) per row - so one sale + * document without the denormalised totals (demo-seeded sales carry + * sales_total only; imports and old PHP data vary too) white-paged the + * whole details view. The totals are answered here with the same fallbacks + * the sales list formatter uses, so every row is renderable. + */ + _renderableSaleRows(rawList) { + const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100; + return (rawList || []).map((doc) => ({ + ...doc, + items_total: round2(doc.items_total ?? doc.sales_total ?? doc.total ?? 0), + items_return_total: round2(doc.items_return_total ?? 0), + })); + } + async itemSaleDetailsPage(value, options = {}, { SaleModel } = {}) { try { const Model = this.getModel(SaleModel); @@ -3004,7 +3053,9 @@ class SalesRepository { const sort = options.sort || { _id: 1 }; // Fetch paginated list for table (similar to PHP parent::page result) - const list = await Model.find(tableFilters).sort(sort).skip(skip).limit(limit).lean(); + const list = this._renderableSaleRows( + await Model.find(tableFilters).sort(sort).skip(skip).limit(limit).lean() + ); const totalCount = await Model.countDocuments(tableFilters); @@ -3157,7 +3208,9 @@ class SalesRepository { const sort = options.sort || { _id: 1 }; // Fetch paginated list for table (similar to PHP parent::page result) - const list = await Model.find(tableFilters).sort(sort).skip(skip).limit(limit).lean(); + const list = this._renderableSaleRows( + await Model.find(tableFilters).sort(sort).skip(skip).limit(limit).lean() + ); const totalCount = await Model.countDocuments(tableFilters); @@ -3289,7 +3342,9 @@ class SalesRepository { const skip = Math.max(0, (page - 1) * limit); const sort = options.sort || { _id: 1 }; - const list = await Model.find(filters).sort(sort).skip(skip).limit(limit).lean(); + const list = this._renderableSaleRows( + await Model.find(filters).sort(sort).skip(skip).limit(limit).lean() + ); const totalCount = await Model.countDocuments(filters); @@ -4233,7 +4288,9 @@ class SalesRepository { const sort = options.sort || { _id: 1 }; // Fetch paginated list of sales for this customer - const list = await Model.find(matchFilter).sort(sort).skip(skip).limit(limit).lean(); + const list = this._renderableSaleRows( + await Model.find(matchFilter).sort(sort).skip(skip).limit(limit).lean() + ); const totalCount = await Model.countDocuments(matchFilter); @@ -4334,7 +4391,9 @@ class SalesRepository { const sort = options.sort || { _id: 1 }; // Fetch paginated list of sales for this customer category - const list = await Model.find(matchFilter).sort(sort).skip(skip).limit(limit).lean(); + const list = this._renderableSaleRows( + await Model.find(matchFilter).sort(sort).skip(skip).limit(limit).lean() + ); const totalCount = await Model.countDocuments(matchFilter); @@ -5851,6 +5910,9 @@ class SalesRepository { return_sales_id: '$sales_id', return_sales_date: '$date', return_customer_state: '$customer_state', + // the credit-note rows printed a placeholder GSTIN; carry + // the real one so the 9B table shows the actual customer + return_customer_gst_number: '$customer_gst_number', return_id: '$items_return.returnArray.returnValue.return_id', return_date: '$items_return.returnArray.returnValue.return_date', return_tax: '$items_return.returnArray.returnValue.tax', @@ -5888,6 +5950,7 @@ class SalesRepository { ? new Date(item._id.return_sales_date).toLocaleDateString('en-GB') : '', return_customer_state: item._id.return_customer_state || '', + return_customer_gst_number: item._id.return_customer_gst_number || '', return_total: Math.round(item._id.return_total * 100) / 100, return_tax: Math.round(item._id.return_tax * 100) / 100, return_subtotal: Math.round((item._id.return_total - returnMultipleValue) * 100) / 100, @@ -6025,6 +6088,182 @@ class SalesRepository { } } + /* + * The Tax Payable view (PURCHASE_TAX_PLAN P4/G4): the plain-words answer + * to "how much do I owe this period" that every regime gets. + * + * Month by month: output tax collected on sales | input tax paid on + * credit-claimable purchases | net payable per head, with the credits + * applied in the statutory order - IGST credit spends against IGST, then + * CGST, then SGST; CGST credit against CGST then IGST; SGST against SGST + * then IGST; CGST and SGST NEVER pay each other. Single-head regimes fall + * out naturally: their cgst/sgst columns are simply zero. + * + * Purchases whose itc_eligible flag is false are excluded from the input + * side; ABSENT means eligible - every purchase recorded before the flag + * existed keeps the credit it always represented. + */ + async taxPayablePage(data) { + try { + const { starting_date, ending_date, branch_id, license } = data; + const FromDate = new Date(starting_date); + const ToDate = new Date(ending_date); + const salesCollection = currentConnection(mongoose.connection).collection('sales'); + const receivingsCollection = currentConnection(mongoose.connection).collection('receivings'); + + const monthKey = { year: { $year: '$date' }, month: { $month: '$date' } }; + const headSums = { + igst: { $sum: { $ifNull: ['$items.igst_tax', 0] } }, + cgst: { $sum: { $ifNull: ['$items.cgst_tax', 0] } }, + sgst: { $sum: { $ifNull: ['$items.sgst_tax', 0] } }, + taxable: { $sum: { $ifNull: ['$items.subtotal', '$items.total_amount'] } }, + }; + + const scope = { + branch_id: new ObjectId(branch_id), + license: new ObjectId(license), + date: { $gte: FromDate, $lte: ToDate }, + }; + + const [salesRows, purchaseRows] = await Promise.all([ + salesCollection + .aggregate([ + { $match: scope }, + { $unwind: '$items' }, + { $group: { _id: monthKey, ...headSums } }, + ]) + .toArray(), + receivingsCollection + .aggregate([ + { + $match: { + ...scope, + itc_eligible: { $ne: false }, + receiving_status: { $ne: 'Cancelled' }, + }, + }, + { $unwind: '$items' }, + { $group: { _id: monthKey, ...headSums } }, + ]) + .toArray(), + ]); + + const r2 = (n) => Math.round((Number(n) || 0) * 100) / 100; + const months = new Map(); + const bucket = (id) => { + const key = `${id.year}-${String(id.month).padStart(2, '0')}`; + if (!months.has(key)) { + months.set(key, { + period: key, + output: { igst: 0, cgst: 0, sgst: 0, taxable: 0 }, + input: { igst: 0, cgst: 0, sgst: 0, taxable: 0 }, + }); + } + return months.get(key); + }; + for (const row of salesRows) { + const b = bucket(row._id).output; + b.igst = r2(row.igst); + b.cgst = r2(row.cgst); + b.sgst = r2(row.sgst); + b.taxable = r2(row.taxable); + } + for (const row of purchaseRows) { + const b = bucket(row._id).input; + b.igst = r2(row.igst); + b.cgst = r2(row.cgst); + b.sgst = r2(row.sgst); + b.taxable = r2(row.taxable); + } + + const rows = Array.from(months.values()).sort((a, b) => a.period.localeCompare(b.period)); + const { netTaxHeads } = require('../utils/tax-netting'); + for (const m of rows) { + const netted = netTaxHeads(m.output, m.input); + m.net = netted.net; + m.credit_carried = netted.credit_carried; + m.output.total = r2(m.output.igst + m.output.cgst + m.output.sgst); + m.input.total = r2(m.input.igst + m.input.cgst + m.input.sgst); + } + + return { status: true, data: { months: rows }, message: 'success' }; + } catch (error) { + console.error('Error in taxPayablePage:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * The purchase register beneath the payable view: every purchase in the + * period with its tax identity - supplier, tax-ID, the three heads, the + * credit flag and whether the declared invoice total disagreed. The + * accountant's working paper, exportable like every other report. + */ + async taxPayableRegisterPage(data) { + try { + const { starting_date, ending_date, branch_id, license } = data; + const receivingsCollection = currentConnection(mongoose.connection).collection('receivings'); + const rows = await receivingsCollection + .aggregate([ + { + $match: { + branch_id: new ObjectId(branch_id), + license: new ObjectId(license), + date: { $gte: new Date(starting_date), $lte: new Date(ending_date) }, + }, + }, + { + $project: { + receiving_id: 1, + receiving_status: 1, + date: 1, + supplier_name: 1, + supplier_gst_number: 1, + itc_eligible: 1, + invoice_total_declared: 1, + invoice_total_mismatch: 1, + total_amount: 1, + subtotal_amount: 1, + has_document: { $gt: [{ $size: { $ifNull: ['$image', []] } }, 0] }, + igst: { + $sum: { + $map: { + input: { $ifNull: ['$items', []] }, + as: 'i', + in: { $ifNull: ['$$i.igst_tax', 0] }, + }, + }, + }, + cgst: { + $sum: { + $map: { + input: { $ifNull: ['$items', []] }, + as: 'i', + in: { $ifNull: ['$$i.cgst_tax', 0] }, + }, + }, + }, + sgst: { + $sum: { + $map: { + input: { $ifNull: ['$items', []] }, + as: 'i', + in: { $ifNull: ['$$i.sgst_tax', 0] }, + }, + }, + }, + }, + }, + { $sort: { date: -1 } }, + ]) + .toArray(); + return { status: true, data: { list: rows }, message: 'success' }; + } catch (error) { + console.error('Error in taxPayableRegisterPage:', error); + return { status: false, data: null, message: error.message }; + } + } + async gstThreeReportPage(data, { SaleModel } = {}) { try { const { starting_date, ending_date, branch_id, license, branch_state } = data; @@ -6342,13 +6581,26 @@ class SalesRepository { } } + /* + * GSTR-1 B2B section for the government offline tool. + * + * Rewritten 2026-08-20 - the previous shape could not be filed: + * - CGST and SGST were summed into `csamt`, which is the CESS field. + * The correct fields are `camt` and `samt`; cess stays 0 until the + * product actually records cess. + * - Every aggregation row became its own b2b entry, so one customer + * appeared many times and one invoice split across entries. The + * tool needs ONE entry per GSTIN holding all of its invoices, and + * one `itms` line per TAX RATE inside each invoice. + * - `rt` summed the rate column across items (9 + 9 = 18%), and both + * `pos` (place of supply) and `num` were hardcoded. + * Money is summed per (invoice, rate) in Mongo, then assembled here. + */ async gstOneReportPageJson(data, { SaleModel } = {}) { try { - // Parse dates const fromDate = new Date(data.starting_date); const toDate = new Date(data.ending_date); - // Get current branch and license from session/context const branchId = data.branch_id ? new ObjectId(data.branch_id) : null; const license = data.license ? new ObjectId(data.license) : null; @@ -6360,7 +6612,7 @@ class SalesRepository { }; } - // Filter for registered customers (regular/composite) + // Registered customers only - B2B is what this section reports. const filters = { $and: [ { @@ -6377,70 +6629,84 @@ class SalesRepository { const salesCollection = currentConnection(mongoose.connection).collection('sales'); - // Sales details aggregation - const salesList = await salesCollection + // One row per (invoice, tax rate) - the exact grain of an `itms` line. + const rows = await salesCollection .aggregate([ { $unwind: '$items' }, { $match: filters }, { $group: { _id: { - item_sales_id: '$sales_id', - item_date: '$date', - item_customer_gst_number: '$customer_gst_number', - tax_amount: '$items.tax_amount', - total_amount: '$items.total_amount', - item_price: '$items.item_price', - }, - total_amount: { $sum: '$items.total_amount' }, - tax_value: { $sum: '$items.tax' }, - item_igst_tax: { $sum: '$items.igst_tax' }, - csgst_multiply: { - $sum: { $add: ['$items.cgst_tax', '$items.sgst_tax'] }, + sales_id: '$sales_id', + ctin: '$customer_gst_number', + rate: '$items.tax', }, + date: { $first: '$date' }, + customer_state: { $first: '$customer_state' }, + invoice_value: { $first: '$sales_total' }, + items_value: { $sum: '$items.total_amount' }, + igst: { $sum: '$items.igst_tax' }, + cgst: { $sum: '$items.cgst_tax' }, + sgst: { $sum: '$items.sgst_tax' }, }, }, - { $sort: { _id: 1 } }, + { $sort: { '_id.ctin': 1, '_id.sales_id': 1, '_id.rate': 1 } }, ]) .toArray(); - // Format data for GST-1 JSON format - const gstOne = salesList.map((item) => { - const multipleValue = item.item_igst_tax > 0 ? item.item_igst_tax : item.csgst_multiply; + const byCtin = new Map(); + for (const row of rows) { + const ctin = String(row._id.ctin || '').trim(); + const inum = String(row._id.sales_id || '').trim(); + if (!inum) continue; + + if (!byCtin.has(ctin)) byCtin.set(ctin, new Map()); + const invoices = byCtin.get(ctin); + + if (!invoices.has(inum)) { + const d = row.date ? new Date(row.date) : null; + const idt = + d && !Number.isNaN(d.getTime()) + ? `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart( + 2, + '0' + )}-${d.getFullYear()}` + : ''; + invoices.set(inum, { + inum, + idt, + // Invoice value: the sale's own total, not a sum of lines - + // charges, round-off and bill-level discounts belong in it. + val: round(Number(row.invoice_value) || 0), + pos: gstStateCode(ctin, row.customer_state), + rchrg: 'N', + inv_typ: 'R', + itms: [], + }); + } + + const invoice = invoices.get(inum); + const igst = round(Number(row.igst) || 0); + const cgst = round(Number(row.cgst) || 0); + const sgst = round(Number(row.sgst) || 0); + invoice.itms.push({ + num: invoice.itms.length + 1, + itm_det: { + rt: round(Number(row._id.rate) || 0), + // taxable value = what the lines came to, less their tax + txval: round((Number(row.items_value) || 0) - (igst + cgst + sgst)), + iamt: igst, + camt: cgst, + samt: sgst, + csamt: 0, + }, + }); + } - return { - ctin: item._id.item_customer_gst_number || '', - inv: [ - { - inum: item._id.item_sales_id || '', - idt: item._id.item_date - ? new Date(item._id.item_date) - .toLocaleDateString('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) - .replace(/\//g, '-') - : '', - val: Math.round(item._id.total_amount * 100) / 100, - pos: '27', - rchrg: 'N', - inv_typ: 'R', - itms: [ - { - num: 151, - itm_det: { - rt: item.tax_value || 0, - txval: Math.round((item._id.total_amount - multipleValue) * 100) / 100, - iamt: Math.round(item.item_igst_tax * 100) / 100, - csamt: Math.round(item.csgst_multiply * 100) / 100, - }, - }, - ], - }, - ], - }; - }); + const gstOne = []; + for (const [ctin, invoices] of byCtin) { + gstOne.push({ ctin, inv: Array.from(invoices.values()) }); + } return { status: true, @@ -6457,9 +6723,9 @@ class SalesRepository { } } - async sendDailySalesMail(input, { SaleModel } = {}) { + async sendDailySalesMail(input, { SaleModel, shopTransport } = {}) { const Model = this.getModel(SaleModel); - return Model.sendDailySalesMail(input); + return Model.sendDailySalesMail(input, shopTransport); } async salesPaymentCloseModel(data, { SaleModel } = {}) { @@ -6549,6 +6815,21 @@ class SalesRepository { wallet_amount: (saleDetails.wallet_amount || 0) + parseFloat(saleData.amount), }, }); + + /* A settled sale that came from an invoice tells the invoice it is + paid (INVOICING_MODULE_DESIGN) - fire-safe, after the sale's own + writes have landed. */ + if (saleDetails.source_invoice_id) { + await require('../services/invoice-sync').afterSaleSettled(saleDetails._id); + } + } + + /* A settlement with no customer to recompute (a walk-in sale marked + paid from its invoice) stops here: the sales are settled, and there + is no ledger to total. Before this guard, ObjectId(undefined) threw + AFTER the sales were already marked paid. */ + if (!data.id || !ObjectId.isValid(String(data.id))) { + return { status: true, data: 0, message: 'Sales settled successfully' }; } // Recalculate customer balance @@ -6932,7 +7213,7 @@ class SalesRepository { // Generate token ID const tokenId = String(clientTokenId || String(Math.floor(Math.random() * 900) + 100)); - // Map items — use raw shape (no Mongoose ObjectId for item ref to avoid validation errors) + // Map items - use raw shape (no Mongoose ObjectId for item ref to avoid validation errors) const itemCollection = db.collection('items'); const saleItems = []; for (const item of items) { @@ -7097,9 +7378,50 @@ class SalesRepository { } } + /* + * The id of the newest sale on this branch. + * + * The multi-till poller asks for this while the sales list is open and + * refreshes the table when the id changes, so a sale rung up on another till + * appears without anyone pressing refresh. + * + * It used to call Model.getNewSaleModel(), which no model has ever defined. + * The route in front of it answered 403 to everyone - it sat in the no-auth + * block while demanding a permission - so the call never ran and the + * TypeError behind it stayed hidden. Fixing the route uncovered this one. + * + * Deliberately the cheapest query that answers the question: one document, + * one field, newest first. It runs on a timer, so anything more would be + * paid over and over for a value that is usually unchanged. + */ async getNewSaleModel({ SaleModel } = {}) { - const Model = this.getModel(SaleModel); - return Model.getNewSaleModel(); + try { + const { ObjectId } = require('mongodb'); + const Model = this.getModel(SaleModel); + + const branchId = BaseModel.currentBranch; + const query = {}; + if (branchId) { + query.branch_id = ObjectId.isValid(branchId) ? new ObjectId(branchId) : branchId; + } + if (BaseModel.license) query.license = BaseModel.license; + + const latest = await Model.findOne(query) + .sort({ created_date: -1 }) + .select({ _id: 1 }) + .lean(); + + return { + status: true, + // null on a branch with no sales yet: the poller stores it and waits, + // which is exactly right for a till that has not sold anything today + data: { sales_document_id: latest ? String(latest._id) : null }, + message: 'success', + }; + } catch (error) { + console.error('Error in getNewSaleModel:', error); + return { status: false, data: null, message: error.message }; + } } async getOrderHistoryModel(branchId, limit, page, status, userId, { SaleModel } = {}) { @@ -7316,7 +7638,7 @@ class SalesRepository { }); } if (!itemDoc) { - // Item not in catalog (e.g. KOT order item) — update in-place using existing data + // Item not in catalog (e.g. KOT order item) - update in-place using existing data if (existingIndex[productId] !== undefined) { const i = existingIndex[productId]; updatedItems[i] = { @@ -9108,20 +9430,20 @@ class SalesRepository { * documents without one are ignored. */ async _ensureSalesIdIndex(db) { - if (this.constructor._salesIdIndexEnsured) return; - try { - await db.collection('sales').createIndex( - { license: 1, sales_id: 1 }, - { - unique: true, - partialFilterExpression: { sales_id: { $type: 'string' } }, - name: 'unique_sales_id_per_license', - } - ); - this.constructor._salesIdIndexEnsured = true; - } catch (e) { - /* legacy duplicates still present - try again on a later sale */ - } + /* Once per DATABASE. The old latch was a static boolean, which on a + process serving many shops meant the FIRST shop to make a sale got this + index and no other shop ever did. For a unique index that guards bill + numbers, that is not a missing optimisation - it is the guarantee + quietly not applying to almost everyone. */ + await ensureIndexOnce( + db.collection('sales'), + { license: 1, sales_id: 1 }, + { + unique: true, + partialFilterExpression: { sales_id: { $type: 'string' } }, + name: 'unique_sales_id_per_license', + } + ); } isDuplicateSalesIdError(err) { diff --git a/api/src/repositories/setting.repository.js b/api/src/repositories/setting.repository.js index 81fdbfbd4..b20091cd7 100644 --- a/api/src/repositories/setting.repository.js +++ b/api/src/repositories/setting.repository.js @@ -213,6 +213,10 @@ class SettingsRepository { return await this.settingModel.editGeneralSetting(data); } + async updateStarterLocale(data) { + return await this.settingModel.updateStarterLocale(data); + } + /** * Update common settings */ diff --git a/api/src/repositories/settings.repository.js b/api/src/repositories/settings.repository.js new file mode 100644 index 000000000..89f55d8ce --- /dev/null +++ b/api/src/repositories/settings.repository.js @@ -0,0 +1,412 @@ +'use strict'; + +/* + * Settings read path (SETTINGS_AND_BRANCH_SCOPE_DESIGN, step S1). + * + * Reads the four new per-group collections and falls back to the legacy + * `branches` document for anything not stored there yet. NOTHING writes + * through here: while the migration runs, the branches document stays the + * source of truth and these collections are additive, so rolling back is + * deleting rows rather than restoring data. + * + * Resolution order for every key, most specific first: + * + * branch row -> account row -> legacy branches doc -> absent + * + * The account row is the level the design calls for and every comparable + * product has: change a policy once and it lands everywhere that has not + * deliberately diverged. That is why `null` in a branch row means INHERIT + * and is not the same as `false` - a distinction a flat copy cannot make, + * and the reason copying settings between branches drifts while inheriting + * them does not. + */ + +const BaseModel = require('../models/base.model'); +const { ObjectId } = require('mongodb'); +const { + GROUPS, + coerceFeatureToggle, + featureToggleRepairs, +} = require('../services/settings-groups'); + +/* group -> collection. Named per group so an ACL can be put on secrets + alone without teaching it about individual keys. */ +const COLLECTION_OF = Object.freeze({ + features: 'branch_features', + preferences: 'branch_preferences', + documents: 'branch_documents', + secrets: 'branch_secrets', + sharing: 'branch_sharing', + tax: 'branch_tax', +}); + +class SettingsRepository extends BaseModel { + constructor() { + super('branches'); + } + + _ids(context) { + const branchId = context && context.branchId; + const licenseId = context && context.licenseId; + if (!branchId || !ObjectId.isValid(String(branchId))) return null; + if (!licenseId || !ObjectId.isValid(String(licenseId))) return null; + return { + branch: new ObjectId(String(branchId)), + license: new ObjectId(String(licenseId)), + }; + } + + /* + * One group, resolved. `values` carries only keys that group owns, so a + * caller handed `secrets` cannot accidentally read a printing preference + * and vice versa. + */ + async resolveGroup(group, context = {}) { + const collectionName = COLLECTION_OF[group]; + if (!collectionName) { + return { status: false, data: null, message: 'Unknown settings group' }; + } + const ids = this._ids(context); + if (!ids) return { status: false, data: null, message: 'Branch context is required' }; + + try { + const collection = await this.getCollection(collectionName); + const [accountRow, branchRow] = await Promise.all([ + collection.findOne({ license: ids.license, branch_id: null }), + collection.findOne({ license: ids.license, branch_id: ids.branch }), + ]); + + const legacy = await this._legacyBranchDoc(ids); + const keys = GROUPS[group] || []; + const values = {}; + const source = {}; + /* S5. What this branch WOULD show if its own override were removed. + Without it a screen can say "overridden here" but cannot say what + "reset to inherited" would actually do - so the one control the whole + inheritance model exists for would be a leap in the dark. */ + const inherited = {}; + + const has = (row, key) => row && row[key] !== undefined && row[key] !== null; + + for (const key of keys) { + // the value in force below this branch, whatever the branch itself says + if (has(accountRow, key)) inherited[key] = accountRow[key]; + else if (legacy && legacy[key] !== undefined) inherited[key] = legacy[key]; + + // null in a branch row means INHERIT, so it must not shadow the + // account value the way false would + if (has(branchRow, key)) { + values[key] = branchRow[key]; + source[key] = 'branch'; + continue; + } + if (has(accountRow, key)) { + values[key] = accountRow[key]; + source[key] = 'account'; + continue; + } + if (legacy && legacy[key] !== undefined) { + values[key] = legacy[key]; + source[key] = 'legacy'; + } + } + + /* Rows written before the save path coerced still hold string + booleans; the resolver answers with the boolean they meant, so a + poisoned row cannot show a switched-off shop as all-on. */ + if (group === 'features') { + for (const k of Object.keys(values)) values[k] = coerceFeatureToggle(values[k]); + for (const k of Object.keys(inherited)) inherited[k] = coerceFeatureToggle(inherited[k]); + } + + return { status: true, data: { group, values, source, inherited }, message: 'success' }; + } catch (error) { + console.error('Error in SettingsRepository.resolveGroup:', error); + return { status: false, data: null, message: error.message }; + } + } + + async _legacyBranchDoc(ids) { + const branches = await this.getCollection('branches'); + return branches.findOne({ _id: ids.branch, license: ids.license }); + } + + /* + * Write one group (S2 - dual write). + * + * Only the keys ACTUALLY SENT are written, and only those belonging to this + * group. That is the whole point of the split: the old path built one $set + * from the entire settings form, so a four-key payload wrote undefined over + * every control the caller never showed. Here a key that was not sent + * cannot appear in the update at all, so that bug is not merely fixed - it + * is unsayable. + * + * Dual write: the same keys also go to the legacy branches document, which + * stays the source of truth until the migration finishes. Both stores are + * true at once, so rolling back means deleting the new rows and nothing is + * lost. `level: 'account'` writes the inherited row (branch_id null). + */ + /* + * S5. The ACCOUNT row on its own, unresolved. + * + * resolveGroup answers "what is in force at this branch". Editing the + * account level needs the opposite: what this level itself says, with + * nothing merged in - otherwise the form would show a branch's override, + * and saving it would silently push that one branch's choice onto every + * other shop. `set` names the keys the account actually decides, so the + * screen can tell "all shops say false" from "no shop-wide rule". + */ + async accountGroup(group, context = {}) { + const collectionName = COLLECTION_OF[group]; + if (!collectionName) { + return { status: false, data: null, message: 'Unknown settings group' }; + } + const ids = this._ids(context); + if (!ids) return { status: false, data: null, message: 'Branch context is required' }; + + try { + const collection = await this.getCollection(collectionName); + const accountRow = await collection.findOne({ license: ids.license, branch_id: null }); + const values = {}; + const set = []; + for (const key of GROUPS[group] || []) { + if (accountRow && accountRow[key] !== undefined && accountRow[key] !== null) { + values[key] = accountRow[key]; + set.push(key); + } + } + return { status: true, data: { group, level: 'account', values, set }, message: 'success' }; + } catch (error) { + console.error('Error in SettingsRepository.accountGroup:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* + * S6 (D4). Make one branch behave like another. + * + * WHAT GETS COPIED is the whole design question. Copying the resolved value + * of every key would bake the source's entire configuration into the target + * as explicit overrides - and the target would then never follow an + * account-level change again. Copying only the target-collection row would + * copy almost nothing today, because most shops still hold their values in + * the legacy branches document. + * + * So: copy the keys the source branch DECIDES - source 'branch' or 'legacy' + * - and skip the ones it merely INHERITS. The target ends up behaving like + * the source, while both keep inheriting the shop-wide rules together. That + * is the difference the design draws between copying, which drifts, and + * inheritance, which stays true. + * + * Secrets are never copied. Credentials belong to the shop that owns them, + * and a copy would silently duplicate a password into a branch nobody + * intended to give it to. + */ + async copyGroups(groups, fromBranchId, toBranchId, context = {}) { + const wanted = Array.isArray(groups) ? groups : []; + if (!wanted.length) { + return { status: false, data: null, message: 'Pick at least one group to copy' }; + } + if (wanted.includes('secrets')) { + return { + status: false, + data: { refused: ['secrets'] }, + message: 'Credentials are never copied between branches', + }; + } + const unknown = wanted.filter((g) => !COLLECTION_OF[g]); + if (unknown.length) { + return { + status: false, + data: { unknown }, + message: 'Unknown settings group: ' + unknown.join(', '), + }; + } + if (!fromBranchId || !toBranchId || String(fromBranchId) === String(toBranchId)) { + return { status: false, data: null, message: 'Pick two different branches' }; + } + + const licenseId = context.licenseId; + const copied = {}; + try { + for (const group of wanted) { + const read = await this.resolveGroup(group, { branchId: fromBranchId, licenseId }); + if (!read.status) return read; + + const own = {}; + for (const [key, where] of Object.entries(read.data.source || {})) { + // 'account' means the source inherits it too - leave the target to + // inherit it as well rather than freezing today's answer into it + if (where === 'branch' || where === 'legacy') own[key] = read.data.values[key]; + } + + if (!Object.keys(own).length) { + copied[group] = []; + continue; + } + const write = await this.saveGroup(group, own, { branchId: toBranchId, licenseId }); + if (!write.status) return write; + copied[group] = write.data.written; + } + return { + status: true, + data: { from: String(fromBranchId), to: String(toBranchId), copied }, + message: 'success', + }; + } catch (error) { + console.error('Error in SettingsRepository.copyGroups:', error); + return { status: false, data: null, message: error.message }; + } + } + + async saveGroup(group, values = {}, context = {}, options = {}) { + const collectionName = COLLECTION_OF[group]; + if (!collectionName) { + return { status: false, data: null, message: 'Unknown settings group' }; + } + const ids = this._ids(context); + if (!ids) return { status: false, data: null, message: 'Branch context is required' }; + + const owned = new Set(GROUPS[group] || []); + /* + * The public demo's analytics id is not the visitor's to change. + * + * Anyone can sign in to the demo as admin - the logins are printed on the + * login page - and settings are deliberately left explorable, because + * poking at them IS the product tour. The measurement id is the one + * exception: it points at the owner's own Google property, and a visitor + * repointing it would send the demo's traffic to a stranger, silently and + * until somebody noticed. Refused server-side; the rest of the group still + * saves, so the tour is unaffected. + */ + const DEMO_LOCKED = new Set(['analytics_enable', 'analytics_ga_id']); + const demoLocked = require('../config/demo-mode').isDemoMode(); + + const accepted = {}; + const rejected = []; + for (const [key, value] of Object.entries(values || {})) { + if (demoLocked && DEMO_LOCKED.has(key)) { + rejected.push(key); + continue; + } + if (owned.has(key)) { + /* The welcome (and any checkbox map) sends 'true'/'false' strings. + Stored verbatim they read as ENABLED through every `!== false` + gate - the all-toggles-on incident. The boolean is stored, so no + reader ever meets the string. null still means inherit. */ + if (key === 'analytics_enable') { + /* same string-boolean trap as the feature toggles */ + accepted[key] = coerceFeatureToggle(value); + } else if (key === 'analytics_ga_id') { + const id = String(value == null ? '' : value) + .trim() + .toUpperCase(); + if (id !== '' && !require('../services/analytics-config').isPlausibleGaId(id)) { + return { + status: false, + data: null, + message: 'The Google Analytics id should look like G-XXXXXXXXXX', + }; + } + accepted[key] = id; + } else { + accepted[key] = group === 'features' ? coerceFeatureToggle(value) : value; + } + } else { + rejected.push(key); + } + } + if (rejected.length) { + // refused, not ignored - a setting that vanishes without a word is the + // failure mode this whole design exists to end + return { + status: false, + data: { rejected }, + message: 'These keys do not belong to ' + group + ': ' + rejected.join(', '), + }; + } + /* S5. `null` means INHERIT - "stop deciding this here and take whatever + the level above says". It is a different instruction from writing a + value, so it is separated out before anything touches the database. + The key is UNSET rather than stored as null: undefined and null already + resolve identically, and an absent key cannot later be mistaken for a + deliberate blank. */ + const toSet = {}; + const toClear = []; + for (const [key, value] of Object.entries(accepted)) { + if (value === null) toClear.push(key); + else toSet[key] = value; + } + + if (!Object.keys(toSet).length && !toClear.length) { + return { status: true, data: { written: [], cleared: [] }, message: 'Nothing to write' }; + } + + try { + const isAccount = options.level === 'account'; + const collection = await this.getCollection(collectionName); + const update = { + $set: { license: ids.license, branch_id: isAccount ? null : ids.branch }, + }; + if (Object.keys(toSet).length) Object.assign(update.$set, toSet); + if (toClear.length) { + update.$unset = toClear.reduce((acc, k) => ({ ...acc, [k]: '' }), {}); + } + await collection.updateOne( + { license: ids.license, branch_id: isAccount ? null : ids.branch }, + update, + { upsert: true } + ); + + /* Legacy mirror. An account-level write has no single branch to mirror + to, so it deliberately does not touch the old document - the resolver + already falls back to it, and account values are new behaviour that + no legacy reader knows about. + ONLY the set keys are mirrored. Mirroring a cleared one would write + null over the legacy value, destroying the very thing the branch is + being told to fall back ON - "reset to inherited" would delete what it + meant to inherit. */ + if (!isAccount && Object.keys(toSet).length) { + const branches = await this.getCollection('branches'); + await branches.updateOne({ _id: ids.branch, license: ids.license }, { $set: toSet }); + } + + /* the CSP + runtime-info read analytics through a 30s cache - a + toggle must bite on the very next request */ + if (group === 'preferences') { + try { + require('../services/analytics-config').invalidate(); + } catch (e) { + /* the cache TTL still catches up */ + } + } + + return { + status: true, + data: { + written: Object.keys(toSet), + cleared: toClear, + level: isAccount ? 'account' : 'branch', + }, + message: 'success', + }; + } catch (error) { + console.error('Error in SettingsRepository.saveGroup:', error); + return { status: false, data: null, message: error.message }; + } + } + + /* Every group at once, for the screens that still show all of it. */ + async resolveAll(context = {}) { + const out = {}; + for (const group of Object.keys(COLLECTION_OF)) { + const r = await this.resolveGroup(group, context); + if (!r.status) return r; + out[group] = r.data.values; + } + return { status: true, data: out, message: 'success' }; + } +} + +module.exports = SettingsRepository; +module.exports.COLLECTION_OF = COLLECTION_OF; diff --git a/api/src/repositories/supplier.repository.js b/api/src/repositories/supplier.repository.js index 594dc32d7..e40233906 100644 --- a/api/src/repositories/supplier.repository.js +++ b/api/src/repositories/supplier.repository.js @@ -1,6 +1,8 @@ // src/repositories/supplier.repository.js const BaseModel = require('../models/base.model'); const { ObjectId } = require('mongodb'); +const { withBranchScope } = require('../services/branch-scope'); +const dataSharing = require('../services/data-sharing'); /** * Supplier Repository @@ -50,12 +52,19 @@ class SupplierRepository extends BaseModel { */ async findById(id) { const collection = await this.getCollection(this.collectionName); - return await collection.findOne({ - _id: new ObjectId(id), - license: BaseModel.license, - ...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}), - is_deleted: { $ne: true }, - }); + return await collection.findOne( + withBranchScope( + { + _id: new ObjectId(id), + license: BaseModel.license, + is_deleted: { $ne: true }, + }, + await dataSharing.scopeBranch('suppliers', BaseModel.currentBranch, { + licenseId: BaseModel.license, + branchId: BaseModel.currentBranch, + }) + ) + ); } /** @@ -102,7 +111,7 @@ class SupplierRepository extends BaseModel { async search(searchTerm, options = {}) { const { page = 1, limit = 10, branchId = null } = options; - const query = { + let query = { license: BaseModel.license, is_deleted: { $ne: true }, $or: [ @@ -113,8 +122,17 @@ class SupplierRepository extends BaseModel { ], }; + /* Under $and, not a second top-level $or - this query already owns one + for name/company/email/phone, and replacing it would turn a search into + a full-table read. */ if (branchId) { - query.branch_id = new ObjectId(branchId); + query = withBranchScope( + query, + await dataSharing.scopeBranch('suppliers', branchId, { + licenseId: BaseModel.license, + branchId, + }) + ); } const collection = await this.getCollection(this.collectionName); @@ -148,6 +166,17 @@ class SupplierRepository extends BaseModel { is_deleted: false, }; + /* S7 (D5), as for customers: record the branch that owns this supplier in + the account-level relation, alongside the legacy branch_id. Access lists + only that branch, so nothing becomes visible anywhere new - purchase + reporting across branches becomes POSSIBLE, it does not just happen. */ + if (!Array.isArray(document.branch_access)) { + const owning = document.branch_id || BaseModel.currentBranch || null; + document.branch_access = owning + ? [{ branch_id: owning, branch_name: document.branch_name || '' }] + : []; + } + const result = await collection.insertOne(document); return await this.findById(result.insertedId); } diff --git a/api/src/routes/client-errors.routes.js b/api/src/routes/client-errors.routes.js new file mode 100644 index 000000000..da46b4896 --- /dev/null +++ b/api/src/routes/client-errors.routes.js @@ -0,0 +1,144 @@ +'use strict'; + +/* + * Where a till's dying words land. + * + * Owner, after clearing his own browser data to recover a white page: + * "i dont [want] users to face these kind of errors." + * + * The white-page bug this follows lived for months because it was CLIENT + * side: the server answered 200 to everything, every log stayed clean, and + * the only witness was a browser console nobody was looking at. Diagnosing + * it took minting a shadow session and booting it in headless Chrome. This + * endpoint is the cheap version of that pipeline, always on: the boot + * watchdog posts the first uncaught error here, and it lands in the shop's + * own pm2 log - `grep client-error` - beside the request lines it belongs + * with. + * + * DELIBERATELY UNAUTHENTICATED. The errors worth hearing about most are the + * ones that happen BEFORE auth works - a boot that dies has no token to + * present. Unauthenticated write surface is held small three ways: + * + * - it stores nothing: one console line, no database, nothing to fill + * - per-IP budget: after a handful an hour, requests are counted and + * dropped without logging, so a hostile loop cannot flood the log + * - every field is truncated before it is printed + * + * It always answers 204, error or not: a crash reporter that can itself + * produce visible failures adds noise to the exact moment that has too + * much of it. + */ +const express = require('express'); + +const router = express.Router(); + +const WINDOW_MS = 60 * 60 * 1000; +const PER_IP_PER_WINDOW = 6; +const seen = new Map(); + +/* The map cannot grow unbounded on a public endpoint: sweep entries whose + window has lapsed whenever it gets large. */ +function sweep(now) { + if (seen.size < 1000) return; + for (const [ip, rec] of seen) { + if (now - rec.start > WINDOW_MS) seen.delete(ip); + } +} + +/* + * TEMPORARY diagnostic window (owner: "add some temporary system and find + * out... i want know very exact reason"): the last few reported lines, + * readable over HTTP, because the shop under investigation runs where no + * audited log-read path exists. Deliberately tiny and already-truncated - + * the ring holds exactly the console lines above, nothing more - and the + * boot-flight death records are the reason it exists. Remove with the + * flight recorder once the mobile crash is closed (OWNER_QUEUE row 193). + */ +const RING_MAX = 30; +const ring = []; + +/* + * The ring must outlive the process. Three api deploys in one afternoon + * each wiped it, and the one thing it exists to carry - a phone's death + * record, filed on the NEXT open, often hours later - was gone before + * anyone read it. A bounded file in tmpdir (per port: several tenant + * processes share a box) carries the same truncated lines across + * restarts. Still nothing like storage: same cap, same truncation, and + * every touch is allowed to fail without taking the reporter with it. + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const RING_FILE = path.join( + os.tmpdir(), + 'posnic-client-errors-' + String(process.env.PORT || 'default') + '.log' +); +try { + const kept = fs.readFileSync(RING_FILE, 'utf8').split('\n').filter(Boolean).slice(-RING_MAX); + ring.push(...kept); +} catch (e) { + /* first boot, or tmp cleaned - the ring just starts empty */ +} + +function remember(line) { + ring.push(new Date().toISOString() + ' ' + line); + if (ring.length > RING_MAX) ring.shift(); + try { + fs.writeFileSync(RING_FILE, ring.join('\n') + '\n'); + } catch (e) { + /* a full disk must not break the reporter */ + } +} + +router.get('/recent', (req, res) => { + res.type('text/plain').send(ring.length ? ring.join('\n') : '(nothing reported yet)'); +}); + +router.post('/', (req, res) => { + try { + const now = Date.now(); + sweep(now); + const ip = String(req.ip || 'unknown'); + const rec = seen.get(ip) || { start: now, count: 0 }; + if (now - rec.start > WINDOW_MS) { + rec.start = now; + rec.count = 0; + } + rec.count += 1; + seen.set(ip, rec); + + const b = req.body && typeof req.body === 'object' ? req.body : {}; + /* Flight-recorder death records outrank the budget: they are the single + highest-value line this endpoint carries, at most one per page open, + and the budget exists to stop floods of everything else. */ + const isFlight = String(b.at || '') === 'boot-flight'; + if (isFlight || rec.count <= PER_IP_PER_WINDOW) { + const line = [ + '[client-error]', + String(req.headers.host || '').slice(0, 80), + /* flight records carry the device's last movements - they get the + room they need; everything else stays tight */ + String(b.message || '(no message)').slice(0, isFlight ? 900 : 300), + '@', + String(b.at || '').slice(0, 200), + ].join(' '); + console.error(line); + remember(line); + const stack = String(b.stack || '').slice(0, 1000); + if (stack) console.error('[client-error] stack:', stack.split('\n').slice(0, 4).join(' | ')); + /* The watchdog's whole journal, one line per entry - the card's View + details, readable remotely. The first beacon field is only the FIRST + capture, and the first capture is routinely noise. */ + if (Array.isArray(b.journal)) { + for (const entry of b.journal.slice(0, 3)) { + console.error('[client-error] journal:', String(entry).slice(0, 220)); + } + } + } + } catch (e) { + /* the reporter must never be the thing that fails */ + } + return res.status(204).end(); +}); + +module.exports = router; diff --git a/api/src/routes/index.js b/api/src/routes/index.js index f865fe5c5..5a483b5cf 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -22,10 +22,12 @@ const dashboardRoutes = require('./dashboard.routes'); const easyTableRoutes = require('./easy-tables.routes'); const expensesRoutes = require('./expenses.routes'); const installRoutes = require('./install.routes'); +const invoicesRoutes = require('./invoices.routes'); const itemsRoutes = require('./items.routes'); const loyaltyRoutes = require('./loyalty.routes'); const messagingRoutes = require('./messaging.routes'); const purchaseOrdersRoutes = require('./purchase-orders.routes'); +const quotesRoutes = require('./quotes.routes'); const receivingsRoutes = require('./receivings.routes'); const registersRoutes = require('./registers.routes'); const rolesRoutes = require('./roles.routes'); @@ -33,6 +35,8 @@ const authorizationsRoutes = require('./authorizations.routes'); const shiftsRoutes = require('./shifts.routes'); const salesRoutes = require('./sales.routes'); const settingsRoutes = require('./settings.routes'); // Note: File not renamed, but route will be /settings +const settingsGroupsRoutes = require('./settings-groups.routes'); +const clientErrorsRoutes = require('./client-errors.routes'); const stockLogsRoutes = require('./stock-logs.routes'); const suppliersRoutes = require('./suppliers.routes'); const usersRoutes = require('./users.routes'); // Updated from userRoutes @@ -81,16 +85,22 @@ router.use('/dashboard', dashboardRoutes); // Removed /api prefix for consistenc router.use('/easy-table', easyTableRoutes); router.use('/expenses', expensesRoutes); router.use('/install', installRoutes); +router.use('/invoices', invoicesRoutes); router.use('/items', itemsRoutes); router.use('/loyalty', loyaltyRoutes); router.use('/messaging', messagingRoutes); router.use('/purchaseOrders', purchaseOrdersRoutes); +router.use('/quotes', quotesRoutes); router.use('/receivings', receivingsRoutes); router.use('/registers', registersRoutes); router.use('/roles', rolesRoutes); router.use('/authorizations', authorizationsRoutes); router.use('/shifts', shiftsRoutes); router.use('/sales', salesRoutes); +// Unauthenticated by design - the errors worth hearing about happen before +// auth works. See the route file for how the surface is held small. +router.use('/client-errors', clientErrorsRoutes); +router.use('/settings/group', settingsGroupsRoutes); router.use('/settings', settingsRoutes); // Primary path router.use('/setting', settingsRoutes); // Legacy PHP path support router.use('/stock-logs', stockLogsRoutes); // Changed from stocklogs to stock-logs diff --git a/api/src/routes/invoices.routes.js b/api/src/routes/invoices.routes.js new file mode 100644 index 000000000..709b78fbd --- /dev/null +++ b/api/src/routes/invoices.routes.js @@ -0,0 +1,25 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/invoices.controller'); +const { protect } = require('../middleware/auth'); +const { invoiceLimiter } = require('../middleware/auth-rate-limit'); + +router.use(protect, invoiceLimiter); + +// Invoices (INVOICING_MODULE_DESIGN) - a draft is a proforma; issuing books +// the sale on the server; payments pay that sale down. The SALE holds the money. +router.post('/', controller.create); +router.get('/', controller.list); +router.get('/summary', controller.summary); +router.post('/from-quote/:quoteId', controller.fromQuote); +router.get('/:id', controller.getById); +router.put('/:id', controller.update); +router.post('/:id/issue', controller.issue); +router.post('/:id/transition', controller.transition); +router.post('/:id/payment', controller.payment); +router.post('/:id/share', controller.share); +router.delete('/:id', controller.remove); + +module.exports = router; diff --git a/api/src/routes/items.routes.js b/api/src/routes/items.routes.js index e151d1c27..32bf51d64 100644 --- a/api/src/routes/items.routes.js +++ b/api/src/routes/items.routes.js @@ -75,6 +75,28 @@ router.post('/itemsImport', bindController(itemsController.itemsImport)); // PHP: exportItems() - Excel export router.post('/exportItems', bindController(itemsController.exportItems)); +// GET /api/items/export/jsonld - the catalogue as schema.org JSON-LD. +// A different SHAPE of what /items already returns, gated on the same +// item:read. See docs PRODUCT_EXPORT_FORMATS.md. +router.get('/export/jsonld', bindController(itemsController.exportCatalogueJsonLd)); + +// DELETE /api/items/demo - remove the sample products for good. +// Separate from the Demo Data switch, which only hides them: this one +// destroys, and refuses anything sold, received or edited. +router.delete('/demo', bindController(itemsController.purgeDemoData)); + +// POST /api/items/demo - put the sample data back, for a shop that removed +// it and later wants it. Refuses if it is already there. An optional +// businessType installs a DIFFERENT trade's catalogue, for the shop that +// signed up as one thing and turned out to be another. +router.post('/demo', bindController(itemsController.reseedDemoData)); + +// GET /api/items/demo/packs - the trades a shop can pick from, and which one +// it is on. Declared BEFORE any '/demo/:something' route would be: Express +// matches in order, and a parameterised sibling added later would otherwise +// swallow this path and answer it with the wrong handler. +router.get('/demo/packs', bindController(itemsController.listDemoPacks)); + // Raise/lower prices across many items at once; and a per-item price history. // V1 variant families: all-or-nothing creation of linked variant items, // and the members of one family for the edit page's strip. @@ -128,6 +150,13 @@ router.get('/itemSearchTable', bindController(itemsController.itemSearchTable)); // PHP: updateItemQuantity() - Update item quantity router.post('/updateItemQuantity', bindController(itemsController.updateItemQuantity)); +// Sale-screen inline edit: targeted money-field patch (write ACL) +router.post('/quickPatch', bindController(itemsController.quickPatch)); + +// GST 2.0 readiness checklist - read-only, report ACL. Declared before the +// /:id route so 'gstReadiness' is not read as an item id. +router.get('/gstReadiness', bindController(itemsController.gstReadiness)); + // PHP: categoryProductDetails() - Category product details router.get('/categoryProductDetails', bindController(itemsController.categoryProductDetails)); diff --git a/api/src/routes/quotes.routes.js b/api/src/routes/quotes.routes.js new file mode 100644 index 000000000..15ad74925 --- /dev/null +++ b/api/src/routes/quotes.routes.js @@ -0,0 +1,19 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/quotes.controller'); +const { protect } = require('../middleware/auth'); + +router.use(protect); + +// Quotes (LS2) - a priced offer, never stock, never payment. +router.post('/', controller.create); +router.get('/', controller.list); +router.get('/:id', controller.getById); +router.put('/:id', controller.update); +router.post('/:id/transition', controller.transition); +router.post('/:id/share', controller.share); +router.delete('/:id', controller.remove); + +module.exports = router; diff --git a/api/src/routes/receivings.routes.js b/api/src/routes/receivings.routes.js index 6e264ee87..79526c832 100644 --- a/api/src/routes/receivings.routes.js +++ b/api/src/routes/receivings.routes.js @@ -86,6 +86,15 @@ router.get('/receivingsPdf', bindController(receivingsController.receivingsPdf)) // Email the same PDF to the supplier (outward-facing: receiving write). router.post('/emailToSupplier', bindController(receivingsController.emailToSupplier)); +// Purchase attachments: the supplier's PO, invoice scans, delivery notes. +const { documentUpload } = require('../middleware/upload'); +router.post( + '/:id/attachments', + documentUpload.single('file'), + bindController(receivingsController.addAttachment) +); +router.delete('/:id/attachments/:attId', bindController(receivingsController.removeAttachment)); + // PHP: exportReceivings() - Excel export router.post('/exportReceivings', bindController(receivingsController.exportReceivings)); @@ -134,6 +143,10 @@ router.get( ); // Legacy bulk delete endpoint used by frontend: DELETE /receivings/delete +// G8: void keeps the record, reverses stock, withdraws the credit +// Partial receiving: goods arrive in steps; close_short cancels the rest +router.post('/:id/receive', bindController(receivingsController.receive)); +router.post('/:id/void', bindController(receivingsController.void)); router.delete('/delete', bindController(receivingsController.delete)); // GET /api/receivings/:id - Single receiving diff --git a/api/src/routes/sales.routes.js b/api/src/routes/sales.routes.js index 8e02de6aa..cd1628034 100644 --- a/api/src/routes/sales.routes.js +++ b/api/src/routes/sales.routes.js @@ -115,7 +115,21 @@ router.post( // ── Kiosk / mobile-app routes (no JWT auth required, branch_id in body) ── router.post('/qrOrder', bindController(salesController.qrOrder)); -router.get('/getNewSale', bindController(salesController.getNewSale)); +/* + * getNewSale sits in the no-JWT block but its handler requires sales:write. + * With no auth middleware at all, req.user was never populated - so the + * permission check saw undefined and answered 403 to EVERYONE, signed in or + * not. It is the only route in this block that was missing optionalProtect; + * its siblings all carry it. With the session read, a logged-in till passes + * the permission check as intended, and anything genuinely anonymous still + * has to present this installation's kiosk key. + */ +router.get( + '/getNewSale', + optionalProtect, + protectOrKioskKey, + bindController(salesController.getNewSale) +); router.post( '/getOrderHistory', optionalProtect, @@ -393,6 +407,10 @@ router.get( bindController(salesController.gstThreeReportTable) ); +// Tax Payable (PURCHASE_TAX_PLAN P4): the plain-words monthly view + register +router.get('/taxPayable', bindController(salesController.taxPayable)); +router.get('/taxPayableRegister', bindController(salesController.taxPayableRegister)); + // PHP: gstOneReportTableJson() - Get GST-1 report JSON router.get( '/gstOneReportTableJson', @@ -441,7 +459,7 @@ router.get('/kotDiscountReports', bindController(salesController.kotDiscountRepo // PHP: kotTablewiseDetails() - Get KOT table-wise detailed item report router.get('/kotTablewiseDetails', bindController(salesController.kotTablewiseDetails)); -// PHP: getListKot() - (moved before protect for kiosk access — see above) +// PHP: getListKot() - (moved before protect for kiosk access - see above) // PHP: pendingCustomerCategoryReportTable() - Get pending customer category report router.get( @@ -486,9 +504,43 @@ router .post(prepareCreateSalePayload, bindController(salesController.holdSale)) .put(prepareCreateSalePayload, bindController(salesController.holdSale)); -// KOT cancel endpoint - must be before generic /:id routes -// Frontend: PosnicPro.get('sales/cancel/' + saleId) -router.get('/cancel/:id', ensureValidSaleIdParam, bindController(salesController.cancel)); +/* + * Cancelling a sale must not be a GET. + * + * A GET is meant to be safe to repeat and safe for anything to follow. + * Browsers prefetch them, link scanners and antivirus fetch them, and - the + * part that matters - a bare `` on any + * page reached while logged in fires one WITH cookies, because SameSite=Lax + * still sends them on top-level GETs. That is a working CSRF vector against a + * destructive operation. + * + * POST is the real route now. The GET stays only because tills already + * deployed still call it (KOT cancel, two call sites), and it is hardened so + * it cannot be triggered from another site: X-Requested-With is a header no + * , Shop' }), + title: '', + greeting: 'Dear Bob,', + bodyHtml: '

safe

', + }); + expect(evil).not.toContain(' © Posnic Innovations Pvt - Ltd - All Rights Reserved + class="text-secondary mb-0" target="_blank">Posnic Innovations Pvt + Ltd - All Rights Reserved @@ -270,8 +270,8 @@

Posnic

-
@@ -293,15 +293,15 @@

class="current-currency">₹  Just Just ₹ 

In Stock + id="available_stock_display">In Stock + style="display:none;">Sold Out
@@ -313,7 +313,7 @@

-
Product Details
+
Product Details
@@ -341,7 +341,7 @@
Product Details
aria-labelledby="guide-tab-line">
-

click branch details

+

click branch details

@@ -367,7 +367,7 @@
diff --git a/frontend/customerview.html b/frontend/customerview.html index 4fd71d789..d9bcac21f 100644 --- a/frontend/customerview.html +++ b/frontend/customerview.html @@ -2,7 +2,7 @@ - Posnic - POS + <lang class="lang_posnic_title">Posnic - POS</lang> @@ -33,7 +33,7 @@

@@ -55,7 +55,7 @@

-
Branch Details
+
Branch Details
image @@ -93,33 +93,33 @@

-
Customer Details
+
Customer Details
-

Name:  Name:  

-

Email:  Email:  

-

Mobile No:  Mobile No:  

-

Address:  Address:  


-
Payment Details
+
Payment Details
-
Tax :
+
Tax :
0.00
-
Disc :
+
Disc :
0%
@@ -128,7 +128,7 @@
0%
-
Extra Disc :
+
Extra Disc :
@@ -148,11 +148,11 @@
0.00

₹ 0.00

-

Sub Amount

+

Sub Amount

₹ 0.00

-

Total Amount

+

Total Amount

diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 43413b3de..bd99f6c2c 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1,5 +1,5 @@ - + + + + + - +