From c57a6e7a4c8b4633411fe64c224c9301e38c0c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:55:14 +0000 Subject: [PATCH 1/2] feat: GraphQL backend over the kits, one endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQL Yoga on Fastify at POST /graphql (plus GET /health) with schema → controller → service layers over auth-kit, db-kit, catalog-kit, report-kit, invoice-kit, tally-kit and finance-kit as internal libraries: 137 queries and mutations for auth, organisation, catalog, lab, billing, books, finance and documents. Branch-aware permissions, tenant isolation, error codes, loaders, depth limit. End-to-end tests over HTTP on PostgreSQL and MongoDB, A-to-Z docs with Mermaid diagrams, examples, a generated API reference and a brief for the local agent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01BA5Vx77ihDsLiZpyg5duJV --- .env.example | 19 + .github/CODEOWNERS | 2 + .github/dependabot.yml | 21 + .github/workflows/ci.yml | 37 + AGENTS.md | 67 + CLAUDE.md | 1 + README.md | 96 + docs/01-overview.md | 55 + docs/02-architecture.md | 164 + docs/03-getting-started.md | 86 + docs/04-api-reference.md | 191 + docs/05-errors.md | 55 + docs/06-auth-and-permissions.md | 120 + docs/07-domain-flows.md | 174 + docs/08-documents.md | 80 + docs/09-configuration.md | 48 + docs/10-data-and-money.md | 68 + docs/11-testing.md | 56 + docs/12-deployment.md | 76 + docs/13-adding-a-feature.md | 93 + docs/14-client.md | 48 + docs/15-glossary.md | 39 + docs/LOCAL-AGENT-BRIEF.md | 172 + docs/README.md | 40 + docs/examples/billing-and-books.graphql | 38 + docs/examples/documents.graphql | 22 + docs/examples/finance.graphql | 59 + docs/examples/lab-day.graphql | 71 + kits.json | 37 + llms-full.txt | 8222 ++++++++++++++++++++ llms.txt | 32 + package.json | 54 + schema.graphql | 1755 +++++ scripts/api-reference.ts | 75 + scripts/kits.mjs | 64 + scripts/llms-full.mjs | 53 + scripts/print-schema.ts | 8 + scripts/seed.ts | 40 + src/config/env.ts | 66 + src/container.ts | 69 + src/controllers/auth.controller.ts | 75 + src/controllers/base.controller.ts | 31 + src/controllers/billing.controller.ts | 44 + src/controllers/books.controller.ts | 64 + src/controllers/catalog.controller.ts | 32 + src/controllers/documents.controller.ts | 54 + src/controllers/finance.controller.ts | 187 + src/controllers/lab.controller.ts | 107 + src/controllers/organization.controller.ts | 29 + src/graphql/context.ts | 49 + src/graphql/errors.ts | 60 + src/graphql/guards.ts | 34 + src/graphql/limits.ts | 28 + src/graphql/loaders.ts | 67 + src/graphql/scalars.ts | 57 + src/graphql/schema.ts | 44 + src/index.ts | 8 + src/main.ts | 20 + src/schema/auth.graphql.ts | 75 + src/schema/base.graphql.ts | 31 + src/schema/billing.graphql.ts | 75 + src/schema/books.graphql.ts | 74 + src/schema/catalog.graphql.ts | 53 + src/schema/documents.graphql.ts | 49 + src/schema/finance.graphql.ts | 326 + src/schema/lab.graphql.ts | 127 + src/schema/organization.graphql.ts | 37 + src/server.ts | 65 + src/services/auth.service.ts | 152 + src/services/billing.service.ts | 103 + src/services/booking.service.ts | 113 + src/services/books.service.ts | 110 + src/services/cash-bank.service.ts | 53 + src/services/catalog.service.ts | 53 + src/services/document.service.ts | 220 + src/services/export.service.ts | 54 + src/services/gst.service.ts | 51 + src/services/index.ts | 51 + src/services/organization.service.ts | 77 + src/services/patient.service.ts | 61 + src/services/period.service.ts | 20 + src/services/receivables.service.ts | 83 + src/services/report.service.ts | 76 + src/services/settlement.service.ts | 99 + src/services/util.ts | 31 + test/run.ts | 282 + tsconfig.json | 16 + tsup.config.ts | 12 + 88 files changed, 16092 insertions(+) create mode 100644 .env.example create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/01-overview.md create mode 100644 docs/02-architecture.md create mode 100644 docs/03-getting-started.md create mode 100644 docs/04-api-reference.md create mode 100644 docs/05-errors.md create mode 100644 docs/06-auth-and-permissions.md create mode 100644 docs/07-domain-flows.md create mode 100644 docs/08-documents.md create mode 100644 docs/09-configuration.md create mode 100644 docs/10-data-and-money.md create mode 100644 docs/11-testing.md create mode 100644 docs/12-deployment.md create mode 100644 docs/13-adding-a-feature.md create mode 100644 docs/14-client.md create mode 100644 docs/15-glossary.md create mode 100644 docs/LOCAL-AGENT-BRIEF.md create mode 100644 docs/README.md create mode 100644 docs/examples/billing-and-books.graphql create mode 100644 docs/examples/documents.graphql create mode 100644 docs/examples/finance.graphql create mode 100644 docs/examples/lab-day.graphql create mode 100644 kits.json create mode 100644 llms-full.txt create mode 100644 llms.txt create mode 100644 package.json create mode 100644 schema.graphql create mode 100644 scripts/api-reference.ts create mode 100644 scripts/kits.mjs create mode 100644 scripts/llms-full.mjs create mode 100644 scripts/print-schema.ts create mode 100644 scripts/seed.ts create mode 100644 src/config/env.ts create mode 100644 src/container.ts create mode 100644 src/controllers/auth.controller.ts create mode 100644 src/controllers/base.controller.ts create mode 100644 src/controllers/billing.controller.ts create mode 100644 src/controllers/books.controller.ts create mode 100644 src/controllers/catalog.controller.ts create mode 100644 src/controllers/documents.controller.ts create mode 100644 src/controllers/finance.controller.ts create mode 100644 src/controllers/lab.controller.ts create mode 100644 src/controllers/organization.controller.ts create mode 100644 src/graphql/context.ts create mode 100644 src/graphql/errors.ts create mode 100644 src/graphql/guards.ts create mode 100644 src/graphql/limits.ts create mode 100644 src/graphql/loaders.ts create mode 100644 src/graphql/scalars.ts create mode 100644 src/graphql/schema.ts create mode 100644 src/index.ts create mode 100644 src/main.ts create mode 100644 src/schema/auth.graphql.ts create mode 100644 src/schema/base.graphql.ts create mode 100644 src/schema/billing.graphql.ts create mode 100644 src/schema/books.graphql.ts create mode 100644 src/schema/catalog.graphql.ts create mode 100644 src/schema/documents.graphql.ts create mode 100644 src/schema/finance.graphql.ts create mode 100644 src/schema/lab.graphql.ts create mode 100644 src/schema/organization.graphql.ts create mode 100644 src/server.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/services/billing.service.ts create mode 100644 src/services/booking.service.ts create mode 100644 src/services/books.service.ts create mode 100644 src/services/cash-bank.service.ts create mode 100644 src/services/catalog.service.ts create mode 100644 src/services/document.service.ts create mode 100644 src/services/export.service.ts create mode 100644 src/services/gst.service.ts create mode 100644 src/services/index.ts create mode 100644 src/services/organization.service.ts create mode 100644 src/services/patient.service.ts create mode 100644 src/services/period.service.ts create mode 100644 src/services/receivables.service.ts create mode 100644 src/services/report.service.ts create mode 100644 src/services/settlement.service.ts create mode 100644 src/services/util.ts create mode 100644 test/run.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f01b58 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy to .env (git-ignored) and load it with your process manager, or export the variables. +NODE_ENV=development +PORT=4000 +# pg | mongo +DB_BACKEND=pg +DATABASE_URL=postgres://postgres:postgres@localhost:5432/mgpl +# Sessions, reset tokens, rate limits and the read cache. Leave empty for in-memory (one server only). +REDIS_URL= +# Comma-separated browser origins allowed to call the API ("*" only in development) +CORS_ORIGINS=http://localhost:3000 +GRAPHIQL=true +INTROSPECTION=true +# Demo report / invoice samples in `documents` +MGPL_SAMPLES=true +# Password-reset links: ?token=… +PASSWORD_RESET_URL=http://localhost:3000/reset-password +# Real signatures (never committed): files outside the repo +# MGPL_SIGNATURE_HEADER_FILE=/secure/signature-header.png +# MGPL_SIGNATURE_NONHEADER_FILE=/secure/signature-nonheader.png diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e18a3fa --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change to this repo needs a review from a code owner (see CONTRIBUTING in mgpl-diagnostics/.github). +* @BackStacked diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fdd195c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: [minor, patch] + ignore: + # our own packages are released together and bumped by hand + - dependency-name: "@mgpl-diagnostics/*" + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8182444 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The job id "ci" is the required status check on main (see mgpl-diagnostics/.github scripts/protect-repos.mjs). + ci: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + # Builds every kit from its public repo at the ref in kits.json, then installs. No package token needed. + # (Switch to the registry once every kit is published: docs/LOCAL-AGENT-BRIEF.md.) + - run: npm run kits + - run: npm run typecheck + - name: End-to-end tests (GraphQL over HTTP, auth-kit sessions) on PostgreSQL and MongoDB + run: npm test + - run: npm run build + - name: schema.graphql is up to date (run `npm run schema` and commit if this fails) + run: npm run schema && git diff --exit-code -- schema.graphql + - name: docs/04-api-reference.md is up to date (run `npm run docs:reference` and commit if this fails) + run: npm run docs:reference && git diff --exit-code -- docs/04-api-reference.md + - name: llms-full.txt is up to date (run `npm run llms` and commit if this fails) + run: npm run llms && git diff --exit-code -- llms-full.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c6c6ab4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md — @mgpl-diagnostics/api + +The MGPL Diagnostics backend: **one GraphQL endpoint** (`POST /graphql`, GraphQL Yoga on Fastify) over +the kits, used as internal libraries. Read [docs/README.md](docs/README.md) for the full picture and +[docs/13-adding-a-feature.md](docs/13-adding-a-feature.md) before adding anything. + +## Map + +``` +src/main.ts start (config → container → server) +src/server.ts Fastify + Yoga + GET /health (no other routes) +src/container.ts createDb (posting: financePosting) + createAuth + services, once per process +src/config/env.ts loadConfig: every environment variable +src/graphql/ schema.ts (MODULES → buildSchema), context, guards, errors, scalars, loaders, limits +src/schema/ SDL per module: base auth organization catalog lab billing books finance documents +src/controllers/ resolvers per module: authorize → map → service +src/services/ business calls on the kits: (actor, input), ownership checks, no GraphQL +scripts/ kits.mjs (build kits from source), seed.ts, print-schema.ts, api-reference.ts, llms-full.mjs +test/run.ts end-to-end over HTTP on PostgreSQL + MongoDB, plus docs/examples validation +docs/ A-to-Z docs (Mermaid), examples/*.graphql, LOCAL-AGENT-BRIEF.md +schema.graphql printed schema (generated) +kits.json kit refs for `npm run kits` +``` + +## Rules + +1. **One endpoint.** Everything is a GraphQL field. No REST routes besides `GET /health`; documents + travel as `Document { base64 | html }`, exports as `CsvFile`. +2. **Layers.** Schema → controller → service → kit. Controllers check permissions and map; services hold + the logic and never import GraphQL; storage changes go to db-kit, finance rules to finance-kit, + posting to tally-kit, permissions to auth-kit, catalog data to catalog-kit, layouts to report-kit / + invoice-kit. +3. **Tenant isolation.** No argument carries an `orgId` or `actorId`. They come from the session + (`actorOf`). Every id is checked against the organisation (`mustOwn`, `owned`, `scopedFind`). +4. **Permissions per branch.** Load a record, then `authorize(ctx, perm, record.branchId)`; lists use + `branchScope`. Every permission must exist in auth-kit's `PERMISSIONS`. +5. **Errors.** Wrap controllers in `withErrorCodes`. Never build a `GraphQLError` with `originalError` + (Yoga masks it). Error codes are a public contract: add, never rename. +6. **Money is integer paise; days are IST `Day` strings; the financial year is April–March.** +7. **No N+1.** Nested fields go through `ctx.loaders`. Lists are capped with `cap()` (≤ 200). +8. **Both databases.** Tests run on PostgreSQL and MongoDB; a change must pass on both. +9. **Generated files stay current** (CI checks): `npm run schema`, `npm run docs:reference`, `npm run llms`. +10. **Client contract.** `@mgpl-diagnostics/client` mirrors the schema; when a field is added or renamed, + update the client (operations, types, method, docs) and bump its ref in `kits.json`. +11. **Compliance values are configuration** verified by the lab's CA (`verifyWithCA`). Ask the owner + before changing one. No doctor referral commissions. +12. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. + +## Commands + +```bash +npm run kits # once, and after changing kits.json +npm run typecheck && npm test && npm run build +npm run schema && npm run docs:reference && npm run llms +``` + +`npm test` downloads PostgreSQL and MongoDB binaries on first run. Behind a proxy that blocks MongoDB, +set `MONGOMS_SYSTEM_BINARY` or run `TEST_BACKENDS=pg npm test` (CI still runs both). + +## Repository rules + +`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check +required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`). + +## Licence + +All rights reserved (see LICENSE). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b24c52 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +
+ +MGPL Diagnostics + +# api + +**The MGPL Diagnostics backend: one GraphQL endpoint for the whole lab.** + +Sign-in, patients, bookings, samples, results, reports, billing, books, GST, receivables, settlements, +cash and bank, documents. TypeScript, GraphQL Yoga on Fastify, PostgreSQL or MongoDB. + +[![Endpoint](https://img.shields.io/badge/endpoint-POST%20%2Fgraphql-14264A)](#) +[![Databases](https://img.shields.io/badge/db-PostgreSQL%20%7C%20MongoDB-14264A)](#) +[![License](https://img.shields.io/badge/license-All%20rights%20reserved-E9A23B)](LICENSE) + +[Docs](docs/README.md) · [Architecture](docs/02-architecture.md) · [Getting started](docs/03-getting-started.md) · [API reference](docs/04-api-reference.md) · [Client](docs/14-client.md) + +
+ +--- + +```mermaid +flowchart LR + APP["Apps
@mgpl-diagnostics/client"] -- "POST /graphql" --> API["api
controllers → services"] + API --> K["auth-kit · db-kit · catalog-kit
report-kit · invoice-kit · tally-kit · finance-kit"] + K --> DB[("PostgreSQL / MongoDB")] + K -.-> R[("Redis")] +``` + +## What you get + +- **One endpoint.** 137 queries and mutations; reports and invoices come back as base64 PDFs or HTML + inside the response, CSV exports as text. No other routes except `GET /health`. +- **The kits as internal libraries.** auth-kit sessions and roles, db-kit storage and workflows, + catalog-kit prices and reference ranges, report-kit and invoice-kit rendering, tally-kit books, + finance-kit GST and finance. See [docs/01-overview.md](docs/01-overview.md). +- **Layers.** `schema/` (SDL) → `controllers/` (permissions, mapping) → `services/` (kit calls) → kits. +- **Safe by construction.** The organisation and user come from the session; every record is checked + against them and against its branch; unknown errors are masked; depth and list limits. +- **Money in paise, books always balanced.** Every invoice, payment and credit note posts its voucher in + the same transaction. + +## Quick start + +```bash +npm run kits # build + install the kits (see docs/03-getting-started.md) +cp .env.example .env # DATABASE_URL, … +SEED_ORG_NAME=… SEED_ORG_CODE=MGPL SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang SEED_BRANCH_STATE=22 \ +SEED_OWNER_NAME=… SEED_OWNER_EMAIL=… SEED_OWNER_PASSWORD=… npm run seed +npm run dev # http://localhost:4000/graphql +``` + +```graphql +mutation { login(input: { email: "owner@example.in", password: "…", orgCode: "MGPL" }) { token } } + +query { viewer { user { name } organization { name } permissions } } +``` + +```ts +import { createMgplGraphQLClient } from "@mgpl-diagnostics/client"; +const mgpl = createMgplGraphQLClient({ url: "http://localhost:4000/graphql" }); +await mgpl.auth.login({ email, password, orgCode: "MGPL" }); +const hits = await mgpl.catalog.search("cbc"); +``` + +## Docs + +Everything from A to Z is in [docs/](docs/README.md), with Mermaid diagrams: overview, architecture, +getting started, the generated API reference, errors, auth and permissions, domain flows, documents, +configuration, data and money, testing, deployment, adding a feature, client, glossary, and runnable +[examples](docs/examples/). What has to happen outside this repo (releases, org settings, deployment) is +in [docs/LOCAL-AGENT-BRIEF.md](docs/LOCAL-AGENT-BRIEF.md). + +## Development + +```bash +npm run typecheck +npm test # end-to-end over HTTP on PostgreSQL + MongoDB (TEST_BACKENDS=pg for one) +npm run build # dist/main.js, dist/index.js +npm run schema # schema.graphql +npm run docs:reference # docs/04-api-reference.md +npm run llms # llms-full.txt +``` + +## Part of MGPL Diagnostics + +| Repo | Role | +|---|---| +| **api** | the backend: one GraphQL endpoint | +| [client](https://github.com/mgpl-diagnostics/client) | frontend SDK (zero dependencies) | +| [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) · [db-kit](https://github.com/mgpl-diagnostics/db-kit) · [catalog-kit](https://github.com/mgpl-diagnostics/catalog-kit) | auth, data, test catalog | +| [report-kit](https://github.com/mgpl-diagnostics/report-kit) · [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | report and invoice rendering | +| [tally-kit](https://github.com/mgpl-diagnostics/tally-kit) · [finance-kit](https://github.com/mgpl-diagnostics/finance-kit) | books, GST and finance | +| [report-studio](https://github.com/mgpl-diagnostics/report-studio) | Next.js reference app | + +© MGPL Diagnostics (Maa Gayatri Path Lab). All rights reserved; see [LICENSE](LICENSE). tally-kit is our own software, not affiliated with Tally Solutions. diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 0000000..fe233a7 --- /dev/null +++ b/docs/01-overview.md @@ -0,0 +1,55 @@ +# 01 · Overview + +`@mgpl-diagnostics/api` is the backend of MGPL Diagnostics (Maa Gayatri Path Lab). It serves **everything +through one endpoint, `POST /graphql`**: sign-in and users, the test catalog, patients, bookings, samples, +results and reports, billing, the books of account, GST returns, B2B receivables, partner settlements, +cash and bank, lock dates, CSV exports, and the rendered report and invoice documents. + +```mermaid +flowchart LR + subgraph Frontends + W["Web app"] + M["Mobile / desk app"] + S["Scripts"] + end + W & M & S -- "POST /graphql
Bearer token" --> API["api
GraphQL Yoga on Fastify"] + API --> PG[("PostgreSQL
or MongoDB")] + API -.-> R[("Redis
optional")] + LB["Load balancer"] -- "GET /health" --> API +``` + +## What it is made of + +The domain logic lives in the kits, which the API uses as **internal libraries**. It has no other HTTP +routes and starts no per-kit servers. The kits never see HTTP: the API calls their functions from its +service layer. + +| Kit | Role inside the API | +|---|---| +| [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) | Passwords (scrypt), sessions, login rate limits, roles and permissions | +| [db-kit](https://github.com/mgpl-diagnostics/db-kit) | Repositories on PostgreSQL or MongoDB, transactions, workflows (bookings, reports, invoices) | +| [catalog-kit](https://github.com/mgpl-diagnostics/catalog-kit) | The test catalog: names, codes, prices, units, reference ranges, search | +| [report-kit](https://github.com/mgpl-diagnostics/report-kit) | Report PDFs and HTML (Navy Sidebar and classic designs) | +| [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | Invoice PDFs and HTML, invoice arithmetic | +| [tally-kit](https://github.com/mgpl-diagnostics/tally-kit) | Our own double-entry engine: ledgers, vouchers, books reports | +| [finance-kit](https://github.com/mgpl-diagnostics/finance-kit) | GST, GSTR-1/3B data, receivables, settlements, cash and bank, lock dates, exports | +| [client](https://github.com/mgpl-diagnostics/client) | Not used by the API; the frontend SDK that calls it | + +## Design choices + +- **One endpoint.** Reports and invoices are GraphQL fields too. A PDF arrives as base64 inside the + response (`Document.base64`), HTML as a string, CSV exports as `CsvFile.content`. +- **Layers.** Schema (SDL) → controllers (resolvers: permissions, mapping) → services (business calls on + the kits) → kits → database. See [02-architecture.md](02-architecture.md). +- **Tenant isolation by construction.** The organisation and the acting user come from the session, + never from arguments. Every id is checked against the session's organisation. +- **Money in integer paise**, days as IST `"YYYY-MM-DD"`, the Indian financial year (April–March). +- **Both databases.** The same code runs on PostgreSQL (Sequelize) or MongoDB (Mongoose, replica set). + +## What it is not + +- Not a public API for patients. Every operation except `health`, `login`, password reset and the + catalog needs a staff session. +- Not tax advice. GST rates, TDS and thresholds are configuration that the lab's CA must confirm + (`verifyWithCA` lists them). +- Not integrated with Tally software. tally-kit is our own engine with Tally-like concepts. diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..7d35f1f --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,164 @@ +# 02 · Architecture + +## Layers + +Each layer talks only to the one below it. Controllers never touch the database directly except through +the per-request loaders; services never see GraphQL. + +```mermaid +flowchart TB + subgraph HTTP["HTTP · src/server.ts"] + F["Fastify
POST /graphql · GET /health"] + Y["GraphQL Yoga
CORS · masked errors · depth limit 12 · introspection switch"] + end + subgraph GQL["GraphQL · src/graphql + src/schema"] + SDL["SDL per module
src/schema/*.graphql.ts"] + CTX["Request context
viewer · can() · loaders"] + end + subgraph CTRL["Controllers · src/controllers"] + C["Resolvers
authorize() · map enums · call services"] + end + subgraph SRV["Services · src/services"] + S["Business calls
(actor, input) → kits"] + end + subgraph KITS["Kits (internal libraries)"] + K1["auth-kit"] + K2["db-kit"] + K3["catalog-kit"] + K4["report-kit · invoice-kit"] + K5["tally-kit · finance-kit"] + end + DB[("PostgreSQL / MongoDB")] + RD[("Redis (optional)")] + F --> Y --> SDL --> C + Y --> CTX --> C + C --> S --> KITS + K2 --> DB + K1 -.-> RD + K2 -.-> RD +``` + +| Layer | Folder | Does | Does not | +|---|---|---|---| +| Server | `src/server.ts`, `src/main.ts` | Fastify, Yoga, `/health`, shutdown | Business logic | +| Schema | `src/schema/` | SDL, one file per module | Code | +| GraphQL plumbing | `src/graphql/` | Context, guards, errors, scalars, loaders, limits, schema assembly | Domain rules | +| Controllers | `src/controllers/` | Permissions, argument mapping, nested fields | Database writes | +| Services | `src/services/` | Calls into the kits with `(actor, input)`, transactions, ownership checks | GraphQL types or errors | +| Container | `src/container.ts` | Builds db, auth and services once per process | Per-request state | +| Config | `src/config/env.ts` | Reads and checks environment variables | Defaults that are unsafe in production | + +## Folders + +``` +src/ + main.ts start: loadConfig → createContainer → buildServer → listen; SIGINT/SIGTERM close + server.ts Fastify + Yoga (one endpoint) + GET /health + container.ts createDb (posting: financePosting), createAuth (Redis or memory stores), services + index.ts library entry (buildServer, createContainer, buildSchema, … for tests and embedding) + config/env.ts loadConfig(env) → Config + graphql/ + schema.ts MODULES (typeDefs + resolvers per module) → buildSchema() + context.ts RequestContext, createContext (bearer token → auth-kit context) + guards.ts actorOf, authorize, canSee, branchScope, cap + errors.ts ERROR_CODES, toGraphQLError, withErrorCodes + scalars.ts DateTime, Day, JSON + loaders.ts per-request BatchLoader per entity (no N+1) + limits.ts depthLimit + schema/.graphql.ts SDL + controllers/.controller.ts + services/.service.ts, index.ts (createServices), util.ts +scripts/ kits.mjs, seed.ts, print-schema.ts, api-reference.ts, llms-full.mjs +test/run.ts end-to-end suite over HTTP (app.inject) +schema.graphql the printed schema (CI checks it is current) +kits.json which kit versions CI builds (see 11-testing.md) +``` + +Modules: `base`, `auth`, `organization`, `catalog`, `lab`, `billing`, `books`, `finance`, `documents`. + +## A request, end to end + +```mermaid +sequenceDiagram + autonumber + participant Cl as Client + participant Fa as Fastify + participant Yo as Yoga + participant Cx as createContext + participant Au as auth-kit + participant Ct as Controller + participant Sv as Service + participant Kt as db-kit / finance-kit + participant DB as Database + Cl->>Fa: POST /graphql (Authorization: Bearer t) + Fa->>Yo: handleNodeRequestAndResponse + Yo->>Yo: parse, validate (depth ≤ 12, introspection rule) + Yo->>Cx: build context + Cx->>Au: context(t) → re-reads the user every request + Au-->>Cx: viewer, can(), branchesFor() + Yo->>Ct: resolve issueInvoice(input) + Ct->>Ct: authorize(ctx, "invoice:create", input.branchId) + Ct->>Sv: billing.issue(actor, input) + Sv->>Kt: issueInvoice(repos, …) inside repos.transaction() + Kt->>DB: invoice + voucher (posting hook) atomically + DB-->>Kt: ok + Kt-->>Sv: Invoice + Sv-->>Ct: Invoice + Ct-->>Yo: Invoice (nested fields via loaders) + Yo-->>Cl: { data: { issueInvoice: … } } +``` + +If anything throws, `withErrorCodes` turns domain errors (from db-kit, auth-kit, finance-kit, +report-kit) into GraphQL errors with a code; any other error is masked as "Unexpected error" +([05-errors.md](05-errors.md)). + +## How the kits depend on each other + +```mermaid +flowchart LR + API["api"] --> AUTH["auth-kit"] + API --> DB["db-kit"] + API --> CAT["catalog-kit"] + API --> INV["invoice-kit"] + API --> REP["report-kit"] + API --> FIN["finance-kit"] + API --> TAL["tally-kit"] + INV --> REP + FIN --> TAL + FIN --> DB + TAL --> DB + DB -. "stores users for" .-> AUTH + CL["client (frontend)"] -. "POST /graphql" .-> API +``` + +- db-kit's repositories satisfy auth-kit's store interfaces (users, orgs, roles, audit), so auth-kit reads + and writes users through db-kit (`container.ts` passes `db.rawRepos`). +- db-kit posts billing to the books through a `PostingHook`. The API sets it to finance-kit's + `financePosting`, which posts through tally-kit with GST split by place of supply. +- `src/graphql` in db-kit is an older, partial GraphQL layer. This API replaces it; db-kit will remove + it in a major version ([LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md)). + +## The container + +```mermaid +flowchart LR + ENV["process.env"] --> CFG["loadConfig()"] + CFG --> CON["createContainer()"] + CON --> DBX["createDb({ backend, url, redis?, posting: financePosting })"] + CON --> AUX["createAuth({ users, orgs, roles, audit from db,
sessions/kv/limiter from Redis or memory })"] + CON --> SVX["createServices(db.repos, auth, mailer, lab)"] + CON --> SRV["buildServer(container)"] +``` + +One container per process. Tests build it with an embedded database and a capturing mailer; production +builds it from the environment. `close()` closes what the container opened. + +## Cross-cutting rules + +1. **Tenant isolation.** Services take an `Actor { userId, orgId }` from the session. `mustOwn` / `owned` + check that a record's `orgId` matches before anything is returned or changed. +2. **Branch scoping.** `authorize(ctx, perm, branchId)` checks the record's own branch; list queries use + `branchScope`, which narrows to the branches where the user holds the permission. +3. **Transactions.** Multi-record writes run in `repos.transaction()` inside the kits. +4. **No N+1.** Nested fields (`Booking.patient`, `Invoice.branch`, …) go through per-request loaders. +5. **Limits.** Query depth ≤ 12, list `limit` ≤ 200, request body ≤ 2 MB by default. diff --git a/docs/03-getting-started.md b/docs/03-getting-started.md new file mode 100644 index 0000000..985dca1 --- /dev/null +++ b/docs/03-getting-started.md @@ -0,0 +1,86 @@ +# 03 · Getting started + +## Requirements + +- Node.js ≥ 20 (CI uses 22) +- PostgreSQL ≥ 14, **or** MongoDB ≥ 6 running as a replica set (transactions need one) +- Redis (optional; needed when you run more than one server) +- Git (to build the kits from source until they are all on the registry) + +## 1. Install + +```bash +git clone https://github.com/mgpl-diagnostics/api && cd api +npm run kits # builds the 7 kits + client from source at the refs in kits.json, then npm install +``` + +`npm run kits` exists because not every kit is published to GitHub Packages yet. Once they are, a plain +`npm install` (with a token that has `read:packages`) is enough; see [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md). +To build from local checkouts instead of GitHub, set `KITS_SRC=/path/to/folder-with-the-kits`. + +## 2. Configure + +```bash +cp .env.example .env # then edit DATABASE_URL etc.; see 09-configuration.md +``` + +## 3. Seed the first organisation + +Once per new database. It creates the organisation, its first branch, the owner, the system roles and the +chart of accounts with finance-kit's ledgers. + +```bash +SEED_ORG_NAME="Maa Gayatri Path Lab" SEED_ORG_CODE=MGPL SEED_ORG_GSTIN=22XXXXX0000X1Z5 \ +SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang SEED_BRANCH_STATE=22 \ +SEED_OWNER_NAME="Lab Owner" SEED_OWNER_EMAIL=owner@example.in SEED_OWNER_PASSWORD='a long passphrase' \ +npm run seed +``` + +- `SEED_BRANCH_STATE` is the GST state code of the branch (22 = Chhattisgarh). **Billing needs it**: the + posting hook decides CGST+SGST vs IGST from it. +- `SEED_ORG_GSTIN` is optional for billing but **required for GSTR-1 / GSTR-3B**. +- The password must pass auth-kit's policy (length, not containing the email or name, not common). + +## 4. Run + +```bash +npm run dev # tsx watch, http://localhost:4000/graphql (GraphiQL in development) +# or +npm run build && npm start +``` + +## 5. First calls + +```mermaid +sequenceDiagram + participant You + participant API + You->>API: mutation login(email, password, orgCode) + API-->>You: token + You->>API: query viewer (Bearer token) + API-->>You: user, organization, permissions + You->>API: mutation setupChartOfAccounts (idempotent) + You->>API: query catalogSearch("cbc") +``` + +```bash +curl -s localhost:4000/graphql -H 'content-type: application/json' \ + -d '{"query":"mutation($i: LoginInput!){ login(input: $i){ token user { name } } }","variables":{"i":{"email":"owner@example.in","password":"…","orgCode":"MGPL"}}}' + +curl -s localhost:4000/graphql -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" \ + -d '{"query":"{ viewer { user { name } organization { name branches { code stateCode } } permissions } }"}' +``` + +More in [examples/](examples/): a full day at the desk ([lab-day.graphql](examples/lab-day.graphql)), +billing and books ([billing-and-books.graphql](examples/billing-and-books.graphql)), finance +([finance.graphql](examples/finance.graphql)) and documents ([documents.graphql](examples/documents.graphql)). + +## 6. Useful commands + +```bash +npm run typecheck +npm test # end-to-end on PostgreSQL and MongoDB (TEST_BACKENDS=pg to pick one) +npm run schema # rewrite schema.graphql +npm run docs:reference # rewrite docs/04-api-reference.md +npm run llms # rewrite llms-full.txt +``` diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md new file mode 100644 index 0000000..85ddb76 --- /dev/null +++ b/docs/04-api-reference.md @@ -0,0 +1,191 @@ +# 04 · API reference + + + +Every operation of `POST /graphql`, grouped by module, with the permission its resolver checks. +**Permission** lists what `authorize()` requires; a branch-scoped permission is checked against the +record's branch (see [06-auth-and-permissions.md](06-auth-and-permissions.md)). `public` means no +sign-in is needed; `(signed in)` means any signed-in user. The full SDL is [schema.graphql](../schema.graphql), +and runnable examples are in [examples/](examples/). + +## base + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `health: Health!` | public | | +| mutation | `ping: String!` | public | Does nothing; lets clients check that mutations reach the server. | + +## auth + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `mySessions: [Session!]!` | (signed in) | | +| query | `permissions: [String!]!` | (signed in) | Every permission name the API checks. | +| query | `roles: [Role!]!` | `role:read` | | +| query | `user(id: ID!): User` | `user:read` | | +| query | `users: [User!]!` | `user:read` | | +| query | `viewer: Viewer` | public | null when not signed in. | +| mutation | `changePassword(currentPassword: String!, newPassword: String!): Boolean!` | (signed in) | | +| mutation | `createUser(input: CreateUserInput!): User!` | `user:create` | | +| mutation | `disableUser(userId: ID!): User!` | `user:disable` | | +| mutation | `enableUser(userId: ID!): User!` | `user:disable` | | +| mutation | `login(input: LoginInput!): LoginResult!` | public | | +| mutation | `logout: Boolean!` | public | Ends this session. | +| mutation | `logoutOtherSessions: Int!` | (signed in) | Ends every other session of the signed-in user; returns how many. | +| mutation | `requestPasswordReset(email: String!, orgCode: String): Boolean!` | public | Always true (never reveals whether the email exists). The link is sent by the server's mailer. | +| mutation | `resetPassword(token: String!, newPassword: String!): Boolean!` | public | | +| mutation | `revokeSession(id: ID!): Boolean!` | (signed in) | | +| mutation | `setUserPassword(userId: ID!, newPassword: String!): Boolean!` | `user:update` | | +| mutation | `setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User!` | `user:update` | | +| mutation | `upsertRole(input: RoleInput!): Role!` | `role:manage` | | + +## organization + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `auditLog(entity: String, entityId: ID, limit: Int, offset: Int): [AuditEntry!]!` | `audit:read` | | +| query | `branch(id: ID!): Branch` | (signed in) | | +| query | `branches: [Branch!]!` | (signed in) | | +| query | `organization: Organization!` | (signed in) | | +| mutation | `createBranch(input: BranchInput!): Branch!` | `branch:create` | | +| mutation | `updateBranch(id: ID!, input: BranchPatch!): Branch!` | `branch:update` | | +| mutation | `updateOrganization(input: OrganizationInput!): Organization!` | `org:update` | | + +## catalog + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `catalogDepartments: [String!]!` | public | | +| query | `catalogSampleTypes: [String!]!` | public | | +| query | `catalogSearch(query: String!, limit: Int, department: String, sampleType: String, fuzzy: Boolean): [CatalogHit!]!` | public | Typo-tolerant search with lab shorthand (cbc, lft, vit d…). | +| query | `catalogTest(id: Int, code: String): CatalogTest` | public | | +| query | `catalogTests(department: String, sampleType: String, limit: Int, offset: Int): [CatalogTest!]!` | public | | +| query | `priceQuote(tests: [String!]!): PriceQuote!` | public | Total MRP for test codes (or numeric ids as strings). | + +## lab + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `booking(id: ID!): Booking` | `booking:read` | | +| query | `bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int, offset: Int): [Booking!]!` | `booking:read` | | +| query | `doctors(activeOnly: Boolean): [Doctor!]!` | `doctor:read` | | +| query | `patient(id: ID!): Patient` | `patient:read` | | +| query | `patients(search: String!, limit: Int): [Patient!]!` | `patient:read` | Name, phone or UHID. | +| query | `report(id: ID!): Report` | `report:read` | | +| query | `sample(id: ID!): Sample` | `sample:read` | | +| query | `sampleByBarcode(barcode: String!): Sample` | `sample:read` | | +| mutation | `amendReport(id: ID!): Report!` | `report:amend` | | +| mutation | `cancelBooking(id: ID!): Booking!` | `booking:cancel` | | +| mutation | `collectSample(id: ID!): Sample!` | `sample:collect` | | +| mutation | `createBooking(input: CreateBookingInput!): CreateBookingResult!` | `booking:create` | | +| mutation | `createDoctor(input: DoctorInput!): Doctor!` | `doctor:manage` | | +| mutation | `createReport(bookingId: ID!, testCode: String!, testName: String): Report!` | `report:enter` | | +| mutation | `receiveSample(id: ID!): Sample!` | `sample:receive` | | +| mutation | `registerPatient(input: RegisterPatientInput!): Patient!` | `patient:create` | | +| mutation | `rejectSample(id: ID!, reason: String!): Sample!` | `sample:reject` | | +| mutation | `releaseReport(id: ID!): Report!` | `report:release` | | +| mutation | `saveResults(reportId: ID!, results: [ResultInput!]!): Report!` | `report:enter` | | +| mutation | `updateDoctor(id: ID!, input: DoctorPatch!): Doctor!` | `doctor:manage` | | +| mutation | `updatePatient(id: ID!, input: UpdatePatientInput!): Patient!` | `patient:update` | | +| mutation | `verifyReport(id: ID!): Report!` | `report:verify` | | + +## billing + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `invoice(id: ID!): Invoice` | `invoice:read` | | +| query | `invoices(branchId: ID, status: InvoiceStatus, patientId: ID, customerId: ID, limit: Int, offset: Int): [Invoice!]!` | `invoice:read` | | +| mutation | `cancelInvoice(id: ID!, reason: String!): Invoice!` | `invoice:cancel` | | +| mutation | `issueInvoice(input: IssueInvoiceInput!): Invoice!` | `invoice:create`, `invoice:discount`, `payment:create` | | +| mutation | `recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!` | `payment:create` | | +| mutation | `refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!` | `payment:refund` | | + +## books + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `accountGroups: [AccountGroup!]!` | `accounts:read` | | +| query | `balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet!` | `accounts:read` | | +| query | `cashBook(from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement!` | `accounts:read` | | +| query | `dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]!` | `accounts:read` | | +| query | `gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary!` | `accounts:read` | | +| query | `ledger(id: ID!): Ledger` | `accounts:read` | | +| query | `ledgers: [Ledger!]!` | `accounts:read` | | +| query | `ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement!` | `accounts:read` | | +| query | `outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding!` | `accounts:read` | | +| query | `profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss!` | `accounts:read` | | +| query | `trialBalance(asOf: DateTime, branchId: ID): TrialBalance!` | `accounts:read` | | +| query | `voucher(id: ID!): Voucher` | `accounts:read` | | +| mutation | `cancelVoucher(id: ID!): Voucher!` | `accounts:post` | | +| mutation | `createLedger(input: LedgerInput!): Ledger!` | `accounts:manage` | | +| mutation | `postVoucher(input: PostVoucherInput!): Voucher!` | `accounts:post` | | +| mutation | `setupChartOfAccounts: [Ledger!]!` | `accounts:manage` | Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent. | + +## finance + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `bankLines(ledgerId: ID, statementId: ID, status: BankLineStatus, limit: Int, offset: Int): [BankLine!]!` | `accounts:read` | | +| query | `bankReconciliation(ledgerId: ID!, asOf: DateTime!): BankReconciliation!` | `accounts:read` | | +| query | `bankStatements(ledgerId: ID, limit: Int, offset: Int): [BankStatement!]!` | `accounts:read` | | +| query | `cashClosings(branchId: ID!, limit: Int, offset: Int): [CashClosing!]!` | `accounts:read` | | +| query | `creditNote(id: ID!): CreditNote` | `accounts:read` | | +| query | `creditNotes(invoiceId: ID, customerId: ID, limit: Int, offset: Int): [CreditNote!]!` | `accounts:read` | | +| query | `customer(id: ID!): Customer` | `accounts:read` | | +| query | `customers(activeOnly: Boolean): [Customer!]!` | `accounts:read` | | +| query | `customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement!` | `accounts:read` | | +| query | `exportBalanceSheet(asOf: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportDayBook(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportGstr1(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean): [CsvFile!]!` | `accounts:read` | One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs. | +| query | `exportGstr3b(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportLedger(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportProfitAndLoss(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportSettlement(settlementId: ID!, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportTrialBalance(asOf: DateTime, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `financeConfig: FinanceConfig!` | `accounts:read` | | +| query | `gstr1(from: DateTime!, to: DateTime!, gstin: String): Gstr1!` | `accounts:read` | | +| query | `gstr3b(from: DateTime!, to: DateTime!, gstin: String): Gstr3b!` | `accounts:read` | | +| query | `lockDates: [PeriodLock!]!` | `accounts:read` | | +| query | `partner(id: ID!): Partner` | `accounts:read` | | +| query | `partners(branchId: ID): [Partner!]!` | `accounts:read` | | +| query | `receivablesSummary(asOf: DateTime!): ReceivablesSummary!` | `accounts:read` | | +| query | `settlement(id: ID!): Settlement` | `accounts:read` | | +| query | `settlementPreview(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): SettlementPreview!` | `accounts:read` | Computes a month's settlement without saving it. period: YYYY-MM. | +| query | `settlements(partnerId: ID, status: SettlementStatus, branchId: ID, limit: Int, offset: Int): [Settlement!]!` | `accounts:read` | | +| query | `taxRules: [TaxRule!]!` | `accounts:read` | | +| query | `verifyWithCA: [String!]!` | (signed in) | Items the lab's CA must confirm before these values are relied on. | +| mutation | `accrueSettlement(id: ID!): Settlement!` | `accounts:post` | | +| mutation | `addAgreement(input: AgreementInput!): PartnerAgreement!` | `accounts:manage` | | +| mutation | `addTaxRule(input: TaxRuleInput!): TaxRule!` | `accounts:manage` | | +| mutation | `cancelSettlement(id: ID!): Settlement!` | `accounts:post` | | +| mutation | `closeCashDay(input: CloseCashDayInput!): CashClosing!` | `accounts:post` | | +| mutation | `closeFinancialYear(fy: String!): YearEndResult!` | `accounts:manage` | Closing vouchers on 31 March and a lock. fy: 2026-27. | +| mutation | `createCustomer(input: CustomerInput!): Customer!` | `accounts:manage` | | +| mutation | `createPartner(input: PartnerInput!): Partner!` | `accounts:manage` | | +| mutation | `createSettlement(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): Settlement!` | `accounts:post` | | +| mutation | `importBankStatement(input: BankImportInput!): BankImportResult!` | `accounts:post` | | +| mutation | `issueCreditNote(input: CreditNoteInput!): CreditNoteResult!` | `accounts:post` | | +| mutation | `issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice!` | `accounts:post`, `invoice:create` | | +| mutation | `matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine!` | `accounts:post` | | +| mutation | `paySettlement(input: PaySettlementInput!): Settlement!` | `accounts:post` | | +| mutation | `reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult!` | `accounts:post` | | +| mutation | `seedTaxRules: [TaxRule!]!` | `accounts:manage` | finance-kit's starting rules. Every rate must be verified with the lab's CA. | +| mutation | `setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine!` | `accounts:post` | | +| mutation | `setLockDate(input: LockDateInput!): PeriodLock!` | `accounts:manage` | | +| mutation | `updateCustomer(id: ID!, input: CustomerPatch!): Customer!` | `accounts:manage` | | +| mutation | `updatePartner(id: ID!, input: PartnerPatch!): Partner!` | `accounts:manage` | | + +## documents + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `bookingReportDocument(bookingId: ID!, reportIds: [ID!], format: DocumentFormat, variant: ReportVariant, design: ReportDesign): Document!` | `report:read` | A booking's verified or released reports, printed together (or only reportIds). | +| query | `documentSamples: [DocumentSample!]!` | (signed in) | Demo samples (empty unless the server enables them). | +| query | `invoiceDocument(invoiceId: ID!, format: DocumentFormat, variant: ReportVariant): Document!` | `invoice:read` | A stored invoice, printed. | +| query | `invoiceSamples: [DocumentSample!]!` | (signed in) | | +| query | `invoiceTotals(request: JSON!): JSON!` | `invoice:read` | invoice-kit's calculation of an InvoiceRequest, without rendering (rupees). | +| query | `letterhead(format: DocumentFormat): Document!` | `report:read` | | +| query | `priceList: [PriceListEntry!]!` | (signed in) | invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables). | +| query | `renderInvoice(request: JSON!, format: DocumentFormat, variant: ReportVariant, fileName: String): Document!` | `invoice:read` | Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample }). | +| query | `renderReport(request: JSON!, format: DocumentFormat, variant: ReportVariant, design: ReportDesign, fileName: String): Document!` | `report:read` | Any report: request = report-kit's ReportRequest JSON (patient + tests \| profile \| report \| sample). | +| query | `reportTemplates: TemplateCatalog!` | (signed in) | | diff --git a/docs/05-errors.md b/docs/05-errors.md new file mode 100644 index 0000000..8f2e308 --- /dev/null +++ b/docs/05-errors.md @@ -0,0 +1,55 @@ +# 05 · Errors + +Every error in a response carries `extensions.code`. Clients switch on the code, never on the message. + +```json +{ "errors": [{ "message": "Missing permission invoice:create", "path": ["issueInvoice"], + "extensions": { "code": "FORBIDDEN", "permission": "invoice:create" } }], + "data": null } +``` + +## Codes + +| Code | HTTP-ish | Raised when | Client should | +|---|---|---|---| +| `UNAUTHENTICATED` | 401 | No token, expired, logged out, or the user was disabled | Send the user to sign in | +| `FORBIDDEN` | 403 | The user lacks the permission (at that branch). `extensions.permission` names it | Hide the action; explain | +| `NOT_FOUND` | 404 | The id does not exist **in this organisation** (other tenants' ids look the same) | Show "not found" | +| `BAD_USER_INPUT` | 400 | Validation failed (db-kit `ValidationError`, report-kit 400s, unknown catalog codes, …) | Show the message next to the form | +| `CONFLICT` | 409 | Duplicate key, or the wrong state (e.g. release before verify, cancel a paid invoice) | Refresh and show the message | +| `PERIOD_LOCKED` | 409 | A posting date is on or before a lock date | Ask an accountant; pick another date | +| `CREDIT_LIMIT_EXCEEDED` | 409 | A B2B invoice would exceed the customer's credit limit | Collect payment or raise the limit | +| `INVALID_CREDENTIALS` | 401 | Wrong email, organisation or password (same answer for each) | "Email or password is wrong" | +| `ACCOUNT_DISABLED` | 403 | The user is disabled | Contact an administrator | +| `INVITE_PENDING` | 403 | The user has not set a password yet | Use the reset link | +| `ORG_REQUIRED` | 400 | The email exists in more than one organisation | Ask for the organisation code | +| `RATE_LIMITED` | 429 | Too many attempts; `extensions.retryAfterMs` says how long to wait | Wait, then retry | +| `WEAK_PASSWORD` | 400 | The new password fails the policy | Show the message | +| `INVALID_TOKEN` | 400 | A reset token is unknown, used or expired | Request a new link | +| `GRAPHQL_VALIDATION_FAILED` | 400 | The document is invalid (unknown field, depth > 12, introspection off) | Fix the query | +| `INTERNAL_SERVER_ERROR` | 500 | Anything else. The message is masked as "Unexpected error" | Retry later; check server logs | + +## How errors are made + +```mermaid +flowchart LR + T["throw in service / kit"] --> W["withErrorCodes (wraps every controller)"] + W -->|GraphQLError| P["passed through"] + W -->|AuthError| A["code = AuthError.code"] + W -->|PeriodLockedError| L["PERIOD_LOCKED"] + W -->|CreditLimitError| CL["CREDIT_LIMIT_EXCEEDED"] + W -->|NotFound / Conflict / ValidationError| D["NOT_FOUND / CONFLICT / BAD_USER_INPUT"] + W -->|report-kit HttpError 400| B["BAD_USER_INPUT"] + W -->|anything else| M["re-thrown → Yoga masks it
'Unexpected error'"] +``` + +Source: `src/graphql/errors.ts`. Two rules there matter: + +1. **Never pass `originalError`** to a `GraphQLError`. Yoga masks any error that wraps a non-GraphQL + error, which would hide the code. +2. **Unknown errors stay masked.** Stack traces and SQL never reach clients. They are logged on the server. + +## Adding a code + +Codes are a public contract: add new ones, never rename. Add it to `ERROR_CODES`, map the domain error in +`toGraphQLError`, document it here and in the client's `docs/graphql.md`, and add a test. diff --git a/docs/06-auth-and-permissions.md b/docs/06-auth-and-permissions.md new file mode 100644 index 0000000..8fc04c3 --- /dev/null +++ b/docs/06-auth-and-permissions.md @@ -0,0 +1,120 @@ +# 06 · Auth and permissions + +## Sessions + +auth-kit owns passwords and sessions. The API never sees a password hash leave the server and never +stores a raw token: stores receive `hashToken(token)` (SHA-256) only. + +```mermaid +sequenceDiagram + participant C as Client + participant A as API (auth controller) + participant K as auth-kit + participant S as Session store (Redis or memory) + participant U as Users (db-kit) + C->>A: login(email, password, orgCode?) + A->>K: login() + K->>U: find user (unknown email still runs one scrypt verify: timing-safe) + K->>K: rate limit per email+ip (10 per 15 min) + K->>S: save hash(token), idle + max age + K-->>C: token, expiresAt, user + C->>A: any request (Bearer token) + A->>K: context(token) + K->>S: get hash(token), extend idle window + K->>U: re-read the user (disable / role changes apply at once) + K-->>A: viewer, can(), branchesFor() +``` + +| Setting | Default | Env | +|---|---|---| +| Idle timeout | 12 hours | `SESSION_IDLE_MS` | +| Maximum age | 30 days | `SESSION_MAX_AGE_MS` | +| Login attempts | 10 per 15 minutes | (auth-kit default) | + +Session operations: `mySessions`, `revokeSession`, `logout`, `logoutOtherSessions`. `changePassword` +ends the user's other sessions; `resetPassword`, an administrator's `setUserPassword` and `disableUser` +end all of them. + +### Password reset + +`requestPasswordReset(email, orgCode?)` always returns `true`, whether or not the account exists. When it +does, the server's `Mailer` receives the link `PASSWORD_RESET_URL?token=…`. The token is never returned by +the API. `resetPassword(token, newPassword)` consumes it once. + +The `main.ts` entry has no mailer; embed the API with `createContainer({ config, mailer })` to send +emails ([12-deployment.md](12-deployment.md)). + +## Permissions + +A permission is `resource:action`. Roles grant permissions, with wildcards (`invoice:*`, `*:read`, `*:*`). +A user holds roles **per branch** (`branchId`) or **organisation-wide** (`branchId: null`). + +```mermaid +flowchart LR + U["User"] -- "role: technician
branch: A01" --> R1["technician"] + U -- "role: front_desk
branch: (all)" --> R2["front_desk"] + R1 --> P1["sample:*, report:enter, …"] + R2 --> P2["patient:create, invoice:create, …"] + P1 --> Q{"can(perm, branchId)?"} + P2 --> Q +``` + +`can(permission, branchId)` is true when a role held at that branch, or organisation-wide, grants it. +Without a branch, a role at any branch counts. `permissions` (query) lists every name the API checks; +`viewer { permissions(branchId) can(permission, branchId) branchesFor(permission) }` lets a UI hide what +the user cannot do. + +### System roles (auth-kit `SYSTEM_ROLES`) + +| Role | Summary | +|---|---| +| `owner` | Everything (`*:*`) | +| `admin` | Branches, users, roles, all lab and billing work, `accounts:*`, audit, settings (not `org:update`) | +| `branch_manager` | Patients, bookings, samples, billing, payments; reads reports, releases them; reads accounts | +| `front_desk` | Registers patients, books tests, issues invoices, takes payments | +| `phlebotomist` | Reads bookings, collects samples | +| `technician` | Samples, enters results | +| `pathologist` | Reports: enter, verify, release, amend | +| `accountant` | Payments, invoice cancellation, all `accounts:*` (books, GST, finance), audit | +| `doctor` | Reads patients and reports | + +Organisations can add their own roles or override these with `upsertRole`. + +### How a resolver checks + +```mermaid +flowchart TD + R["resolver"] --> A{"signed in?"} + A -- no --> U["UNAUTHENTICATED"] + A -- yes --> B{"record has a branch?"} + B -- yes --> C["load record (orgId checked)
authorize(ctx, perm, record.branchId)"] + B -- "no / list" --> D["branchScope(ctx, perm, branchId?)
→ allowed branches"] + C --> E{"allowed?"} + D --> E + E -- no --> F["FORBIDDEN"] + E -- yes --> G["service call"] +``` + +- Single records are loaded first (checking `orgId`), then authorised **against their own branch**, so + a user cannot read another branch's booking by guessing its id. +- Lists are filtered to the branches where the user holds the permission. +- Nested fields the user may not see (e.g. `Patient.invoices` without `invoice:read`) resolve to empty + lists, not errors. +- Books, GST and finance need `accounts:read` (reports), `accounts:post` (vouchers, settlements, cash, + bank) or `accounts:manage` (chart, tax rules, lock dates, year end). + +The generated [04-api-reference.md](04-api-reference.md) lists the exact permission per operation. + +## Tenant isolation + +```mermaid +flowchart LR + T["token"] --> V["viewer.orgId"] + V --> S["service(actor = { userId, orgId })"] + S --> O{"record.orgId == actor.orgId?"} + O -- no --> N["NOT_FOUND (indistinguishable from missing)"] + O -- yes --> OK["continue"] +``` + +No operation accepts an `orgId`. Inputs that reference other records (patient, branch, invoice, ledger, +customer, partner) are checked against the session's organisation before use. diff --git a/docs/07-domain-flows.md b/docs/07-domain-flows.md new file mode 100644 index 0000000..badc9e0 --- /dev/null +++ b/docs/07-domain-flows.md @@ -0,0 +1,174 @@ +# 07 · Domain flows + +## The lab day, end to end + +```mermaid +flowchart LR + P["registerPatient"] --> B["createBooking
(tests from the catalog)"] + B --> S["samples created
one per sample type"] + B --> I["issueInvoice
(bookingId, items by code)"] + I --> V1["sales voucher posted
(GST split, finance-kit)"] + I --> PAY["recordPayment"] --> V2["receipt voucher"] + S --> C["collectSample"] --> R["receiveSample"] + R --> RP["createReport → saveResults
(unit, range, flag from catalog)"] + RP --> VR["verifyReport"] --> RL["releaseReport"] + RL --> D["bookingReportDocument
PDF / HTML"] + I --> ID["invoiceDocument
PDF / HTML"] +``` + +## Booking + +```mermaid +stateDiagram-v2 + [*] --> booked: createBooking + booked --> sample_collected: first collectSample + booked --> in_process: receiveSample + sample_collected --> in_process: receiveSample + in_process --> completed: last report released + booked --> cancelled: cancelBooking + sample_collected --> cancelled: cancelBooking + in_process --> cancelled: cancelBooking + note right of cancelled: needs the booking's invoice cancelled first +``` + +`createBooking` accepts `tests: [{ testCode }]`; the name, price (paise) and sample type come from the +catalog unless given. One sample (with a barcode) is created per distinct sample type. + +## Sample + +```mermaid +stateDiagram-v2 + [*] --> pending + pending --> collected: collectSample + collected --> received: receiveSample + pending --> rejected: rejectSample(reason) + collected --> rejected: rejectSample(reason) + received --> rejected: rejectSample(reason) +``` + +## Report + +```mermaid +stateDiagram-v2 + [*] --> draft: createReport + draft --> entered: saveResults + entered --> entered: saveResults (again) + entered --> verified: verifyReport + verified --> released: releaseReport + released --> amended: amendReport + amended --> verified: verifyReport +``` + +`saveResults` fills `unit`, `range` (by the patient's sex) and `flag` (L / H by the reference interval) from +the catalog when the client leaves them out. `flag: NONE` means normal. Releasing the last report of a +booking marks the booking `completed`. `bookingReportDocument` prints verified or released reports only ([08-documents.md](08-documents.md)). + +## Invoice and payments + +```mermaid +stateDiagram-v2 + [*] --> issued: issueInvoice (nothing paid) + [*] --> partially_paid: issueInvoice with part payment + [*] --> paid: issueInvoice paid in full + issued --> partially_paid: recordPayment + partially_paid --> paid: recordPayment + issued --> paid: recordPayment + paid --> partially_paid: refundPayment (part) + paid --> refunded: refundPayment (all) + issued --> credited: issueCreditNote (whole value) + partially_paid --> credited: issueCreditNote + issued --> cancelled: cancelInvoice(reason) + refunded --> cancelled: cancelInvoice(reason) + note right of cancelled: only with nothing paid; refund first +``` + +- Items may be just `{ code: "CBC" }`: name and MRP come from the catalog. `applyTaxRules: true` takes + HSN/SAC, taxability and rate from the organisation's tax rules (diagnostic services are usually exempt). +- Arithmetic (db-kit, integer paise): line gross → line discount → bill discount shared by largest + remainder → GST → round-off to the rupee. +- Every issue, payment, refund, credit note and cancel posts a voucher **in the same transaction** + (`financePosting` → tally-kit). A failed posting rolls the whole operation back. + +```mermaid +sequenceDiagram + participant API as billing service + participant DB as db-kit issueInvoice + participant H as financePosting (hook) + participant T as tally-kit postVoucher + API->>DB: repos.transaction(tx => …) + DB->>DB: compute totals, number MGPL/ARG01/2026-27/000123 + DB->>H: invoiceIssued(tx, invoice) + H->>H: place of supply from branch stateCode → CGST+SGST or IGST + H->>T: sales voucher (Dr patient/customer, Cr income, Cr output GST) + T->>T: assertPeriodOpen (lock dates), assertBalanced + T-->>DB: voucher + DB-->>API: invoice (committed with its voucher) +``` + +## B2B receivables + +```mermaid +flowchart LR + CU["createCustomer
(credit limit, days)"] --> CI["issueCustomerInvoice"] + CI --> CS["customerStatement
open items, ageing"] + CI --> CN["issueCreditNote
(lines or whole)"] + CS --> RS["receivablesSummary"] +``` + +`issueCustomerInvoice` refuses with `CREDIT_LIMIT_EXCEEDED` when the customer's open balance plus this +invoice exceeds the limit. Credit notes reverse tax line by line and reduce what is owed. + +## Partner settlements (collection centres, franchises) + +```mermaid +stateDiagram-v2 + [*] --> draft: createSettlement(partner, "2026-09") + draft --> accrued: accrueSettlement (journal: expense / partner) + accrued --> paid: paySettlement (payment voucher, TDS) + draft --> cancelled: cancelSettlement + accrued --> cancelled: cancelSettlement (reverses accrual) + note right of paid: corrections go into the next month +``` + +`settlementPreview` computes the month without saving: lines per invoice from the partner's agreements +(revenue share in basis points, or fixed rates per test code), deductions, TDS (section and rate from the +partner or the finance config) and net. No doctor referral commissions: settlements are for collection +centres and franchises only. + +## Cash and bank + +```mermaid +flowchart LR + subgraph Cash + CC["closeCashDay(branch, day, counted)"] --> DIFF{"book = counted?"} + DIFF -- no --> J["journal for the difference"] + end + subgraph Bank + IM["importBankStatement(csv)"] --> RC["reconcileBank
auto-match, card/UPI settlements with MDR"] + RC --> UM["bankLines(status: unmatched)"] + UM --> MB["matchBankLine / setBankLineStatus"] + MB --> BR["bankReconciliation (BRS)"] + end +``` + +## Periods + +```mermaid +stateDiagram-v2 + direction LR + [*] --> open + open --> locked: setLockDate(upTo) + locked --> open: setLockDate(null) + open --> closed: closeFinancialYear("2026-27") + note right of locked: postings on or before upTo fail with PERIOD_LOCKED +``` + +`closeFinancialYear` posts the closing vouchers on 31 March (income and expenses into capital) and locks +the year. Lock dates are checked inside the posting transaction, so nothing slips past them. + +## Books + +`setupChartOfAccounts` seeds the 28 standard groups, the default ledgers and finance-kit's ledgers +(idempotent; the seed script runs it). Reports: `trialBalance`, `ledgerStatement`, `cashBook`, `dayBook`, +`profitAndLoss`, `balanceSheet`, `outstandingReceivables`, `gstSummary`. Manual entries go through +`postVoucher` (balanced, one side per line) and `cancelVoucher`. diff --git a/docs/08-documents.md b/docs/08-documents.md new file mode 100644 index 0000000..e6c2214 --- /dev/null +++ b/docs/08-documents.md @@ -0,0 +1,80 @@ +# 08 · Documents (reports and invoices) + +report-kit and invoice-kit render inside the API process. There is no separate document server and no +REST route: documents travel in GraphQL responses. + +```mermaid +flowchart LR + subgraph Stored["From stored records"] + BR["bookingReportDocument(bookingId, reportIds?)"] + ID["invoiceDocument(invoiceId)"] + end + subgraph Ad-hoc["From a request (JSON)"] + RR["renderReport(request)"] + RI["renderInvoice(request)"] + IT["invoiceTotals(request)"] + end + LH["letterhead"] + BR & RR --> RK["report-kit
renderReportPdf / Html"] + ID & RI --> IK["invoice-kit
renderInvoicePdf / Html"] + LH --> RK + RK & IK --> DOC["Document
{ format, fileName, mimeType, byteLength, base64 | html }"] +``` + +## The Document type + +| Field | PDF | HTML | +|---|---|---| +| `format` | `pdf` | `html` | +| `mimeType` | `application/pdf` | `text/html; charset=utf-8` | +| `base64` | the file | null | +| `html` | null | a self-contained page (fonts and images inlined) | +| `byteLength` | size of the PDF | size of the HTML (UTF-8) | + +Arguments: `format` (`pdf` default), `variant` (`header` prints the letterhead; `non_header` for +pre-printed paper, same positions otherwise), `design` (`sidebar` default, or `classic`) for reports. + +In a browser: `documentBlob(doc)` from `@mgpl-diagnostics/client` gives a Blob for download, a new tab or +printing ([14-client.md](14-client.md)). + +## Stored reports → print + +```mermaid +flowchart TD + B["booking + patient + doctor + samples"] --> M["map to report-kit's Report"] + R["verified / released reports"] --> T{"every result matches a
template field key or label?"} + T -- yes --> TPL["template section (e.g. cbc, lft)
with the lab's layout"] + T -- no --> GEN["generic section
Test · Result · Unit · Range"] + TPL & GEN --> M + M --> RK["report-kit render"] +``` + +The sample-collection time is the earliest `collectedAt` of the booking's samples. Draft or entered +reports never print: if any selected report is not verified or released the call fails with `CONFLICT` +(pass `reportIds` to print only the ready ones), and `NOT_FOUND` when there are none. + +## Stored invoices → print + +The stored invoice (paise) is mapped to invoice-kit's print shape (rupees). invoice-kit recomputes the +totals; the API checks that its total equals the stored total and refuses a mismatch (`CONFLICT`), so a +printed invoice can never disagree with the books. + +## Ad-hoc rendering + +`renderReport(request)` and `renderInvoice(request)` take the same JSON as report-kit's and invoice-kit's +HTTP APIs did (`ReportRequest`: patient + tests | profile | report | sample; `InvoiceRequest`: +`{ invoice }` or `{ sample }`). They are for previews (result entry with a live preview, quotes). Samples +(`documentSamples`, `invoiceSamples`, `{ sample: id }`) work only when `MGPL_SAMPLES=true`. + +## Signatures and branding + +The lab profile is `defaultLab()` from report-kit: branding plus signatures read from +`MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE` (or `MGPL_SIGNATURE_FILE`). The files live +outside every repository. Without them a placeholder signature prints. Embedders can pass their own +`lab` to `createContainer`. + +## Cost + +A PDF takes tens to a few hundred milliseconds of CPU and the response is about 4/3 of the file size +(base64). Keep `BODY_LIMIT_BYTES` for requests; responses are not limited. Render on demand; cache the +result client-side if the same document is opened repeatedly. diff --git a/docs/09-configuration.md b/docs/09-configuration.md new file mode 100644 index 0000000..a9799e4 --- /dev/null +++ b/docs/09-configuration.md @@ -0,0 +1,48 @@ +# 09 · Configuration + +All configuration comes from environment variables, read once by `loadConfig()` (`src/config/env.ts`). +Copy `.env.example` to `.env` for local work; never commit it. + +| Variable | Default | Notes | +|---|---|---| +| `NODE_ENV` | `development` | `production` turns off GraphiQL, introspection, samples and `*` CORS by default | +| `HOST` | `0.0.0.0` | | +| `PORT` | `4000` | | +| `GRAPHQL_PATH` | `/graphql` | The one endpoint | +| `DB_BACKEND` | `pg` | `pg` or `mongo` | +| `DATABASE_URL` | local default in development; **required** in production | Postgres URL, or a MongoDB URI with `replicaSet=` | +| `REDIS_URL` | empty | Sessions, reset tokens, login limits and db-kit's read cache. Required for more than one server | +| `CORS_ORIGINS` | `*` in development, empty in production | Comma-separated origins. `*` is refused in production | +| `GRAPHIQL` | on in development | GraphiQL at the endpoint (GET) | +| `INTROSPECTION` | on in development | Off adds `NoSchemaIntrospectionCustomRule` | +| `BODY_LIMIT_BYTES` | `2000000` | Request body limit (bank CSV imports count) | +| `TRUST_PROXY` | `false` | `true` behind a load balancer, so login limits see the client IP | +| `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | +| `SESSION_IDLE_MS` | `43200000` (12 h) | | +| `SESSION_MAX_AGE_MS` | `2592000000` (30 days) | | +| `PASSWORD_RESET_URL` | empty (`/reset-password`) | The page that receives `?token=` | +| `MGPL_SIGNATURE_HEADER_FILE` | — | PNG for the header variant (outside the repo) | +| `MGPL_SIGNATURE_NONHEADER_FILE` | — | PNG for the non-header variant | +| `MGPL_SIGNATURE_FILE` | — | One file for both | + +Seed-only variables (`npm run seed`): `SEED_ORG_NAME`, `SEED_ORG_CODE`, `SEED_ORG_GSTIN` (optional), +`SEED_BRANCH_CODE`, `SEED_BRANCH_NAME`, `SEED_BRANCH_STATE`, `SEED_OWNER_NAME`, `SEED_OWNER_EMAIL`, +`SEED_OWNER_PASSWORD`. + +Test-only: `TEST_BACKENDS=pg,mongo` (default both), `DEBUG=1` prints resolver errors, +`MONGOMS_SYSTEM_BINARY` points mongodb-memory-server at a local `mongod`. + +## Finance configuration + +GST and TDS values are not environment variables. They are data in the organisation's settings +(`settings.finance`, merged over finance-kit's `DEFAULT_FINANCE_CONFIG`) and tax rules (`taxRules`, +`addTaxRule`, `seedTaxRules`). Every value the CA must confirm is listed by `verifyWithCA`. + +```mermaid +flowchart LR + D["finance-kit DEFAULT_FINANCE_CONFIG"] --> MRG["merged config
(financeConfig query)"] + O["organization.settings.finance"] --> MRG + TR["taxRules (per org)"] --> GST["GST per invoice line"] + MRG --> GST + MRG --> TDS["TDS on settlements"] +``` diff --git a/docs/10-data-and-money.md b/docs/10-data-and-money.md new file mode 100644 index 0000000..67e875f --- /dev/null +++ b/docs/10-data-and-money.md @@ -0,0 +1,68 @@ +# 10 · Data and money + +## Money + +- **Integer paise everywhere** (`totalPaise: 35400` = Rs 354.00). Inputs and outputs, no floats. +- The catalog's `mrp` and invoice-kit's print/price-list amounts are **rupees**; the API converts at the + boundary (`pricePaise = mrp × 100`). +- Rounding is explicit: bill discounts by largest remainder, GST per line, round-off to the rupee. +- Every voucher balances (debits = credits), checked by tally-kit before it is written. +- Print shows `Rs.` (the report font has no `₹` glyph). + +## Dates + +| Kind | GraphQL | Meaning | +|---|---|---| +| Instant | `DateTime` (ISO string) | `createdAt`, `verifiedAt`, voucher dates | +| Day | `Day` (`"YYYY-MM-DD"`) | Calendar day **in IST** (cash closings, settlement lines, lock dates, GST invoice days) | +| Period | `String` | Settlement month `"2026-09"`; financial year `"2026-27"` | + +The financial year runs 1 April to 31 March (IST). Voucher and invoice numbers restart each year +(`MGPL/ARG01/2026-27/000001`: organisation / branch / year / sequence). + +## Storage + +```mermaid +erDiagram + ORGANIZATION ||--o{ BRANCH : has + ORGANIZATION ||--o{ USER : employs + ORGANIZATION ||--o{ ROLE : defines + BRANCH ||--o{ PATIENT : registers + PATIENT ||--o{ BOOKING : books + BOOKING ||--o{ SAMPLE : yields + BOOKING ||--o{ REPORT : produces + BOOKING |o--o| INVOICE : billed_by + INVOICE ||--o{ PAYMENT : receives + INVOICE ||--o{ CREDIT_NOTE : reduced_by + INVOICE ||--o{ VOUCHER : posts + VOUCHER ||--|{ LEDGER_ENTRY : contains + LEDGER ||--o{ LEDGER_ENTRY : records + ACCOUNT_GROUP ||--o{ LEDGER : groups + CUSTOMER ||--|| LEDGER : owns + CUSTOMER ||--o{ INVOICE : billed + PARTNER ||--o{ AGREEMENT : signs + PARTNER ||--o{ SETTLEMENT : earns + BRANCH ||--o{ CASH_CLOSING : closes + BANK_STATEMENT ||--o{ BANK_LINE : lists + ORGANIZATION ||--o{ PERIOD_LOCK : locks +``` + +All tables and collections belong to **db-kit**, on PostgreSQL (Sequelize) or MongoDB (Mongoose, replica +set for transactions). Both backends pass the same contract tests, and this API's suite runs on both. +Schema changes are db-kit pull requests, never local tables here. + +## GST prerequisites + +| Needed for | Set | +|---|---| +| Posting any invoice (place of supply → CGST+SGST or IGST) | The branch's `stateCode` (or its `gstin`) | +| GSTR-1 / GSTR-3B | The organisation's `gstin` (or pass `gstin`) | +| B2B invoices to registered buyers | The customer's `gstin` and `stateCode` | +| Taxable lines | Tax rules (`seedTaxRules`, then confirm each with the CA) | + +A branch without a state code fails billing with `BAD_USER_INPUT`. The seed script asks for it. + +## Personal data + +Patients' names, phones, addresses and results are health data. Do not log request bodies, do not copy +production data into tests (fixtures use fictional names), and keep backups encrypted. diff --git a/docs/11-testing.md b/docs/11-testing.md new file mode 100644 index 0000000..30afa09 --- /dev/null +++ b/docs/11-testing.md @@ -0,0 +1,56 @@ +# 11 · Testing + +`npm test` runs `test/run.ts`: the real server (Fastify + Yoga) driven over HTTP with `app.inject`, real +auth-kit sessions, and real databases started in-process. + +```mermaid +flowchart LR + T["test/run.ts"] --> PG["embedded-postgres
(temp dir)"] + T --> MG["mongodb-memory-server
replica set"] + PG & MG --> S["suite(db, tag)"] + S --> SEED["seed: org APL (GSTIN, branch state 22)
org OTH (tenant isolation)"] + SEED --> RUN["≈64 checks per backend over POST /graphql"] + T --> CFG["fromConfig(url): production container path"] +``` + +| Area | What the suite proves | +|---|---| +| Server | `/health` and `{ health }`, introspection off when `INTROSPECTION=0`, depth limit 12 | +| Auth | no token → `UNAUTHENTICATED`, wrong password → `INVALID_CREDENTIALS`, login, viewer (null without a token), weak password, reset link through the mailer (unknown emails not revealed), reset ends old sessions, disable signs out at once, `mySessions`, logout | +| Permissions | front desk cannot read the books; registers in branch A, not B; lists show only the viewer's branches; discounts need `invoice:discount`; nested vouchers hidden without `accounts:read` | +| Tenancy | another organisation cannot read the invoice, the patient, or print it; cannot post into its ledgers | +| Catalog | search and price quote without sign-in | +| Lab | patient → booking by catalog code → collect → receive → results with unit, range and flag from the catalog → unverified report does not print → verify, release → booking completed | +| Billing | invoice priced from the catalog, partial payment, overpayment refused, payment → paid with sales and receipt vouchers, qty 0 refused | +| Books | chart setup, trial balance balances, manual voucher, unbalanced voucher refused | +| Finance | tax rules, customer + credit invoice, credit limit, statement, credit note, GSTR-1/3B data with the verify-with-CA list, trial balance CSV with TOTAL row, partner + agreement + settlement preview, lock date → `PERIOD_LOCKED` | +| Documents | booking report PDF and non-header HTML, stored invoice PDF + HTML (totals match), `renderReport` / `invoiceTotals` / `reportTemplates`, bad request → `BAD_USER_INPUT` | +| Performance | 10 invoices with nested `patient` → one batched lookup (no N+1) | + +Not covered yet (good next tests): refunds and invoice cancellation, settlement accrue/pay/cancel, cash +closing, bank import and reconciliation, year-end close, P&L / balance sheet values, CORS headers, the +login rate limit. + +## Running + +```bash +npm test # PostgreSQL and MongoDB (first run downloads the binaries) +TEST_BACKENDS=pg npm test # PostgreSQL only +DEBUG=1 TEST_BACKENDS=pg npm test # print resolver errors +MONGOMS_SYSTEM_BINARY=/usr/bin/mongod npm test # behind a proxy that blocks the MongoDB download +``` + +CI (`.github/workflows/ci.yml`, job id `ci`, required on `main`) runs `npm run kits`, typecheck, the tests +on both backends, the build, and checks that `schema.graphql`, `docs/04-api-reference.md` and +`llms-full.txt` are current. + +## The client contract + +`kits.json` pins `@mgpl-diagnostics/client`. Once the client ships its GraphQL operations, the suite +validates every operation the client sends against `buildSchema()` and checks that each root field has +one, so a renamed field fails here before it reaches an app. + +## Adding a test + +Add checks to `suite()` in `test/run.ts` with `ok(condition, "[tag] what it proves")`. Use the `gql(token, +query, variables)` helper and the seeded tokens (`owner`, `desk`, …). Keep fixtures fictional. diff --git a/docs/12-deployment.md b/docs/12-deployment.md new file mode 100644 index 0000000..f1bc05e --- /dev/null +++ b/docs/12-deployment.md @@ -0,0 +1,76 @@ +# 12 · Deployment + +## Shape + +```mermaid +flowchart LR + U["Browsers / apps"] -- HTTPS --> LB["Load balancer / reverse proxy
TLS, gzip"] + LB -- "/graphql, /health" --> A1["api #1"] + LB --> A2["api #2"] + A1 & A2 --> DB[("PostgreSQL
or MongoDB replica set")] + A1 & A2 --> R[("Redis")] + A1 & A2 -. reads .-> SIG["signature files
(secret volume)"] +``` + +- **One server** can run without Redis (sessions in memory, lost on restart). +- **Two or more servers need Redis**: sessions, reset tokens and login limits must be shared. +- The process is stateless otherwise; scale horizontally. + +## Build and run + +```bash +npm run kits # or `npm ci` once every kit is on the registry +npm run build # dist/main.js +NODE_ENV=production node dist/main.js +``` + +A container image: Node 22 slim, copy `dist/`, `node_modules/` and `package.json`, run +`node dist/main.js`, expose `PORT`. The PDF engine needs no system fonts (fonts are bundled), but +report-kit's resvg is a native module: build the image on the same platform it runs on. + +## Production checklist + +| | Item | +|---|---| +| ☐ | `NODE_ENV=production`, `DATABASE_URL`, `REDIS_URL` set from secrets | +| ☐ | `CORS_ORIGINS` lists exactly the app origins (no `*`) | +| ☐ | `GRAPHIQL` and `INTROSPECTION` off (the defaults in production) | +| ☐ | `TRUST_PROXY=true` behind the load balancer (login limits use the client IP) | +| ☐ | `MGPL_SAMPLES` off (the default in production) | +| ☐ | Signature files mounted read-only; `MGPL_SIGNATURE_*_FILE` point at them | +| ☐ | `PASSWORD_RESET_URL` points at the app's reset page, and a `Mailer` is wired (below) | +| ☐ | First organisation seeded (`npm run seed`) with the branch `stateCode` and the org GSTIN | +| ☐ | Tax rules seeded **and confirmed by the CA** (`verifyWithCA`) | +| ☐ | Database backups (point-in-time for Postgres), encrypted | +| ☐ | Health check on `GET /health` (200 `{ ok: true }`, 503 when the database is down) | +| ☐ | Logs shipped; request bodies are not logged (they hold patient data) | + +## Sending password-reset emails + +`src/main.ts` starts the API without a mailer, so reset links are not sent (the API still answers +`true`). Wire one by embedding: + +```ts +import { buildServer, createContainer, loadConfig, type Mailer } from "@mgpl-diagnostics/api"; + +const mailer: Mailer = { + async passwordReset(to, link, expiresAt) { + await sendEmail({ to: to.email, subject: "Reset your password", text: `Hi ${to.name}, ${link} (until ${expiresAt.toISOString()})` }); + }, +}; +const container = await createContainer({ config: loadConfig(), mailer }); +await (await buildServer(container)).listen({ port: container.config.port, host: container.config.host }); +``` + +## Database operations + +- **PostgreSQL**: `createDb` creates missing tables on start (`sequelize.sync()`, which never alters + existing ones). Take a backup before upgrading db-kit and apply any migration its changelog lists. +- **MongoDB**: must be a replica set (transactions). `createDb` syncs indexes on start (`syncIndexes`). +- **Redis**: no persistence needed beyond sessions; losing it signs everyone out. + +## Upgrading kits + +Bump the version in `package.json` (and the ref in `kits.json` while CI builds from source), run +`npm test` on both backends, regenerate `schema.graphql` if types changed, deploy. tally-kit changes of +behaviour are versioned decisions; read its changelog. diff --git a/docs/13-adding-a-feature.md b/docs/13-adding-a-feature.md new file mode 100644 index 0000000..08b2ced --- /dev/null +++ b/docs/13-adding-a-feature.md @@ -0,0 +1,93 @@ +# 13 · Adding a feature + +Every feature walks the same layers. Example: **a query `patientInvoicesDue(patientId)`**. + +```mermaid +flowchart LR + A["1 · Kit
(if new logic or storage)"] --> B["2 · Service
src/services"] + B --> C["3 · Schema
src/schema/*.graphql.ts"] + C --> D["4 · Controller
src/controllers"] + D --> E["5 · Test
test/run.ts"] + E --> F["6 · Generated files
schema · reference · llms"] + F --> G["7 · Client
operations · types · method"] +``` + +## 1. Does it belong in a kit? + +| The change is… | Where | +|---|---| +| A new table, column or repository method | **db-kit** PR (both `mongo/` and `pg/`, contract tests) | +| A domain rule (GST, settlements, credit limits) | **finance-kit** | +| Posting or books reports | **tally-kit** | +| A permission | **auth-kit** `PERMISSIONS` + the right `SYSTEM_ROLES` | +| A test, range or price | **catalog-kit** `data/catalog.json` | +| A print layout | **report-kit** / **invoice-kit** | +| Wiring, authorisation, mapping to GraphQL | here | + +## 2. Service + +```ts +// src/services/billing.service.ts +async dueForPatient(actor: Actor, patientId: Id, scope: "all" | Id[]) { + mustOwn(await this.repos.patients.get(patientId), actor.orgId, "Patient", patientId); + const invoices = await scopedFind(this.repos.invoices, { patientId }, scope, 200, 0, "createdAt"); + return invoices.filter((i) => i.duePaise > 0 && i.status !== "cancelled"); +} +``` + +Services take `(actor, …)`, check ownership, use the kits, and never import GraphQL. + +## 3. Schema + +```ts +// src/schema/billing.graphql.ts +extend type Query { + """Unpaid invoices of one patient, newest first.""" + patientInvoicesDue(patientId: ID!): [Invoice!]! +} +``` + +## 4. Controller + +```ts +// src/controllers/billing.controller.ts, inside Query +patientInvoicesDue: (_: unknown, a: { patientId: string }, ctx: Ctx) => + ctx.services.billing.dueForPatient(actorOf(ctx), a.patientId, branchScope(ctx, "invoice:read")), +``` + +Always: `authorize` / `branchScope` first, ids checked against the organisation, `cap()` on limits. +Controllers are wrapped in `withErrorCodes`, so domain errors get codes automatically. + +## 5. Test + +```ts +const due = await gql(desk, `query($p: ID!) { patientInvoicesDue(patientId: $p) { invoiceNo duePaise } }`, { p: patientId }); +ok(due.data?.patientInvoicesDue.length === 1, `[${tag}] patientInvoicesDue lists the unpaid invoice`); +``` + +Add a negative check too (another organisation, or a user without the permission). + +## 6. Generated files + +```bash +npm run typecheck && npm test +npm run schema && npm run docs:reference && npm run llms +``` + +Update the hand-written docs that describe the area (flows, errors) and add an example to `docs/examples/`. + +## 7. Client + +In `mgpl-diagnostics/client`: add the operation to `src/graphql/operations.ts`, the result type to +`src/graphql/types.ts`, a method to `src/graphql/client.ts`, a row to `docs/graphql.md`; release; then bump +the client ref here so the contract test covers it. + +## Checklist + +- [ ] Permission exists in auth-kit and the right roles grant it +- [ ] No `orgId` or `actorId` arguments +- [ ] Money in paise, days as `Day` +- [ ] Multi-record writes inside a kit transaction +- [ ] Nested fields through loaders +- [ ] Positive and negative tests on both backends +- [ ] schema.graphql, 04-api-reference.md, llms-full.txt regenerated diff --git a/docs/14-client.md b/docs/14-client.md new file mode 100644 index 0000000..4d1dd99 --- /dev/null +++ b/docs/14-client.md @@ -0,0 +1,48 @@ +# 14 · Client + +Frontends use [`@mgpl-diagnostics/client`](https://github.com/mgpl-diagnostics/client) (zero dependencies). +Its `createMgplGraphQLClient` has one typed method per operation of this API. + +```mermaid +flowchart LR + subgraph App["Your app (any framework)"] + UI["UI + state"] --> SDK["createMgplGraphQLClient()"] + end + SDK -- "POST /graphql" --> API["api"] + SDK -. "documentBlob / csvBlob" .-> DL["download · open · print"] +``` + +```ts +import { createMgplGraphQLClient, documentBlob, printBlob, MgplGraphQLError } from "@mgpl-diagnostics/client"; + +const mgpl = createMgplGraphQLClient({ url: import.meta.env.VITE_API_URL + "/graphql", onToken: saveToken, token: loadToken() }); + +await mgpl.auth.login({ email, password, orgCode: "MGPL" }); +const me = await mgpl.auth.viewer(); +const canBill = await mgpl.auth.can("invoice:create", branchId); + +const hits = await mgpl.catalog.search("lft"); +const { booking } = await mgpl.bookings.create({ branchId, patientId, tests: hits.slice(0, 1).map((h) => ({ testCode: h.test.code! })) }); +const doc = await mgpl.documents.bookingReports(booking.id, { variant: "non_header" }); +printBlob(documentBlob(doc)); +``` + +| Need | Method | +|---|---| +| Sign in / out, sessions | `auth.login`, `auth.logout`, `auth.sessions`, `auth.changePassword` | +| Show or hide UI by permission | `auth.viewer().permissions`, `auth.can(permission, branchId)` | +| Result-entry form | `catalog.test(code, sex)` (parameters, units, ranges) or `documents.templates()` | +| Live preview | `documents.renderReport(request, { format: "html" })` | +| Desk | `patients.*`, `bookings.*`, `samples.*`, `invoices.*` | +| Pathologist | `reports.saveResults`, `reports.verify`, `reports.release` | +| Accounts | `books.*`, `gst.*`, `receivables.*`, `settlements.*`, `cash.*`, `bank.*`, `periods.*`, `exports.*` | +| Anything custom | `request(query, variables)` | + +Errors arrive as `MgplGraphQLError` with `code` ([05-errors.md](05-errors.md)). The full method list is in +the client's `docs/graphql.md`. + +## Without the SDK + +Any GraphQL client works (Apollo, urql, graphql-request, plain `fetch`). Send +`Authorization: Bearer ` and `content-type: application/json` to `POST /graphql`. The schema is +in [schema.graphql](../schema.graphql) for code generators; introspection is off in production. diff --git a/docs/15-glossary.md b/docs/15-glossary.md new file mode 100644 index 0000000..27842be --- /dev/null +++ b/docs/15-glossary.md @@ -0,0 +1,39 @@ +# 15 · Glossary + +| Term | Meaning | +|---|---| +| **Booking** | A patient's order for one or more tests on one visit | +| **Sample** | A specimen (blood, urine, …) collected for a booking, with a barcode; one per sample type | +| **Report** | The results of one test for a booking; goes draft → entered → verified → released | +| **Template** | report-kit's layout for a test (rows, units, ranges); results keyed by field | +| **Profile** | A group of tests sold together (e.g. LFT, KFT) | +| **UHID** | Unique health ID: the lab's patient number | +| **MRP** | Maximum retail price of a test, in rupees, from the catalog | +| **Paise** | 1/100 of a rupee; every amount in the API is integer paise | +| **Branch** | A lab or collection centre of the organisation; holds its own stock of patients, bookings, cash | +| **Header / non-header** | Print with the letterhead, or without it for pre-printed stationery | +| **Voucher** | A double-entry accounting document: sales, receipt, payment, journal, contra, credit note, … | +| **Ledger** | An account (Cash, Bank, Sales, a customer, a partner) inside an account group | +| **Trial balance** | Every ledger's debit/credit balance; totals must be equal | +| **Day book** | All vouchers of a period | +| **Lock date** | No posting on or before it (period closed) | +| **Financial year (FY)** | 1 April – 31 March, written `2026-27` | +| **GSTIN** | 15-character GST registration number; its first two digits are the state code | +| **State code** | GST code of a state (22 = Chhattisgarh); decides CGST+SGST (same state) vs IGST | +| **Place of supply** | The state where the service is supplied, for GST | +| **HSN / SAC** | Classification codes for goods / services on invoices | +| **GSTR-1 / GSTR-3B** | Monthly GST returns: outward supplies detail / summary with tax payable | +| **B2B / B2C / B2CL / B2CS** | Supplies to registered businesses / consumers (large / small) | +| **CDNR / CDNUR** | Credit and debit notes to registered / unregistered buyers | +| **Credit note** | Reduces an issued invoice (with its tax); our "partial cancel" | +| **Receivable** | Money owed by B2B customers (hospitals, insurers, TPAs, corporates) | +| **Ageing** | Open amounts by how overdue they are (0–30, 31–60, 61–90, 90+ days) | +| **Partner** | A collection centre or franchise that earns a share of billing | +| **Settlement** | A month's partner share: preview → draft → accrued → paid | +| **TDS** | Tax deducted at source on payments to partners (section and rate are CA-confirmed config) | +| **bp (basis points)** | 1/100 of a percent: 2500 bp = 25 % | +| **MDR** | Merchant discount rate: the card/UPI fee the bank deducts from settlements | +| **BRS** | Bank reconciliation statement: book balance vs bank balance and the items between | +| **Verify with CA** | Values (rates, thresholds, sections) the lab's chartered accountant must confirm | +| **Kit** | One of our libraries (auth, db, catalog, report, invoice, tally, finance) used inside the API | +| **Actor** | `{ userId, orgId }` of the signed-in user, from the session | diff --git a/docs/LOCAL-AGENT-BRIEF.md b/docs/LOCAL-AGENT-BRIEF.md new file mode 100644 index 0000000..f886129 --- /dev/null +++ b/docs/LOCAL-AGENT-BRIEF.md @@ -0,0 +1,172 @@ +# Brief for the local agent: what to do outside this repo + +**Audience:** an AI agent (Claude Code or similar) or a developer working on the owner's machine with +an org-owner GitHub login (`gh auth status`), Node 22, and Docker or local databases. +**Why local:** the cloud session that built this API could not create releases or tags (403), change org +settings, or download MongoDB binaries. Everything below needs one of those. +**Written:** 2026-10-14, when `mgpl-diagnostics/api` was first published. + +Work top to bottom. Each step says how to check it is done. Report back with the checklist at the end. + +```mermaid +flowchart TD + A["1 · Protect the api repo"] --> B["2 · Run the Mongo tests locally"] + B --> C["3 · Release client 1.1.0"] + C --> D["4 · Publish finance-kit 1.0.0"] + D --> E{"5 · tally-kit name
confirmed by owner?"} + E -- yes --> F["publish tally-kit"] + E -- no --> G["leave unpublished"] + F & G --> H["6 · Package access for api's Actions"] + H --> I["7 · Switch api CI to the registry"] + I --> J["8 · db-kit 2.0.0: remove src/graphql"] + J --> K["9 · Deploy + seed"] + K --> L["10 · CA review"] +``` + +## Ground rules + +- `main` in every repo takes pull requests only (squash, 1 code-owner review by `@BackStacked`, the + `ci` check). Never push to `main`; never force-push someone else's branch. +- Release tags `v*` are admin-only and publish packages (`publish.yml`). Bump the version in a PR first, + merge it, then tag the merge commit. +- Never commit real patient data, signatures, `.env` files or tokens. +- **Ask the owner before changing any value marked "verify with CA"** (GST rates, TDS sections and + rates, thresholds). +- **Do not release tally-kit until the owner confirms its final package name** ("Tally" is a trademark). + +## 1. Protect the api repo + +In `mgpl-diagnostics/.github`, `scripts/protect-repos.mjs` lists required checks per repo in `CHECKS`. +Add `api: ["ci"]` (a PR from the cloud session may already do this; check first), merge, then: + +```bash +cd .github && node scripts/protect-repos.mjs api +``` + +Done when: `gh api repos/mgpl-diagnostics/api/rulesets` lists "main" and "release tags", and a direct +push to `api/main` is refused. + +## 2. Run the full test suite with MongoDB + +The cloud session ran the api's tests on PostgreSQL only (MongoDB downloads were blocked); CI runs both. +Confirm locally: + +```bash +git clone https://github.com/mgpl-diagnostics/api && cd api +npm run kits +npm test # both backends; first run downloads mongod and postgres +# behind a proxy: MONGOMS_SYSTEM_BINARY=$(which mongod) npm test +``` + +Done when: `… passed, 0 failed` with both `[pg]` and `[mongo]` sections. If Mongo fails and Postgres +passes, the bug is backend-specific: reproduce it in db-kit's contract suite and fix it there (rule 1 of +db-kit's AGENTS.md: both backends behave identically). + +## 3. Release client 1.1.0 + +The GraphQL client (`createMgplGraphQLClient`) was merged with version `1.1.0` in package.json. + +```bash +cd client && git checkout main && git pull +git tag v1.1.0 && git push origin v1.1.0 # publish.yml publishes to GitHub Packages +``` + +Done when: `npm view @mgpl-diagnostics/client@1.1.0 --registry https://npm.pkg.github.com` works. + +## 4. Publish finance-kit 1.0.0 + +finance-kit has no tag yet. Its package.json says `1.0.0`. + +```bash +cd finance-kit && git checkout main && git pull && npm run typecheck && npm test && npm run build +git tag v1.0.0 && git push origin v1.0.0 +``` + +Done when: the publish workflow is green and the package is visible in the org's packages. + +## 5. tally-kit: name, then publish + +Ask the owner to confirm the final package name (tally-kit's AGENTS.md rule 7). Only then, tag +`v0.1.0` (or the renamed package) and publish. If the name changes, update every `@mgpl-diagnostics/tally-kit` +import and dependency (finance-kit, api) in one coordinated set of PRs. + +## 6. Give the api repo access to the packages + +For each package (auth-kit, catalog-kit, db-kit, report-kit, invoice-kit, client, finance-kit, +tally-kit): GitHub → org → Packages → the package → Package settings → **Manage Actions access** → add +`mgpl-diagnostics/api` with **Read**. (Alternatively make the packages public.) + +Done when: a workflow in api can `npm install` them with `GITHUB_TOKEN`. + +## 7. Switch api CI from source builds to the registry + +Today `npm run kits` (scripts/kits.mjs) clones each kit at the ref in `kits.json` and builds it, because +the packages were not all published or readable. After steps 3–6: + +1. In `api/.github/workflows/ci.yml`: replace `npm run kits` with + ```yaml + - uses: actions/setup-node@v7 + with: { node-version: 22, registry-url: https://npm.pkg.github.com, scope: "@mgpl-diagnostics" } + - run: npm install + env: { NODE_AUTH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } + ``` + and add `packages: read` to the workflow `permissions`. +2. Add `"@mgpl-diagnostics/client": "^1.1.0"` to api's devDependencies (today `npm run kits` installs it without saving), so the client contract test + uses the released client. +3. Commit a `package-lock.json` (`npm install` with the registry configured). +4. Keep `scripts/kits.mjs` and `kits.json` for building unreleased kit branches locally (`KITS_SRC`). +5. Update docs/03-getting-started.md and docs/11-testing.md (they describe `npm run kits` as the default). + +Done when: api CI is green with the registry install. + +## 8. db-kit 2.0.0: remove the old GraphQL layer + +db-kit still carries `src/graphql/` and the `./graphql` export (see db-kit +`docs/MOVE-GRAPHQL-TO-BACKEND.md`). The api replaces it. Removing an export is a breaking change: + +1. db-kit PR: delete `src/graphql/`, the `./graphql` export and entry, its tests and graphql peer + dependencies; update README / AGENTS.md / llms; mark the handoff doc "done (moved to mgpl-diagnostics/api)"; + version `2.0.0`. +2. Before tagging, widen the peer ranges in **finance-kit** and **tally-kit** to + `"@mgpl-diagnostics/db-kit": "^1.1.0 || ^2.0.0"` (PRs, with their tests green against 2.0.0). +3. Tag db-kit `v2.0.0`; release finance-kit/tally-kit patch versions with the widened ranges. +4. Bump api to `^2.0.0` and run its tests on both backends. + +Done when: no repo imports `@mgpl-diagnostics/db-kit/graphql` (`gh search code "db-kit/graphql" --owner mgpl-diagnostics`). + +## 9. Deploy and seed + +Follow [12-deployment.md](12-deployment.md). In particular: + +- `REDIS_URL` for more than one server; `CORS_ORIGINS` set to the app origins; `TRUST_PROXY=true`. +- Real signature images in a secret volume, `MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE`. + Never commit them. +- Wire a `Mailer` (email provider) for password-reset links, and `PASSWORD_RESET_URL`. +- `npm run seed` once, with the branch's GST state code (`SEED_BRANCH_STATE`, 22 for Chhattisgarh) and + the organisation's GSTIN (`SEED_ORG_GSTIN`); billing needs the first, GST returns the second. +- Add every other branch with `createBranch` **including its `stateCode`**. + +Done when: `GET /health` → 200, an owner can sign in, issue an invoice, and print it. + +## 10. CA review (owner + CA, not the agent) + +Call `verifyWithCA` and `taxRules` after `seedTaxRules`, and give the list to the lab's chartered +accountant. The agent records answers only as the owner instructs (`addTaxRule`, `settings.finance`); +it never picks rates itself. finance-kit's `docs/gst-notes.md` explains each item. + +## Report back + +Post one message with: + +``` +1 protect api ............ done / blocked: … +2 tests on Mongo ......... done (N passed) / failing: … +3 client v1.1.0 .......... done / … +4 finance-kit v1.0.0 ..... done / … +5 tally-kit name ......... confirmed "" + published / waiting on owner +6 package access ......... done / … +7 api CI on registry ..... PR , green / … +8 db-kit 2.0.0 ........... PRs / not started +9 deploy + seed .......... done at / not started +10 CA review ............. handed to owner / … +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6f599e1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# MGPL Diagnostics API: documentation + +Everything about the backend, from A to Z. Start with the overview, then read what you need. + +```mermaid +flowchart TD + A["01 Overview"] --> B["02 Architecture"] + B --> C["03 Getting started"] + C --> D["04 API reference"] + D --> E["05 Errors"] + D --> F["06 Auth and permissions"] + D --> G["07 Domain flows"] + G --> H["08 Documents"] + C --> I["09 Configuration"] + G --> J["10 Data and money"] + C --> K["11 Testing"] + I --> L["12 Deployment"] + B --> M["13 Adding a feature"] + D --> N["14 Client"] +``` + +| # | Page | What it answers | +|---|---|---| +| 01 | [Overview](01-overview.md) | What the API is, what it serves, and what it is not | +| 02 | [Architecture](02-architecture.md) | Layers, folders, the request lifecycle, how the kits fit | +| 03 | [Getting started](03-getting-started.md) | Run it locally, seed the first organisation, the first queries | +| 04 | [API reference](04-api-reference.md) | Every query and mutation, with its permission (generated) | +| 05 | [Errors](05-errors.md) | Error codes, masking, how clients should react | +| 06 | [Auth and permissions](06-auth-and-permissions.md) | Sessions, roles, branch scoping, tenant isolation | +| 07 | [Domain flows](07-domain-flows.md) | Booking → sample → report → invoice → books, with state diagrams | +| 08 | [Documents](08-documents.md) | Report and invoice PDFs/HTML through GraphQL | +| 09 | [Configuration](09-configuration.md) | Every environment variable | +| 10 | [Data and money](10-data-and-money.md) | Paise, IST days, financial years, storage backends, GST prerequisites | +| 11 | [Testing](11-testing.md) | The end-to-end suite and how to extend it | +| 12 | [Deployment](12-deployment.md) | Production checklist, scaling, Redis, signatures | +| 13 | [Adding a feature](13-adding-a-feature.md) | Schema → controller → service → test → docs, step by step | +| 14 | [Client](14-client.md) | Using `@mgpl-diagnostics/client` against this API | +| 15 | [Glossary](15-glossary.md) | Lab, billing and accounting terms | +| — | [examples/](examples/) | Runnable `.graphql` operations | +| — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | diff --git a/docs/examples/billing-and-books.graphql b/docs/examples/billing-and-books.graphql new file mode 100644 index 0000000..f52c40e --- /dev/null +++ b/docs/examples/billing-and-books.graphql @@ -0,0 +1,38 @@ +# Billing a booking, taking payment, and reading the books it posted to. + +mutation Bill($branchId: ID!, $patientId: ID!, $bookingId: ID) { + issueInvoice(input: { + branchId: $branchId, patientId: $patientId, bookingId: $bookingId + items: [{ code: "CBC" }, { code: "LFT" }] # name and MRP from the catalog + billDiscountPaise: 5000 + payments: [{ amountPaise: 20000, mode: upi, reference: "UPI-123" }] + }) { + id invoiceNo status totalPaise paidPaise duePaise + items { name qty ratePaise amountPaise } + } +} + +mutation Pay($invoiceId: ID!) { + recordPayment(invoiceId: $invoiceId, payment: { amountPaise: 10000, mode: cash }) { status paidPaise duePaise } +} + +query InvoiceWithVouchers($invoiceId: ID!) { + invoice(id: $invoiceId) { + invoiceNo status + vouchers { voucherNo type entries { ledger { name } debitPaise creditPaise } } + } +} + +query Books($from: DateTime!, $to: DateTime!) { + trialBalance { totalDebitPaise totalCreditPaise differencePaise } + profitAndLoss(from: $from, to: $to) { grossProfitPaise netProfitPaise income { group { name } amountPaise } } + cashBook(from: $from, to: $to) { openingPaise closingPaise rows { date voucherNo debitPaise creditPaise balancePaise } } + dayBook(from: $from, to: $to) { voucherNo type date narration } +} + +mutation Journal($branchId: ID!, $rentLedger: ID!, $cashLedger: ID!) { + postVoucher(input: { + branchId: $branchId, type: payment, narration: "Rent, October" + entries: [{ ledgerId: $rentLedger, debitPaise: 1500000 }, { ledgerId: $cashLedger, creditPaise: 1500000 }] + }) { voucherNo date } +} diff --git a/docs/examples/documents.graphql b/docs/examples/documents.graphql new file mode 100644 index 0000000..cdd14d2 --- /dev/null +++ b/docs/examples/documents.graphql @@ -0,0 +1,22 @@ +# Documents through GraphQL: PDFs arrive as base64, HTML as a string. + +query Templates { + reportTemplates { templates { id name category fields { key label unit range } } profiles { id name tests } } +} + +# Preview a report from request data (no stored records), e.g. while entering results. +query Preview($request: JSON!) { + renderReport(request: $request, format: html, variant: non_header) { html } +} +# variables: { "request": { "patient": { "name": "Kavita Sharma", "age": 32, "sex": "Female" }, +# "tests": [{ "template": "cbc", "results": { "hb": "10.8" } }] } } + +query StoredInvoice($invoiceId: ID!) { + invoiceDocument(invoiceId: $invoiceId, format: pdf) { fileName mimeType byteLength base64 } +} + +query Quote($request: JSON!) { + invoiceTotals(request: $request) +} + +query Letterhead { letterhead(format: pdf) { fileName base64 } } diff --git a/docs/examples/finance.graphql b/docs/examples/finance.graphql new file mode 100644 index 0000000..3fdee74 --- /dev/null +++ b/docs/examples/finance.graphql @@ -0,0 +1,59 @@ +# GST, B2B receivables, partner settlements, cash, bank and periods. Needs accounts:* permissions. + +query TaxSetup { + taxRules { code category hsnSac taxability ratePercent effectiveFrom note } + financeConfig { b2clThresholdPaise tds { section rateBp noPanRateBp thresholdPaise } } + verifyWithCA +} + +query Returns($from: DateTime!, $to: DateTime!) { + gstr1(from: $from, to: $to) { + gstin + b2b { invoiceNo valuePaise placeOfSupply items { ratePercent taxablePaise cgstPaise sgstPaise igstPaise } } + b2cs { placeOfSupply ratePercent taxablePaise } + hsn { b2c { hsnSac taxablePaise } } + totals { taxablePaise cgstPaise sgstPaise igstPaise } + } + gstr3b(from: $from, to: $to) { outwardTaxable { taxablePaise igstPaise cgstPaise sgstPaise } outwardNilExemptPaise } +} + +mutation NewCustomer { + createCustomer(input: { code: "CITYHOSP", name: "City Hospital", kind: hospital, gstin: "22AAAAA0000A1Z5", stateCode: "22", creditLimitPaise: 50000000, creditDays: 30 }) { + id ledger { name } + } +} + +query Statement($customerId: ID!, $from: DateTime!, $to: DateTime!) { + customerStatement(customerId: $customerId, from: $from, to: $to) { + openingPaise closingPaise + open { invoiceNo dueDay duePaise bucket } + ageing { notDue d1to30 d31to60 d61to90 d90plus } + } +} + +query SettlementPreview($partnerId: ID!) { + settlementPreview(partnerId: $partnerId, period: "2026-09") { + grossPaise sharePaise tds { section rateBp tdsPaise } netPaise + lines { invoiceNo name netPaise sharePaise } + } +} + +mutation CloseCash($branchId: ID!) { + closeCashDay(input: { branchId: $branchId, day: "2026-10-14", countedPaise: 1245000 }) { + bookPaise countedPaise differencePaise voucher { voucherNo } + } +} + +mutation ImportBank($branchId: ID!, $ledgerId: ID!, $csv: String!) { + importBankStatement(input: { branchId: $branchId, ledgerId: $ledgerId, csv: $csv, fileName: "sbi-oct.csv" }) { + added skipped statement { id lineCount } + } +} + +mutation Reconcile($ledgerId: ID!) { reconcileBank(ledgerId: $ledgerId) { matched unmatched settlements } } + +mutation Lock { setLockDate(input: { upTo: "2026-09-30", reason: "September filed" }) { scope lockedUpTo } } + +query ExportGstr1($from: DateTime!, $to: DateTime!) { + exportGstr1(from: $from, to: $to, bom: true) { fileName mimeType content } +} diff --git a/docs/examples/lab-day.graphql b/docs/examples/lab-day.graphql new file mode 100644 index 0000000..4a14753 --- /dev/null +++ b/docs/examples/lab-day.graphql @@ -0,0 +1,71 @@ +# A day at the front desk and the lab bench. Run in order; pass the ids each step returns. +# Header on every call after Login: Authorization: Bearer + +mutation Login { + login(input: { email: "desk@example.in", password: "…", orgCode: "MGPL" }) { + token + expiresAt + user { id name roles { role branchId } } + } +} + +query Me { + viewer { + user { name } + organization { name branches { id code name stateCode } } + canBill: can(permission: "invoice:create") + } +} + +query FindTest { + catalogSearch(query: "cbc", limit: 3) { score test { id code name mrp sampleType } } +} + +mutation Register($branchId: ID!) { + registerPatient(input: { branchId: $branchId, title: "Mrs", name: "Kavita Sharma", sex: female, ageYears: 32, phone: "9800000000" }) { + id + uhid + } +} + +mutation Book($branchId: ID!, $patientId: ID!) { + createBooking(input: { branchId: $branchId, patientId: $patientId, tests: [{ testCode: "CBC" }, { testCode: "LFT" }] }) { + booking { id bookingNo tests { testCode name pricePaise sampleType } } + samples { id barcode sampleType status } + } +} + +mutation Collect($sampleId: ID!) { + collectSample(id: $sampleId) { status collectedAt } +} + +mutation Receive($sampleId: ID!) { + receiveSample(id: $sampleId) { status receivedAt } +} + +mutation StartReport($bookingId: ID!) { + createReport(bookingId: $bookingId, testCode: "CBC") { id status } +} + +# Unit, range and flag come from the catalog when left out. +mutation EnterResults($reportId: ID!) { + saveResults(reportId: $reportId, results: [ + { parameter: "Hemoglobin", value: "10.2" } + { parameter: "Total Leucocytes Count", value: "7.5" } + ]) { + status + results { parameter value unit range flag } + } +} + +mutation Verify($reportId: ID!) { verifyReport(id: $reportId) { status verifiedAt } } +mutation Release($reportId: ID!) { releaseReport(id: $reportId) { status releasedAt } } + +query PrintReports($bookingId: ID!) { + bookingReportDocument(bookingId: $bookingId, format: pdf, variant: header, design: sidebar) { + fileName + mimeType + byteLength + base64 + } +} diff --git a/kits.json b/kits.json new file mode 100644 index 0000000..80a4e04 --- /dev/null +++ b/kits.json @@ -0,0 +1,37 @@ +{ + "$comment": "Kit sources for `npm run kits` (CI and local builds until every kit is on GitHub Packages with Actions access for this repo). Order = build order. `ref` is a tag or commit on github.com/mgpl-diagnostics/.", + "kits": [ + { + "name": "catalog-kit", + "ref": "v1.0.0" + }, + { + "name": "auth-kit", + "ref": "v1.0.0" + }, + { + "name": "db-kit", + "ref": "v1.1.0" + }, + { + "name": "report-kit", + "ref": "v2.0.0" + }, + { + "name": "tally-kit", + "ref": "0629673c04e999ff4a3998d1fda8a9573ca3f68a" + }, + { + "name": "client", + "ref": "b26a341f91aef3665a646cc6efe0a748d50806f3" + }, + { + "name": "invoice-kit", + "ref": "v2.0.0" + }, + { + "name": "finance-kit", + "ref": "dbaafec32fcc2f9c34c1c8878f3fb2e14d2d6356" + } + ] +} diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..dc825e6 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,8222 @@ +# @mgpl-diagnostics/api — full documentation and source + +> Generated by scripts/llms-full.mjs. Do not edit by hand. 76 files. + +## Contents + +- README.md +- AGENTS.md +- docs/01-overview.md +- docs/02-architecture.md +- docs/03-getting-started.md +- docs/04-api-reference.md +- docs/05-errors.md +- docs/06-auth-and-permissions.md +- docs/07-domain-flows.md +- docs/08-documents.md +- docs/09-configuration.md +- docs/10-data-and-money.md +- docs/11-testing.md +- docs/12-deployment.md +- docs/13-adding-a-feature.md +- docs/14-client.md +- docs/15-glossary.md +- docs/LOCAL-AGENT-BRIEF.md +- docs/README.md +- kits.json +- llms.txt +- package.json +- schema.graphql +- scripts/api-reference.ts +- scripts/kits.mjs +- scripts/llms-full.mjs +- scripts/print-schema.ts +- scripts/seed.ts +- src/config/env.ts +- src/container.ts +- src/controllers/auth.controller.ts +- src/controllers/base.controller.ts +- src/controllers/billing.controller.ts +- src/controllers/books.controller.ts +- src/controllers/catalog.controller.ts +- src/controllers/documents.controller.ts +- src/controllers/finance.controller.ts +- src/controllers/lab.controller.ts +- src/controllers/organization.controller.ts +- src/graphql/context.ts +- src/graphql/errors.ts +- src/graphql/guards.ts +- src/graphql/limits.ts +- src/graphql/loaders.ts +- src/graphql/scalars.ts +- src/graphql/schema.ts +- src/index.ts +- src/main.ts +- src/schema/auth.graphql.ts +- src/schema/base.graphql.ts +- src/schema/billing.graphql.ts +- src/schema/books.graphql.ts +- src/schema/catalog.graphql.ts +- src/schema/documents.graphql.ts +- src/schema/finance.graphql.ts +- src/schema/lab.graphql.ts +- src/schema/organization.graphql.ts +- src/server.ts +- src/services/auth.service.ts +- src/services/billing.service.ts +- src/services/booking.service.ts +- src/services/books.service.ts +- src/services/cash-bank.service.ts +- src/services/catalog.service.ts +- src/services/document.service.ts +- src/services/export.service.ts +- src/services/gst.service.ts +- src/services/index.ts +- src/services/organization.service.ts +- src/services/patient.service.ts +- src/services/period.service.ts +- src/services/receivables.service.ts +- src/services/report.service.ts +- src/services/settlement.service.ts +- src/services/util.ts +- test/run.ts + + +--- + +## File: README.md + +
+ +MGPL Diagnostics + +# api + +**The MGPL Diagnostics backend: one GraphQL endpoint for the whole lab.** + +Sign-in, patients, bookings, samples, results, reports, billing, books, GST, receivables, settlements, +cash and bank, documents. TypeScript, GraphQL Yoga on Fastify, PostgreSQL or MongoDB. + +[![Endpoint](https://img.shields.io/badge/endpoint-POST%20%2Fgraphql-14264A)](#) +[![Databases](https://img.shields.io/badge/db-PostgreSQL%20%7C%20MongoDB-14264A)](#) +[![License](https://img.shields.io/badge/license-All%20rights%20reserved-E9A23B)](LICENSE) + +[Docs](docs/README.md) · [Architecture](docs/02-architecture.md) · [Getting started](docs/03-getting-started.md) · [API reference](docs/04-api-reference.md) · [Client](docs/14-client.md) + +
+ +--- + +```mermaid +flowchart LR + APP["Apps
@mgpl-diagnostics/client"] -- "POST /graphql" --> API["api
controllers → services"] + API --> K["auth-kit · db-kit · catalog-kit
report-kit · invoice-kit · tally-kit · finance-kit"] + K --> DB[("PostgreSQL / MongoDB")] + K -.-> R[("Redis")] +``` + +## What you get + +- **One endpoint.** 137 queries and mutations; reports and invoices come back as base64 PDFs or HTML + inside the response, CSV exports as text. No other routes except `GET /health`. +- **The kits as internal libraries.** auth-kit sessions and roles, db-kit storage and workflows, + catalog-kit prices and reference ranges, report-kit and invoice-kit rendering, tally-kit books, + finance-kit GST and finance. See [docs/01-overview.md](docs/01-overview.md). +- **Layers.** `schema/` (SDL) → `controllers/` (permissions, mapping) → `services/` (kit calls) → kits. +- **Safe by construction.** The organisation and user come from the session; every record is checked + against them and against its branch; unknown errors are masked; depth and list limits. +- **Money in paise, books always balanced.** Every invoice, payment and credit note posts its voucher in + the same transaction. + +## Quick start + +```bash +npm run kits # build + install the kits (see docs/03-getting-started.md) +cp .env.example .env # DATABASE_URL, … +SEED_ORG_NAME=… SEED_ORG_CODE=MGPL SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang SEED_BRANCH_STATE=22 \ +SEED_OWNER_NAME=… SEED_OWNER_EMAIL=… SEED_OWNER_PASSWORD=… npm run seed +npm run dev # http://localhost:4000/graphql +``` + +```graphql +mutation { login(input: { email: "owner@example.in", password: "…", orgCode: "MGPL" }) { token } } + +query { viewer { user { name } organization { name } permissions } } +``` + +```ts +import { createMgplGraphQLClient } from "@mgpl-diagnostics/client"; +const mgpl = createMgplGraphQLClient({ url: "http://localhost:4000/graphql" }); +await mgpl.auth.login({ email, password, orgCode: "MGPL" }); +const hits = await mgpl.catalog.search("cbc"); +``` + +## Docs + +Everything from A to Z is in [docs/](docs/README.md), with Mermaid diagrams: overview, architecture, +getting started, the generated API reference, errors, auth and permissions, domain flows, documents, +configuration, data and money, testing, deployment, adding a feature, client, glossary, and runnable +[examples](docs/examples/). What has to happen outside this repo (releases, org settings, deployment) is +in [docs/LOCAL-AGENT-BRIEF.md](docs/LOCAL-AGENT-BRIEF.md). + +## Development + +```bash +npm run typecheck +npm test # end-to-end over HTTP on PostgreSQL + MongoDB (TEST_BACKENDS=pg for one) +npm run build # dist/main.js, dist/index.js +npm run schema # schema.graphql +npm run docs:reference # docs/04-api-reference.md +npm run llms # llms-full.txt +``` + +## Part of MGPL Diagnostics + +| Repo | Role | +|---|---| +| **api** | the backend: one GraphQL endpoint | +| [client](https://github.com/mgpl-diagnostics/client) | frontend SDK (zero dependencies) | +| [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) · [db-kit](https://github.com/mgpl-diagnostics/db-kit) · [catalog-kit](https://github.com/mgpl-diagnostics/catalog-kit) | auth, data, test catalog | +| [report-kit](https://github.com/mgpl-diagnostics/report-kit) · [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | report and invoice rendering | +| [tally-kit](https://github.com/mgpl-diagnostics/tally-kit) · [finance-kit](https://github.com/mgpl-diagnostics/finance-kit) | books, GST and finance | +| [report-studio](https://github.com/mgpl-diagnostics/report-studio) | Next.js reference app | + +© MGPL Diagnostics (Maa Gayatri Path Lab). All rights reserved; see [LICENSE](LICENSE). tally-kit is our own software, not affiliated with Tally Solutions. + + +--- + +## File: AGENTS.md + +# AGENTS.md — @mgpl-diagnostics/api + +The MGPL Diagnostics backend: **one GraphQL endpoint** (`POST /graphql`, GraphQL Yoga on Fastify) over +the kits, used as internal libraries. Read [docs/README.md](docs/README.md) for the full picture and +[docs/13-adding-a-feature.md](docs/13-adding-a-feature.md) before adding anything. + +## Map + +``` +src/main.ts start (config → container → server) +src/server.ts Fastify + Yoga + GET /health (no other routes) +src/container.ts createDb (posting: financePosting) + createAuth + services, once per process +src/config/env.ts loadConfig: every environment variable +src/graphql/ schema.ts (MODULES → buildSchema), context, guards, errors, scalars, loaders, limits +src/schema/ SDL per module: base auth organization catalog lab billing books finance documents +src/controllers/ resolvers per module: authorize → map → service +src/services/ business calls on the kits: (actor, input), ownership checks, no GraphQL +scripts/ kits.mjs (build kits from source), seed.ts, print-schema.ts, api-reference.ts, llms-full.mjs +test/run.ts end-to-end over HTTP on PostgreSQL + MongoDB, plus docs/examples validation +docs/ A-to-Z docs (Mermaid), examples/*.graphql, LOCAL-AGENT-BRIEF.md +schema.graphql printed schema (generated) +kits.json kit refs for `npm run kits` +``` + +## Rules + +1. **One endpoint.** Everything is a GraphQL field. No REST routes besides `GET /health`; documents + travel as `Document { base64 | html }`, exports as `CsvFile`. +2. **Layers.** Schema → controller → service → kit. Controllers check permissions and map; services hold + the logic and never import GraphQL; storage changes go to db-kit, finance rules to finance-kit, + posting to tally-kit, permissions to auth-kit, catalog data to catalog-kit, layouts to report-kit / + invoice-kit. +3. **Tenant isolation.** No argument carries an `orgId` or `actorId`. They come from the session + (`actorOf`). Every id is checked against the organisation (`mustOwn`, `owned`, `scopedFind`). +4. **Permissions per branch.** Load a record, then `authorize(ctx, perm, record.branchId)`; lists use + `branchScope`. Every permission must exist in auth-kit's `PERMISSIONS`. +5. **Errors.** Wrap controllers in `withErrorCodes`. Never build a `GraphQLError` with `originalError` + (Yoga masks it). Error codes are a public contract: add, never rename. +6. **Money is integer paise; days are IST `Day` strings; the financial year is April–March.** +7. **No N+1.** Nested fields go through `ctx.loaders`. Lists are capped with `cap()` (≤ 200). +8. **Both databases.** Tests run on PostgreSQL and MongoDB; a change must pass on both. +9. **Generated files stay current** (CI checks): `npm run schema`, `npm run docs:reference`, `npm run llms`. +10. **Client contract.** `@mgpl-diagnostics/client` mirrors the schema; when a field is added or renamed, + update the client (operations, types, method, docs) and bump its ref in `kits.json`. +11. **Compliance values are configuration** verified by the lab's CA (`verifyWithCA`). Ask the owner + before changing one. No doctor referral commissions. +12. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. + +## Commands + +```bash +npm run kits # once, and after changing kits.json +npm run typecheck && npm test && npm run build +npm run schema && npm run docs:reference && npm run llms +``` + +`npm test` downloads PostgreSQL and MongoDB binaries on first run. Behind a proxy that blocks MongoDB, +set `MONGOMS_SYSTEM_BINARY` or run `TEST_BACKENDS=pg npm test` (CI still runs both). + +## Repository rules + +`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check +required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`). + +## Licence + +All rights reserved (see LICENSE). + + +--- + +## File: docs/01-overview.md + +# 01 · Overview + +`@mgpl-diagnostics/api` is the backend of MGPL Diagnostics (Maa Gayatri Path Lab). It serves **everything +through one endpoint, `POST /graphql`**: sign-in and users, the test catalog, patients, bookings, samples, +results and reports, billing, the books of account, GST returns, B2B receivables, partner settlements, +cash and bank, lock dates, CSV exports, and the rendered report and invoice documents. + +```mermaid +flowchart LR + subgraph Frontends + W["Web app"] + M["Mobile / desk app"] + S["Scripts"] + end + W & M & S -- "POST /graphql
Bearer token" --> API["api
GraphQL Yoga on Fastify"] + API --> PG[("PostgreSQL
or MongoDB")] + API -.-> R[("Redis
optional")] + LB["Load balancer"] -- "GET /health" --> API +``` + +## What it is made of + +The domain logic lives in the kits, which the API uses as **internal libraries**. It has no other HTTP +routes and starts no per-kit servers. The kits never see HTTP: the API calls their functions from its +service layer. + +| Kit | Role inside the API | +|---|---| +| [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) | Passwords (scrypt), sessions, login rate limits, roles and permissions | +| [db-kit](https://github.com/mgpl-diagnostics/db-kit) | Repositories on PostgreSQL or MongoDB, transactions, workflows (bookings, reports, invoices) | +| [catalog-kit](https://github.com/mgpl-diagnostics/catalog-kit) | The test catalog: names, codes, prices, units, reference ranges, search | +| [report-kit](https://github.com/mgpl-diagnostics/report-kit) | Report PDFs and HTML (Navy Sidebar and classic designs) | +| [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | Invoice PDFs and HTML, invoice arithmetic | +| [tally-kit](https://github.com/mgpl-diagnostics/tally-kit) | Our own double-entry engine: ledgers, vouchers, books reports | +| [finance-kit](https://github.com/mgpl-diagnostics/finance-kit) | GST, GSTR-1/3B data, receivables, settlements, cash and bank, lock dates, exports | +| [client](https://github.com/mgpl-diagnostics/client) | Not used by the API; the frontend SDK that calls it | + +## Design choices + +- **One endpoint.** Reports and invoices are GraphQL fields too. A PDF arrives as base64 inside the + response (`Document.base64`), HTML as a string, CSV exports as `CsvFile.content`. +- **Layers.** Schema (SDL) → controllers (resolvers: permissions, mapping) → services (business calls on + the kits) → kits → database. See [02-architecture.md](02-architecture.md). +- **Tenant isolation by construction.** The organisation and the acting user come from the session, + never from arguments. Every id is checked against the session's organisation. +- **Money in integer paise**, days as IST `"YYYY-MM-DD"`, the Indian financial year (April–March). +- **Both databases.** The same code runs on PostgreSQL (Sequelize) or MongoDB (Mongoose, replica set). + +## What it is not + +- Not a public API for patients. Every operation except `health`, `login`, password reset and the + catalog needs a staff session. +- Not tax advice. GST rates, TDS and thresholds are configuration that the lab's CA must confirm + (`verifyWithCA` lists them). +- Not integrated with Tally software. tally-kit is our own engine with Tally-like concepts. + + +--- + +## File: docs/02-architecture.md + +# 02 · Architecture + +## Layers + +Each layer talks only to the one below it. Controllers never touch the database directly except through +the per-request loaders; services never see GraphQL. + +```mermaid +flowchart TB + subgraph HTTP["HTTP · src/server.ts"] + F["Fastify
POST /graphql · GET /health"] + Y["GraphQL Yoga
CORS · masked errors · depth limit 12 · introspection switch"] + end + subgraph GQL["GraphQL · src/graphql + src/schema"] + SDL["SDL per module
src/schema/*.graphql.ts"] + CTX["Request context
viewer · can() · loaders"] + end + subgraph CTRL["Controllers · src/controllers"] + C["Resolvers
authorize() · map enums · call services"] + end + subgraph SRV["Services · src/services"] + S["Business calls
(actor, input) → kits"] + end + subgraph KITS["Kits (internal libraries)"] + K1["auth-kit"] + K2["db-kit"] + K3["catalog-kit"] + K4["report-kit · invoice-kit"] + K5["tally-kit · finance-kit"] + end + DB[("PostgreSQL / MongoDB")] + RD[("Redis (optional)")] + F --> Y --> SDL --> C + Y --> CTX --> C + C --> S --> KITS + K2 --> DB + K1 -.-> RD + K2 -.-> RD +``` + +| Layer | Folder | Does | Does not | +|---|---|---|---| +| Server | `src/server.ts`, `src/main.ts` | Fastify, Yoga, `/health`, shutdown | Business logic | +| Schema | `src/schema/` | SDL, one file per module | Code | +| GraphQL plumbing | `src/graphql/` | Context, guards, errors, scalars, loaders, limits, schema assembly | Domain rules | +| Controllers | `src/controllers/` | Permissions, argument mapping, nested fields | Database writes | +| Services | `src/services/` | Calls into the kits with `(actor, input)`, transactions, ownership checks | GraphQL types or errors | +| Container | `src/container.ts` | Builds db, auth and services once per process | Per-request state | +| Config | `src/config/env.ts` | Reads and checks environment variables | Defaults that are unsafe in production | + +## Folders + +``` +src/ + main.ts start: loadConfig → createContainer → buildServer → listen; SIGINT/SIGTERM close + server.ts Fastify + Yoga (one endpoint) + GET /health + container.ts createDb (posting: financePosting), createAuth (Redis or memory stores), services + index.ts library entry (buildServer, createContainer, buildSchema, … for tests and embedding) + config/env.ts loadConfig(env) → Config + graphql/ + schema.ts MODULES (typeDefs + resolvers per module) → buildSchema() + context.ts RequestContext, createContext (bearer token → auth-kit context) + guards.ts actorOf, authorize, canSee, branchScope, cap + errors.ts ERROR_CODES, toGraphQLError, withErrorCodes + scalars.ts DateTime, Day, JSON + loaders.ts per-request BatchLoader per entity (no N+1) + limits.ts depthLimit + schema/.graphql.ts SDL + controllers/.controller.ts + services/.service.ts, index.ts (createServices), util.ts +scripts/ kits.mjs, seed.ts, print-schema.ts, api-reference.ts, llms-full.mjs +test/run.ts end-to-end suite over HTTP (app.inject) +schema.graphql the printed schema (CI checks it is current) +kits.json which kit versions CI builds (see 11-testing.md) +``` + +Modules: `base`, `auth`, `organization`, `catalog`, `lab`, `billing`, `books`, `finance`, `documents`. + +## A request, end to end + +```mermaid +sequenceDiagram + autonumber + participant Cl as Client + participant Fa as Fastify + participant Yo as Yoga + participant Cx as createContext + participant Au as auth-kit + participant Ct as Controller + participant Sv as Service + participant Kt as db-kit / finance-kit + participant DB as Database + Cl->>Fa: POST /graphql (Authorization: Bearer t) + Fa->>Yo: handleNodeRequestAndResponse + Yo->>Yo: parse, validate (depth ≤ 12, introspection rule) + Yo->>Cx: build context + Cx->>Au: context(t) → re-reads the user every request + Au-->>Cx: viewer, can(), branchesFor() + Yo->>Ct: resolve issueInvoice(input) + Ct->>Ct: authorize(ctx, "invoice:create", input.branchId) + Ct->>Sv: billing.issue(actor, input) + Sv->>Kt: issueInvoice(repos, …) inside repos.transaction() + Kt->>DB: invoice + voucher (posting hook) atomically + DB-->>Kt: ok + Kt-->>Sv: Invoice + Sv-->>Ct: Invoice + Ct-->>Yo: Invoice (nested fields via loaders) + Yo-->>Cl: { data: { issueInvoice: … } } +``` + +If anything throws, `withErrorCodes` turns domain errors (from db-kit, auth-kit, finance-kit, +report-kit) into GraphQL errors with a code; any other error is masked as "Unexpected error" +([05-errors.md](05-errors.md)). + +## How the kits depend on each other + +```mermaid +flowchart LR + API["api"] --> AUTH["auth-kit"] + API --> DB["db-kit"] + API --> CAT["catalog-kit"] + API --> INV["invoice-kit"] + API --> REP["report-kit"] + API --> FIN["finance-kit"] + API --> TAL["tally-kit"] + INV --> REP + FIN --> TAL + FIN --> DB + TAL --> DB + DB -. "stores users for" .-> AUTH + CL["client (frontend)"] -. "POST /graphql" .-> API +``` + +- db-kit's repositories satisfy auth-kit's store interfaces (users, orgs, roles, audit), so auth-kit reads + and writes users through db-kit (`container.ts` passes `db.rawRepos`). +- db-kit posts billing to the books through a `PostingHook`. The API sets it to finance-kit's + `financePosting`, which posts through tally-kit with GST split by place of supply. +- `src/graphql` in db-kit is an older, partial GraphQL layer. This API replaces it; db-kit will remove + it in a major version ([LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md)). + +## The container + +```mermaid +flowchart LR + ENV["process.env"] --> CFG["loadConfig()"] + CFG --> CON["createContainer()"] + CON --> DBX["createDb({ backend, url, redis?, posting: financePosting })"] + CON --> AUX["createAuth({ users, orgs, roles, audit from db,
sessions/kv/limiter from Redis or memory })"] + CON --> SVX["createServices(db.repos, auth, mailer, lab)"] + CON --> SRV["buildServer(container)"] +``` + +One container per process. Tests build it with an embedded database and a capturing mailer; production +builds it from the environment. `close()` closes what the container opened. + +## Cross-cutting rules + +1. **Tenant isolation.** Services take an `Actor { userId, orgId }` from the session. `mustOwn` / `owned` + check that a record's `orgId` matches before anything is returned or changed. +2. **Branch scoping.** `authorize(ctx, perm, branchId)` checks the record's own branch; list queries use + `branchScope`, which narrows to the branches where the user holds the permission. +3. **Transactions.** Multi-record writes run in `repos.transaction()` inside the kits. +4. **No N+1.** Nested fields (`Booking.patient`, `Invoice.branch`, …) go through per-request loaders. +5. **Limits.** Query depth ≤ 12, list `limit` ≤ 200, request body ≤ 2 MB by default. + + +--- + +## File: docs/03-getting-started.md + +# 03 · Getting started + +## Requirements + +- Node.js ≥ 20 (CI uses 22) +- PostgreSQL ≥ 14, **or** MongoDB ≥ 6 running as a replica set (transactions need one) +- Redis (optional; needed when you run more than one server) +- Git (to build the kits from source until they are all on the registry) + +## 1. Install + +```bash +git clone https://github.com/mgpl-diagnostics/api && cd api +npm run kits # builds the 7 kits + client from source at the refs in kits.json, then npm install +``` + +`npm run kits` exists because not every kit is published to GitHub Packages yet. Once they are, a plain +`npm install` (with a token that has `read:packages`) is enough; see [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md). +To build from local checkouts instead of GitHub, set `KITS_SRC=/path/to/folder-with-the-kits`. + +## 2. Configure + +```bash +cp .env.example .env # then edit DATABASE_URL etc.; see 09-configuration.md +``` + +## 3. Seed the first organisation + +Once per new database. It creates the organisation, its first branch, the owner, the system roles and the +chart of accounts with finance-kit's ledgers. + +```bash +SEED_ORG_NAME="Maa Gayatri Path Lab" SEED_ORG_CODE=MGPL SEED_ORG_GSTIN=22XXXXX0000X1Z5 \ +SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang SEED_BRANCH_STATE=22 \ +SEED_OWNER_NAME="Lab Owner" SEED_OWNER_EMAIL=owner@example.in SEED_OWNER_PASSWORD='a long passphrase' \ +npm run seed +``` + +- `SEED_BRANCH_STATE` is the GST state code of the branch (22 = Chhattisgarh). **Billing needs it**: the + posting hook decides CGST+SGST vs IGST from it. +- `SEED_ORG_GSTIN` is optional for billing but **required for GSTR-1 / GSTR-3B**. +- The password must pass auth-kit's policy (length, not containing the email or name, not common). + +## 4. Run + +```bash +npm run dev # tsx watch, http://localhost:4000/graphql (GraphiQL in development) +# or +npm run build && npm start +``` + +## 5. First calls + +```mermaid +sequenceDiagram + participant You + participant API + You->>API: mutation login(email, password, orgCode) + API-->>You: token + You->>API: query viewer (Bearer token) + API-->>You: user, organization, permissions + You->>API: mutation setupChartOfAccounts (idempotent) + You->>API: query catalogSearch("cbc") +``` + +```bash +curl -s localhost:4000/graphql -H 'content-type: application/json' \ + -d '{"query":"mutation($i: LoginInput!){ login(input: $i){ token user { name } } }","variables":{"i":{"email":"owner@example.in","password":"…","orgCode":"MGPL"}}}' + +curl -s localhost:4000/graphql -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" \ + -d '{"query":"{ viewer { user { name } organization { name branches { code stateCode } } permissions } }"}' +``` + +More in [examples/](examples/): a full day at the desk ([lab-day.graphql](examples/lab-day.graphql)), +billing and books ([billing-and-books.graphql](examples/billing-and-books.graphql)), finance +([finance.graphql](examples/finance.graphql)) and documents ([documents.graphql](examples/documents.graphql)). + +## 6. Useful commands + +```bash +npm run typecheck +npm test # end-to-end on PostgreSQL and MongoDB (TEST_BACKENDS=pg to pick one) +npm run schema # rewrite schema.graphql +npm run docs:reference # rewrite docs/04-api-reference.md +npm run llms # rewrite llms-full.txt +``` + + +--- + +## File: docs/04-api-reference.md + +# 04 · API reference + + + +Every operation of `POST /graphql`, grouped by module, with the permission its resolver checks. +**Permission** lists what `authorize()` requires; a branch-scoped permission is checked against the +record's branch (see [06-auth-and-permissions.md](06-auth-and-permissions.md)). `public` means no +sign-in is needed; `(signed in)` means any signed-in user. The full SDL is [schema.graphql](../schema.graphql), +and runnable examples are in [examples/](examples/). + +## base + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `health: Health!` | public | | +| mutation | `ping: String!` | public | Does nothing; lets clients check that mutations reach the server. | + +## auth + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `mySessions: [Session!]!` | (signed in) | | +| query | `permissions: [String!]!` | (signed in) | Every permission name the API checks. | +| query | `roles: [Role!]!` | `role:read` | | +| query | `user(id: ID!): User` | `user:read` | | +| query | `users: [User!]!` | `user:read` | | +| query | `viewer: Viewer` | public | null when not signed in. | +| mutation | `changePassword(currentPassword: String!, newPassword: String!): Boolean!` | (signed in) | | +| mutation | `createUser(input: CreateUserInput!): User!` | `user:create` | | +| mutation | `disableUser(userId: ID!): User!` | `user:disable` | | +| mutation | `enableUser(userId: ID!): User!` | `user:disable` | | +| mutation | `login(input: LoginInput!): LoginResult!` | public | | +| mutation | `logout: Boolean!` | public | Ends this session. | +| mutation | `logoutOtherSessions: Int!` | (signed in) | Ends every other session of the signed-in user; returns how many. | +| mutation | `requestPasswordReset(email: String!, orgCode: String): Boolean!` | public | Always true (never reveals whether the email exists). The link is sent by the server's mailer. | +| mutation | `resetPassword(token: String!, newPassword: String!): Boolean!` | public | | +| mutation | `revokeSession(id: ID!): Boolean!` | (signed in) | | +| mutation | `setUserPassword(userId: ID!, newPassword: String!): Boolean!` | `user:update` | | +| mutation | `setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User!` | `user:update` | | +| mutation | `upsertRole(input: RoleInput!): Role!` | `role:manage` | | + +## organization + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `auditLog(entity: String, entityId: ID, limit: Int, offset: Int): [AuditEntry!]!` | `audit:read` | | +| query | `branch(id: ID!): Branch` | (signed in) | | +| query | `branches: [Branch!]!` | (signed in) | | +| query | `organization: Organization!` | (signed in) | | +| mutation | `createBranch(input: BranchInput!): Branch!` | `branch:create` | | +| mutation | `updateBranch(id: ID!, input: BranchPatch!): Branch!` | `branch:update` | | +| mutation | `updateOrganization(input: OrganizationInput!): Organization!` | `org:update` | | + +## catalog + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `catalogDepartments: [String!]!` | public | | +| query | `catalogSampleTypes: [String!]!` | public | | +| query | `catalogSearch(query: String!, limit: Int, department: String, sampleType: String, fuzzy: Boolean): [CatalogHit!]!` | public | Typo-tolerant search with lab shorthand (cbc, lft, vit d…). | +| query | `catalogTest(id: Int, code: String): CatalogTest` | public | | +| query | `catalogTests(department: String, sampleType: String, limit: Int, offset: Int): [CatalogTest!]!` | public | | +| query | `priceQuote(tests: [String!]!): PriceQuote!` | public | Total MRP for test codes (or numeric ids as strings). | + +## lab + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `booking(id: ID!): Booking` | `booking:read` | | +| query | `bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int, offset: Int): [Booking!]!` | `booking:read` | | +| query | `doctors(activeOnly: Boolean): [Doctor!]!` | `doctor:read` | | +| query | `patient(id: ID!): Patient` | `patient:read` | | +| query | `patients(search: String!, limit: Int): [Patient!]!` | `patient:read` | Name, phone or UHID. | +| query | `report(id: ID!): Report` | `report:read` | | +| query | `sample(id: ID!): Sample` | `sample:read` | | +| query | `sampleByBarcode(barcode: String!): Sample` | `sample:read` | | +| mutation | `amendReport(id: ID!): Report!` | `report:amend` | | +| mutation | `cancelBooking(id: ID!): Booking!` | `booking:cancel` | | +| mutation | `collectSample(id: ID!): Sample!` | `sample:collect` | | +| mutation | `createBooking(input: CreateBookingInput!): CreateBookingResult!` | `booking:create` | | +| mutation | `createDoctor(input: DoctorInput!): Doctor!` | `doctor:manage` | | +| mutation | `createReport(bookingId: ID!, testCode: String!, testName: String): Report!` | `report:enter` | | +| mutation | `receiveSample(id: ID!): Sample!` | `sample:receive` | | +| mutation | `registerPatient(input: RegisterPatientInput!): Patient!` | `patient:create` | | +| mutation | `rejectSample(id: ID!, reason: String!): Sample!` | `sample:reject` | | +| mutation | `releaseReport(id: ID!): Report!` | `report:release` | | +| mutation | `saveResults(reportId: ID!, results: [ResultInput!]!): Report!` | `report:enter` | | +| mutation | `updateDoctor(id: ID!, input: DoctorPatch!): Doctor!` | `doctor:manage` | | +| mutation | `updatePatient(id: ID!, input: UpdatePatientInput!): Patient!` | `patient:update` | | +| mutation | `verifyReport(id: ID!): Report!` | `report:verify` | | + +## billing + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `invoice(id: ID!): Invoice` | `invoice:read` | | +| query | `invoices(branchId: ID, status: InvoiceStatus, patientId: ID, customerId: ID, limit: Int, offset: Int): [Invoice!]!` | `invoice:read` | | +| mutation | `cancelInvoice(id: ID!, reason: String!): Invoice!` | `invoice:cancel` | | +| mutation | `issueInvoice(input: IssueInvoiceInput!): Invoice!` | `invoice:create`, `invoice:discount`, `payment:create` | | +| mutation | `recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!` | `payment:create` | | +| mutation | `refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!` | `payment:refund` | | + +## books + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `accountGroups: [AccountGroup!]!` | `accounts:read` | | +| query | `balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet!` | `accounts:read` | | +| query | `cashBook(from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement!` | `accounts:read` | | +| query | `dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]!` | `accounts:read` | | +| query | `gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary!` | `accounts:read` | | +| query | `ledger(id: ID!): Ledger` | `accounts:read` | | +| query | `ledgers: [Ledger!]!` | `accounts:read` | | +| query | `ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement!` | `accounts:read` | | +| query | `outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding!` | `accounts:read` | | +| query | `profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss!` | `accounts:read` | | +| query | `trialBalance(asOf: DateTime, branchId: ID): TrialBalance!` | `accounts:read` | | +| query | `voucher(id: ID!): Voucher` | `accounts:read` | | +| mutation | `cancelVoucher(id: ID!): Voucher!` | `accounts:post` | | +| mutation | `createLedger(input: LedgerInput!): Ledger!` | `accounts:manage` | | +| mutation | `postVoucher(input: PostVoucherInput!): Voucher!` | `accounts:post` | | +| mutation | `setupChartOfAccounts: [Ledger!]!` | `accounts:manage` | Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent. | + +## finance + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `bankLines(ledgerId: ID, statementId: ID, status: BankLineStatus, limit: Int, offset: Int): [BankLine!]!` | `accounts:read` | | +| query | `bankReconciliation(ledgerId: ID!, asOf: DateTime!): BankReconciliation!` | `accounts:read` | | +| query | `bankStatements(ledgerId: ID, limit: Int, offset: Int): [BankStatement!]!` | `accounts:read` | | +| query | `cashClosings(branchId: ID!, limit: Int, offset: Int): [CashClosing!]!` | `accounts:read` | | +| query | `creditNote(id: ID!): CreditNote` | `accounts:read` | | +| query | `creditNotes(invoiceId: ID, customerId: ID, limit: Int, offset: Int): [CreditNote!]!` | `accounts:read` | | +| query | `customer(id: ID!): Customer` | `accounts:read` | | +| query | `customers(activeOnly: Boolean): [Customer!]!` | `accounts:read` | | +| query | `customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement!` | `accounts:read` | | +| query | `exportBalanceSheet(asOf: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportDayBook(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportGstr1(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean): [CsvFile!]!` | `accounts:read` | One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs. | +| query | `exportGstr3b(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportLedger(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportProfitAndLoss(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportSettlement(settlementId: ID!, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `exportTrialBalance(asOf: DateTime, branchId: ID, bom: Boolean): CsvFile!` | `accounts:read` | | +| query | `financeConfig: FinanceConfig!` | `accounts:read` | | +| query | `gstr1(from: DateTime!, to: DateTime!, gstin: String): Gstr1!` | `accounts:read` | | +| query | `gstr3b(from: DateTime!, to: DateTime!, gstin: String): Gstr3b!` | `accounts:read` | | +| query | `lockDates: [PeriodLock!]!` | `accounts:read` | | +| query | `partner(id: ID!): Partner` | `accounts:read` | | +| query | `partners(branchId: ID): [Partner!]!` | `accounts:read` | | +| query | `receivablesSummary(asOf: DateTime!): ReceivablesSummary!` | `accounts:read` | | +| query | `settlement(id: ID!): Settlement` | `accounts:read` | | +| query | `settlementPreview(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): SettlementPreview!` | `accounts:read` | Computes a month's settlement without saving it. period: YYYY-MM. | +| query | `settlements(partnerId: ID, status: SettlementStatus, branchId: ID, limit: Int, offset: Int): [Settlement!]!` | `accounts:read` | | +| query | `taxRules: [TaxRule!]!` | `accounts:read` | | +| query | `verifyWithCA: [String!]!` | (signed in) | Items the lab's CA must confirm before these values are relied on. | +| mutation | `accrueSettlement(id: ID!): Settlement!` | `accounts:post` | | +| mutation | `addAgreement(input: AgreementInput!): PartnerAgreement!` | `accounts:manage` | | +| mutation | `addTaxRule(input: TaxRuleInput!): TaxRule!` | `accounts:manage` | | +| mutation | `cancelSettlement(id: ID!): Settlement!` | `accounts:post` | | +| mutation | `closeCashDay(input: CloseCashDayInput!): CashClosing!` | `accounts:post` | | +| mutation | `closeFinancialYear(fy: String!): YearEndResult!` | `accounts:manage` | Closing vouchers on 31 March and a lock. fy: 2026-27. | +| mutation | `createCustomer(input: CustomerInput!): Customer!` | `accounts:manage` | | +| mutation | `createPartner(input: PartnerInput!): Partner!` | `accounts:manage` | | +| mutation | `createSettlement(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): Settlement!` | `accounts:post` | | +| mutation | `importBankStatement(input: BankImportInput!): BankImportResult!` | `accounts:post` | | +| mutation | `issueCreditNote(input: CreditNoteInput!): CreditNoteResult!` | `accounts:post` | | +| mutation | `issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice!` | `accounts:post`, `invoice:create` | | +| mutation | `matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine!` | `accounts:post` | | +| mutation | `paySettlement(input: PaySettlementInput!): Settlement!` | `accounts:post` | | +| mutation | `reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult!` | `accounts:post` | | +| mutation | `seedTaxRules: [TaxRule!]!` | `accounts:manage` | finance-kit's starting rules. Every rate must be verified with the lab's CA. | +| mutation | `setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine!` | `accounts:post` | | +| mutation | `setLockDate(input: LockDateInput!): PeriodLock!` | `accounts:manage` | | +| mutation | `updateCustomer(id: ID!, input: CustomerPatch!): Customer!` | `accounts:manage` | | +| mutation | `updatePartner(id: ID!, input: PartnerPatch!): Partner!` | `accounts:manage` | | + +## documents + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `bookingReportDocument(bookingId: ID!, reportIds: [ID!], format: DocumentFormat, variant: ReportVariant, design: ReportDesign): Document!` | `report:read` | A booking's verified or released reports, printed together (or only reportIds). | +| query | `documentSamples: [DocumentSample!]!` | (signed in) | Demo samples (empty unless the server enables them). | +| query | `invoiceDocument(invoiceId: ID!, format: DocumentFormat, variant: ReportVariant): Document!` | `invoice:read` | A stored invoice, printed. | +| query | `invoiceSamples: [DocumentSample!]!` | (signed in) | | +| query | `invoiceTotals(request: JSON!): JSON!` | `invoice:read` | invoice-kit's calculation of an InvoiceRequest, without rendering (rupees). | +| query | `letterhead(format: DocumentFormat): Document!` | `report:read` | | +| query | `priceList: [PriceListEntry!]!` | (signed in) | invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables). | +| query | `renderInvoice(request: JSON!, format: DocumentFormat, variant: ReportVariant, fileName: String): Document!` | `invoice:read` | Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample }). | +| query | `renderReport(request: JSON!, format: DocumentFormat, variant: ReportVariant, design: ReportDesign, fileName: String): Document!` | `report:read` | Any report: request = report-kit's ReportRequest JSON (patient + tests \| profile \| report \| sample). | +| query | `reportTemplates: TemplateCatalog!` | (signed in) | | + + +--- + +## File: docs/05-errors.md + +# 05 · Errors + +Every error in a response carries `extensions.code`. Clients switch on the code, never on the message. + +```json +{ "errors": [{ "message": "Missing permission invoice:create", "path": ["issueInvoice"], + "extensions": { "code": "FORBIDDEN", "permission": "invoice:create" } }], + "data": null } +``` + +## Codes + +| Code | HTTP-ish | Raised when | Client should | +|---|---|---|---| +| `UNAUTHENTICATED` | 401 | No token, expired, logged out, or the user was disabled | Send the user to sign in | +| `FORBIDDEN` | 403 | The user lacks the permission (at that branch). `extensions.permission` names it | Hide the action; explain | +| `NOT_FOUND` | 404 | The id does not exist **in this organisation** (other tenants' ids look the same) | Show "not found" | +| `BAD_USER_INPUT` | 400 | Validation failed (db-kit `ValidationError`, report-kit 400s, unknown catalog codes, …) | Show the message next to the form | +| `CONFLICT` | 409 | Duplicate key, or the wrong state (e.g. release before verify, cancel a paid invoice) | Refresh and show the message | +| `PERIOD_LOCKED` | 409 | A posting date is on or before a lock date | Ask an accountant; pick another date | +| `CREDIT_LIMIT_EXCEEDED` | 409 | A B2B invoice would exceed the customer's credit limit | Collect payment or raise the limit | +| `INVALID_CREDENTIALS` | 401 | Wrong email, organisation or password (same answer for each) | "Email or password is wrong" | +| `ACCOUNT_DISABLED` | 403 | The user is disabled | Contact an administrator | +| `INVITE_PENDING` | 403 | The user has not set a password yet | Use the reset link | +| `ORG_REQUIRED` | 400 | The email exists in more than one organisation | Ask for the organisation code | +| `RATE_LIMITED` | 429 | Too many attempts; `extensions.retryAfterMs` says how long to wait | Wait, then retry | +| `WEAK_PASSWORD` | 400 | The new password fails the policy | Show the message | +| `INVALID_TOKEN` | 400 | A reset token is unknown, used or expired | Request a new link | +| `GRAPHQL_VALIDATION_FAILED` | 400 | The document is invalid (unknown field, depth > 12, introspection off) | Fix the query | +| `INTERNAL_SERVER_ERROR` | 500 | Anything else. The message is masked as "Unexpected error" | Retry later; check server logs | + +## How errors are made + +```mermaid +flowchart LR + T["throw in service / kit"] --> W["withErrorCodes (wraps every controller)"] + W -->|GraphQLError| P["passed through"] + W -->|AuthError| A["code = AuthError.code"] + W -->|PeriodLockedError| L["PERIOD_LOCKED"] + W -->|CreditLimitError| CL["CREDIT_LIMIT_EXCEEDED"] + W -->|NotFound / Conflict / ValidationError| D["NOT_FOUND / CONFLICT / BAD_USER_INPUT"] + W -->|report-kit HttpError 400| B["BAD_USER_INPUT"] + W -->|anything else| M["re-thrown → Yoga masks it
'Unexpected error'"] +``` + +Source: `src/graphql/errors.ts`. Two rules there matter: + +1. **Never pass `originalError`** to a `GraphQLError`. Yoga masks any error that wraps a non-GraphQL + error, which would hide the code. +2. **Unknown errors stay masked.** Stack traces and SQL never reach clients. They are logged on the server. + +## Adding a code + +Codes are a public contract: add new ones, never rename. Add it to `ERROR_CODES`, map the domain error in +`toGraphQLError`, document it here and in the client's `docs/graphql.md`, and add a test. + + +--- + +## File: docs/06-auth-and-permissions.md + +# 06 · Auth and permissions + +## Sessions + +auth-kit owns passwords and sessions. The API never sees a password hash leave the server and never +stores a raw token: stores receive `hashToken(token)` (SHA-256) only. + +```mermaid +sequenceDiagram + participant C as Client + participant A as API (auth controller) + participant K as auth-kit + participant S as Session store (Redis or memory) + participant U as Users (db-kit) + C->>A: login(email, password, orgCode?) + A->>K: login() + K->>U: find user (unknown email still runs one scrypt verify: timing-safe) + K->>K: rate limit per email+ip (10 per 15 min) + K->>S: save hash(token), idle + max age + K-->>C: token, expiresAt, user + C->>A: any request (Bearer token) + A->>K: context(token) + K->>S: get hash(token), extend idle window + K->>U: re-read the user (disable / role changes apply at once) + K-->>A: viewer, can(), branchesFor() +``` + +| Setting | Default | Env | +|---|---|---| +| Idle timeout | 12 hours | `SESSION_IDLE_MS` | +| Maximum age | 30 days | `SESSION_MAX_AGE_MS` | +| Login attempts | 10 per 15 minutes | (auth-kit default) | + +Session operations: `mySessions`, `revokeSession`, `logout`, `logoutOtherSessions`. `changePassword` +ends the user's other sessions; `resetPassword`, an administrator's `setUserPassword` and `disableUser` +end all of them. + +### Password reset + +`requestPasswordReset(email, orgCode?)` always returns `true`, whether or not the account exists. When it +does, the server's `Mailer` receives the link `PASSWORD_RESET_URL?token=…`. The token is never returned by +the API. `resetPassword(token, newPassword)` consumes it once. + +The `main.ts` entry has no mailer; embed the API with `createContainer({ config, mailer })` to send +emails ([12-deployment.md](12-deployment.md)). + +## Permissions + +A permission is `resource:action`. Roles grant permissions, with wildcards (`invoice:*`, `*:read`, `*:*`). +A user holds roles **per branch** (`branchId`) or **organisation-wide** (`branchId: null`). + +```mermaid +flowchart LR + U["User"] -- "role: technician
branch: A01" --> R1["technician"] + U -- "role: front_desk
branch: (all)" --> R2["front_desk"] + R1 --> P1["sample:*, report:enter, …"] + R2 --> P2["patient:create, invoice:create, …"] + P1 --> Q{"can(perm, branchId)?"} + P2 --> Q +``` + +`can(permission, branchId)` is true when a role held at that branch, or organisation-wide, grants it. +Without a branch, a role at any branch counts. `permissions` (query) lists every name the API checks; +`viewer { permissions(branchId) can(permission, branchId) branchesFor(permission) }` lets a UI hide what +the user cannot do. + +### System roles (auth-kit `SYSTEM_ROLES`) + +| Role | Summary | +|---|---| +| `owner` | Everything (`*:*`) | +| `admin` | Branches, users, roles, all lab and billing work, `accounts:*`, audit, settings (not `org:update`) | +| `branch_manager` | Patients, bookings, samples, billing, payments; reads reports, releases them; reads accounts | +| `front_desk` | Registers patients, books tests, issues invoices, takes payments | +| `phlebotomist` | Reads bookings, collects samples | +| `technician` | Samples, enters results | +| `pathologist` | Reports: enter, verify, release, amend | +| `accountant` | Payments, invoice cancellation, all `accounts:*` (books, GST, finance), audit | +| `doctor` | Reads patients and reports | + +Organisations can add their own roles or override these with `upsertRole`. + +### How a resolver checks + +```mermaid +flowchart TD + R["resolver"] --> A{"signed in?"} + A -- no --> U["UNAUTHENTICATED"] + A -- yes --> B{"record has a branch?"} + B -- yes --> C["load record (orgId checked)
authorize(ctx, perm, record.branchId)"] + B -- "no / list" --> D["branchScope(ctx, perm, branchId?)
→ allowed branches"] + C --> E{"allowed?"} + D --> E + E -- no --> F["FORBIDDEN"] + E -- yes --> G["service call"] +``` + +- Single records are loaded first (checking `orgId`), then authorised **against their own branch**, so + a user cannot read another branch's booking by guessing its id. +- Lists are filtered to the branches where the user holds the permission. +- Nested fields the user may not see (e.g. `Patient.invoices` without `invoice:read`) resolve to empty + lists, not errors. +- Books, GST and finance need `accounts:read` (reports), `accounts:post` (vouchers, settlements, cash, + bank) or `accounts:manage` (chart, tax rules, lock dates, year end). + +The generated [04-api-reference.md](04-api-reference.md) lists the exact permission per operation. + +## Tenant isolation + +```mermaid +flowchart LR + T["token"] --> V["viewer.orgId"] + V --> S["service(actor = { userId, orgId })"] + S --> O{"record.orgId == actor.orgId?"} + O -- no --> N["NOT_FOUND (indistinguishable from missing)"] + O -- yes --> OK["continue"] +``` + +No operation accepts an `orgId`. Inputs that reference other records (patient, branch, invoice, ledger, +customer, partner) are checked against the session's organisation before use. + + +--- + +## File: docs/07-domain-flows.md + +# 07 · Domain flows + +## The lab day, end to end + +```mermaid +flowchart LR + P["registerPatient"] --> B["createBooking
(tests from the catalog)"] + B --> S["samples created
one per sample type"] + B --> I["issueInvoice
(bookingId, items by code)"] + I --> V1["sales voucher posted
(GST split, finance-kit)"] + I --> PAY["recordPayment"] --> V2["receipt voucher"] + S --> C["collectSample"] --> R["receiveSample"] + R --> RP["createReport → saveResults
(unit, range, flag from catalog)"] + RP --> VR["verifyReport"] --> RL["releaseReport"] + RL --> D["bookingReportDocument
PDF / HTML"] + I --> ID["invoiceDocument
PDF / HTML"] +``` + +## Booking + +```mermaid +stateDiagram-v2 + [*] --> booked: createBooking + booked --> sample_collected: first collectSample + booked --> in_process: receiveSample + sample_collected --> in_process: receiveSample + in_process --> completed: last report released + booked --> cancelled: cancelBooking + sample_collected --> cancelled: cancelBooking + in_process --> cancelled: cancelBooking + note right of cancelled: needs the booking's invoice cancelled first +``` + +`createBooking` accepts `tests: [{ testCode }]`; the name, price (paise) and sample type come from the +catalog unless given. One sample (with a barcode) is created per distinct sample type. + +## Sample + +```mermaid +stateDiagram-v2 + [*] --> pending + pending --> collected: collectSample + collected --> received: receiveSample + pending --> rejected: rejectSample(reason) + collected --> rejected: rejectSample(reason) + received --> rejected: rejectSample(reason) +``` + +## Report + +```mermaid +stateDiagram-v2 + [*] --> draft: createReport + draft --> entered: saveResults + entered --> entered: saveResults (again) + entered --> verified: verifyReport + verified --> released: releaseReport + released --> amended: amendReport + amended --> verified: verifyReport +``` + +`saveResults` fills `unit`, `range` (by the patient's sex) and `flag` (L / H by the reference interval) from +the catalog when the client leaves them out. `flag: NONE` means normal. Releasing the last report of a +booking marks the booking `completed`. `bookingReportDocument` prints verified or released reports only ([08-documents.md](08-documents.md)). + +## Invoice and payments + +```mermaid +stateDiagram-v2 + [*] --> issued: issueInvoice (nothing paid) + [*] --> partially_paid: issueInvoice with part payment + [*] --> paid: issueInvoice paid in full + issued --> partially_paid: recordPayment + partially_paid --> paid: recordPayment + issued --> paid: recordPayment + paid --> partially_paid: refundPayment (part) + paid --> refunded: refundPayment (all) + issued --> credited: issueCreditNote (whole value) + partially_paid --> credited: issueCreditNote + issued --> cancelled: cancelInvoice(reason) + refunded --> cancelled: cancelInvoice(reason) + note right of cancelled: only with nothing paid; refund first +``` + +- Items may be just `{ code: "CBC" }`: name and MRP come from the catalog. `applyTaxRules: true` takes + HSN/SAC, taxability and rate from the organisation's tax rules (diagnostic services are usually exempt). +- Arithmetic (db-kit, integer paise): line gross → line discount → bill discount shared by largest + remainder → GST → round-off to the rupee. +- Every issue, payment, refund, credit note and cancel posts a voucher **in the same transaction** + (`financePosting` → tally-kit). A failed posting rolls the whole operation back. + +```mermaid +sequenceDiagram + participant API as billing service + participant DB as db-kit issueInvoice + participant H as financePosting (hook) + participant T as tally-kit postVoucher + API->>DB: repos.transaction(tx => …) + DB->>DB: compute totals, number MGPL/ARG01/2026-27/000123 + DB->>H: invoiceIssued(tx, invoice) + H->>H: place of supply from branch stateCode → CGST+SGST or IGST + H->>T: sales voucher (Dr patient/customer, Cr income, Cr output GST) + T->>T: assertPeriodOpen (lock dates), assertBalanced + T-->>DB: voucher + DB-->>API: invoice (committed with its voucher) +``` + +## B2B receivables + +```mermaid +flowchart LR + CU["createCustomer
(credit limit, days)"] --> CI["issueCustomerInvoice"] + CI --> CS["customerStatement
open items, ageing"] + CI --> CN["issueCreditNote
(lines or whole)"] + CS --> RS["receivablesSummary"] +``` + +`issueCustomerInvoice` refuses with `CREDIT_LIMIT_EXCEEDED` when the customer's open balance plus this +invoice exceeds the limit. Credit notes reverse tax line by line and reduce what is owed. + +## Partner settlements (collection centres, franchises) + +```mermaid +stateDiagram-v2 + [*] --> draft: createSettlement(partner, "2026-09") + draft --> accrued: accrueSettlement (journal: expense / partner) + accrued --> paid: paySettlement (payment voucher, TDS) + draft --> cancelled: cancelSettlement + accrued --> cancelled: cancelSettlement (reverses accrual) + note right of paid: corrections go into the next month +``` + +`settlementPreview` computes the month without saving: lines per invoice from the partner's agreements +(revenue share in basis points, or fixed rates per test code), deductions, TDS (section and rate from the +partner or the finance config) and net. No doctor referral commissions: settlements are for collection +centres and franchises only. + +## Cash and bank + +```mermaid +flowchart LR + subgraph Cash + CC["closeCashDay(branch, day, counted)"] --> DIFF{"book = counted?"} + DIFF -- no --> J["journal for the difference"] + end + subgraph Bank + IM["importBankStatement(csv)"] --> RC["reconcileBank
auto-match, card/UPI settlements with MDR"] + RC --> UM["bankLines(status: unmatched)"] + UM --> MB["matchBankLine / setBankLineStatus"] + MB --> BR["bankReconciliation (BRS)"] + end +``` + +## Periods + +```mermaid +stateDiagram-v2 + direction LR + [*] --> open + open --> locked: setLockDate(upTo) + locked --> open: setLockDate(null) + open --> closed: closeFinancialYear("2026-27") + note right of locked: postings on or before upTo fail with PERIOD_LOCKED +``` + +`closeFinancialYear` posts the closing vouchers on 31 March (income and expenses into capital) and locks +the year. Lock dates are checked inside the posting transaction, so nothing slips past them. + +## Books + +`setupChartOfAccounts` seeds the 28 standard groups, the default ledgers and finance-kit's ledgers +(idempotent; the seed script runs it). Reports: `trialBalance`, `ledgerStatement`, `cashBook`, `dayBook`, +`profitAndLoss`, `balanceSheet`, `outstandingReceivables`, `gstSummary`. Manual entries go through +`postVoucher` (balanced, one side per line) and `cancelVoucher`. + + +--- + +## File: docs/08-documents.md + +# 08 · Documents (reports and invoices) + +report-kit and invoice-kit render inside the API process. There is no separate document server and no +REST route: documents travel in GraphQL responses. + +```mermaid +flowchart LR + subgraph Stored["From stored records"] + BR["bookingReportDocument(bookingId, reportIds?)"] + ID["invoiceDocument(invoiceId)"] + end + subgraph Ad-hoc["From a request (JSON)"] + RR["renderReport(request)"] + RI["renderInvoice(request)"] + IT["invoiceTotals(request)"] + end + LH["letterhead"] + BR & RR --> RK["report-kit
renderReportPdf / Html"] + ID & RI --> IK["invoice-kit
renderInvoicePdf / Html"] + LH --> RK + RK & IK --> DOC["Document
{ format, fileName, mimeType, byteLength, base64 | html }"] +``` + +## The Document type + +| Field | PDF | HTML | +|---|---|---| +| `format` | `pdf` | `html` | +| `mimeType` | `application/pdf` | `text/html; charset=utf-8` | +| `base64` | the file | null | +| `html` | null | a self-contained page (fonts and images inlined) | +| `byteLength` | size of the PDF | size of the HTML (UTF-8) | + +Arguments: `format` (`pdf` default), `variant` (`header` prints the letterhead; `non_header` for +pre-printed paper, same positions otherwise), `design` (`sidebar` default, or `classic`) for reports. + +In a browser: `documentBlob(doc)` from `@mgpl-diagnostics/client` gives a Blob for download, a new tab or +printing ([14-client.md](14-client.md)). + +## Stored reports → print + +```mermaid +flowchart TD + B["booking + patient + doctor + samples"] --> M["map to report-kit's Report"] + R["verified / released reports"] --> T{"every result matches a
template field key or label?"} + T -- yes --> TPL["template section (e.g. cbc, lft)
with the lab's layout"] + T -- no --> GEN["generic section
Test · Result · Unit · Range"] + TPL & GEN --> M + M --> RK["report-kit render"] +``` + +The sample-collection time is the earliest `collectedAt` of the booking's samples. Draft or entered +reports never print: if any selected report is not verified or released the call fails with `CONFLICT` +(pass `reportIds` to print only the ready ones), and `NOT_FOUND` when there are none. + +## Stored invoices → print + +The stored invoice (paise) is mapped to invoice-kit's print shape (rupees). invoice-kit recomputes the +totals; the API checks that its total equals the stored total and refuses a mismatch (`CONFLICT`), so a +printed invoice can never disagree with the books. + +## Ad-hoc rendering + +`renderReport(request)` and `renderInvoice(request)` take the same JSON as report-kit's and invoice-kit's +HTTP APIs did (`ReportRequest`: patient + tests | profile | report | sample; `InvoiceRequest`: +`{ invoice }` or `{ sample }`). They are for previews (result entry with a live preview, quotes). Samples +(`documentSamples`, `invoiceSamples`, `{ sample: id }`) work only when `MGPL_SAMPLES=true`. + +## Signatures and branding + +The lab profile is `defaultLab()` from report-kit: branding plus signatures read from +`MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE` (or `MGPL_SIGNATURE_FILE`). The files live +outside every repository. Without them a placeholder signature prints. Embedders can pass their own +`lab` to `createContainer`. + +## Cost + +A PDF takes tens to a few hundred milliseconds of CPU and the response is about 4/3 of the file size +(base64). Keep `BODY_LIMIT_BYTES` for requests; responses are not limited. Render on demand; cache the +result client-side if the same document is opened repeatedly. + + +--- + +## File: docs/09-configuration.md + +# 09 · Configuration + +All configuration comes from environment variables, read once by `loadConfig()` (`src/config/env.ts`). +Copy `.env.example` to `.env` for local work; never commit it. + +| Variable | Default | Notes | +|---|---|---| +| `NODE_ENV` | `development` | `production` turns off GraphiQL, introspection, samples and `*` CORS by default | +| `HOST` | `0.0.0.0` | | +| `PORT` | `4000` | | +| `GRAPHQL_PATH` | `/graphql` | The one endpoint | +| `DB_BACKEND` | `pg` | `pg` or `mongo` | +| `DATABASE_URL` | local default in development; **required** in production | Postgres URL, or a MongoDB URI with `replicaSet=` | +| `REDIS_URL` | empty | Sessions, reset tokens, login limits and db-kit's read cache. Required for more than one server | +| `CORS_ORIGINS` | `*` in development, empty in production | Comma-separated origins. `*` is refused in production | +| `GRAPHIQL` | on in development | GraphiQL at the endpoint (GET) | +| `INTROSPECTION` | on in development | Off adds `NoSchemaIntrospectionCustomRule` | +| `BODY_LIMIT_BYTES` | `2000000` | Request body limit (bank CSV imports count) | +| `TRUST_PROXY` | `false` | `true` behind a load balancer, so login limits see the client IP | +| `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | +| `SESSION_IDLE_MS` | `43200000` (12 h) | | +| `SESSION_MAX_AGE_MS` | `2592000000` (30 days) | | +| `PASSWORD_RESET_URL` | empty (`/reset-password`) | The page that receives `?token=` | +| `MGPL_SIGNATURE_HEADER_FILE` | — | PNG for the header variant (outside the repo) | +| `MGPL_SIGNATURE_NONHEADER_FILE` | — | PNG for the non-header variant | +| `MGPL_SIGNATURE_FILE` | — | One file for both | + +Seed-only variables (`npm run seed`): `SEED_ORG_NAME`, `SEED_ORG_CODE`, `SEED_ORG_GSTIN` (optional), +`SEED_BRANCH_CODE`, `SEED_BRANCH_NAME`, `SEED_BRANCH_STATE`, `SEED_OWNER_NAME`, `SEED_OWNER_EMAIL`, +`SEED_OWNER_PASSWORD`. + +Test-only: `TEST_BACKENDS=pg,mongo` (default both), `DEBUG=1` prints resolver errors, +`MONGOMS_SYSTEM_BINARY` points mongodb-memory-server at a local `mongod`. + +## Finance configuration + +GST and TDS values are not environment variables. They are data in the organisation's settings +(`settings.finance`, merged over finance-kit's `DEFAULT_FINANCE_CONFIG`) and tax rules (`taxRules`, +`addTaxRule`, `seedTaxRules`). Every value the CA must confirm is listed by `verifyWithCA`. + +```mermaid +flowchart LR + D["finance-kit DEFAULT_FINANCE_CONFIG"] --> MRG["merged config
(financeConfig query)"] + O["organization.settings.finance"] --> MRG + TR["taxRules (per org)"] --> GST["GST per invoice line"] + MRG --> GST + MRG --> TDS["TDS on settlements"] +``` + + +--- + +## File: docs/10-data-and-money.md + +# 10 · Data and money + +## Money + +- **Integer paise everywhere** (`totalPaise: 35400` = Rs 354.00). Inputs and outputs, no floats. +- The catalog's `mrp` and invoice-kit's print/price-list amounts are **rupees**; the API converts at the + boundary (`pricePaise = mrp × 100`). +- Rounding is explicit: bill discounts by largest remainder, GST per line, round-off to the rupee. +- Every voucher balances (debits = credits), checked by tally-kit before it is written. +- Print shows `Rs.` (the report font has no `₹` glyph). + +## Dates + +| Kind | GraphQL | Meaning | +|---|---|---| +| Instant | `DateTime` (ISO string) | `createdAt`, `verifiedAt`, voucher dates | +| Day | `Day` (`"YYYY-MM-DD"`) | Calendar day **in IST** (cash closings, settlement lines, lock dates, GST invoice days) | +| Period | `String` | Settlement month `"2026-09"`; financial year `"2026-27"` | + +The financial year runs 1 April to 31 March (IST). Voucher and invoice numbers restart each year +(`MGPL/ARG01/2026-27/000001`: organisation / branch / year / sequence). + +## Storage + +```mermaid +erDiagram + ORGANIZATION ||--o{ BRANCH : has + ORGANIZATION ||--o{ USER : employs + ORGANIZATION ||--o{ ROLE : defines + BRANCH ||--o{ PATIENT : registers + PATIENT ||--o{ BOOKING : books + BOOKING ||--o{ SAMPLE : yields + BOOKING ||--o{ REPORT : produces + BOOKING |o--o| INVOICE : billed_by + INVOICE ||--o{ PAYMENT : receives + INVOICE ||--o{ CREDIT_NOTE : reduced_by + INVOICE ||--o{ VOUCHER : posts + VOUCHER ||--|{ LEDGER_ENTRY : contains + LEDGER ||--o{ LEDGER_ENTRY : records + ACCOUNT_GROUP ||--o{ LEDGER : groups + CUSTOMER ||--|| LEDGER : owns + CUSTOMER ||--o{ INVOICE : billed + PARTNER ||--o{ AGREEMENT : signs + PARTNER ||--o{ SETTLEMENT : earns + BRANCH ||--o{ CASH_CLOSING : closes + BANK_STATEMENT ||--o{ BANK_LINE : lists + ORGANIZATION ||--o{ PERIOD_LOCK : locks +``` + +All tables and collections belong to **db-kit**, on PostgreSQL (Sequelize) or MongoDB (Mongoose, replica +set for transactions). Both backends pass the same contract tests, and this API's suite runs on both. +Schema changes are db-kit pull requests, never local tables here. + +## GST prerequisites + +| Needed for | Set | +|---|---| +| Posting any invoice (place of supply → CGST+SGST or IGST) | The branch's `stateCode` (or its `gstin`) | +| GSTR-1 / GSTR-3B | The organisation's `gstin` (or pass `gstin`) | +| B2B invoices to registered buyers | The customer's `gstin` and `stateCode` | +| Taxable lines | Tax rules (`seedTaxRules`, then confirm each with the CA) | + +A branch without a state code fails billing with `BAD_USER_INPUT`. The seed script asks for it. + +## Personal data + +Patients' names, phones, addresses and results are health data. Do not log request bodies, do not copy +production data into tests (fixtures use fictional names), and keep backups encrypted. + + +--- + +## File: docs/11-testing.md + +# 11 · Testing + +`npm test` runs `test/run.ts`: the real server (Fastify + Yoga) driven over HTTP with `app.inject`, real +auth-kit sessions, and real databases started in-process. + +```mermaid +flowchart LR + T["test/run.ts"] --> PG["embedded-postgres
(temp dir)"] + T --> MG["mongodb-memory-server
replica set"] + PG & MG --> S["suite(db, tag)"] + S --> SEED["seed: org APL (GSTIN, branch state 22)
org OTH (tenant isolation)"] + SEED --> RUN["≈64 checks per backend over POST /graphql"] + T --> CFG["fromConfig(url): production container path"] +``` + +| Area | What the suite proves | +|---|---| +| Server | `/health` and `{ health }`, introspection off when `INTROSPECTION=0`, depth limit 12 | +| Auth | no token → `UNAUTHENTICATED`, wrong password → `INVALID_CREDENTIALS`, login, viewer (null without a token), weak password, reset link through the mailer (unknown emails not revealed), reset ends old sessions, disable signs out at once, `mySessions`, logout | +| Permissions | front desk cannot read the books; registers in branch A, not B; lists show only the viewer's branches; discounts need `invoice:discount`; nested vouchers hidden without `accounts:read` | +| Tenancy | another organisation cannot read the invoice, the patient, or print it; cannot post into its ledgers | +| Catalog | search and price quote without sign-in | +| Lab | patient → booking by catalog code → collect → receive → results with unit, range and flag from the catalog → unverified report does not print → verify, release → booking completed | +| Billing | invoice priced from the catalog, partial payment, overpayment refused, payment → paid with sales and receipt vouchers, qty 0 refused | +| Books | chart setup, trial balance balances, manual voucher, unbalanced voucher refused | +| Finance | tax rules, customer + credit invoice, credit limit, statement, credit note, GSTR-1/3B data with the verify-with-CA list, trial balance CSV with TOTAL row, partner + agreement + settlement preview, lock date → `PERIOD_LOCKED` | +| Documents | booking report PDF and non-header HTML, stored invoice PDF + HTML (totals match), `renderReport` / `invoiceTotals` / `reportTemplates`, bad request → `BAD_USER_INPUT` | +| Performance | 10 invoices with nested `patient` → one batched lookup (no N+1) | + +Not covered yet (good next tests): refunds and invoice cancellation, settlement accrue/pay/cancel, cash +closing, bank import and reconciliation, year-end close, P&L / balance sheet values, CORS headers, the +login rate limit. + +## Running + +```bash +npm test # PostgreSQL and MongoDB (first run downloads the binaries) +TEST_BACKENDS=pg npm test # PostgreSQL only +DEBUG=1 TEST_BACKENDS=pg npm test # print resolver errors +MONGOMS_SYSTEM_BINARY=/usr/bin/mongod npm test # behind a proxy that blocks the MongoDB download +``` + +CI (`.github/workflows/ci.yml`, job id `ci`, required on `main`) runs `npm run kits`, typecheck, the tests +on both backends, the build, and checks that `schema.graphql`, `docs/04-api-reference.md` and +`llms-full.txt` are current. + +## The client contract + +`kits.json` pins `@mgpl-diagnostics/client`. Once the client ships its GraphQL operations, the suite +validates every operation the client sends against `buildSchema()` and checks that each root field has +one, so a renamed field fails here before it reaches an app. + +## Adding a test + +Add checks to `suite()` in `test/run.ts` with `ok(condition, "[tag] what it proves")`. Use the `gql(token, +query, variables)` helper and the seeded tokens (`owner`, `desk`, …). Keep fixtures fictional. + + +--- + +## File: docs/12-deployment.md + +# 12 · Deployment + +## Shape + +```mermaid +flowchart LR + U["Browsers / apps"] -- HTTPS --> LB["Load balancer / reverse proxy
TLS, gzip"] + LB -- "/graphql, /health" --> A1["api #1"] + LB --> A2["api #2"] + A1 & A2 --> DB[("PostgreSQL
or MongoDB replica set")] + A1 & A2 --> R[("Redis")] + A1 & A2 -. reads .-> SIG["signature files
(secret volume)"] +``` + +- **One server** can run without Redis (sessions in memory, lost on restart). +- **Two or more servers need Redis**: sessions, reset tokens and login limits must be shared. +- The process is stateless otherwise; scale horizontally. + +## Build and run + +```bash +npm run kits # or `npm ci` once every kit is on the registry +npm run build # dist/main.js +NODE_ENV=production node dist/main.js +``` + +A container image: Node 22 slim, copy `dist/`, `node_modules/` and `package.json`, run +`node dist/main.js`, expose `PORT`. The PDF engine needs no system fonts (fonts are bundled), but +report-kit's resvg is a native module: build the image on the same platform it runs on. + +## Production checklist + +| | Item | +|---|---| +| ☐ | `NODE_ENV=production`, `DATABASE_URL`, `REDIS_URL` set from secrets | +| ☐ | `CORS_ORIGINS` lists exactly the app origins (no `*`) | +| ☐ | `GRAPHIQL` and `INTROSPECTION` off (the defaults in production) | +| ☐ | `TRUST_PROXY=true` behind the load balancer (login limits use the client IP) | +| ☐ | `MGPL_SAMPLES` off (the default in production) | +| ☐ | Signature files mounted read-only; `MGPL_SIGNATURE_*_FILE` point at them | +| ☐ | `PASSWORD_RESET_URL` points at the app's reset page, and a `Mailer` is wired (below) | +| ☐ | First organisation seeded (`npm run seed`) with the branch `stateCode` and the org GSTIN | +| ☐ | Tax rules seeded **and confirmed by the CA** (`verifyWithCA`) | +| ☐ | Database backups (point-in-time for Postgres), encrypted | +| ☐ | Health check on `GET /health` (200 `{ ok: true }`, 503 when the database is down) | +| ☐ | Logs shipped; request bodies are not logged (they hold patient data) | + +## Sending password-reset emails + +`src/main.ts` starts the API without a mailer, so reset links are not sent (the API still answers +`true`). Wire one by embedding: + +```ts +import { buildServer, createContainer, loadConfig, type Mailer } from "@mgpl-diagnostics/api"; + +const mailer: Mailer = { + async passwordReset(to, link, expiresAt) { + await sendEmail({ to: to.email, subject: "Reset your password", text: `Hi ${to.name}, ${link} (until ${expiresAt.toISOString()})` }); + }, +}; +const container = await createContainer({ config: loadConfig(), mailer }); +await (await buildServer(container)).listen({ port: container.config.port, host: container.config.host }); +``` + +## Database operations + +- **PostgreSQL**: `createDb` creates missing tables on start (`sequelize.sync()`, which never alters + existing ones). Take a backup before upgrading db-kit and apply any migration its changelog lists. +- **MongoDB**: must be a replica set (transactions). `createDb` syncs indexes on start (`syncIndexes`). +- **Redis**: no persistence needed beyond sessions; losing it signs everyone out. + +## Upgrading kits + +Bump the version in `package.json` (and the ref in `kits.json` while CI builds from source), run +`npm test` on both backends, regenerate `schema.graphql` if types changed, deploy. tally-kit changes of +behaviour are versioned decisions; read its changelog. + + +--- + +## File: docs/13-adding-a-feature.md + +# 13 · Adding a feature + +Every feature walks the same layers. Example: **a query `patientInvoicesDue(patientId)`**. + +```mermaid +flowchart LR + A["1 · Kit
(if new logic or storage)"] --> B["2 · Service
src/services"] + B --> C["3 · Schema
src/schema/*.graphql.ts"] + C --> D["4 · Controller
src/controllers"] + D --> E["5 · Test
test/run.ts"] + E --> F["6 · Generated files
schema · reference · llms"] + F --> G["7 · Client
operations · types · method"] +``` + +## 1. Does it belong in a kit? + +| The change is… | Where | +|---|---| +| A new table, column or repository method | **db-kit** PR (both `mongo/` and `pg/`, contract tests) | +| A domain rule (GST, settlements, credit limits) | **finance-kit** | +| Posting or books reports | **tally-kit** | +| A permission | **auth-kit** `PERMISSIONS` + the right `SYSTEM_ROLES` | +| A test, range or price | **catalog-kit** `data/catalog.json` | +| A print layout | **report-kit** / **invoice-kit** | +| Wiring, authorisation, mapping to GraphQL | here | + +## 2. Service + +```ts +// src/services/billing.service.ts +async dueForPatient(actor: Actor, patientId: Id, scope: "all" | Id[]) { + mustOwn(await this.repos.patients.get(patientId), actor.orgId, "Patient", patientId); + const invoices = await scopedFind(this.repos.invoices, { patientId }, scope, 200, 0, "createdAt"); + return invoices.filter((i) => i.duePaise > 0 && i.status !== "cancelled"); +} +``` + +Services take `(actor, …)`, check ownership, use the kits, and never import GraphQL. + +## 3. Schema + +```ts +// src/schema/billing.graphql.ts +extend type Query { + """Unpaid invoices of one patient, newest first.""" + patientInvoicesDue(patientId: ID!): [Invoice!]! +} +``` + +## 4. Controller + +```ts +// src/controllers/billing.controller.ts, inside Query +patientInvoicesDue: (_: unknown, a: { patientId: string }, ctx: Ctx) => + ctx.services.billing.dueForPatient(actorOf(ctx), a.patientId, branchScope(ctx, "invoice:read")), +``` + +Always: `authorize` / `branchScope` first, ids checked against the organisation, `cap()` on limits. +Controllers are wrapped in `withErrorCodes`, so domain errors get codes automatically. + +## 5. Test + +```ts +const due = await gql(desk, `query($p: ID!) { patientInvoicesDue(patientId: $p) { invoiceNo duePaise } }`, { p: patientId }); +ok(due.data?.patientInvoicesDue.length === 1, `[${tag}] patientInvoicesDue lists the unpaid invoice`); +``` + +Add a negative check too (another organisation, or a user without the permission). + +## 6. Generated files + +```bash +npm run typecheck && npm test +npm run schema && npm run docs:reference && npm run llms +``` + +Update the hand-written docs that describe the area (flows, errors) and add an example to `docs/examples/`. + +## 7. Client + +In `mgpl-diagnostics/client`: add the operation to `src/graphql/operations.ts`, the result type to +`src/graphql/types.ts`, a method to `src/graphql/client.ts`, a row to `docs/graphql.md`; release; then bump +the client ref here so the contract test covers it. + +## Checklist + +- [ ] Permission exists in auth-kit and the right roles grant it +- [ ] No `orgId` or `actorId` arguments +- [ ] Money in paise, days as `Day` +- [ ] Multi-record writes inside a kit transaction +- [ ] Nested fields through loaders +- [ ] Positive and negative tests on both backends +- [ ] schema.graphql, 04-api-reference.md, llms-full.txt regenerated + + +--- + +## File: docs/14-client.md + +# 14 · Client + +Frontends use [`@mgpl-diagnostics/client`](https://github.com/mgpl-diagnostics/client) (zero dependencies). +Its `createMgplGraphQLClient` has one typed method per operation of this API. + +```mermaid +flowchart LR + subgraph App["Your app (any framework)"] + UI["UI + state"] --> SDK["createMgplGraphQLClient()"] + end + SDK -- "POST /graphql" --> API["api"] + SDK -. "documentBlob / csvBlob" .-> DL["download · open · print"] +``` + +```ts +import { createMgplGraphQLClient, documentBlob, printBlob, MgplGraphQLError } from "@mgpl-diagnostics/client"; + +const mgpl = createMgplGraphQLClient({ url: import.meta.env.VITE_API_URL + "/graphql", onToken: saveToken, token: loadToken() }); + +await mgpl.auth.login({ email, password, orgCode: "MGPL" }); +const me = await mgpl.auth.viewer(); +const canBill = await mgpl.auth.can("invoice:create", branchId); + +const hits = await mgpl.catalog.search("lft"); +const { booking } = await mgpl.bookings.create({ branchId, patientId, tests: hits.slice(0, 1).map((h) => ({ testCode: h.test.code! })) }); +const doc = await mgpl.documents.bookingReports(booking.id, { variant: "non_header" }); +printBlob(documentBlob(doc)); +``` + +| Need | Method | +|---|---| +| Sign in / out, sessions | `auth.login`, `auth.logout`, `auth.sessions`, `auth.changePassword` | +| Show or hide UI by permission | `auth.viewer().permissions`, `auth.can(permission, branchId)` | +| Result-entry form | `catalog.test(code, sex)` (parameters, units, ranges) or `documents.templates()` | +| Live preview | `documents.renderReport(request, { format: "html" })` | +| Desk | `patients.*`, `bookings.*`, `samples.*`, `invoices.*` | +| Pathologist | `reports.saveResults`, `reports.verify`, `reports.release` | +| Accounts | `books.*`, `gst.*`, `receivables.*`, `settlements.*`, `cash.*`, `bank.*`, `periods.*`, `exports.*` | +| Anything custom | `request(query, variables)` | + +Errors arrive as `MgplGraphQLError` with `code` ([05-errors.md](05-errors.md)). The full method list is in +the client's `docs/graphql.md`. + +## Without the SDK + +Any GraphQL client works (Apollo, urql, graphql-request, plain `fetch`). Send +`Authorization: Bearer ` and `content-type: application/json` to `POST /graphql`. The schema is +in [schema.graphql](../schema.graphql) for code generators; introspection is off in production. + + +--- + +## File: docs/15-glossary.md + +# 15 · Glossary + +| Term | Meaning | +|---|---| +| **Booking** | A patient's order for one or more tests on one visit | +| **Sample** | A specimen (blood, urine, …) collected for a booking, with a barcode; one per sample type | +| **Report** | The results of one test for a booking; goes draft → entered → verified → released | +| **Template** | report-kit's layout for a test (rows, units, ranges); results keyed by field | +| **Profile** | A group of tests sold together (e.g. LFT, KFT) | +| **UHID** | Unique health ID: the lab's patient number | +| **MRP** | Maximum retail price of a test, in rupees, from the catalog | +| **Paise** | 1/100 of a rupee; every amount in the API is integer paise | +| **Branch** | A lab or collection centre of the organisation; holds its own stock of patients, bookings, cash | +| **Header / non-header** | Print with the letterhead, or without it for pre-printed stationery | +| **Voucher** | A double-entry accounting document: sales, receipt, payment, journal, contra, credit note, … | +| **Ledger** | An account (Cash, Bank, Sales, a customer, a partner) inside an account group | +| **Trial balance** | Every ledger's debit/credit balance; totals must be equal | +| **Day book** | All vouchers of a period | +| **Lock date** | No posting on or before it (period closed) | +| **Financial year (FY)** | 1 April – 31 March, written `2026-27` | +| **GSTIN** | 15-character GST registration number; its first two digits are the state code | +| **State code** | GST code of a state (22 = Chhattisgarh); decides CGST+SGST (same state) vs IGST | +| **Place of supply** | The state where the service is supplied, for GST | +| **HSN / SAC** | Classification codes for goods / services on invoices | +| **GSTR-1 / GSTR-3B** | Monthly GST returns: outward supplies detail / summary with tax payable | +| **B2B / B2C / B2CL / B2CS** | Supplies to registered businesses / consumers (large / small) | +| **CDNR / CDNUR** | Credit and debit notes to registered / unregistered buyers | +| **Credit note** | Reduces an issued invoice (with its tax); our "partial cancel" | +| **Receivable** | Money owed by B2B customers (hospitals, insurers, TPAs, corporates) | +| **Ageing** | Open amounts by how overdue they are (0–30, 31–60, 61–90, 90+ days) | +| **Partner** | A collection centre or franchise that earns a share of billing | +| **Settlement** | A month's partner share: preview → draft → accrued → paid | +| **TDS** | Tax deducted at source on payments to partners (section and rate are CA-confirmed config) | +| **bp (basis points)** | 1/100 of a percent: 2500 bp = 25 % | +| **MDR** | Merchant discount rate: the card/UPI fee the bank deducts from settlements | +| **BRS** | Bank reconciliation statement: book balance vs bank balance and the items between | +| **Verify with CA** | Values (rates, thresholds, sections) the lab's chartered accountant must confirm | +| **Kit** | One of our libraries (auth, db, catalog, report, invoice, tally, finance) used inside the API | +| **Actor** | `{ userId, orgId }` of the signed-in user, from the session | + + +--- + +## File: docs/LOCAL-AGENT-BRIEF.md + +# Brief for the local agent: what to do outside this repo + +**Audience:** an AI agent (Claude Code or similar) or a developer working on the owner's machine with +an org-owner GitHub login (`gh auth status`), Node 22, and Docker or local databases. +**Why local:** the cloud session that built this API could not create releases or tags (403), change org +settings, or download MongoDB binaries. Everything below needs one of those. +**Written:** 2026-10-14, when `mgpl-diagnostics/api` was first published. + +Work top to bottom. Each step says how to check it is done. Report back with the checklist at the end. + +```mermaid +flowchart TD + A["1 · Protect the api repo"] --> B["2 · Run the Mongo tests locally"] + B --> C["3 · Release client 1.1.0"] + C --> D["4 · Publish finance-kit 1.0.0"] + D --> E{"5 · tally-kit name
confirmed by owner?"} + E -- yes --> F["publish tally-kit"] + E -- no --> G["leave unpublished"] + F & G --> H["6 · Package access for api's Actions"] + H --> I["7 · Switch api CI to the registry"] + I --> J["8 · db-kit 2.0.0: remove src/graphql"] + J --> K["9 · Deploy + seed"] + K --> L["10 · CA review"] +``` + +## Ground rules + +- `main` in every repo takes pull requests only (squash, 1 code-owner review by `@BackStacked`, the + `ci` check). Never push to `main`; never force-push someone else's branch. +- Release tags `v*` are admin-only and publish packages (`publish.yml`). Bump the version in a PR first, + merge it, then tag the merge commit. +- Never commit real patient data, signatures, `.env` files or tokens. +- **Ask the owner before changing any value marked "verify with CA"** (GST rates, TDS sections and + rates, thresholds). +- **Do not release tally-kit until the owner confirms its final package name** ("Tally" is a trademark). + +## 1. Protect the api repo + +In `mgpl-diagnostics/.github`, `scripts/protect-repos.mjs` lists required checks per repo in `CHECKS`. +Add `api: ["ci"]` (a PR from the cloud session may already do this; check first), merge, then: + +```bash +cd .github && node scripts/protect-repos.mjs api +``` + +Done when: `gh api repos/mgpl-diagnostics/api/rulesets` lists "main" and "release tags", and a direct +push to `api/main` is refused. + +## 2. Run the full test suite with MongoDB + +The cloud session ran the api's tests on PostgreSQL only (MongoDB downloads were blocked); CI runs both. +Confirm locally: + +```bash +git clone https://github.com/mgpl-diagnostics/api && cd api +npm run kits +npm test # both backends; first run downloads mongod and postgres +# behind a proxy: MONGOMS_SYSTEM_BINARY=$(which mongod) npm test +``` + +Done when: `… passed, 0 failed` with both `[pg]` and `[mongo]` sections. If Mongo fails and Postgres +passes, the bug is backend-specific: reproduce it in db-kit's contract suite and fix it there (rule 1 of +db-kit's AGENTS.md: both backends behave identically). + +## 3. Release client 1.1.0 + +The GraphQL client (`createMgplGraphQLClient`) was merged with version `1.1.0` in package.json. + +```bash +cd client && git checkout main && git pull +git tag v1.1.0 && git push origin v1.1.0 # publish.yml publishes to GitHub Packages +``` + +Done when: `npm view @mgpl-diagnostics/client@1.1.0 --registry https://npm.pkg.github.com` works. + +## 4. Publish finance-kit 1.0.0 + +finance-kit has no tag yet. Its package.json says `1.0.0`. + +```bash +cd finance-kit && git checkout main && git pull && npm run typecheck && npm test && npm run build +git tag v1.0.0 && git push origin v1.0.0 +``` + +Done when: the publish workflow is green and the package is visible in the org's packages. + +## 5. tally-kit: name, then publish + +Ask the owner to confirm the final package name (tally-kit's AGENTS.md rule 7). Only then, tag +`v0.1.0` (or the renamed package) and publish. If the name changes, update every `@mgpl-diagnostics/tally-kit` +import and dependency (finance-kit, api) in one coordinated set of PRs. + +## 6. Give the api repo access to the packages + +For each package (auth-kit, catalog-kit, db-kit, report-kit, invoice-kit, client, finance-kit, +tally-kit): GitHub → org → Packages → the package → Package settings → **Manage Actions access** → add +`mgpl-diagnostics/api` with **Read**. (Alternatively make the packages public.) + +Done when: a workflow in api can `npm install` them with `GITHUB_TOKEN`. + +## 7. Switch api CI from source builds to the registry + +Today `npm run kits` (scripts/kits.mjs) clones each kit at the ref in `kits.json` and builds it, because +the packages were not all published or readable. After steps 3–6: + +1. In `api/.github/workflows/ci.yml`: replace `npm run kits` with + ```yaml + - uses: actions/setup-node@v7 + with: { node-version: 22, registry-url: https://npm.pkg.github.com, scope: "@mgpl-diagnostics" } + - run: npm install + env: { NODE_AUTH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" } + ``` + and add `packages: read` to the workflow `permissions`. +2. Add `"@mgpl-diagnostics/client": "^1.1.0"` to api's devDependencies (today `npm run kits` installs it without saving), so the client contract test + uses the released client. +3. Commit a `package-lock.json` (`npm install` with the registry configured). +4. Keep `scripts/kits.mjs` and `kits.json` for building unreleased kit branches locally (`KITS_SRC`). +5. Update docs/03-getting-started.md and docs/11-testing.md (they describe `npm run kits` as the default). + +Done when: api CI is green with the registry install. + +## 8. db-kit 2.0.0: remove the old GraphQL layer + +db-kit still carries `src/graphql/` and the `./graphql` export (see db-kit +`docs/MOVE-GRAPHQL-TO-BACKEND.md`). The api replaces it. Removing an export is a breaking change: + +1. db-kit PR: delete `src/graphql/`, the `./graphql` export and entry, its tests and graphql peer + dependencies; update README / AGENTS.md / llms; mark the handoff doc "done (moved to mgpl-diagnostics/api)"; + version `2.0.0`. +2. Before tagging, widen the peer ranges in **finance-kit** and **tally-kit** to + `"@mgpl-diagnostics/db-kit": "^1.1.0 || ^2.0.0"` (PRs, with their tests green against 2.0.0). +3. Tag db-kit `v2.0.0`; release finance-kit/tally-kit patch versions with the widened ranges. +4. Bump api to `^2.0.0` and run its tests on both backends. + +Done when: no repo imports `@mgpl-diagnostics/db-kit/graphql` (`gh search code "db-kit/graphql" --owner mgpl-diagnostics`). + +## 9. Deploy and seed + +Follow [12-deployment.md](12-deployment.md). In particular: + +- `REDIS_URL` for more than one server; `CORS_ORIGINS` set to the app origins; `TRUST_PROXY=true`. +- Real signature images in a secret volume, `MGPL_SIGNATURE_HEADER_FILE` / `MGPL_SIGNATURE_NONHEADER_FILE`. + Never commit them. +- Wire a `Mailer` (email provider) for password-reset links, and `PASSWORD_RESET_URL`. +- `npm run seed` once, with the branch's GST state code (`SEED_BRANCH_STATE`, 22 for Chhattisgarh) and + the organisation's GSTIN (`SEED_ORG_GSTIN`); billing needs the first, GST returns the second. +- Add every other branch with `createBranch` **including its `stateCode`**. + +Done when: `GET /health` → 200, an owner can sign in, issue an invoice, and print it. + +## 10. CA review (owner + CA, not the agent) + +Call `verifyWithCA` and `taxRules` after `seedTaxRules`, and give the list to the lab's chartered +accountant. The agent records answers only as the owner instructs (`addTaxRule`, `settings.finance`); +it never picks rates itself. finance-kit's `docs/gst-notes.md` explains each item. + +## Report back + +Post one message with: + +``` +1 protect api ............ done / blocked: … +2 tests on Mongo ......... done (N passed) / failing: … +3 client v1.1.0 .......... done / … +4 finance-kit v1.0.0 ..... done / … +5 tally-kit name ......... confirmed "" + published / waiting on owner +6 package access ......... done / … +7 api CI on registry ..... PR , green / … +8 db-kit 2.0.0 ........... PRs / not started +9 deploy + seed .......... done at / not started +10 CA review ............. handed to owner / … +``` + + +--- + +## File: docs/README.md + +# MGPL Diagnostics API: documentation + +Everything about the backend, from A to Z. Start with the overview, then read what you need. + +```mermaid +flowchart TD + A["01 Overview"] --> B["02 Architecture"] + B --> C["03 Getting started"] + C --> D["04 API reference"] + D --> E["05 Errors"] + D --> F["06 Auth and permissions"] + D --> G["07 Domain flows"] + G --> H["08 Documents"] + C --> I["09 Configuration"] + G --> J["10 Data and money"] + C --> K["11 Testing"] + I --> L["12 Deployment"] + B --> M["13 Adding a feature"] + D --> N["14 Client"] +``` + +| # | Page | What it answers | +|---|---|---| +| 01 | [Overview](01-overview.md) | What the API is, what it serves, and what it is not | +| 02 | [Architecture](02-architecture.md) | Layers, folders, the request lifecycle, how the kits fit | +| 03 | [Getting started](03-getting-started.md) | Run it locally, seed the first organisation, the first queries | +| 04 | [API reference](04-api-reference.md) | Every query and mutation, with its permission (generated) | +| 05 | [Errors](05-errors.md) | Error codes, masking, how clients should react | +| 06 | [Auth and permissions](06-auth-and-permissions.md) | Sessions, roles, branch scoping, tenant isolation | +| 07 | [Domain flows](07-domain-flows.md) | Booking → sample → report → invoice → books, with state diagrams | +| 08 | [Documents](08-documents.md) | Report and invoice PDFs/HTML through GraphQL | +| 09 | [Configuration](09-configuration.md) | Every environment variable | +| 10 | [Data and money](10-data-and-money.md) | Paise, IST days, financial years, storage backends, GST prerequisites | +| 11 | [Testing](11-testing.md) | The end-to-end suite and how to extend it | +| 12 | [Deployment](12-deployment.md) | Production checklist, scaling, Redis, signatures | +| 13 | [Adding a feature](13-adding-a-feature.md) | Schema → controller → service → test → docs, step by step | +| 14 | [Client](14-client.md) | Using `@mgpl-diagnostics/client` against this API | +| 15 | [Glossary](15-glossary.md) | Lab, billing and accounting terms | +| — | [examples/](examples/) | Runnable `.graphql` operations | +| — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | + + +--- + +## File: kits.json + +```json +{ + "$comment": "Kit sources for `npm run kits` (CI and local builds until every kit is on GitHub Packages with Actions access for this repo). Order = build order. `ref` is a tag or commit on github.com/mgpl-diagnostics/.", + "kits": [ + { + "name": "catalog-kit", + "ref": "v1.0.0" + }, + { + "name": "auth-kit", + "ref": "v1.0.0" + }, + { + "name": "db-kit", + "ref": "v1.1.0" + }, + { + "name": "report-kit", + "ref": "v2.0.0" + }, + { + "name": "tally-kit", + "ref": "0629673c04e999ff4a3998d1fda8a9573ca3f68a" + }, + { + "name": "client", + "ref": "b26a341f91aef3665a646cc6efe0a748d50806f3" + }, + { + "name": "invoice-kit", + "ref": "v2.0.0" + }, + { + "name": "finance-kit", + "ref": "dbaafec32fcc2f9c34c1c8878f3fb2e14d2d6356" + } + ] +} +``` + + +--- + +## File: llms.txt + +# @mgpl-diagnostics/api + +> The MGPL Diagnostics backend (Node.js ≥ 20, TypeScript): one GraphQL endpoint, `POST /graphql` (GraphQL Yoga on Fastify), for sign-in, users and roles, the test catalog, patients, bookings, samples, results and reports, billing, double-entry books, GST returns, B2B receivables, partner settlements, cash and bank, lock dates, CSV exports, and rendered report/invoice documents (base64 PDF or HTML). The kits (auth, db, catalog, report, invoice, tally, finance) are internal libraries behind a controller → service layer. PostgreSQL or MongoDB; Redis optional. + +Key facts for agents: +- Layers: `src/schema` (SDL) → `src/controllers` (authorize, map) → `src/services` (kit calls) → kits. No REST routes except `GET /health`. +- Auth: `login` returns a bearer token (auth-kit session); every request re-reads the user. Permissions are `resource:action`, checked per branch with `authorize(ctx, perm, branchId)`. +- Tenant isolation: orgId and actorId come from the session, never from arguments; every id is checked against the organisation. +- Errors carry `extensions.code`: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, CONFLICT, PERIOD_LOCKED, CREDIT_LIMIT_EXCEEDED, plus auth-kit codes. Unknown errors are masked. +- Money is integer paise; days are IST "YYYY-MM-DD"; the financial year is April–March. Billing needs the branch's GST stateCode; GSTR-1/3B need the organisation's GSTIN. +- Frontends use `createMgplGraphQLClient` from `@mgpl-diagnostics/client`. +- Generated files (CI-checked): schema.graphql (`npm run schema`), docs/04-api-reference.md (`npm run docs:reference`), llms-full.txt (`npm run llms`). + +## Docs + +- [README](README.md): what it is, quick start +- [docs/README.md](docs/README.md): index of the A-to-Z docs (architecture, getting started, API reference, errors, auth, flows, documents, configuration, data and money, testing, deployment, adding a feature, client, glossary) +- [docs/04-api-reference.md](docs/04-api-reference.md): every operation with its permission +- [docs/LOCAL-AGENT-BRIEF.md](docs/LOCAL-AGENT-BRIEF.md): releases, org settings and deployment to do outside this repo +- [AGENTS.md](AGENTS.md): map and rules +- [schema.graphql](schema.graphql): the SDL +- [llms-full.txt](llms-full.txt): everything above plus the source + +## Source + +- [src/server.ts](src/server.ts): Fastify + Yoga, one endpoint +- [src/container.ts](src/container.ts): db, auth, services +- [src/graphql/](src/graphql/): context, guards, errors, scalars, loaders, schema assembly +- [src/schema/](src/schema/): SDL per module +- [src/controllers/](src/controllers/): resolvers per module +- [src/services/](src/services/): business logic on the kits +- [test/run.ts](test/run.ts): end-to-end suite + + +--- + +## File: package.json + +```json +{ + "name": "@mgpl-diagnostics/api", + "version": "0.1.0", + "private": true, + "description": "MGPL Diagnostics backend: one GraphQL endpoint (GraphQL Yoga on Fastify) over the kits (auth, db, catalog, report, invoice, finance, tally) used as internal libraries.", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "repository": "github:mgpl-diagnostics/api", + "main": "./dist/main.js", + "scripts": { + "kits": "node scripts/kits.mjs", + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js", + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "tsx test/run.ts", + "schema": "tsx scripts/print-schema.ts", + "seed": "tsx scripts/seed.ts", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/api README.md AGENTS.md llms.txt docs package.json kits.json schema.graphql src scripts test", + "docs:reference": "tsx scripts/api-reference.ts" + }, + "dependencies": { + "@graphql-tools/schema": "^10.1.4", + "@mgpl-diagnostics/auth-kit": "^1.0.0", + "@mgpl-diagnostics/catalog-kit": "^1.0.0", + "@mgpl-diagnostics/db-kit": "^1.1.0", + "@mgpl-diagnostics/finance-kit": "^1.0.0", + "@mgpl-diagnostics/invoice-kit": "^2.0.0", + "@mgpl-diagnostics/report-kit": "^2.0.0", + "@mgpl-diagnostics/tally-kit": "^0.1.0", + "fastify": "^5.12.7", + "graphql": "^16.14.0", + "graphql-yoga": "^5.24.5", + "ioredis": "^5.11.1", + "mongoose": "^9.11.2", + "pg": "^8.23.1", + "pg-hstore": "^2.3.4", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sequelize": "^6.37.8" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "embedded-postgres": "^18.4.0-beta.17", + "ioredis-mock": "^8.13.1", + "mongodb-memory-server": "^11.3.0", + "tsup": "^8.5.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} +``` + + +--- + +## File: schema.graphql + +```graphql +# Generated by `npm run schema` from src/schema. Do not edit by hand. + +type AccountGroup { + affectsGrossProfit: Boolean! + id: ID! + name: String! + nature: AccountNature! + parent: AccountGroup +} + +enum AccountNature { + assets + expenses + income + liabilities +} + +type Address { + city: String + district: String + line1: String + line2: String + pincode: String + state: String +} + +input AddressInput { + city: String + district: String + line1: String + line2: String + pincode: String + state: String +} + +enum AgeBucket { + D0_30 + D31_60 + D61_90 + D90_PLUS +} + +type Ageing { + d1to30: Int! + d31to60: Int! + d61to90: Int! + d90plus: Int! + notDue: Int! +} + +input AgreementInput { + categories: [ItemCategory!] + defaultSharePaise: Int + effectiveFrom: Day! + effectiveTo: Day + kind: AgreementKind! + notes: String + partnerId: ID! + rates: [ShareRateInput!] + shareBp: Int +} + +enum AgreementKind { + fixed_rate + revenue_share +} + +type AuditEntry { + action: String! + at: DateTime! + branchId: ID + data: JSON + entity: String + entityId: ID + id: ID! + ip: String + user: User +} + +type B2csRow { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + placeOfSupply: String! + ratePercent: Float! + sgstPaise: Int! + supplyType: String! + taxablePaise: Int! +} + +type BalanceSheet { + assets: [GroupLine!]! + differencePaise: Int! + liabilities: [GroupLine!]! + profitPaise: Int! + totalAssetsPaise: Int! + totalLiabilitiesPaise: Int! +} + +input BankImportInput { + branchId: ID! + + """The bank's CSV export, as text.""" + csv: String! + fileName: String + ledgerId: ID! +} + +type BankImportResult { + added: Int! + skipped: Int! + statement: BankStatement! +} + +type BankLine { + balancePaise: Int + creditPaise: Int! + date: DateTime! + debitPaise: Int! + description: String! + id: ID! + matchKind: String + matchedAt: DateTime + reference: String + settlement: BankSettlementMatch + statementId: ID! + status: BankLineStatus! + voucherIds: [ID!]! +} + +enum BankLineManualStatus { + ignored + unmatched +} + +enum BankLineStatus { + ignored + matched + unmatched +} + +type BankReconciliation { + bankCreditsNotInBooksPaise: Int! + bankDebitsNotInBooksPaise: Int! + bankPaise: Int + bookPaise: Int! + depositsNotCreditedPaise: Int! + differencePaise: Int + expectedBankPaise: Int! + paymentsNotPresentedPaise: Int! + reconciled: ReconciledTotals! + unmatchedLines: [BankLine!]! + unreconciledEntries: [LedgerEntryRow!]! +} + +type BankSettlementMatch { + day: Day! + feePaise: Int! + grossPaise: Int! + ledgerId: ID! +} + +type BankStatement { + branch: Branch! + closingPaise: Int + createdAt: DateTime! + fileName: String + from: DateTime + id: ID! + ledger: Ledger! + lineCount: Int! + openingPaise: Int + to: DateTime +} + +type Booking { + bookingNo: String! + branch: Branch! + createdAt: DateTime! + doctor: Doctor + id: ID! + invoice: Invoice + notes: String + patient: Patient! + reports: [Report!]! + sampleCollection: SampleCollection! + samples: [Sample!]! + status: BookingStatus! + tests: [BookingTest!]! +} + +enum BookingStatus { + booked + cancelled + completed + in_process + sample_collected +} + +type BookingTest { + """The catalog entry, when the code is in catalog-kit.""" + catalog: CatalogTest + name: String! + pricePaise: Int! + sampleType: String + testCode: String! +} + +""" +A test by catalog code: name, price and sample type come from catalog-kit unless given. +""" +input BookingTestInput { + name: String + pricePaise: Int + sampleType: String + testCode: String! +} + +type Branch { + active: Boolean! + address: Address + code: String! + gstin: String + id: ID! + kind: BranchKind! + name: String! + phone: String + stateCode: String +} + +input BranchInput { + active: Boolean + address: AddressInput + code: String! + gstin: String + kind: BranchKind! + name: String! + phone: String + stateCode: String +} + +enum BranchKind { + collection_center + lab +} + +input BranchPatch { + active: Boolean + address: AddressInput + gstin: String + kind: BranchKind + name: String + phone: String + stateCode: String +} + +type BucketTotals { + d0to30: Int! + d31to60: Int! + d61to90: Int! + d90plus: Int! +} + +type CashClosing { + bookPaise: Int! + branch: Branch! + countedPaise: Int! + createdAt: DateTime! + day: Day! + denominations: [CashDenomination!] + + """counted − book; negative = short.""" + differencePaise: Int! + id: ID! + notes: String + voucher: Voucher +} + +type CashDenomination { + count: Int! + valuePaise: Int! +} + +input CashDenominationInput { + count: Int! + valuePaise: Int! +} + +type CatalogHit { + score: Float! + test: CatalogTest! +} + +type CatalogInterval { + high: String + low: String +} + +type CatalogParameter { + calculated: Boolean! + female: CatalogInterval + + """Readable formula of a calculated parameter.""" + formulaText: String + male: CatalogInterval + method: String + name: String + options: [String!] + + """The printed range for a sex (or both).""" + range(sex: Sex): String! + referenceText: String + type: ParameterType! + unit: String +} + +type CatalogTest { + code: String + department: String! + id: Int! + isProfile: Boolean! + + """MRP in rupees.""" + mrp: Int + name: String! + parameterCount: Int! + parameters: [CatalogParameter!]! + + """MRP in paise (what bookings and invoices use).""" + pricePaise: Int + sampleType: String + secondarySampleTypes: [String!] + shortCode: String +} + +input CloseCashDayInput { + branchId: ID! + countedPaise: Int! + day: Day! + denominations: [CashDenominationInput!] + notes: String +} + +enum CollectionType { + home + walk_in +} + +input CreateBookingInput { + branchId: ID! + collectionType: CollectionType = walk_in + doctorId: ID + notes: String + patientId: ID! + scheduledAt: DateTime + tests: [BookingTestInput!]! +} + +type CreateBookingResult { + booking: Booking! + samples: [Sample!]! +} + +input CreateUserInput { + email: String! + name: String! + password: String! + phone: String + roles: [RoleAssignmentInput!]! +} + +type CreditNote { + buyerGstin: String + cgstPaise: Int! + customer: Customer + date: DateTime! + id: ID! + igstPaise: Int! + invoice: Invoice! + lines: [CreditNoteLine!]! + noteNo: String! + placeOfSupply: String + reason: String! + roundOffPaise: Int! + sgstPaise: Int! + status: String! + taxPaise: Int! + taxablePaise: Int! + totalPaise: Int! +} + +input CreditNoteInput { + date: DateTime + invoiceId: ID! + + """Default: everything not yet credited.""" + lines: [CreditNoteLineInput!] + reason: String! +} + +type CreditNoteLine { + cgstPaise: Int! + code: String + hsnSac: String + igstPaise: Int! + itemIndex: Int! + name: String! + sgstPaise: Int! + taxPaise: Int! + taxPercent: Float! + taxability: Taxability + taxablePaise: Int! +} + +input CreditNoteLineInput { + itemIndex: Int! + taxablePaise: Int +} + +type CreditNoteResult { + creditNote: CreditNote! + invoice: Invoice! + + """Refund with refundPayment.""" + refundablePaise: Int! +} + +type CsvFile { + content: String! + fileName: String! + mimeType: String! +} + +type Customer { + active: Boolean! + address: Address + code: String! + creditDays: Int! + + """null = no limit.""" + creditLimitPaise: Int + email: String + gstin: String + id: ID! + kind: CustomerKind! + ledger: Ledger! + legalName: String + name: String! + pan: String + phone: String + stateCode: String +} + +input CustomerInput { + address: AddressInput + code: String! + creditDays: Int + creditLimitPaise: Int + email: String + gstin: String + kind: CustomerKind! + legalName: String + name: String! + pan: String + phone: String + stateCode: String +} + +input CustomerInvoiceInput { + applyTaxRules: Boolean = true + billDiscountPaise: Int = 0 + bookingId: ID + branchId: ID! + customerId: ID! + items: [InvoiceItemInput!]! + patientId: ID! + payments: [PaymentInput!] + + """State code override.""" + placeOfSupply: String +} + +enum CustomerKind { + corporate + hospital + insurer + other + tpa +} + +input CustomerPatch { + active: Boolean + address: AddressInput + creditDays: Int + + """null removes the limit.""" + creditLimitPaise: Int + email: String + gstin: String + kind: CustomerKind + legalName: String + name: String + pan: String + phone: String + stateCode: String +} + +type CustomerStatement { + ageing: Ageing! + availableCreditPaise: Int + closingPaise: Int! + customer: Customer! + open: [OpenItem!]! + openTotalPaise: Int! + openingPaise: Int! + rows: [StatementRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +"""ISO-8601 date-time (UTC), e.g. 2026-10-14T09:30:00.000Z""" +scalar DateTime + +"""Calendar day in IST, YYYY-MM-DD""" +scalar Day + +type DocSeries { + cancelled: Int! + from: String! + nature: String! + net: Int! + series: String! + to: String! + total: Int! +} + +type Doctor { + active: Boolean! + clinic: String + id: ID! + name: String! + phone: String + qualification: String +} + +input DoctorInput { + active: Boolean = true + clinic: String + name: String! + phone: String + qualification: String +} + +input DoctorPatch { + active: Boolean + clinic: String + name: String + phone: String + qualification: String +} + +type Document { + """PDF bytes, base64 (null for HTML).""" + base64: String + byteLength: Int! + fileName: String! + format: DocumentFormat! + + """Self-contained HTML (null for PDF).""" + html: String + mimeType: String! +} + +enum DocumentFormat { + html + pdf +} + +type DocumentSample { + group: String + id: String! + label: String! +} + +type FinanceConfig { + b2clThresholdPaise: Int! + bankMaxFeeBp: Int! + bankToleranceDays: Int! + clearingLedgerKeys: [String!]! + tds: TdsConfig! +} + +type GroupLine { + amountPaise: Int! + group: AccountGroup! + ledgers: [LedgerAmount!]! +} + +type GstSummary { + cgstPaise: Int! + igstPaise: Int! + sgstPaise: Int! + taxableValuePaise: Int! + totalTaxPaise: Int! +} + +type Gstr1 { + b2b: [Gstr1Invoice!]! + b2cl: [Gstr1Invoice!]! + b2cs: [B2csRow!]! + cdnr: [Gstr1Note!]! + cdnur: [Gstr1Note!]! + docs: [DocSeries!]! + gstin: String! + hsn: HsnSummary! + nil: [NilRow!]! + period: Period! + totals: TaxTotals! + verifyWithCA: [String!]! +} + +type Gstr1Invoice { + ctin: String + day: Day! + invoiceId: ID! + invoiceNo: String! + items: [RateTotals!]! + placeOfSupply: String! + reverseCharge: String! + valuePaise: Int! +} + +type Gstr1Note { + ctin: String + day: Day! + invoiceDay: Day! + invoiceNo: String! + items: [RateTotals!]! + noteId: ID! + noteNo: String! + noteType: String! + placeOfSupply: String! + type: String + valuePaise: Int! +} + +type Gstr3b { + gstin: String! + + """3.2""" + interStateUnregistered: [InterStateRow!]! + + """3.1(c)""" + outwardNilExemptPaise: Int! + + """3.1(e)""" + outwardNonGstPaise: Int! + + """3.1(a)""" + outwardTaxable: TaxTotals! + period: Period! + verifyWithCA: [String!]! +} + +"""Service health: database reachable, versions.""" +type Health { + database: String! + ok: Boolean! + service: String! + time: DateTime! + version: String! +} + +type HsnRow { + cessPaise: Int! + cgstPaise: Int! + hsnSac: String! + igstPaise: Int! + qty: Float! + ratePercent: Float! + sgstPaise: Int! + taxability: Taxability! + taxablePaise: Int! + totalValuePaise: Int! +} + +type HsnSummary { + b2b: [HsnRow!]! + b2c: [HsnRow!]! +} + +type InterStateRow { + igstPaise: Int! + placeOfSupply: String! + taxablePaise: Int! +} + +type Invoice { + booking: Booking + branch: Branch! + buyerGstin: String + cancelReason: String + createdAt: DateTime! + creditNotes: [CreditNote!]! + creditedPaise: Int! + customer: Customer + discountPaise: Int! + duePaise: Int! + id: ID! + invoiceNo: String! + issuedAt: DateTime + items: [InvoiceItem!]! + paidPaise: Int! + patient: Patient! + payments: [Payment!]! + placeOfSupply: String + roundOffPaise: Int! + status: InvoiceStatus! + subtotalPaise: Int! + taxPaise: Int! + totalPaise: Int! + vouchers: [Voucher!]! +} + +type InvoiceItem { + amountPaise: Int! + category: ItemCategory! + code: String + discountPaise: Int! + hsnSac: String + name: String! + qty: Float! + ratePaise: Int! + taxPercent: Float! + taxability: Taxability +} + +""" +A catalog-kit test code alone is enough: name and MRP come from the catalog. +""" +input InvoiceItemInput { + category: ItemCategory = test + code: String + discountPaise: Int = 0 + hsnSac: String + name: String + qty: Float = 1 + ratePaise: Int + taxPercent: Float = 0 + taxability: Taxability +} + +enum InvoiceStatus { + cancelled + credited + draft + issued + paid + partially_paid + refunded +} + +input IssueInvoiceInput { + """Fill HSN/SAC, taxability and GST rate from the tax rules.""" + applyTaxRules: Boolean = false + billDiscountPaise: Int = 0 + bookingId: ID + branchId: ID! + items: [InvoiceItemInput!]! + patientId: ID! + + """Taken at the counter with the invoice.""" + payments: [PaymentInput!] +} + +enum ItemCategory { + consumable + medicine + other + package + service + test +} + +"""Any JSON value""" +scalar JSON + +type Ledger { + active: Boolean! + + """Closing balance, positive = Debit.""" + balancePaise(asOf: DateTime, branchId: ID): Int! + group: AccountGroup! + gstin: String + id: ID! + name: String! + openingPaise: Int! + systemKey: String +} + +type LedgerAmount { + amountPaise: Int! + ledger: Ledger! +} + +type LedgerEntryRow { + creditPaise: Int! + date: DateTime! + debitPaise: Int! + id: ID! + invoiceId: ID + voucherId: ID! + voucherNo: String! + voucherType: VoucherType! +} + +input LedgerInput { + groupId: ID! + gstin: String + name: String! + openingPaise: Int = 0 +} + +type LedgerStatement { + closingPaise: Int! + ledger: Ledger! + openingPaise: Int! + rows: [StatementRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +input LockDateInput { + """Omit for the whole organisation.""" + branchId: ID + reason: String + + """Last locked IST day; null removes the lock.""" + upTo: Day +} + +input LoginInput { + email: String! + + """Only when the same email exists in several organisations.""" + orgCode: String + password: String! +} + +type LoginResult { + expiresAt: DateTime! + + """ + Send as 'Authorization: Bearer '. Shown once; the server stores only its SHA-256. + """ + token: String! + user: User! +} + +type Mutation { + accrueSettlement(id: ID!): Settlement! + addAgreement(input: AgreementInput!): PartnerAgreement! + addTaxRule(input: TaxRuleInput!): TaxRule! + amendReport(id: ID!): Report! + cancelBooking(id: ID!): Booking! + cancelInvoice(id: ID!, reason: String!): Invoice! + cancelSettlement(id: ID!): Settlement! + cancelVoucher(id: ID!): Voucher! + changePassword(currentPassword: String!, newPassword: String!): Boolean! + closeCashDay(input: CloseCashDayInput!): CashClosing! + + """Closing vouchers on 31 March and a lock. fy: 2026-27.""" + closeFinancialYear(fy: String!): YearEndResult! + collectSample(id: ID!): Sample! + createBooking(input: CreateBookingInput!): CreateBookingResult! + createBranch(input: BranchInput!): Branch! + createCustomer(input: CustomerInput!): Customer! + createDoctor(input: DoctorInput!): Doctor! + createLedger(input: LedgerInput!): Ledger! + createPartner(input: PartnerInput!): Partner! + createReport(bookingId: ID!, testCode: String!, testName: String): Report! + createSettlement(deductions: [SettlementDeductionInput!], partnerId: ID!, period: String!): Settlement! + createUser(input: CreateUserInput!): User! + disableUser(userId: ID!): User! + enableUser(userId: ID!): User! + importBankStatement(input: BankImportInput!): BankImportResult! + issueCreditNote(input: CreditNoteInput!): CreditNoteResult! + issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice! + issueInvoice(input: IssueInvoiceInput!): Invoice! + login(input: LoginInput!): LoginResult! + + """Ends this session.""" + logout: Boolean! + + """Ends every other session of the signed-in user; returns how many.""" + logoutOtherSessions: Int! + matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine! + paySettlement(input: PaySettlementInput!): Settlement! + + """Does nothing; lets clients check that mutations reach the server.""" + ping: String! + postVoucher(input: PostVoucherInput!): Voucher! + receiveSample(id: ID!): Sample! + reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult! + recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + registerPatient(input: RegisterPatientInput!): Patient! + rejectSample(id: ID!, reason: String!): Sample! + releaseReport(id: ID!): Report! + + """ + Always true (never reveals whether the email exists). The link is sent by the server's mailer. + """ + requestPasswordReset(email: String!, orgCode: String): Boolean! + resetPassword(newPassword: String!, token: String!): Boolean! + revokeSession(id: ID!): Boolean! + saveResults(reportId: ID!, results: [ResultInput!]!): Report! + + """ + finance-kit's starting rules. Every rate must be verified with the lab's CA. + """ + seedTaxRules: [TaxRule!]! + setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine! + setLockDate(input: LockDateInput!): PeriodLock! + setUserPassword(newPassword: String!, userId: ID!): Boolean! + setUserRoles(roles: [RoleAssignmentInput!]!, userId: ID!): User! + + """ + Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent. + """ + setupChartOfAccounts: [Ledger!]! + updateBranch(id: ID!, input: BranchPatch!): Branch! + updateCustomer(id: ID!, input: CustomerPatch!): Customer! + updateDoctor(id: ID!, input: DoctorPatch!): Doctor! + updateOrganization(input: OrganizationInput!): Organization! + updatePartner(id: ID!, input: PartnerPatch!): Partner! + updatePatient(id: ID!, input: UpdatePatientInput!): Patient! + upsertRole(input: RoleInput!): Role! + verifyReport(id: ID!): Report! +} + +type NilRow { + description: String! + exemptedPaise: Int! + nilRatedPaise: Int! + nonGstPaise: Int! +} + +type OpenItem { + bucket: OpenItemBucket! + creditedPaise: Int! + day: Day! + dueDay: Day! + duePaise: Int! + invoiceId: ID! + invoiceNo: String! + overdueDays: Int! + paidPaise: Int! + totalPaise: Int! +} + +enum OpenItemBucket { + D1_30 + D31_60 + D61_90 + D90_PLUS + not_due +} + +type Organization { + address: Address + branches: [Branch!]! + code: String! + email: String + gstin: String + id: ID! + legalName: String + name: String! + phone: String +} + +input OrganizationInput { + address: AddressInput + email: String + gstin: String + legalName: String + name: String + phone: String +} + +type Outstanding { + buckets: BucketTotals! + rows: [OutstandingRow!]! + totalDuePaise: Int! +} + +type OutstandingRow { + ageDays: Int! + bucket: AgeBucket! + invoice: Invoice! +} + +enum ParameterType { + image + numeric + options + text + title +} + +type Partner { + active: Boolean! + address: Address + agreements: [PartnerAgreement!]! + branch: Branch! + code: String! + email: String + gstin: String + id: ID! + kind: PartnerKind! + ledger: Ledger! + legalName: String + name: String! + pan: String + phone: String + stateCode: String + + """Overrides the default TDS (verify with CA).""" + tds: PartnerTds +} + +type PartnerAgreement { + categories: [ItemCategory!] + defaultSharePaise: Int + effectiveFrom: DateTime! + effectiveTo: DateTime + id: ID! + kind: AgreementKind! + notes: String + rates: [ShareRate!] + shareBp: Int +} + +input PartnerInput { + address: AddressInput + branchId: ID! + code: String! + email: String + gstin: String + kind: PartnerKind! + legalName: String + name: String! + pan: String + phone: String + stateCode: String + tds: PartnerTdsInput +} + +enum PartnerKind { + collection_centre + franchise +} + +input PartnerPatch { + active: Boolean + address: AddressInput + email: String + gstin: String + kind: PartnerKind + legalName: String + name: String + pan: String + phone: String + stateCode: String + + """null = default TDS.""" + tds: PartnerTdsInput +} + +type PartnerTds { + rateBp: Int! + section: String! +} + +input PartnerTdsInput { + rateBp: Int! + section: String! +} + +type Patient { + address: Address + ageYears: Int + bookings(limit: Int = 20): [Booking!]! + branch: Branch + createdAt: DateTime! + dob: DateTime + email: String + id: ID! + invoices(limit: Int = 20): [Invoice!]! + name: String! + phone: String + sex: Sex! + title: String + uhid: String! +} + +input PaySettlementInput { + date: DateTime + mode: PaymentMode! + reference: String + settlementId: ID! +} + +type Payment { + amountPaise: Int! + id: ID! + kind: PaymentKind! + mode: PaymentMode! + receivedAt: DateTime! + reference: String +} + +input PaymentInput { + amountPaise: Int! + mode: PaymentMode! + reference: String +} + +enum PaymentKind { + payment + refund +} + +enum PaymentMode { + card + cash + cheque + insurance + netbanking + upi + wallet +} + +type Period { + from: DateTime! + to: DateTime! +} + +type PeriodLock { + branch: Branch + id: ID! + lockedUpTo: DateTime + reason: String + scope: String! + updatedAt: DateTime! +} + +input PostVoucherInput { + branchId: ID! + date: DateTime + entries: [VoucherEntryInput!]! + narration: String + type: VoucherType! +} + +type PriceListEntry { + category: String! + code: String! + hsn: String + name: String! + + """rupees""" + rate: Float! + taxPercent: Float +} + +type PriceQuote { + tests: [CatalogTest!]! + + """Rupees.""" + total: Int! + totalPaise: Int! + unknown: [String!]! +} + +type ProfitAndLoss { + expenses: [GroupLine!]! + grossProfitPaise: Int! + income: [GroupLine!]! + netProfitPaise: Int! +} + +type Query { + accountGroups: [AccountGroup!]! + auditLog(entity: String, entityId: ID, limit: Int = 50, offset: Int = 0): [AuditEntry!]! + balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! + bankLines(ledgerId: ID, limit: Int = 100, offset: Int = 0, statementId: ID, status: BankLineStatus): [BankLine!]! + bankReconciliation(asOf: DateTime!, ledgerId: ID!): BankReconciliation! + bankStatements(ledgerId: ID, limit: Int = 50, offset: Int = 0): [BankStatement!]! + booking(id: ID!): Booking + + """ + A booking's verified or released reports, printed together (or only reportIds). + """ + bookingReportDocument(bookingId: ID!, design: ReportDesign = sidebar, format: DocumentFormat = pdf, reportIds: [ID!], variant: ReportVariant = header): Document! + bookings(branchId: ID, limit: Int = 50, offset: Int = 0, patientId: ID, status: BookingStatus): [Booking!]! + branch(id: ID!): Branch + branches: [Branch!]! + cashBook(branchId: ID, from: DateTime!, to: DateTime!): LedgerStatement! + cashClosings(branchId: ID!, limit: Int = 31, offset: Int = 0): [CashClosing!]! + catalogDepartments: [String!]! + catalogSampleTypes: [String!]! + + """Typo-tolerant search with lab shorthand (cbc, lft, vit d…).""" + catalogSearch(department: String, fuzzy: Boolean = true, limit: Int = 20, query: String!, sampleType: String): [CatalogHit!]! + catalogTest(code: String, id: Int): CatalogTest + catalogTests(department: String, limit: Int = 50, offset: Int = 0, sampleType: String): [CatalogTest!]! + creditNote(id: ID!): CreditNote + creditNotes(customerId: ID, invoiceId: ID, limit: Int = 50, offset: Int = 0): [CreditNote!]! + customer(id: ID!): Customer + customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement! + customers(activeOnly: Boolean = true): [Customer!]! + dayBook(branchId: ID, from: DateTime!, to: DateTime!, type: VoucherType): [Voucher!]! + doctors(activeOnly: Boolean = true): [Doctor!]! + + """Demo samples (empty unless the server enables them).""" + documentSamples: [DocumentSample!]! + exportBalanceSheet(asOf: DateTime!, bom: Boolean = false, branchId: ID): CsvFile! + exportDayBook(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile! + + """One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs.""" + exportGstr1(bom: Boolean = false, from: DateTime!, gstin: String, to: DateTime!): [CsvFile!]! + exportGstr3b(bom: Boolean = false, from: DateTime!, gstin: String, to: DateTime!): CsvFile! + exportLedger(bom: Boolean = false, branchId: ID, from: DateTime!, ledgerId: ID!, to: DateTime!): CsvFile! + exportProfitAndLoss(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile! + exportSettlement(bom: Boolean = false, settlementId: ID!): CsvFile! + exportTrialBalance(asOf: DateTime, bom: Boolean = false, branchId: ID): CsvFile! + financeConfig: FinanceConfig! + gstSummary(branchId: ID, from: DateTime!, to: DateTime!): GstSummary! + gstr1(from: DateTime!, gstin: String, to: DateTime!): Gstr1! + gstr3b(from: DateTime!, gstin: String, to: DateTime!): Gstr3b! + health: Health! + invoice(id: ID!): Invoice + + """A stored invoice, printed.""" + invoiceDocument(format: DocumentFormat = pdf, invoiceId: ID!, variant: ReportVariant = header): Document! + invoiceSamples: [DocumentSample!]! + + """ + invoice-kit's calculation of an InvoiceRequest, without rendering (rupees). + """ + invoiceTotals(request: JSON!): JSON! + invoices(branchId: ID, customerId: ID, limit: Int = 50, offset: Int = 0, patientId: ID, status: InvoiceStatus): [Invoice!]! + ledger(id: ID!): Ledger + ledgerStatement(branchId: ID, from: DateTime!, ledgerId: ID!, to: DateTime!): LedgerStatement! + ledgers: [Ledger!]! + letterhead(format: DocumentFormat = pdf): Document! + lockDates: [PeriodLock!]! + mySessions: [Session!]! + organization: Organization! + outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! + partner(id: ID!): Partner + partners(branchId: ID): [Partner!]! + patient(id: ID!): Patient + + """Name, phone or UHID.""" + patients(limit: Int = 20, search: String!): [Patient!]! + + """Every permission name the API checks.""" + permissions: [String!]! + + """ + invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables). + """ + priceList: [PriceListEntry!]! + + """Total MRP for test codes (or numeric ids as strings).""" + priceQuote(tests: [String!]!): PriceQuote! + profitAndLoss(branchId: ID, from: DateTime!, to: DateTime!): ProfitAndLoss! + receivablesSummary(asOf: DateTime!): ReceivablesSummary! + + """ + Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample }). + """ + renderInvoice(fileName: String, format: DocumentFormat = pdf, request: JSON!, variant: ReportVariant = header): Document! + + """ + Any report: request = report-kit's ReportRequest JSON (patient + tests | profile | report | sample). + """ + renderReport(design: ReportDesign = sidebar, fileName: String, format: DocumentFormat = pdf, request: JSON!, variant: ReportVariant = header): Document! + report(id: ID!): Report + reportTemplates: TemplateCatalog! + roles: [Role!]! + sample(id: ID!): Sample + sampleByBarcode(barcode: String!): Sample + settlement(id: ID!): Settlement + + """Computes a month's settlement without saving it. period: YYYY-MM.""" + settlementPreview(deductions: [SettlementDeductionInput!], partnerId: ID!, period: String!): SettlementPreview! + settlements(branchId: ID, limit: Int = 50, offset: Int = 0, partnerId: ID, status: SettlementStatus): [Settlement!]! + taxRules: [TaxRule!]! + trialBalance(asOf: DateTime, branchId: ID): TrialBalance! + user(id: ID!): User + users: [User!]! + + """Items the lab's CA must confirm before these values are relied on.""" + verifyWithCA: [String!]! + + """null when not signed in.""" + viewer: Viewer + voucher(id: ID!): Voucher +} + +type RateTotals { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + ratePercent: Float! + sgstPaise: Int! + taxablePaise: Int! +} + +type ReceivablesRow { + ageing: Ageing! + balancePaise: Int! + customer: Customer! + overduePaise: Int! +} + +type ReceivablesSummary { + rows: [ReceivablesRow!]! + totalPaise: Int! +} + +type ReconcileResult { + matched: Int! + settlements: Int! + unmatched: Int! +} + +type ReconciledTotals { + amountPaise: Int! + lines: Int! +} + +input RegisterPatientInput { + address: AddressInput + ageYears: Int + branchId: ID! + dob: DateTime + email: String + name: String! + phone: String + sex: Sex! + title: String +} + +type Report { + booking: Booking! + id: ID! + patient: Patient! + releasedAt: DateTime + results: [ResultValue!]! + status: ReportStatus! + testCode: String! + testName: String! + updatedAt: DateTime! + verifiedAt: DateTime + version: Int! +} + +enum ReportDesign { + classic + sidebar +} + +type ReportProfile { + id: String! + name: String! + tests: [String!]! +} + +enum ReportStatus { + amended + draft + entered + released + verified +} + +type ReportTemplate { + category: String + fields: [TemplateField!]! + id: String! + method: String + name: String! + specimen: String +} + +enum ReportVariant { + header + non_header +} + +enum ResultFlag { + A + H + L + NONE +} + +""" +Unit, range and flag are filled from catalog-kit when omitted and the test is in the catalog. +""" +input ResultInput { + flag: ResultFlag + parameter: String! + range: String + unit: String + value: String! +} + +type ResultValue { + flag: ResultFlag + parameter: String! + range: String + unit: String + value: String! +} + +type Role { + id: ID! + key: String! + name: String! + permissions: [String!]! + system: Boolean! +} + +type RoleAssignment { + branch: Branch + branchId: ID + role: String! +} + +input RoleAssignmentInput { + branchId: ID + role: String! +} + +input RoleInput { + key: String! + name: String! + permissions: [String!]! +} + +type Sample { + barcode: String! + booking: Booking! + collectedAt: DateTime + id: ID! + receivedAt: DateTime + rejectReason: String + sampleType: String! + status: SampleStatus! +} + +type SampleCollection { + address: Address + scheduledAt: DateTime + type: CollectionType! +} + +enum SampleStatus { + collected + pending + received + rejected +} + +type Session { + createdAt: DateTime! + current: Boolean! + expiresAt: DateTime! + id: ID! + ip: String + lastSeenAt: DateTime! + userAgent: String +} + +type Settlement { + accrualVoucher: Voucher + branch: Branch! + createdAt: DateTime! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + from: DateTime! + grossPaise: Int! + id: ID! + lines: [SettlementLine!]! + netPaise: Int! + paidAt: DateTime + partner: Partner! + payoutVoucher: Voucher + period: String! + sharePaise: Int! + status: SettlementStatus! + tdsBasePaise: Int! + tdsPaise: Int! + tdsRateBp: Int! + tdsSection: String + to: DateTime! +} + +type SettlementDeduction { + amountPaise: Int! + label: String! + ledgerId: ID +} + +input SettlementDeductionInput { + amountPaise: Int! + label: String! + ledgerId: ID +} + +type SettlementLine { + agreementId: ID! + code: String + creditNoteId: ID + day: Day! + invoiceId: ID! + invoiceNo: String! + name: String! + netPaise: Int! + sharePaise: Int! +} + +"""A settlement computed but not saved.""" +type SettlementPreview { + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + from: DateTime! + grossPaise: Int! + lines: [SettlementLine!]! + netPaise: Int! + partner: Partner! + period: String! + sharePaise: Int! + tds: Tds! + to: DateTime! + + """Invoice numbers with no agreement in force on their date.""" + uncoveredInvoices: [String!]! +} + +enum SettlementStatus { + accrued + cancelled + draft + paid +} + +enum Sex { + female + male + other +} + +type ShareRate { + code: String! + sharePaise: Int! +} + +input ShareRateInput { + code: String! + sharePaise: Int! +} + +type StatementRow { + balancePaise: Int! + creditPaise: Int! + date: DateTime! + debitPaise: Int! + invoiceId: ID + voucherNo: String! + voucherType: VoucherType! +} + +type TaxRule { + category: ItemCategory + code: String + effectiveFrom: DateTime! + effectiveTo: DateTime + hsnSac: String! + id: ID! + note: String + ratePercent: Float! + taxability: Taxability! +} + +input TaxRuleInput { + category: ItemCategory + code: String + effectiveFrom: Day! + hsnSac: String! + note: String + ratePercent: Float! + taxability: Taxability! +} + +type TaxTotals { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + sgstPaise: Int! + taxablePaise: Int! +} + +enum Taxability { + exempt + nil_rated + non_gst + taxable +} + +type Tds { + basePaise: Int! + rateBp: Int! + section: String + tdsPaise: Int! +} + +type TdsConfig { + noPanRateBp: Int! + rateBp: Int! + section: String! + thresholdPaise: Int +} + +type TemplateCatalog { + profiles: [ReportProfile!]! + templates: [ReportTemplate!]! +} + +type TemplateField { + key: String! + label: String! + range: [String!] + unit: String +} + +type TrialBalance { + differencePaise: Int! + rows: [TrialBalanceRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +type TrialBalanceRow { + creditPaise: Int! + debitPaise: Int! + group: AccountGroup! + ledger: Ledger! + primaryGroup: AccountGroup! +} + +input UpdatePatientInput { + address: AddressInput + ageYears: Int + dob: DateTime + email: String + name: String + phone: String + sex: Sex + title: String +} + +type User { + createdAt: DateTime! + email: String! + id: ID! + lastLoginAt: DateTime + name: String! + phone: String + roles: [RoleAssignment!]! + status: UserStatus! +} + +enum UserStatus { + active + disabled + invited +} + +"""The signed-in user and what they may do.""" +type Viewer { + """Branches where the user has this permission; null means every branch.""" + branchesFor(permission: String!): [ID!] + can(branchId: ID, permission: String!): Boolean! + organization: Organization! + + """ + Permissions held in this branch (or organisation-wide when branchId is omitted). + """ + permissions(branchId: ID): [String!]! + user: User! +} + +type Voucher { + branch: Branch! + date: DateTime! + entries: [VoucherEntry!]! + id: ID! + invoiceId: ID + narration: String + status: VoucherStatus! + type: VoucherType! + voucherNo: String! +} + +type VoucherEntry { + creditPaise: Int! + debitPaise: Int! + invoiceId: ID + ledger: Ledger! +} + +input VoucherEntryInput { + creditPaise: Int = 0 + debitPaise: Int = 0 + invoiceId: ID + ledgerId: ID! +} + +enum VoucherStatus { + cancelled + posted +} + +enum VoucherType { + closing + contra + credit_note + debit_note + journal + payment + purchase + receipt + sales +} + +type YearEndResult { + netProfitPaise: Int! + vouchers: [Voucher!]! +} +``` + + +--- + +## File: scripts/api-reference.ts + +```ts +// Writes docs/04-api-reference.md from the schema (operations, arguments, return types, descriptions) +// and the controllers (the permission each resolver checks). Run `npm run docs:reference`; CI fails when it is stale. +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { GraphQLObjectType, type GraphQLField } from "graphql"; +import { buildSchema, MODULES } from "../src/graphql/schema"; + +const root = new URL("..", import.meta.url).pathname; +const schema = buildSchema(); + +/** field → permissions found in its resolver body, per root type. */ +function permissionsByField(): Record<"Query" | "Mutation", Map>> { + const out = { Query: new Map>(), Mutation: new Map>() }; + for (const file of readdirSync(`${root}src/controllers`)) { + const src = readFileSync(`${root}src/controllers/${file}`, "utf8"); + // helpers like `reportFor(ctx, id, "report:read")` pass the permission literally, so a literal scan is enough + for (const kind of ["Query", "Mutation"] as const) { + const start = src.indexOf(`\n ${kind}: {`); + if (start < 0) continue; + const end = src.indexOf("\n },", start); + const block = src.slice(start, end); + const keys = [...block.matchAll(/^ {4}(?:async )?(\w+)(?=\s*[:(])/gm)]; + keys.forEach((k, i) => { + const body = block.slice(k.index!, keys[i + 1]?.index ?? block.length); + const perms = new Set([...body.matchAll(/"([a-z]+:[a-z]+)"/g)].map((m) => m[1]!)); + const signedIn = /actorOf|authorize|viewerOrThrow|requireViewer/.test(body); + if (!perms.size && signedIn) perms.add("(signed in)"); + out[kind].set(k[1]!, perms); + }); + } + } + return out; +} + +const perms = permissionsByField(); +const moduleOf = new Map(); +for (const { name, typeDefs } of MODULES) { + const sdl = String(typeDefs).replace(/"""[\s\S]*?"""/g, ""); + for (const kind of ["Query", "Mutation"]) { + for (const m of sdl.matchAll(new RegExp(`type ${kind} \\{([\\s\\S]*?)\\n\\s*\\}`, "g"))) + for (const f of m[1]!.matchAll(/^\s+(\w+)\s*[(:]/gm)) moduleOf.set(`${kind}.${f[1]}`, name); + } +} + +const sig = (f: GraphQLField) => + `${f.name}${f.args.length ? `(${f.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""}: ${f.type}`; +const one = (s?: string | null) => (s ?? "").replace(/\s+/g, " ").replace(/\|/g, "\\|").trim(); + +const lines: string[] = [ + "# 04 · API reference", + "", + "", + "", + "Every operation of `POST /graphql`, grouped by module, with the permission its resolver checks.", + "**Permission** lists what `authorize()` requires; a branch-scoped permission is checked against the", + "record's branch (see [06-auth-and-permissions.md](06-auth-and-permissions.md)). `public` means no", + "sign-in is needed; `(signed in)` means any signed-in user. The full SDL is [schema.graphql](../schema.graphql),", + "and runnable examples are in [examples/](examples/).", + "", +]; +for (const { name } of MODULES) { + const rows: string[] = []; + for (const kind of ["Query", "Mutation"] as const) { + const type = (kind === "Query" ? schema.getQueryType() : schema.getMutationType()) as GraphQLObjectType; + for (const f of Object.values(type.getFields()).sort((a, b) => a.name.localeCompare(b.name))) { + if (moduleOf.get(`${kind}.${f.name}`) !== name) continue; + const p = perms[kind].get(f.name); + const perm = p && p.size ? [...p].map((x) => (x.startsWith("(") ? x : `\`${x}\``)).join(", ") : "public"; + rows.push(`| ${kind === "Query" ? "query" : "mutation"} | \`${sig(f)}\` | ${perm} | ${one(f.description)} |`); + } + } + if (!rows.length) continue; + lines.push(`## ${name}`, "", "| Kind | Operation | Permission | Notes |", "|---|---|---|---|", ...rows, ""); +} +writeFileSync(`${root}docs/04-api-reference.md`, lines.join("\n")); +console.log(`docs/04-api-reference.md: ${lines.filter((l) => l.startsWith("| q") || l.startsWith("| m")).length} operations`); +``` + + +--- + +## File: scripts/kits.mjs + +```js +// Builds every @mgpl-diagnostics kit from source into .kits/*.tgz and installs them with this repo's +// dependencies. Needed because not every kit is on GitHub Packages yet (finance-kit), and this repo's +// CI token has no package access yet. See docs/12-local-agent-brief.md for the switch to the registry. +// +// npm run kits # clone each kit at the ref in kits.json (public repos, no token) +// KITS_SRC=.. npm run kits # use local checkouts next to this repo instead (their working tree) +// npm run kits -- --no-install # only build the tarballs +// +// Each kit is copied to .kits/src/, its @mgpl-diagnostics dependencies are pointed at the +// tarballs built before it, it is built with tsup and packed with its ORIGINAL package.json. +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const out = join(root, ".kits"); +const { kits } = JSON.parse(readFileSync(join(root, "kits.json"), "utf8")); +const local = process.env.KITS_SRC ? resolve(root, process.env.KITS_SRC) : null; +const install = !process.argv.includes("--no-install"); +const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, npm_config_audit: "false", npm_config_fund: "false" } }); +const tgz = (name) => join(out, `${name}.tgz`); + +rmSync(out, { recursive: true, force: true }); +mkdirSync(join(out, "src"), { recursive: true }); + +for (const { name, ref } of kits) { + const dir = join(out, "src", name); + console.log(`\n▶ ${name} (${local ? `local ${join(local, name)}` : ref})`); + if (local) { + cpSync(join(local, name), dir, { recursive: true, filter: (p) => !/[\\/](node_modules|dist|\.git)$/.test(p) }); + } else { + mkdirSync(dir); + run("git", ["init", "-q"], dir); + run("git", ["fetch", "-q", "--depth", "1", `https://github.com/mgpl-diagnostics/${name}.git`, ref], dir); + run("git", ["checkout", "-q", "FETCH_HEAD"], dir); + } + const pkgFile = join(dir, "package.json"); + const original = readFileSync(pkgFile, "utf8"); + const pkg = JSON.parse(original); + // build-time view of the package: our kits come from the tarballs built so far + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + for (const dep of Object.keys(pkg[field] ?? {})) { + if (!dep.startsWith("@mgpl-diagnostics/")) continue; + const file = tgz(dep.split("/")[1]); + if (!existsSync(file)) throw new Error(`${name} needs ${dep}; list it earlier in kits.json`); + pkg[field][dep] = `file:${file}`; // same field, so tsup still treats peers as external + } + } + writeFileSync(pkgFile, JSON.stringify(pkg, null, 2)); + run("npm", ["install", "--no-package-lock", "--ignore-scripts", "--loglevel=error"], dir); + run("npm", ["run", "prepare"], dir); + writeFileSync(pkgFile, original); + run("npm", ["pack", "--ignore-scripts", "--silent", "--pack-destination", out], dir); + const packed = readdirSync(out).find((f) => f.startsWith(`mgpl-diagnostics-${name}-`) && f.endsWith(".tgz")); + if (!packed) throw new Error(`npm pack produced no tarball for ${name}`); + cpSync(join(out, packed), tgz(name)); + rmSync(join(out, packed)); +} + +if (install) { + console.log("\n▶ installing this repo's dependencies with the kits"); + run("npm", ["install", "--no-package-lock", "--no-save", "--loglevel=error", ...kits.map((k) => tgz(k.name))], root); +} +console.log("\n✓ kits ready"); +``` + + +--- + +## File: scripts/llms-full.mjs + +````js +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`); +```` + + +--- + +## File: scripts/print-schema.ts + +```ts +// Writes schema.graphql (the whole API in SDL) for clients, codegen and review. CI checks it is current. +import { writeFileSync } from "node:fs"; +import { lexicographicSortSchema, printSchema } from "graphql"; +import { buildSchema } from "../src/graphql/schema"; + +const sdl = `# Generated by \`npm run schema\` from src/schema. Do not edit by hand.\n\n${printSchema(lexicographicSortSchema(buildSchema()))}\n`; +writeFileSync("schema.graphql", sdl); +console.log(`schema.graphql: ${sdl.split("\n").length} lines`); +``` + + +--- + +## File: scripts/seed.ts + +```ts +// Creates the first organisation, its first branch, the owner account, auth-kit's system roles and +// the chart of accounts. Run once per new database: +// SEED_ORG_NAME="Maa Gayatri Path Lab" SEED_ORG_CODE=MGPL SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang \ +// SEED_BRANCH_STATE=22 SEED_OWNER_NAME="…" SEED_OWNER_EMAIL=… SEED_OWNER_PASSWORD=… npm run seed +// The password is read from the environment and never printed. Change it after the first sign-in. +import { assertPasswordPolicy, hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; +import { createDb, seedOrganization } from "@mgpl-diagnostics/db-kit"; +import { financePosting, seedFinanceLedgers } from "@mgpl-diagnostics/finance-kit"; +import { loadConfig } from "../src/config/env"; + +const need = (k: string) => { + const v = process.env[k]?.trim(); + if (!v) throw new Error(`Set ${k}`); + return v; +}; + +const config = loadConfig(); +const org = { name: need("SEED_ORG_NAME"), code: need("SEED_ORG_CODE").toUpperCase(), gstin: process.env.SEED_ORG_GSTIN || undefined }; +const branch = { code: need("SEED_BRANCH_CODE").toUpperCase(), name: need("SEED_BRANCH_NAME"), stateCode: need("SEED_BRANCH_STATE") }; +const owner = { name: need("SEED_OWNER_NAME"), email: need("SEED_OWNER_EMAIL"), password: need("SEED_OWNER_PASSWORD") }; +assertPasswordPolicy(owner.password, { email: owner.email, name: owner.name }); + +const db = await createDb({ + backend: config.db.backend, + ...(config.db.backend === "pg" ? { pg: { url: config.db.url } } : { mongo: { uri: config.db.url } }), + posting: financePosting, +} as Parameters<typeof createDb>[0]); +try { + if (await db.rawRepos.orgs.findOne({ code: org.code })) throw new Error(`Organisation ${org.code} already exists`); + const seeded = await seedOrganization(db.rawRepos, { + org, + branch, + owner: { name: owner.name, email: owner.email, passwordHash: await hashPassword(owner.password) }, + roles: SYSTEM_ROLES, + }); + const ledgers = await seedFinanceLedgers(db.rawRepos, seeded.org.id); + console.log(`Organisation ${seeded.org.code} (${seeded.org.id}), branch ${seeded.branch.code}, owner ${seeded.owner.email}, ${ledgers.length} ledgers.`); +} finally { + await db.close(); +} +``` + + +--- + +## File: src/config/env.ts + +```ts +// Runtime configuration, read once from the environment. Every value has a safe development default; +// production sets them explicitly (see docs/09-configuration.md). + +export type DbBackend = "pg" | "mongo"; + +export interface Config { + env: "development" | "production" | "test"; + host: string; + port: number; + /** URL path of the one GraphQL endpoint. */ + graphqlPath: string; + db: { backend: DbBackend; url: string }; + /** Sessions, reset tokens, rate limits and the read cache. Without it they live in process memory (one server only). */ + redisUrl: string | null; + /** Origins allowed to call the API from a browser; "*" only in development. */ + corsOrigins: string[]; + /** GraphiQL in the browser at the GraphQL path (development only by default). */ + graphiql: boolean; + /** Enables introspection (schema download). Development only by default. */ + introspection: boolean; + /** Largest request body, bytes. */ + bodyLimit: number; + /** Behind a proxy (nginx, a load balancer): read the client IP from X-Forwarded-For. */ + trustProxy: boolean; + /** Demo report and invoice samples (report-kit / invoice-kit) in `documents`. */ + samples: boolean; + /** Session lifetime knobs, passed to auth-kit. */ + session: { idleMs: number; maxAgeMs: number }; + /** Password-reset links point here: `${resetUrl}?token=…`. */ + resetUrl: string | null; +} + +const bool = (v: string | undefined, d: boolean) => (v == null || v === "" ? d : ["1", "true", "yes", "on"].includes(v.toLowerCase())); +const int = (v: string | undefined, d: number) => { + if (v == null || v === "") return d; + const n = Number(v); + if (!Number.isInteger(n) || n < 0) throw new Error(`Expected a whole number, got "${v}"`); + return n; +}; + +export function loadConfig(env: Record<string, string | undefined> = process.env): Config { + const mode = env.NODE_ENV === "production" ? "production" : env.NODE_ENV === "test" ? "test" : "development"; + const dev = mode !== "production"; + const backend = (env.DB_BACKEND ?? "pg") as DbBackend; + if (backend !== "pg" && backend !== "mongo") throw new Error(`DB_BACKEND must be "pg" or "mongo", got "${backend}"`); + const url = env.DATABASE_URL ?? (dev ? (backend === "pg" ? "postgres://postgres:postgres@localhost:5432/mgpl" : "mongodb://localhost:27017/mgpl?replicaSet=rs0") : ""); + if (!url) throw new Error("DATABASE_URL is required in production"); + const corsOrigins = (env.CORS_ORIGINS ?? (dev ? "*" : "")).split(",").map((s) => s.trim()).filter(Boolean); + if (!dev && corsOrigins.includes("*")) throw new Error('CORS_ORIGINS cannot be "*" in production'); + return { + env: mode, + host: env.HOST ?? "0.0.0.0", + port: int(env.PORT, 4000), + graphqlPath: env.GRAPHQL_PATH ?? "/graphql", + db: { backend, url }, + redisUrl: env.REDIS_URL || null, + corsOrigins, + graphiql: bool(env.GRAPHIQL, dev), + introspection: bool(env.INTROSPECTION, dev), + bodyLimit: int(env.BODY_LIMIT_BYTES, 2_000_000), + trustProxy: bool(env.TRUST_PROXY, false), + samples: bool(env.MGPL_SAMPLES, dev), + session: { idleMs: int(env.SESSION_IDLE_MS, 12 * 3600_000), maxAgeMs: int(env.SESSION_MAX_AGE_MS, 30 * 86400_000) }, + resetUrl: env.PASSWORD_RESET_URL || null, + }; +} +``` + + +--- + +## File: src/container.ts + +```ts +import { createAuth, memoryKeyValueStore, memoryRateLimiter, memorySessionStore, type Auth } from "@mgpl-diagnostics/auth-kit"; +import { createDb, type Db } from "@mgpl-diagnostics/db-kit"; +import { financePosting } from "@mgpl-diagnostics/finance-kit"; +import type { LabProfile } from "@mgpl-diagnostics/report-kit"; +import type { Config } from "./config/env"; +import { createServices, type Mailer, type Services } from "./services"; + +export interface ContainerOptions { + config: Config; + /** Inject a database (tests). Default: createDb from config, posting through finance-kit. */ + db?: Db; + /** Inject auth (tests). Default: auth-kit on db-kit's users/orgs/roles/audit, sessions in Redis or memory. */ + auth?: Auth; + mailer?: Mailer; + /** Lab branding and signatures for documents. Default: report-kit's defaultLab() (MGPL_SIGNATURE_* files). */ + lab?: LabProfile; +} + +/** The composition root: one database, one auth, one set of services, shared by every request. */ +export interface Container { + config: Config; + db: Db; + auth: Auth; + services: Services; + close(): Promise<void>; +} + +export async function createContainer(o: ContainerOptions): Promise<Container> { + const { config } = o; + const ownsDb = !o.db; + const db = + o.db ?? + (await createDb({ + backend: config.db.backend, + ...(config.db.backend === "pg" ? { pg: { url: config.db.url } } : { mongo: { uri: config.db.url } }), + redis: config.redisUrl ?? undefined, + // invoices and payments post to the books through finance-kit → tally-kit + posting: financePosting, + } as Parameters<typeof createDb>[0])); + + let auth = o.auth; + if (!auth) { + const redis = db.redis; + const stores = redis + ? await (async () => { + const r = await import("@mgpl-diagnostics/auth-kit/redis"); + return { + sessions: r.redisSessionStore(redis), + kv: r.redisKeyValueStore(redis), + limiter: r.redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }), + }; + })() + : { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 }) }; + // auth reads users on every request: use the raw (uncached) repositories so a disabled user is out at once + const r = db.rawRepos; + auth = createAuth({ users: r.users, orgs: r.orgs, roles: r.roles, audit: r.audit, ...stores, session: config.session }); + } + + const services = createServices({ repos: db.repos, auth, mailer: o.mailer, resetUrl: config.resetUrl, lab: o.lab, samples: config.samples }); + return { + config, + db, + auth, + services, + async close() { + if (ownsDb) await db.close(); + }, + }; +} +``` + + +--- + +## File: src/controllers/auth.controller.ts + +```ts +import type { RoleAssignment, User } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { unauthenticated, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; + +/** auth-kit: sign-in, sessions, passwords; users and roles. */ +export const authController = withErrorCodes({ + Query: { + viewer: (_: unknown, __: unknown, ctx: Ctx) => (ctx.viewer ? {} : null), + mySessions: async (_: unknown, __: unknown, ctx: Ctx) => { + const sessions = await ctx.services.auth.sessions(actorOf(ctx)); + return sessions.map((s) => ({ ...s, current: s.id === ctx.viewer!.sessionId, createdAt: new Date(s.createdAt), lastSeenAt: new Date(s.lastSeenAt), expiresAt: new Date(s.expiresAt) })); + }, + users: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.users(authorize(ctx, "user:read")), + user: (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.auth.user(authorize(ctx, "user:read"), a.id), + roles: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.roles(authorize(ctx, "role:read")), + permissions: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.auth.permissions()), + }, + + Viewer: { + user: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.me(actorOf(ctx)), + organization: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.organization(actorOf(ctx)), + permissions: (_: unknown, a: { branchId?: string | null }, ctx: Ctx) => ctx.permissions(a.branchId), + branchesFor: (_: unknown, a: { permission: string }, ctx: Ctx) => { + const b = ctx.branchesFor(a.permission); + return b === "all" ? null : b; + }, + can: (_: unknown, a: { permission: string; branchId?: string | null }, ctx: Ctx) => ctx.can(a.permission, a.branchId), + }, + + RoleAssignment: { + branch: (r: RoleAssignment, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(r.branchId), + }, + + User: { + createdAt: (u: User) => u.createdAt, + }, + + Mutation: { + login: (_: unknown, { input }: { input: { email: string; password: string; orgCode?: string | null } }, ctx: Ctx) => + ctx.services.auth.login({ ...input, ip: ctx.ip, userAgent: ctx.userAgent }), + logout: async (_: unknown, __: unknown, ctx: Ctx) => { + if (!ctx.token) throw unauthenticated(); + await ctx.services.auth.logout(ctx.token); + return true; + }, + logoutOtherSessions: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.logoutOthers(actorOf(ctx), ctx.token!), + revokeSession: (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.auth.revokeSession(actorOf(ctx), a.id), + changePassword: async (_: unknown, a: { currentPassword: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.changePassword(actorOf(ctx), a.currentPassword, a.newPassword, ctx.token ?? undefined); + return true; + }, + requestPasswordReset: async (_: unknown, a: { email: string; orgCode?: string | null }, ctx: Ctx) => { + await ctx.services.auth.requestPasswordReset({ ...a, ip: ctx.ip }); + return true; + }, + resetPassword: async (_: unknown, a: { token: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.resetPassword(a.token, a.newPassword); + return true; + }, + + createUser: (_: unknown, { input }: { input: { name: string; email: string; phone?: string | null; password: string; roles: RoleAssignment[] } }, ctx: Ctx) => + ctx.services.auth.createUser(authorize(ctx, "user:create"), { ...input, roles: input.roles.map((r) => ({ role: r.role, branchId: r.branchId ?? null })) }), + setUserRoles: (_: unknown, a: { userId: string; roles: RoleAssignment[] }, ctx: Ctx) => + ctx.services.auth.setUserRoles(authorize(ctx, "user:update"), a.userId, a.roles.map((r) => ({ role: r.role, branchId: r.branchId ?? null }))), + disableUser: (_: unknown, a: { userId: string }, ctx: Ctx) => ctx.services.auth.setUserStatus(authorize(ctx, "user:disable"), a.userId, "disabled"), + enableUser: (_: unknown, a: { userId: string }, ctx: Ctx) => ctx.services.auth.setUserStatus(authorize(ctx, "user:disable"), a.userId, "active"), + setUserPassword: async (_: unknown, a: { userId: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.setUserPassword(authorize(ctx, "user:update"), a.userId, a.newPassword); + return true; + }, + upsertRole: (_: unknown, { input }: { input: { key: string; name: string; permissions: string[] } }, ctx: Ctx) => + ctx.services.auth.upsertRole(authorize(ctx, "role:manage"), input), + }, +}); +``` + + +--- + +## File: src/controllers/base.controller.ts + +```ts +import { readFileSync } from "node:fs"; +import type { RequestContext } from "../graphql/context"; +import { Day, DateTime, JSONScalar } from "../graphql/scalars"; + +const VERSION = (() => { + try { + return (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version; + } catch { + return "0.0.0"; + } +})(); + +export const baseController = { + DateTime, + Day, + JSON: JSONScalar, + Query: { + health: async (_: unknown, __: unknown, ctx: RequestContext) => { + let database = "ok"; + try { + await ctx.repos.orgs.count(); + } catch { + database = "unreachable"; + } + return { ok: database === "ok", service: "@mgpl-diagnostics/api", version: VERSION, database, time: new Date() }; + }, + }, + Mutation: { + ping: () => "pong", + }, +}; +``` + + +--- + +## File: src/controllers/billing.controller.ts + +```ts +import type { Invoice, InvoiceStatus } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards"; +import { BillingService, type IssueInvoiceInput, type PaymentInput } from "../services/billing.service"; + +async function invoiceFor(ctx: Ctx, id: string, permission: string) { + const inv = await ctx.services.billing.get(actorOf(ctx), id); + authorize(ctx, permission, inv.branchId); + return inv; +} + +/** Invoices and payments. */ +export const billingController = withErrorCodes({ + Query: { + invoice: (_: unknown, a: { id: string }, ctx: Ctx) => invoiceFor(ctx, a.id, "invoice:read"), + invoices: (_: unknown, a: { branchId?: string | null; status?: InvoiceStatus | null; patientId?: string | null; customerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.billing.list(actorOf(ctx), { scope: branchScope(ctx, "invoice:read", a.branchId), status: a.status, patientId: a.patientId, customerId: a.customerId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + }, + Invoice: { + patient: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(i.patientId), + branch: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(i.branchId), + booking: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(i.bookingId), + customer: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.customer.load(i.customerId), + creditedPaise: (i: Invoice) => i.creditedPaise ?? 0, + payments: (i: Invoice, _: unknown, ctx: Ctx) => ctx.services.billing.payments(i.id), + vouchers: (i: Invoice, _: unknown, ctx: Ctx) => (canSee(ctx, "accounts:read", i.branchId) ? ctx.services.billing.vouchers(i.id) : []), + creditNotes: (i: Invoice, _: unknown, ctx: Ctx) => ctx.services.billing.creditNotes(i.id), + }, + Mutation: { + issueInvoice: (_: unknown, { input }: { input: IssueInvoiceInput }, ctx: Ctx) => { + const actor = authorize(ctx, "invoice:create", input.branchId); + if (BillingService.discounted(input)) authorize(ctx, "invoice:discount", input.branchId); + if (input.payments?.length) authorize(ctx, "payment:create", input.branchId); + return ctx.services.billing.issue(actor, input); + }, + recordPayment: async (_: unknown, a: { invoiceId: string; payment: PaymentInput }, ctx: Ctx) => + ctx.services.billing.pay(actorOf(ctx), await invoiceFor(ctx, a.invoiceId, "payment:create"), a.payment, "payment"), + refundPayment: async (_: unknown, a: { invoiceId: string; payment: PaymentInput }, ctx: Ctx) => + ctx.services.billing.pay(actorOf(ctx), await invoiceFor(ctx, a.invoiceId, "payment:refund"), a.payment, "refund"), + cancelInvoice: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => + ctx.services.billing.cancel(actorOf(ctx), await invoiceFor(ctx, a.id, "invoice:cancel"), a.reason), + }, +}); +``` + + +--- + +## File: src/controllers/books.controller.ts + +```ts +import type { AccountGroup, Ledger, Voucher, VoucherEntry, VoucherType } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; +import type { PostVoucherInput } from "../services/books.service"; + +const BUCKET = { "0-30": "D0_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" } as const; + +type Range = { from: Date; to: Date; branchId?: string | null }; + +/** The books (tally-kit). Reads need accounts:read, manual vouchers accounts:post, setup accounts:manage. */ +export const booksController = withErrorCodes({ + Query: { + accountGroups: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.groups(authorize(ctx, "accounts:read")), + ledgers: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.ledgers(authorize(ctx, "accounts:read")), + ledger: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const l = await ctx.loaders.ledger.load(a.id); + return l && l.orgId === authorize(ctx, "accounts:read").orgId ? l : null; + }, + voucher: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const v = await ctx.services.books.voucher(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", v.branchId); + return v; + }, + ledgerStatement: (_: unknown, a: Range & { ledgerId: string }, ctx: Ctx) => ctx.services.books.ledgerStatement(authorize(ctx, "accounts:read", a.branchId), a), + cashBook: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.cashBook(authorize(ctx, "accounts:read", a.branchId), a), + trialBalance: (_: unknown, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => ctx.services.books.trialBalance(authorize(ctx, "accounts:read", a.branchId), a), + profitAndLoss: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.profitAndLoss(authorize(ctx, "accounts:read", a.branchId), a), + balanceSheet: (_: unknown, a: { asOf: Date; branchId?: string | null }, ctx: Ctx) => ctx.services.books.balanceSheet(authorize(ctx, "accounts:read", a.branchId), a), + dayBook: (_: unknown, a: Range & { type?: VoucherType | null }, ctx: Ctx) => ctx.services.books.dayBook(authorize(ctx, "accounts:read", a.branchId), a), + outstandingReceivables: async (_: unknown, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => { + const out = await ctx.services.books.outstanding(authorize(ctx, "accounts:read", a.branchId), a); + return { + rows: out.rows.map((r) => ({ ...r, bucket: BUCKET[r.bucket] })), + buckets: { d0to30: out.buckets["0-30"], d31to60: out.buckets["31-60"], d61to90: out.buckets["61-90"], d90plus: out.buckets["90+"] }, + totalDuePaise: out.totalDuePaise, + }; + }, + gstSummary: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.gstSummary(authorize(ctx, "accounts:read", a.branchId), a), + }, + AccountGroup: { + parent: (g: AccountGroup, _: unknown, ctx: Ctx) => ctx.loaders.group.load(g.parentId), + }, + Ledger: { + group: (l: Ledger, _: unknown, ctx: Ctx) => ctx.loaders.group.load(l.groupId), + balancePaise: (l: Ledger, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => ctx.services.books.balance(l.orgId, l.id, a), + }, + Voucher: { + branch: (v: Voucher, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(v.branchId), + }, + VoucherEntry: { + ledger: (e: VoucherEntry, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(e.ledgerId), + }, + Mutation: { + setupChartOfAccounts: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.setupChartOfAccounts(authorize(ctx, "accounts:manage")), + createLedger: (_: unknown, { input }: { input: { name: string; groupId: string; openingPaise?: number | null; gstin?: string | null } }, ctx: Ctx) => + ctx.services.books.createLedger(authorize(ctx, "accounts:manage"), input), + postVoucher: (_: unknown, { input }: { input: PostVoucherInput }, ctx: Ctx) => ctx.services.books.postVoucher(authorize(ctx, "accounts:post", input.branchId), input), + cancelVoucher: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const v = await ctx.services.books.voucher(actorOf(ctx), a.id); + return ctx.services.books.cancelVoucher(authorize(ctx, "accounts:post", v.branchId), a.id); + }, + }, +}); +``` + + +--- + +## File: src/controllers/catalog.controller.ts + +```ts +import type { Interval, LabTest, Parameter } from "@mgpl-diagnostics/catalog-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { cap } from "../graphql/guards"; + +const bound = (b: Interval["low"]) => (b == null ? null : String(b)); + +/** catalog-kit. Public: the same tests and prices are on the website. */ +export const catalogController = withErrorCodes({ + Query: { + catalogSearch: (_: unknown, a: { query: string; limit?: number; department?: string | null; sampleType?: string | null; fuzzy?: boolean | null }, ctx: Ctx) => + ctx.services.catalog.search(a.query, { limit: cap(a.limit, 20), department: a.department, sampleType: a.sampleType, fuzzy: a.fuzzy }), + catalogTest: (_: unknown, a: { id?: number | null; code?: string | null }, ctx: Ctx) => ctx.services.catalog.test(a), + catalogTests: (_: unknown, a: { department?: string | null; sampleType?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.catalog.tests({ department: a.department, sampleType: a.sampleType, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + catalogDepartments: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.catalog.departments(), + catalogSampleTypes: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.catalog.sampleTypes(), + priceQuote: (_: unknown, a: { tests: string[] }, ctx: Ctx) => { + const q = ctx.services.catalog.quote(a.tests.slice(0, 200).map((t) => (/^\d+$/.test(t) ? Number(t) : t))); + return { total: q.total, totalPaise: q.total * 100, tests: q.tests, unknown: q.unknown.map(String) }; + }, + }, + CatalogTest: { + pricePaise: (t: LabTest) => (t.mrp == null ? null : t.mrp * 100), + isProfile: (t: LabTest, _: unknown, ctx: Ctx) => ctx.services.catalog.isProfile(t), + }, + CatalogParameter: { + male: (p: Parameter) => (p.male ? { low: bound(p.male.low), high: bound(p.male.high) } : null), + female: (p: Parameter) => (p.female ? { low: bound(p.female.low), high: bound(p.female.high) } : null), + range: (p: Parameter, a: { sex?: "male" | "female" | "other" | null }, ctx: Ctx) => ctx.services.catalog.formatRange(p, a.sex === "other" ? null : a.sex), + }, +}); +``` + + +--- + +## File: src/controllers/documents.controller.ts + +```ts +import type { InvoiceRequest } from "@mgpl-diagnostics/invoice-kit"; +import type { ReportDesign, ReportRequest, ReportVariant } from "@mgpl-diagnostics/report-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { badInput, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; +import type { DocumentFormat } from "../services/document.service"; + +type Opts = { format?: DocumentFormat; variant?: "header" | "non_header"; design?: ReportDesign; fileName?: string | null }; + +const variantOf = (v?: string): ReportVariant => (v === "non_header" ? "non-header" : "header"); +const options = (a: Opts) => ({ format: a.format ?? "pdf", variant: variantOf(a.variant), design: a.design ?? "sidebar", fileName: a.fileName }); +const objectOf = <T>(v: unknown, what: string): T => { + if (!v || typeof v !== "object" || Array.isArray(v)) throw badInput(`${what} must be a JSON object`); + return v as T; +}; + +/** report-kit and invoice-kit, used as internal libraries behind this one endpoint. */ +export const documentsController = withErrorCodes({ + Query: { + reportTemplates: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.templates()), + priceList: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.priceList()), + documentSamples: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.reportSamples()), + invoiceSamples: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.invoiceSamples()), + + renderReport: (_: unknown, a: Opts & { request: unknown }, ctx: Ctx) => { + authorize(ctx, "report:read"); + return ctx.services.documents.renderReportRequest(objectOf<ReportRequest>(a.request, "request"), options(a)); + }, + renderInvoice: (_: unknown, a: Opts & { request: unknown }, ctx: Ctx) => { + authorize(ctx, "invoice:read"); + return ctx.services.documents.renderInvoiceRequest(objectOf<InvoiceRequest>(a.request, "request"), options(a)); + }, + invoiceTotals: (_: unknown, a: { request: unknown }, ctx: Ctx) => { + authorize(ctx, "invoice:read"); + return ctx.services.documents.invoiceTotals(objectOf<InvoiceRequest>(a.request, "request")); + }, + letterhead: (_: unknown, a: { format?: DocumentFormat }, ctx: Ctx) => { + authorize(ctx, "report:read"); + return ctx.services.documents.letterhead(a.format ?? "pdf"); + }, + + invoiceDocument: async (_: unknown, a: Opts & { invoiceId: string }, ctx: Ctx) => { + const inv = await ctx.services.billing.get(actorOf(ctx), a.invoiceId); + return ctx.services.documents.renderStoredInvoice(authorize(ctx, "invoice:read", inv.branchId), inv, options(a)); + }, + bookingReportDocument: async (_: unknown, a: Opts & { bookingId: string; reportIds?: string[] | null }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.bookingId); + return ctx.services.documents.renderBookingReports(authorize(ctx, "report:read", b.branchId), a.bookingId, a.reportIds ?? null, options(a)); + }, + }, + TemplateField: { + range: (f: { range?: string | string[] }) => (f.range == null ? null : Array.isArray(f.range) ? f.range : [f.range]), + }, +}); +``` + + +--- + +## File: src/controllers/finance.controller.ts + +```ts +import type { + BankLineStatus, BankStatement, CashClosing, CreditNote, Customer, Partner, PeriodLock, Settlement, SettlementDeduction, SettlementStatus, +} from "@mgpl-diagnostics/db-kit"; +import type { AgeBucket, FinanceConfig } from "@mgpl-diagnostics/finance-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, cap } from "../graphql/guards"; +import type { CustomerInput, CustomerInvoiceInput } from "../services/receivables.service"; +import type { AgreementInput, PartnerInput } from "../services/settlement.service"; +import type { TaxRuleInput } from "../services/gst.service"; + +const OPEN_BUCKET: Record<AgeBucket, string> = { not_due: "not_due", "1-30": "D1_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" }; +const ageing = (a: Record<AgeBucket, number>) => ({ notDue: a.not_due, d1to30: a["1-30"], d31to60: a["31-60"], d61to90: a["61-90"], d90plus: a["90+"] }); +const voucherById = (ctx: Ctx, id?: string | null) => (id ? ctx.repos.vouchers.get(id) : null); + +type Range = { from: Date; to: Date; branchId?: string | null; bom?: boolean }; +type Gst = { from: Date; to: Date; gstin?: string | null; bom?: boolean }; + +async function settlementFor(ctx: Ctx, id: string, permission: string) { + const s = await ctx.services.settlements.settlement(actorOf(ctx), id); + authorize(ctx, permission, s.branchId); + return s; +} + +/** + * finance-kit. Permissions as in finance-kit's docs/api.md: accounts:read for returns, statements and + * exports; accounts:post for invoices, credit notes, settlements, cash and bank; accounts:manage for + * tax rules, customers, partners, lock dates and the year-end close. + */ +export const financeController = withErrorCodes({ + Query: { + taxRules: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.gst.taxRules(authorize(ctx, "accounts:read")), + financeConfig: async (_: unknown, __: unknown, ctx: Ctx) => { + const c: FinanceConfig = await ctx.services.gst.config(authorize(ctx, "accounts:read")); + return { b2clThresholdPaise: c.gst.b2clThresholdPaise, tds: c.tds, bankToleranceDays: c.bank.toleranceDays, bankMaxFeeBp: c.bank.maxFeeBp, clearingLedgerKeys: c.bank.clearingLedgerKeys }; + }, + verifyWithCA: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.gst.verifyWithCA()), + gstr1: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.gst.gstr1(authorize(ctx, "accounts:read"), a), + gstr3b: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.gst.gstr3b(authorize(ctx, "accounts:read"), a), + + customers: (_: unknown, a: { activeOnly?: boolean }, ctx: Ctx) => ctx.services.receivables.customers(authorize(ctx, "accounts:read"), a.activeOnly ?? true), + customer: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const c = await ctx.loaders.customer.load(a.id); + return c && c.orgId === authorize(ctx, "accounts:read").orgId ? c : null; + }, + customerStatement: async (_: unknown, a: { customerId: string; from: Date; to: Date }, ctx: Ctx) => { + const s = await ctx.services.receivables.statement(authorize(ctx, "accounts:read"), a); + return { ...s, open: s.open.map((o) => ({ ...o, bucket: OPEN_BUCKET[o.bucket] })), ageing: ageing(s.ageing) }; + }, + receivablesSummary: async (_: unknown, a: { asOf: Date }, ctx: Ctx) => { + const s = await ctx.services.receivables.summary(authorize(ctx, "accounts:read"), a.asOf); + return { ...s, rows: s.rows.map((r) => ({ ...r, ageing: ageing(r.ageing) })) }; + }, + creditNote: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const n = await ctx.services.receivables.creditNote(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", n.branchId); + return n; + }, + creditNotes: (_: unknown, a: { invoiceId?: string | null; customerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.receivables.creditNotes(authorize(ctx, "accounts:read"), { ...a, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + + partners: (_: unknown, a: { branchId?: string | null }, ctx: Ctx) => ctx.services.settlements.partners(actorOf(ctx), { branchIds: branchScope(ctx, "accounts:read", a.branchId) }), + partner: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", p.branchId); + return p; + }, + settlementPreview: async (_: unknown, a: { partnerId: string; period: string; deductions?: SettlementDeduction[] | null }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.partnerId); + return ctx.services.settlements.compute(authorize(ctx, "accounts:read", p.branchId), a); + }, + settlements: (_: unknown, a: { partnerId?: string | null; status?: SettlementStatus | null; branchId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.settlements.settlements(actorOf(ctx), { ...a, branchIds: branchScope(ctx, "accounts:read", a.branchId), limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + settlement: (_: unknown, a: { id: string }, ctx: Ctx) => settlementFor(ctx, a.id, "accounts:read"), + + cashClosings: (_: unknown, a: { branchId: string; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.cashClosings(authorize(ctx, "accounts:read", a.branchId), { branchId: a.branchId, limit: cap(a.limit, 31), offset: a.offset ?? 0 }), + bankStatements: (_: unknown, a: { ledgerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.bankStatements(authorize(ctx, "accounts:read"), { ledgerId: a.ledgerId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + bankLines: (_: unknown, a: { ledgerId?: string | null; statementId?: string | null; status?: BankLineStatus | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.bankLines(authorize(ctx, "accounts:read"), { ...a, limit: cap(a.limit, 100), offset: a.offset ?? 0 }), + bankReconciliation: (_: unknown, a: { ledgerId: string; asOf: Date }, ctx: Ctx) => ctx.services.cashBank.reconciliation(authorize(ctx, "accounts:read"), a), + + lockDates: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.periods.locks(authorize(ctx, "accounts:read")), + + exportDayBook: (_: unknown, a: Range, ctx: Ctx) => ctx.services.exports.dayBook(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportLedger: async (_: unknown, a: Range & { ledgerId: string }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:read", a.branchId); + await ctx.services.books.ledger(actor, a.ledgerId); + return ctx.services.exports.ledger(actor, { ...a, bom: !!a.bom }); + }, + exportTrialBalance: (_: unknown, a: { asOf?: Date | null; branchId?: string | null; bom?: boolean }, ctx: Ctx) => + ctx.services.exports.trialBalance(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportProfitAndLoss: (_: unknown, a: Range, ctx: Ctx) => ctx.services.exports.profitAndLoss(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportBalanceSheet: (_: unknown, a: { asOf: Date; branchId?: string | null; bom?: boolean }, ctx: Ctx) => + ctx.services.exports.balanceSheet(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportSettlement: async (_: unknown, a: { settlementId: string; bom?: boolean }, ctx: Ctx) => { + await settlementFor(ctx, a.settlementId, "accounts:read"); + return ctx.services.exports.settlement(actorOf(ctx), { settlementId: a.settlementId, bom: !!a.bom }); + }, + exportGstr1: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.exports.gstr1(authorize(ctx, "accounts:read"), { ...a, bom: !!a.bom }), + exportGstr3b: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.exports.gstr3b(authorize(ctx, "accounts:read"), { ...a, bom: !!a.bom }), + }, + + Customer: { + ledger: (c: Customer, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(c.ledgerId), + }, + CreditNote: { + invoice: (n: CreditNote, _: unknown, ctx: Ctx) => ctx.loaders.invoice.load(n.invoiceId), + customer: (n: CreditNote, _: unknown, ctx: Ctx) => ctx.loaders.customer.load(n.customerId), + }, + Partner: { + branch: (p: Partner, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(p.branchId), + ledger: (p: Partner, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(p.ledgerId), + agreements: (p: Partner, _: unknown, ctx: Ctx) => ctx.services.settlements.agreements(p.id), + }, + Settlement: { + partner: (s: Settlement, _: unknown, ctx: Ctx) => ctx.loaders.partner.load(s.partnerId), + branch: (s: Settlement, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(s.branchId), + accrualVoucher: (s: Settlement, _: unknown, ctx: Ctx) => voucherById(ctx, s.accrualVoucherId), + payoutVoucher: (s: Settlement, _: unknown, ctx: Ctx) => voucherById(ctx, s.payoutVoucherId), + }, + CashClosing: { + branch: (c: CashClosing, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(c.branchId), + voucher: (c: CashClosing, _: unknown, ctx: Ctx) => voucherById(ctx, c.voucherId), + }, + BankStatement: { + branch: (s: BankStatement, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(s.branchId), + ledger: (s: BankStatement, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(s.ledgerId), + }, + PeriodLock: { + branch: (l: PeriodLock, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(l.branchId), + }, + + Mutation: { + addTaxRule: (_: unknown, { input }: { input: TaxRuleInput }, ctx: Ctx) => ctx.services.gst.addTaxRule(authorize(ctx, "accounts:manage"), input), + seedTaxRules: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.gst.seedTaxRules(authorize(ctx, "accounts:manage")), + + createCustomer: (_: unknown, { input }: { input: CustomerInput }, ctx: Ctx) => ctx.services.receivables.createCustomer(authorize(ctx, "accounts:manage"), input), + updateCustomer: (_: unknown, a: { id: string; input: Partial<CustomerInput> & { active?: boolean | null } }, ctx: Ctx) => + ctx.services.receivables.updateCustomer(authorize(ctx, "accounts:manage"), a.id, a.input), + issueCustomerInvoice: (_: unknown, { input }: { input: CustomerInvoiceInput }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:post", input.branchId); + authorize(ctx, "invoice:create", input.branchId); + return ctx.services.receivables.issueInvoice(actor, input); + }, + issueCreditNote: async (_: unknown, { input }: { input: { invoiceId: string; reason: string; lines?: { itemIndex: number; taxablePaise?: number | null }[] | null; date?: Date | null } }, ctx: Ctx) => { + const inv = await ctx.services.billing.get(actorOf(ctx), input.invoiceId); + return ctx.services.receivables.issueCreditNote(authorize(ctx, "accounts:post", inv.branchId), input); + }, + + createPartner: (_: unknown, { input }: { input: PartnerInput }, ctx: Ctx) => ctx.services.settlements.createPartner(authorize(ctx, "accounts:manage", input.branchId), input), + updatePartner: async (_: unknown, a: { id: string; input: Partial<PartnerInput> & { active?: boolean | null } }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.id); + return ctx.services.settlements.updatePartner(authorize(ctx, "accounts:manage", p.branchId), a.id, a.input); + }, + addAgreement: async (_: unknown, { input }: { input: AgreementInput }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), input.partnerId); + return ctx.services.settlements.addAgreement(authorize(ctx, "accounts:manage", p.branchId), input); + }, + createSettlement: async (_: unknown, a: { partnerId: string; period: string; deductions?: SettlementDeduction[] | null }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.partnerId); + return ctx.services.settlements.create(authorize(ctx, "accounts:post", p.branchId), a); + }, + accrueSettlement: async (_: unknown, a: { id: string }, ctx: Ctx) => (await settlementFor(ctx, a.id, "accounts:post"), ctx.services.settlements.accrue(actorOf(ctx), a.id)), + paySettlement: async (_: unknown, { input }: { input: { settlementId: string; mode: never; date?: Date | null; reference?: string | null } }, ctx: Ctx) => { + await settlementFor(ctx, input.settlementId, "accounts:post"); + return ctx.services.settlements.pay(actorOf(ctx), input); + }, + cancelSettlement: async (_: unknown, a: { id: string }, ctx: Ctx) => (await settlementFor(ctx, a.id, "accounts:post"), ctx.services.settlements.cancel(actorOf(ctx), a.id)), + + closeCashDay: (_: unknown, { input }: { input: { branchId: string; day: string; countedPaise: number; denominations?: { valuePaise: number; count: number }[] | null; notes?: string | null } }, ctx: Ctx) => + ctx.services.cashBank.closeCashDay(authorize(ctx, "accounts:post", input.branchId), input), + importBankStatement: async (_: unknown, { input }: { input: { branchId: string; ledgerId: string; csv: string; fileName?: string | null } }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:post", input.branchId); + await ctx.services.books.ledger(actor, input.ledgerId); + return ctx.services.cashBank.importBankStatement(actor, input); + }, + reconcileBank: (_: unknown, a: { ledgerId: string; statementId?: string | null }, ctx: Ctx) => ctx.services.cashBank.reconcile(authorize(ctx, "accounts:post"), a), + matchBankLine: (_: unknown, a: { lineId: string; voucherIds: string[] }, ctx: Ctx) => ctx.services.cashBank.matchLine(authorize(ctx, "accounts:post"), a), + setBankLineStatus: (_: unknown, a: { lineId: string; status: "ignored" | "unmatched" }, ctx: Ctx) => ctx.services.cashBank.setLineStatus(authorize(ctx, "accounts:post"), a), + + setLockDate: (_: unknown, { input }: { input: { branchId?: string | null; upTo: string | null; reason?: string | null } }, ctx: Ctx) => + ctx.services.periods.setLockDate(authorize(ctx, "accounts:manage", input.branchId), input), + closeFinancialYear: (_: unknown, a: { fy: string }, ctx: Ctx) => ctx.services.periods.closeFinancialYear(authorize(ctx, "accounts:manage"), a.fy), + }, +}); +``` + + +--- + +## File: src/controllers/lab.controller.ts + +```ts +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import type { Booking, BookingStatus, BookingTest, Patient, Report, ResultValue, Sample } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards"; +import type { CreateBookingInput } from "../services/booking.service"; +import type { DoctorInput, RegisterPatientInput } from "../services/patient.service"; +import { scopedFind } from "../services/util"; + +/** A result flag "" (normal) is NONE in GraphQL. */ +export const flagOut = (f: ResultValue["flag"]) => (f === "" ? "NONE" : f ?? null); +export const flagIn = (f?: string | null): ResultValue["flag"] => (f === "NONE" ? "" : ((f ?? undefined) as ResultValue["flag"])); + +async function sampleFor(ctx: Ctx, id: string, permission: string) { + const s = await ctx.services.bookings.sample(actorOf(ctx), id); + authorize(ctx, permission, s.branchId); + return s; +} + +async function reportFor(ctx: Ctx, id: string, permission: string) { + const r = await ctx.services.reports.get(actorOf(ctx), id); + authorize(ctx, permission, r.branchId); + return r; +} + +/** Patients, doctors, bookings, samples, reports. */ +export const labController = withErrorCodes({ + Query: { + patient: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.patients.get(authorize(ctx, "patient:read"), a.id), + patients: (_: unknown, a: { search: string; limit?: number }, ctx: Ctx) => ctx.services.patients.search(authorize(ctx, "patient:read"), a.search, cap(a.limit, 20)), + doctors: (_: unknown, a: { activeOnly?: boolean }, ctx: Ctx) => ctx.services.patients.doctors(authorize(ctx, "doctor:read"), a.activeOnly ?? true), + + booking: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.id); + authorize(ctx, "booking:read", b.branchId); + return b; + }, + bookings: (_: unknown, a: { branchId?: string | null; status?: BookingStatus | null; patientId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.bookings.list(actorOf(ctx), { scope: branchScope(ctx, "booking:read", a.branchId), status: a.status, patientId: a.patientId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + sample: (_: unknown, a: { id: string }, ctx: Ctx) => sampleFor(ctx, a.id, "sample:read"), + sampleByBarcode: async (_: unknown, a: { barcode: string }, ctx: Ctx) => { + const s = await ctx.services.bookings.sampleByBarcode(actorOf(ctx), a.barcode); + if (s) authorize(ctx, "sample:read", s.branchId); + return s; + }, + report: (_: unknown, a: { id: string }, ctx: Ctx) => reportFor(ctx, a.id, "report:read"), + }, + + Patient: { + branch: (p: Patient, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(p.branchId), + bookings: (p: Patient, a: { limit?: number }, ctx: Ctx) => + canSee(ctx, "booking:read") ? scopedFind(ctx.repos.bookings, { patientId: p.id }, ctx.branchesFor("booking:read"), cap(a.limit, 20), 0, "createdAt") : [], + invoices: (p: Patient, a: { limit?: number }, ctx: Ctx) => + canSee(ctx, "invoice:read") ? scopedFind(ctx.repos.invoices, { patientId: p.id }, ctx.branchesFor("invoice:read"), cap(a.limit, 20), 0, "createdAt") : [], + }, + Booking: { + patient: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(b.patientId), + doctor: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.doctor.load(b.doctorId), + branch: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(b.branchId), + samples: (b: Booking, _: unknown, ctx: Ctx) => ctx.services.bookings.samplesOf(b.id), + reports: (b: Booking, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", b.branchId) ? ctx.services.reports.ofBooking(b.id) : []), + invoice: (b: Booking, _: unknown, ctx: Ctx) => (canSee(ctx, "invoice:read", b.branchId) ? ctx.loaders.invoice.load(b.invoiceId) : null), + }, + BookingTest: { + catalog: (t: BookingTest) => getTestByCode(t.testCode) ?? null, + }, + Sample: { + booking: (s: Sample, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(s.bookingId), + }, + Report: { + results: (r: Report) => r.results.map((x) => ({ ...x, flag: flagOut(x.flag) })), + patient: (r: Report, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(r.patientId), + booking: (r: Report, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(r.bookingId), + }, + + Mutation: { + registerPatient: (_: unknown, { input }: { input: RegisterPatientInput }, ctx: Ctx) => ctx.services.patients.register(authorize(ctx, "patient:create", input.branchId), input), + updatePatient: async (_: unknown, a: { id: string; input: Partial<RegisterPatientInput> }, ctx: Ctx) => { + const actor = authorize(ctx, "patient:update"); + return ctx.services.patients.update(actor, a.id, a.input); + }, + createDoctor: (_: unknown, { input }: { input: DoctorInput }, ctx: Ctx) => ctx.services.patients.createDoctor(authorize(ctx, "doctor:manage"), input), + updateDoctor: (_: unknown, a: { id: string; input: Partial<DoctorInput> }, ctx: Ctx) => ctx.services.patients.updateDoctor(authorize(ctx, "doctor:manage"), a.id, a.input), + + createBooking: (_: unknown, { input }: { input: CreateBookingInput }, ctx: Ctx) => ctx.services.bookings.create(authorize(ctx, "booking:create", input.branchId), input), + cancelBooking: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.id); + return ctx.services.bookings.cancel(authorize(ctx, "booking:cancel", b.branchId), a.id); + }, + collectSample: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.bookings.collectSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:collect")), + receiveSample: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.bookings.receiveSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:receive")), + rejectSample: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => ctx.services.bookings.rejectSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:reject"), a.reason), + + createReport: async (_: unknown, a: { bookingId: string; testCode: string; testName?: string | null }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.bookingId); + return ctx.services.reports.create(authorize(ctx, "report:enter", b.branchId), b, a.testCode, a.testName); + }, + saveResults: async (_: unknown, a: { reportId: string; results: (Omit<ResultValue, "flag"> & { flag?: string | null })[] }, ctx: Ctx) => { + const r = await reportFor(ctx, a.reportId, "report:enter"); + const results = a.results.map((x) => ({ parameter: x.parameter, value: x.value, ...(x.unit != null ? { unit: x.unit } : {}), ...(x.range != null ? { range: x.range } : {}), ...(x.flag != null ? { flag: flagIn(x.flag) } : {}) })); + return ctx.services.reports.saveResults(actorOf(ctx), r, results); + }, + verifyReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.verify(actorOf(ctx), await reportFor(ctx, a.id, "report:verify")), + releaseReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.release(actorOf(ctx), await reportFor(ctx, a.id, "report:release")), + amendReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.amend(actorOf(ctx), await reportFor(ctx, a.id, "report:amend")), + }, +}); +``` + + +--- + +## File: src/controllers/organization.controller.ts + +```ts +import type { AuditEntry, Organization } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, cap } from "../graphql/guards"; +import type { BranchInput, OrganizationPatch } from "../services/organization.service"; + +export const organizationController = withErrorCodes({ + Query: { + organization: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.organization(actorOf(ctx)), + branches: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.branches(actorOf(ctx)), + branch: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.loaders.branch.load(a.id); + return b && b.orgId === actorOf(ctx).orgId ? b : null; + }, + auditLog: (_: unknown, a: { entity?: string | null; entityId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.organization.audit(authorize(ctx, "audit:read"), { entity: a.entity, entityId: a.entityId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + }, + Organization: { + branches: (o: Organization, _: unknown, ctx: Ctx) => ctx.services.organization.branches({ orgId: o.id, userId: ctx.viewer!.userId }), + }, + AuditEntry: { + user: (e: AuditEntry, _: unknown, ctx: Ctx) => ctx.loaders.user.load(e.userId).then((u) => (u ? (({ passwordHash: _p, ...rest }) => rest)(u) : null)), + }, + Mutation: { + updateOrganization: (_: unknown, { input }: { input: OrganizationPatch }, ctx: Ctx) => ctx.services.organization.updateOrganization(authorize(ctx, "org:update"), input), + createBranch: (_: unknown, { input }: { input: BranchInput }, ctx: Ctx) => ctx.services.organization.createBranch(authorize(ctx, "branch:create"), input), + updateBranch: (_: unknown, a: { id: string; input: Partial<BranchInput> }, ctx: Ctx) => ctx.services.organization.updateBranch(authorize(ctx, "branch:update", a.id), a.id, a.input), + }, +}); +``` + + +--- + +## File: src/graphql/context.ts + +```ts +import type { AuthContext, Viewer } from "@mgpl-diagnostics/auth-kit"; +import type { Repositories } from "@mgpl-diagnostics/db-kit"; +import type { Container } from "../container"; +import type { Services } from "../services"; +import { createLoaders, type Loaders } from "./loaders"; + +/** Who is acting: always taken from the session, never from client input. */ +export interface Actor { + userId: string; + orgId: string; +} + +/** Everything a controller (resolver) gets for one request. */ +export interface RequestContext extends AuthContext { + services: Services; + /** Cached repositories (db-kit `db.repos`); workflows open their own transactions. */ + repos: Repositories; + viewer: Viewer | null; + /** The bearer token of this request (needed by logout and password change). */ + token: string | null; + ip?: string; + userAgent?: string; + /** Per-request batch loaders, created on first use. */ + readonly loaders: Loaders; +} + +export function bearerToken(header: string | null | undefined): string | null { + const m = /^Bearer\s+(.+)$/i.exec(header?.trim() ?? ""); + return m ? m[1]!.trim() : null; +} + +/** Builds the request context from the Authorization header, through auth-kit. */ +export async function createContext(container: Container, req: { headers: Headers; ip?: string }): Promise<RequestContext> { + const token = bearerToken(req.headers.get("authorization")); + const auth = await container.auth.context(token); + let loaders: Loaders | undefined; + const repos = container.db.repos; + return { + ...auth, + services: container.services, + repos, + token, + ip: req.ip, + userAgent: req.headers.get("user-agent") ?? undefined, + get loaders() { + return (loaders ??= createLoaders(repos)); + }, + }; +} +``` + + +--- + +## File: src/graphql/errors.ts + +```ts +import { AuthError } from "@mgpl-diagnostics/auth-kit"; +import { ConflictError, NotFoundError, PeriodLockedError, ValidationError } from "@mgpl-diagnostics/db-kit"; +import { CreditLimitError } from "@mgpl-diagnostics/finance-kit"; +import { HttpError } from "@mgpl-diagnostics/report-kit"; +import { GraphQLError } from "graphql"; + +/** + * Every error code a client can see in `errors[].extensions.code`. A public contract: add codes, + * never rename them (docs/05-errors.md). Anything else is masked as "Unexpected error". + */ +export const ERROR_CODES = [ + "UNAUTHENTICATED", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "BAD_USER_INPUT", + "PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED", + // auth-kit's own codes, passed through + "INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN", +] as const; +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export function apiError(code: ErrorCode, message: string, status: number, extra: Record<string, unknown> = {}): GraphQLError { + // never pass `originalError`: Yoga masks any GraphQLError that wraps a non-GraphQL error as "Unexpected error" + return new GraphQLError(message, { extensions: { code, http: { status }, ...extra } }); +} + +export const unauthenticated = () => apiError("UNAUTHENTICATED", "Sign in required", 401); +export const forbidden = (permission: string) => apiError("FORBIDDEN", `Missing permission ${permission}`, 403, { permission }); +export const notFound = (what: string) => apiError("NOT_FOUND", `${what} not found`, 404); +export const badInput = (message: string) => apiError("BAD_USER_INPUT", message, 400); + +/** Domain errors from the kits → GraphQL errors with codes. Anything unknown is re-thrown (and masked by Yoga). */ +export function toGraphQLError(e: unknown): unknown { + if (e instanceof GraphQLError) return e; + if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {}); + if (e instanceof PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409); + if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409); + if (e instanceof NotFoundError) return apiError("NOT_FOUND", e.message, 404); + if (e instanceof ConflictError) return apiError("CONFLICT", e.message, 409); + if (e instanceof ValidationError) return apiError("BAD_USER_INPUT", e.message, 400); + // report-kit / invoice-kit input validation (HttpError 400) + if (e instanceof HttpError && e.status === 400) return apiError("BAD_USER_INPUT", e.message, 400); + return e; +} + +type ResolverMap = Record<string, Record<string, unknown>>; + +/** Wraps every resolver function of a map so domain errors surface with proper codes. */ +export function withErrorCodes<R extends ResolverMap>(resolvers: R): R { + for (const type of Object.values(resolvers)) { + for (const [field, fn] of Object.entries(type)) { + if (typeof fn !== "function") continue; + type[field] = async (...args: unknown[]) => { + try { + return await (fn as (...a: unknown[]) => unknown)(...args); + } catch (e) { + throw toGraphQLError(e); + } + }; + } + } + return resolvers; +} +``` + + +--- + +## File: src/graphql/guards.ts + +```ts +import type { Id } from "@mgpl-diagnostics/db-kit"; +import type { Actor, RequestContext } from "./context"; +import { forbidden, unauthenticated } from "./errors"; + +/** The signed-in user as an Actor; UNAUTHENTICATED otherwise. */ +export function actorOf(ctx: RequestContext): Actor { + if (!ctx.viewer) throw unauthenticated(); + return { userId: ctx.viewer.userId, orgId: ctx.viewer.orgId }; +} + +/** FORBIDDEN unless the viewer has `permission` in `branchId` (anywhere, when omitted). */ +export function authorize(ctx: RequestContext, permission: string, branchId?: Id | null): Actor { + const actor = actorOf(ctx); + if (!ctx.can(permission, branchId)) throw forbidden(permission); + return actor; +} + +/** True when the viewer may see a nested field (no error: the field resolves to empty). */ +export const canSee = (ctx: RequestContext, permission: string, branchId?: Id | null) => !!ctx.viewer && ctx.can(permission, branchId); + +/** The branch filter for a list: the requested branch (checked) or every branch the viewer may read. */ +export function branchScope(ctx: RequestContext, permission: string, branchId?: Id | null): "all" | Id[] { + if (branchId) { + authorize(ctx, permission, branchId); + return [branchId]; + } + actorOf(ctx); + const allowed = ctx.branchesFor(permission); + if (allowed !== "all" && !allowed.length) throw forbidden(permission); + return allowed; +} + +/** List limits are capped at 200. */ +export const cap = (n: number | null | undefined, d: number) => Math.min(Math.max(1, n ?? d), 200); +``` + + +--- + +## File: src/graphql/limits.ts + +```ts +import { GraphQLError, Kind, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql"; + +/** Rejects queries nested deeper than `max` levels (a cheap guard against abusive queries). */ +export function depthLimit(max: number) { + return (context: ValidationContext): ASTVisitor => { + const fragments = new Map<string, FragmentDefinitionNode>(); + for (const d of context.getDocument().definitions) if (d.kind === Kind.FRAGMENT_DEFINITION) fragments.set(d.name.value, d); + const depthOf = (set: SelectionSetNode | undefined, depth: number, seen: Set<string>): number => { + if (!set) return depth; + let deepest = depth; + for (const s of set.selections) { + if (s.kind === Kind.FIELD) deepest = Math.max(deepest, depthOf(s.selectionSet, s.selectionSet ? depth + 1 : depth, seen)); + else if (s.kind === Kind.INLINE_FRAGMENT) deepest = Math.max(deepest, depthOf(s.selectionSet, depth, seen)); + else if (!seen.has(s.name.value)) { + const f = fragments.get(s.name.value); + if (f) deepest = Math.max(deepest, depthOf(f.selectionSet, depth, new Set(seen).add(s.name.value))); + } + } + return deepest; + }; + return { + OperationDefinition(op) { + const depth = depthOf(op.selectionSet, 0, new Set()); + if (depth > max) context.reportError(new GraphQLError(`Query is ${depth} levels deep; the limit is ${max}`, { extensions: { code: "BAD_USER_INPUT" } })); + }, + }; + }; +} +``` + + +--- + +## File: src/graphql/loaders.ts + +```ts +import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, Repositories, User } from "@mgpl-diagnostics/db-kit"; + +/** + * Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results are + * memoised for the request. Prevents N+1 queries when a list resolves nested fields + * (50 invoices → 1 query for their patients). + */ +export class BatchLoader<T extends { id: string }> { + private cache = new Map<string, Promise<T | null>>(); + private queue: { id: string; resolve: (v: T | null) => void; reject: (e: unknown) => void }[] = []; + + constructor(private readonly fetchMany: (ids: readonly string[]) => Promise<T[]>) {} + + load(id: string | null | undefined): Promise<T | null> { + if (!id) return Promise.resolve(null); + const hit = this.cache.get(id); + if (hit) return hit; + const p = new Promise<T | null>((resolve, reject) => { + this.queue.push({ id, resolve, reject }); + if (this.queue.length === 1) queueMicrotask(() => void this.flush()); + }); + this.cache.set(id, p); + return p; + } + + /** Seeds the cache with an entity you already have (e.g. just created). */ + prime(entity: T): void { + this.cache.set(entity.id, Promise.resolve(entity)); + } + + clear(id: string): void { + this.cache.delete(id); + } + + private async flush(): Promise<void> { + const batch = this.queue; + this.queue = []; + try { + const rows = await this.fetchMany([...new Set(batch.map((b) => b.id))]); + const byId = new Map(rows.map((r) => [r.id, r])); + for (const b of batch) b.resolve(byId.get(b.id) ?? null); + } catch (e) { + for (const b of batch) { + this.cache.delete(b.id); + b.reject(e); + } + } + } +} + +/** One set per request (created lazily by the context). */ +export function createLoaders(r: Repositories) { + return { + patient: new BatchLoader<Patient>((ids) => r.patients.getMany(ids)), + branch: new BatchLoader<Branch>((ids) => r.branches.getMany(ids)), + doctor: new BatchLoader<Doctor>((ids) => r.doctors.getMany(ids)), + booking: new BatchLoader<Booking>((ids) => r.bookings.getMany(ids)), + invoice: new BatchLoader<Invoice>((ids) => r.invoices.getMany(ids)), + ledger: new BatchLoader<Ledger>((ids) => r.ledgers.getMany(ids)), + group: new BatchLoader<AccountGroup>((ids) => r.accountGroups.getMany(ids)), + customer: new BatchLoader<Customer>((ids) => r.customers.getMany(ids)), + partner: new BatchLoader<Partner>((ids) => r.partners.getMany(ids)), + user: new BatchLoader<User>((ids) => r.users.getMany(ids)), + }; +} + +export type Loaders = ReturnType<typeof createLoaders>; +``` + + +--- + +## File: src/graphql/scalars.ts + +```ts +import { istDay } from "@mgpl-diagnostics/finance-kit"; +import { GraphQLScalarType, Kind } from "graphql"; + +const DAY = /^\d{4}-\d{2}-\d{2}$/; + +const toDate = (v: unknown): Date => { + const d = v instanceof Date ? v : new Date(v as string); + if (Number.isNaN(d.getTime())) throw new TypeError("Invalid DateTime"); + return d; +}; + +/** ISO-8601 instant, e.g. "2026-10-14T09:30:00.000Z". */ +export const DateTime = new GraphQLScalarType({ + name: "DateTime", + description: "ISO-8601 date-time (UTC), e.g. 2026-10-14T09:30:00.000Z", + serialize: (v) => toDate(v).toISOString(), + parseValue: (v) => toDate(v), + parseLiteral: (ast) => (ast.kind === Kind.STRING ? toDate(ast.value) : null), +}); + +const toDay = (v: unknown): string => { + if (typeof v !== "string" || !DAY.test(v) || Number.isNaN(Date.parse(`${v}T00:00:00Z`))) throw new TypeError(`Invalid Day "${String(v)}", expected YYYY-MM-DD`); + return v; +}; + +/** A calendar day in India (IST), e.g. "2026-10-14". */ +export const Day = new GraphQLScalarType({ + name: "Day", + description: "Calendar day in IST, YYYY-MM-DD", + serialize: (v) => (v instanceof Date ? istDay(v) : toDay(v)), + parseValue: toDay, + parseLiteral: (ast) => (ast.kind === Kind.STRING ? toDay(ast.value) : null), +}); + +/** Free-form JSON (report-kit report requests and other open shapes). */ +export const JSONScalar = new GraphQLScalarType({ + name: "JSON", + description: "Any JSON value", + serialize: (v) => v, + parseValue: (v) => v, + parseLiteral: function literal(ast): unknown { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: + return ast.value; + case Kind.INT: + case Kind.FLOAT: + return Number(ast.value); + case Kind.OBJECT: + return Object.fromEntries(ast.fields.map((f) => [f.name.value, literal(f.value)])); + case Kind.LIST: + return ast.values.map((v) => literal(v)); + default: + return null; + } + }, +}); +``` + + +--- + +## File: src/graphql/schema.ts + +```ts +import { makeExecutableSchema } from "@graphql-tools/schema"; +import type { GraphQLSchema } from "graphql"; +import { authController } from "../controllers/auth.controller"; +import { baseController } from "../controllers/base.controller"; +import { billingController } from "../controllers/billing.controller"; +import { booksController } from "../controllers/books.controller"; +import { catalogController } from "../controllers/catalog.controller"; +import { documentsController } from "../controllers/documents.controller"; +import { financeController } from "../controllers/finance.controller"; +import { labController } from "../controllers/lab.controller"; +import { organizationController } from "../controllers/organization.controller"; +import { authTypeDefs } from "../schema/auth.graphql"; +import { baseTypeDefs } from "../schema/base.graphql"; +import { billingTypeDefs } from "../schema/billing.graphql"; +import { booksTypeDefs } from "../schema/books.graphql"; +import { catalogTypeDefs } from "../schema/catalog.graphql"; +import { documentsTypeDefs } from "../schema/documents.graphql"; +import { financeTypeDefs } from "../schema/finance.graphql"; +import { labTypeDefs } from "../schema/lab.graphql"; +import { organizationTypeDefs } from "../schema/organization.graphql"; + +/** One domain = one SDL file in src/schema + one controller in src/controllers. */ +export interface GraphQLModule { + name: string; + typeDefs: string; + resolvers: Record<string, unknown>; +} + +export const MODULES: GraphQLModule[] = [ + { name: "base", typeDefs: baseTypeDefs, resolvers: baseController }, + { name: "auth", typeDefs: authTypeDefs, resolvers: authController }, + { name: "organization", typeDefs: organizationTypeDefs, resolvers: organizationController }, + { name: "catalog", typeDefs: catalogTypeDefs, resolvers: catalogController }, + { name: "lab", typeDefs: labTypeDefs, resolvers: labController }, + { name: "billing", typeDefs: billingTypeDefs, resolvers: billingController }, + { name: "books", typeDefs: booksTypeDefs, resolvers: booksController }, + { name: "finance", typeDefs: financeTypeDefs, resolvers: financeController }, + { name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController }, +]; + +/** The whole API as one executable schema. */ +export function buildSchema(modules: GraphQLModule[] = MODULES): GraphQLSchema { + return makeExecutableSchema({ typeDefs: modules.map((m) => m.typeDefs), resolvers: modules.map((m) => m.resolvers) as never }); +} +``` + + +--- + +## File: src/index.ts + +```ts +// The api as a library (tests, scripts, other entry points): build a container and a server. +export { loadConfig, type Config } from "./config/env"; +export { createContainer, type Container, type ContainerOptions } from "./container"; +export { buildServer } from "./server"; +export { buildSchema, MODULES, type GraphQLModule } from "./graphql/schema"; +export { ERROR_CODES, type ErrorCode } from "./graphql/errors"; +export type { RequestContext, Actor } from "./graphql/context"; +export type { Mailer } from "./services/auth.service"; +``` + + +--- + +## File: src/main.ts + +```ts +// Starts the API: `npm run dev` (watch) or `npm run build && npm start`. +import { loadConfig } from "./config/env"; +import { createContainer } from "./container"; +import { buildServer } from "./server"; + +const config = loadConfig(); +const container = await createContainer({ config }); +if (!container.db.redis && config.env === "production") console.warn("[api] REDIS_URL is not set: sessions live in this process only (one server, lost on restart)"); +// No mailer is wired in this entry: requestPasswordReset still answers true but sends nothing. +// Embed the API with createContainer({ config, mailer }) to send reset links (docs/12-deployment.md). +if (config.env === "production") console.warn("[api] no Mailer configured: password-reset links are not sent"); +const app = await buildServer(container); +await app.listen({ host: config.host, port: config.port }); +console.log(`[api] GraphQL on http://${config.host}:${config.port}${config.graphqlPath} (${config.db.backend})`); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + app.close().then(() => process.exit(0), () => process.exit(1)); + }); +} +``` + + +--- + +## File: src/schema/auth.graphql.ts + +```ts +/** Sign-in, sessions, passwords (auth-kit) and users and roles. */ +export const authTypeDefs = /* GraphQL */ ` + enum UserStatus { active disabled invited } + + type RoleAssignment { role: String!, branchId: ID, branch: Branch } + input RoleAssignmentInput { role: String!, branchId: ID } + + type User { + id: ID! + name: String! + email: String! + phone: String + status: UserStatus! + roles: [RoleAssignment!]! + lastLoginAt: DateTime + createdAt: DateTime! + } + + type Role { id: ID!, key: String!, name: String!, permissions: [String!]!, system: Boolean! } + + "The signed-in user and what they may do." + type Viewer { + user: User! + organization: Organization! + "Permissions held in this branch (or organisation-wide when branchId is omitted)." + permissions(branchId: ID): [String!]! + "Branches where the user has this permission; null means every branch." + branchesFor(permission: String!): [ID!] + can(permission: String!, branchId: ID): Boolean! + } + + type Session { id: ID!, current: Boolean!, createdAt: DateTime!, lastSeenAt: DateTime!, expiresAt: DateTime!, ip: String, userAgent: String } + + type LoginResult { + "Send as 'Authorization: Bearer <token>'. Shown once; the server stores only its SHA-256." + token: String! + expiresAt: DateTime! + user: User! + } + + extend type Query { + "null when not signed in." + viewer: Viewer + mySessions: [Session!]! + users: [User!]! + user(id: ID!): User + roles: [Role!]! + "Every permission name the API checks." + permissions: [String!]! + } + + input LoginInput { email: String!, password: String!, "Only when the same email exists in several organisations." orgCode: String } + input CreateUserInput { name: String!, email: String!, phone: String, password: String!, roles: [RoleAssignmentInput!]! } + input RoleInput { key: String!, name: String!, permissions: [String!]! } + + extend type Mutation { + login(input: LoginInput!): LoginResult! + "Ends this session." + logout: Boolean! + "Ends every other session of the signed-in user; returns how many." + logoutOtherSessions: Int! + revokeSession(id: ID!): Boolean! + changePassword(currentPassword: String!, newPassword: String!): Boolean! + "Always true (never reveals whether the email exists). The link is sent by the server's mailer." + requestPasswordReset(email: String!, orgCode: String): Boolean! + resetPassword(token: String!, newPassword: String!): Boolean! + + createUser(input: CreateUserInput!): User! + setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User! + disableUser(userId: ID!): User! + enableUser(userId: ID!): User! + setUserPassword(userId: ID!, newPassword: String!): Boolean! + upsertRole(input: RoleInput!): Role! + } +`; +``` + + +--- + +## File: src/schema/base.graphql.ts + +```ts +/** + * Shared scalars, enums and the root types. Money is Int paise (Rs 1 = 100). Instants are DateTime + * (ISO-8601, UTC); calendar days are Day ("2026-10-14", IST). Every query is scoped to the signed-in + * user's organisation: nothing takes an orgId. + */ +export const baseTypeDefs = /* GraphQL */ ` + scalar DateTime + scalar Day + scalar JSON + + enum Sex { male female other } + enum PaymentMode { cash upi card netbanking wallet cheque insurance } + enum ItemCategory { test package service consumable medicine other } + enum Taxability { taxable exempt nil_rated non_gst } + enum VoucherType { sales receipt payment journal contra credit_note debit_note purchase closing } + + type Address { line1: String, line2: String, city: String, district: String, state: String, pincode: String } + input AddressInput { line1: String, line2: String, city: String, district: String, state: String, pincode: String } + + "Service health: database reachable, versions." + type Health { ok: Boolean!, service: String!, version: String!, database: String!, time: DateTime! } + + type Query { + health: Health! + } + + type Mutation { + "Does nothing; lets clients check that mutations reach the server." + ping: String! + } +`; +``` + + +--- + +## File: src/schema/billing.graphql.ts + +```ts +/** Patient invoices and payments. Issuing and paying post to the books automatically. */ +export const billingTypeDefs = /* GraphQL */ ` + enum InvoiceStatus { draft issued partially_paid paid cancelled refunded credited } + enum PaymentKind { payment refund } + + type InvoiceItem { + code: String + name: String! + category: ItemCategory! + qty: Float! + ratePaise: Int! + discountPaise: Int! + taxPercent: Float! + hsnSac: String + taxability: Taxability + amountPaise: Int! + } + + type Invoice { + id: ID! + invoiceNo: String! + status: InvoiceStatus! + patient: Patient! + branch: Branch! + booking: Booking + customer: Customer + placeOfSupply: String + buyerGstin: String + items: [InvoiceItem!]! + subtotalPaise: Int! + discountPaise: Int! + taxPaise: Int! + roundOffPaise: Int! + totalPaise: Int! + paidPaise: Int! + creditedPaise: Int! + duePaise: Int! + issuedAt: DateTime + cancelReason: String + createdAt: DateTime! + payments: [Payment!]! + vouchers: [Voucher!]! + creditNotes: [CreditNote!]! + } + + type Payment { id: ID!, kind: PaymentKind!, amountPaise: Int!, mode: PaymentMode!, reference: String, receivedAt: DateTime! } + + extend type Query { + invoice(id: ID!): Invoice + invoices(branchId: ID, status: InvoiceStatus, patientId: ID, customerId: ID, limit: Int = 50, offset: Int = 0): [Invoice!]! + } + + "A catalog-kit test code alone is enough: name and MRP come from the catalog." + input InvoiceItemInput { code: String, name: String, category: ItemCategory = test, qty: Float = 1, ratePaise: Int, discountPaise: Int = 0, taxPercent: Float = 0, hsnSac: String, taxability: Taxability } + input PaymentInput { amountPaise: Int!, mode: PaymentMode!, reference: String } + + input IssueInvoiceInput { + branchId: ID! + patientId: ID! + bookingId: ID + items: [InvoiceItemInput!]! + billDiscountPaise: Int = 0 + "Taken at the counter with the invoice." + payments: [PaymentInput!] + "Fill HSN/SAC, taxability and GST rate from the tax rules." + applyTaxRules: Boolean = false + } + + extend type Mutation { + issueInvoice(input: IssueInvoiceInput!): Invoice! + recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + cancelInvoice(id: ID!, reason: String!): Invoice! + } +`; +``` + + +--- + +## File: src/schema/books.graphql.ts + +```ts +/** The books (tally-kit, our accounting engine): chart of accounts, vouchers, reports. */ +export const booksTypeDefs = /* GraphQL */ ` + enum AccountNature { assets liabilities income expenses } + enum VoucherStatus { posted cancelled } + enum AgeBucket { D0_30 D31_60 D61_90 D90_PLUS } + + type AccountGroup { id: ID!, name: String!, nature: AccountNature!, parent: AccountGroup, affectsGrossProfit: Boolean! } + + type Ledger { + id: ID! + name: String! + group: AccountGroup! + openingPaise: Int! + systemKey: String + gstin: String + active: Boolean! + "Closing balance, positive = Debit." + balancePaise(asOf: DateTime, branchId: ID): Int! + } + + type VoucherEntry { ledger: Ledger!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } + + type Voucher { + id: ID! + type: VoucherType! + voucherNo: String! + date: DateTime! + narration: String + status: VoucherStatus! + entries: [VoucherEntry!]! + branch: Branch! + invoiceId: ID + } + + type StatementRow { date: DateTime!, voucherNo: String!, voucherType: VoucherType!, debitPaise: Int!, creditPaise: Int!, balancePaise: Int!, invoiceId: ID } + type LedgerStatement { ledger: Ledger!, openingPaise: Int!, closingPaise: Int!, totalDebitPaise: Int!, totalCreditPaise: Int!, rows: [StatementRow!]! } + type TrialBalanceRow { ledger: Ledger!, group: AccountGroup!, primaryGroup: AccountGroup!, debitPaise: Int!, creditPaise: Int! } + type TrialBalance { rows: [TrialBalanceRow!]!, totalDebitPaise: Int!, totalCreditPaise: Int!, differencePaise: Int! } + type LedgerAmount { ledger: Ledger!, amountPaise: Int! } + type GroupLine { group: AccountGroup!, amountPaise: Int!, ledgers: [LedgerAmount!]! } + type ProfitAndLoss { income: [GroupLine!]!, expenses: [GroupLine!]!, grossProfitPaise: Int!, netProfitPaise: Int! } + type BalanceSheet { assets: [GroupLine!]!, liabilities: [GroupLine!]!, profitPaise: Int!, totalAssetsPaise: Int!, totalLiabilitiesPaise: Int!, differencePaise: Int! } + type OutstandingRow { invoice: Invoice!, ageDays: Int!, bucket: AgeBucket! } + type BucketTotals { d0to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } + type Outstanding { rows: [OutstandingRow!]!, buckets: BucketTotals!, totalDuePaise: Int! } + type GstSummary { taxableValuePaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int!, totalTaxPaise: Int! } + + extend type Query { + accountGroups: [AccountGroup!]! + ledgers: [Ledger!]! + ledger(id: ID!): Ledger + voucher(id: ID!): Voucher + ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! + cashBook(from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! + trialBalance(asOf: DateTime, branchId: ID): TrialBalance! + profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss! + balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! + dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]! + outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! + gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary! + } + + input VoucherEntryInput { ledgerId: ID!, debitPaise: Int = 0, creditPaise: Int = 0, invoiceId: ID } + input PostVoucherInput { branchId: ID!, type: VoucherType!, date: DateTime, narration: String, entries: [VoucherEntryInput!]! } + input LedgerInput { name: String!, groupId: ID!, openingPaise: Int = 0, gstin: String } + + extend type Mutation { + "Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent." + setupChartOfAccounts: [Ledger!]! + createLedger(input: LedgerInput!): Ledger! + postVoucher(input: PostVoucherInput!): Voucher! + cancelVoucher(id: ID!): Voucher! + } +`; +``` + + +--- + +## File: src/schema/catalog.graphql.ts + +```ts +/** The test catalog (catalog-kit): tests, parameters, ranges, sample types, prices, search. */ +export const catalogTypeDefs = /* GraphQL */ ` + enum ParameterType { numeric text options image title } + + type CatalogInterval { low: String, high: String } + + type CatalogParameter { + name: String + unit: String + method: String + type: ParameterType! + male: CatalogInterval + female: CatalogInterval + options: [String!] + calculated: Boolean! + "Readable formula of a calculated parameter." + formulaText: String + referenceText: String + "The printed range for a sex (or both)." + range(sex: Sex): String! + } + + type CatalogTest { + id: Int! + name: String! + code: String + shortCode: String + department: String! + sampleType: String + secondarySampleTypes: [String!] + "MRP in rupees." + mrp: Int + "MRP in paise (what bookings and invoices use)." + pricePaise: Int + isProfile: Boolean! + parameterCount: Int! + parameters: [CatalogParameter!]! + } + + type CatalogHit { test: CatalogTest!, score: Float! } + type PriceQuote { "Rupees." total: Int!, totalPaise: Int!, tests: [CatalogTest!]!, unknown: [String!]! } + + extend type Query { + "Typo-tolerant search with lab shorthand (cbc, lft, vit d…)." + catalogSearch(query: String!, limit: Int = 20, department: String, sampleType: String, fuzzy: Boolean = true): [CatalogHit!]! + catalogTest(id: Int, code: String): CatalogTest + catalogTests(department: String, sampleType: String, limit: Int = 50, offset: Int = 0): [CatalogTest!]! + catalogDepartments: [String!]! + catalogSampleTypes: [String!]! + "Total MRP for test codes (or numeric ids as strings)." + priceQuote(tests: [String!]!): PriceQuote! + } +`; +``` + + +--- + +## File: src/schema/documents.graphql.ts + +```ts +/** + * Documents rendered by report-kit (lab reports, letterhead) and invoice-kit (invoices), used as + * internal libraries. PDFs come back base64-encoded in \`base64\`; HTML is self-contained in \`html\`. + */ +export const documentsTypeDefs = /* GraphQL */ ` + enum DocumentFormat { pdf html } + enum ReportVariant { header non_header } + enum ReportDesign { sidebar classic } + + type Document { + format: DocumentFormat! + fileName: String! + mimeType: String! + byteLength: Int! + "PDF bytes, base64 (null for HTML)." + base64: String + "Self-contained HTML (null for PDF)." + html: String + } + + type TemplateField { key: String!, label: String!, unit: String, range: [String!] } + type ReportTemplate { id: String!, name: String!, category: String, specimen: String, method: String, fields: [TemplateField!]! } + type ReportProfile { id: String!, name: String!, tests: [String!]! } + type TemplateCatalog { templates: [ReportTemplate!]!, profiles: [ReportProfile!]! } + type PriceListEntry { code: String!, name: String!, category: String!, "rupees" rate: Float!, taxPercent: Float, hsn: String } + type DocumentSample { id: String!, label: String!, group: String } + + extend type Query { + reportTemplates: TemplateCatalog! + "invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables)." + priceList: [PriceListEntry!]! + "Demo samples (empty unless the server enables them)." + documentSamples: [DocumentSample!]! + invoiceSamples: [DocumentSample!]! + + "Any report: request = report-kit's ReportRequest JSON (patient + tests | profile | report | sample)." + renderReport(request: JSON!, format: DocumentFormat = pdf, variant: ReportVariant = header, design: ReportDesign = sidebar, fileName: String): Document! + "Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample })." + renderInvoice(request: JSON!, format: DocumentFormat = pdf, variant: ReportVariant = header, fileName: String): Document! + "invoice-kit's calculation of an InvoiceRequest, without rendering (rupees)." + invoiceTotals(request: JSON!): JSON! + letterhead(format: DocumentFormat = pdf): Document! + + "A stored invoice, printed." + invoiceDocument(invoiceId: ID!, format: DocumentFormat = pdf, variant: ReportVariant = header): Document! + "A booking's verified or released reports, printed together (or only reportIds)." + bookingReportDocument(bookingId: ID!, reportIds: [ID!], format: DocumentFormat = pdf, variant: ReportVariant = header, design: ReportDesign = sidebar): Document! + } +`; +``` + + +--- + +## File: src/schema/finance.graphql.ts + +```ts +/** + * finance-kit: GST, B2B receivables and credit notes, collection-centre settlements with TDS, day-end + * cash, bank reconciliation, lock dates, year-end close, CSV exports. Compliance values are + * configuration to be verified with the lab's CA (see verifyWithCA); nothing here is legal advice. + */ +export const financeTypeDefs = /* GraphQL */ ` + # ---------------------------------------------------------------- GST + type TaxRule { id: ID!, code: String, category: ItemCategory, hsnSac: String!, taxability: Taxability!, ratePercent: Float!, effectiveFrom: DateTime!, effectiveTo: DateTime, note: String } + + type TdsConfig { section: String!, rateBp: Int!, noPanRateBp: Int!, thresholdPaise: Int } + type FinanceConfig { b2clThresholdPaise: Int!, tds: TdsConfig!, bankToleranceDays: Int!, bankMaxFeeBp: Int!, clearingLedgerKeys: [String!]! } + + type RateTotals { ratePercent: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type TaxTotals { taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type Gstr1Invoice { invoiceId: ID!, invoiceNo: String!, day: Day!, valuePaise: Int!, placeOfSupply: String!, reverseCharge: String!, ctin: String, items: [RateTotals!]! } + type Gstr1Note { noteId: ID!, noteNo: String!, day: Day!, noteType: String!, invoiceNo: String!, invoiceDay: Day!, valuePaise: Int!, placeOfSupply: String!, ctin: String, type: String, items: [RateTotals!]! } + type B2csRow { supplyType: String!, placeOfSupply: String!, ratePercent: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type NilRow { description: String!, nilRatedPaise: Int!, exemptedPaise: Int!, nonGstPaise: Int! } + type HsnRow { hsnSac: String!, ratePercent: Float!, taxability: Taxability!, qty: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int!, totalValuePaise: Int! } + type HsnSummary { b2b: [HsnRow!]!, b2c: [HsnRow!]! } + type DocSeries { nature: String!, series: String!, from: String!, to: String!, total: Int!, cancelled: Int!, net: Int! } + type Period { from: DateTime!, to: DateTime! } + + type Gstr1 { + gstin: String! + period: Period! + b2b: [Gstr1Invoice!]! + b2cl: [Gstr1Invoice!]! + b2cs: [B2csRow!]! + cdnr: [Gstr1Note!]! + cdnur: [Gstr1Note!]! + nil: [NilRow!]! + hsn: HsnSummary! + docs: [DocSeries!]! + totals: TaxTotals! + verifyWithCA: [String!]! + } + + type InterStateRow { placeOfSupply: String!, taxablePaise: Int!, igstPaise: Int! } + type Gstr3b { + gstin: String! + period: Period! + "3.1(a)" + outwardTaxable: TaxTotals! + "3.1(c)" + outwardNilExemptPaise: Int! + "3.1(e)" + outwardNonGstPaise: Int! + "3.2" + interStateUnregistered: [InterStateRow!]! + verifyWithCA: [String!]! + } + + # ---------------------------------------------------------------- receivables + enum CustomerKind { hospital insurer tpa corporate other } + enum OpenItemBucket { not_due D1_30 D31_60 D61_90 D90_PLUS } + + type Customer { + id: ID! + code: String! + name: String! + kind: CustomerKind! + legalName: String + gstin: String + stateCode: String + pan: String + phone: String + email: String + address: Address + "null = no limit." + creditLimitPaise: Int + creditDays: Int! + ledger: Ledger! + active: Boolean! + } + + type OpenItem { invoiceId: ID!, invoiceNo: String!, day: Day!, dueDay: Day!, totalPaise: Int!, paidPaise: Int!, creditedPaise: Int!, duePaise: Int!, overdueDays: Int!, bucket: OpenItemBucket! } + type Ageing { notDue: Int!, d1to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } + type CustomerStatement { + customer: Customer! + openingPaise: Int! + rows: [StatementRow!]! + totalDebitPaise: Int! + totalCreditPaise: Int! + closingPaise: Int! + open: [OpenItem!]! + ageing: Ageing! + openTotalPaise: Int! + availableCreditPaise: Int + } + type ReceivablesRow { customer: Customer!, balancePaise: Int!, overduePaise: Int!, ageing: Ageing! } + type ReceivablesSummary { rows: [ReceivablesRow!]!, totalPaise: Int! } + + type CreditNoteLine { itemIndex: Int!, name: String!, code: String, hsnSac: String, taxability: Taxability, taxPercent: Float!, taxablePaise: Int!, taxPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int! } + type CreditNote { + id: ID! + noteNo: String! + date: DateTime! + reason: String! + invoice: Invoice! + customer: Customer + lines: [CreditNoteLine!]! + taxablePaise: Int! + taxPaise: Int! + cgstPaise: Int! + sgstPaise: Int! + igstPaise: Int! + roundOffPaise: Int! + totalPaise: Int! + placeOfSupply: String + buyerGstin: String + status: String! + } + type CreditNoteResult { creditNote: CreditNote!, invoice: Invoice!, "Refund with refundPayment." refundablePaise: Int! } + + # ---------------------------------------------------------------- settlements + enum PartnerKind { collection_centre franchise } + enum AgreementKind { revenue_share fixed_rate } + enum SettlementStatus { draft accrued paid cancelled } + + type PartnerTds { section: String!, rateBp: Int! } + type Partner { + id: ID! + code: String! + name: String! + kind: PartnerKind! + branch: Branch! + legalName: String + pan: String + gstin: String + stateCode: String + phone: String + email: String + address: Address + ledger: Ledger! + "Overrides the default TDS (verify with CA)." + tds: PartnerTds + active: Boolean! + agreements: [PartnerAgreement!]! + } + + type ShareRate { code: String!, sharePaise: Int! } + type PartnerAgreement { id: ID!, kind: AgreementKind!, shareBp: Int, rates: [ShareRate!], defaultSharePaise: Int, categories: [ItemCategory!], effectiveFrom: DateTime!, effectiveTo: DateTime, notes: String } + + type SettlementLine { invoiceId: ID!, invoiceNo: String!, day: Day!, code: String, name: String!, netPaise: Int!, sharePaise: Int!, agreementId: ID!, creditNoteId: ID } + type SettlementDeduction { label: String!, amountPaise: Int!, ledgerId: ID } + type Tds { section: String, rateBp: Int!, basePaise: Int!, tdsPaise: Int! } + + "A settlement computed but not saved." + type SettlementPreview { + partner: Partner! + period: String! + from: DateTime! + to: DateTime! + lines: [SettlementLine!]! + grossPaise: Int! + sharePaise: Int! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + tds: Tds! + netPaise: Int! + "Invoice numbers with no agreement in force on their date." + uncoveredInvoices: [String!]! + } + + type Settlement { + id: ID! + partner: Partner! + branch: Branch! + period: String! + from: DateTime! + to: DateTime! + lines: [SettlementLine!]! + grossPaise: Int! + sharePaise: Int! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + tdsSection: String + tdsRateBp: Int! + tdsBasePaise: Int! + tdsPaise: Int! + netPaise: Int! + status: SettlementStatus! + accrualVoucher: Voucher + payoutVoucher: Voucher + paidAt: DateTime + createdAt: DateTime! + } + + # ---------------------------------------------------------------- cash and bank + enum BankLineStatus { unmatched matched ignored } + + type CashDenomination { valuePaise: Int!, count: Int! } + type CashClosing { id: ID!, branch: Branch!, day: Day!, bookPaise: Int!, countedPaise: Int!, "counted − book; negative = short." differencePaise: Int!, denominations: [CashDenomination!], voucher: Voucher, notes: String, createdAt: DateTime! } + + type BankStatement { id: ID!, branch: Branch!, ledger: Ledger!, fileName: String, from: DateTime, to: DateTime, openingPaise: Int, closingPaise: Int, lineCount: Int!, createdAt: DateTime! } + type BankSettlementMatch { ledgerId: ID!, day: Day!, grossPaise: Int!, feePaise: Int! } + type BankLine { id: ID!, statementId: ID!, date: DateTime!, description: String!, reference: String, debitPaise: Int!, creditPaise: Int!, balancePaise: Int, status: BankLineStatus!, matchKind: String, voucherIds: [ID!]!, settlement: BankSettlementMatch, matchedAt: DateTime } + type BankImportResult { statement: BankStatement!, added: Int!, skipped: Int! } + type ReconcileResult { matched: Int!, settlements: Int!, unmatched: Int! } + type LedgerEntryRow { id: ID!, voucherId: ID!, voucherType: VoucherType!, voucherNo: String!, date: DateTime!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } + type ReconciledTotals { lines: Int!, amountPaise: Int! } + type BankReconciliation { + bookPaise: Int! + bankPaise: Int + paymentsNotPresentedPaise: Int! + depositsNotCreditedPaise: Int! + bankCreditsNotInBooksPaise: Int! + bankDebitsNotInBooksPaise: Int! + expectedBankPaise: Int! + differencePaise: Int + reconciled: ReconciledTotals! + unmatchedLines: [BankLine!]! + unreconciledEntries: [LedgerEntryRow!]! + } + + # ---------------------------------------------------------------- periods + type PeriodLock { id: ID!, scope: String!, branch: Branch, lockedUpTo: DateTime, reason: String, updatedAt: DateTime! } + type YearEndResult { vouchers: [Voucher!]!, netProfitPaise: Int! } + + # ---------------------------------------------------------------- exports + type CsvFile { fileName: String!, mimeType: String!, content: String! } + + extend type Query { + taxRules: [TaxRule!]! + financeConfig: FinanceConfig! + "Items the lab's CA must confirm before these values are relied on." + verifyWithCA: [String!]! + gstr1(from: DateTime!, to: DateTime!, gstin: String): Gstr1! + gstr3b(from: DateTime!, to: DateTime!, gstin: String): Gstr3b! + + customers(activeOnly: Boolean = true): [Customer!]! + customer(id: ID!): Customer + customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement! + receivablesSummary(asOf: DateTime!): ReceivablesSummary! + creditNote(id: ID!): CreditNote + creditNotes(invoiceId: ID, customerId: ID, limit: Int = 50, offset: Int = 0): [CreditNote!]! + + partners(branchId: ID): [Partner!]! + partner(id: ID!): Partner + "Computes a month's settlement without saving it. period: YYYY-MM." + settlementPreview(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): SettlementPreview! + settlements(partnerId: ID, status: SettlementStatus, branchId: ID, limit: Int = 50, offset: Int = 0): [Settlement!]! + settlement(id: ID!): Settlement + + cashClosings(branchId: ID!, limit: Int = 31, offset: Int = 0): [CashClosing!]! + bankStatements(ledgerId: ID, limit: Int = 50, offset: Int = 0): [BankStatement!]! + bankLines(ledgerId: ID, statementId: ID, status: BankLineStatus, limit: Int = 100, offset: Int = 0): [BankLine!]! + bankReconciliation(ledgerId: ID!, asOf: DateTime!): BankReconciliation! + + lockDates: [PeriodLock!]! + + exportDayBook(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportLedger(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportTrialBalance(asOf: DateTime, branchId: ID, bom: Boolean = false): CsvFile! + exportProfitAndLoss(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportBalanceSheet(asOf: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportSettlement(settlementId: ID!, bom: Boolean = false): CsvFile! + "One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs." + exportGstr1(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean = false): [CsvFile!]! + exportGstr3b(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean = false): CsvFile! + } + + input TaxRuleInput { code: String, category: ItemCategory, hsnSac: String!, taxability: Taxability!, ratePercent: Float!, effectiveFrom: Day!, note: String } + + input CustomerInput { code: String!, name: String!, kind: CustomerKind!, legalName: String, gstin: String, stateCode: String, pan: String, phone: String, email: String, address: AddressInput, creditLimitPaise: Int, creditDays: Int } + input CustomerPatch { name: String, kind: CustomerKind, legalName: String, gstin: String, stateCode: String, pan: String, phone: String, email: String, address: AddressInput, "null removes the limit." creditLimitPaise: Int, creditDays: Int, active: Boolean } + input CustomerInvoiceInput { + branchId: ID! + patientId: ID! + customerId: ID! + bookingId: ID + items: [InvoiceItemInput!]! + billDiscountPaise: Int = 0 + payments: [PaymentInput!] + "State code override." + placeOfSupply: String + applyTaxRules: Boolean = true + } + input CreditNoteLineInput { itemIndex: Int!, taxablePaise: Int } + input CreditNoteInput { invoiceId: ID!, reason: String!, "Default: everything not yet credited." lines: [CreditNoteLineInput!], date: DateTime } + + input PartnerTdsInput { section: String!, rateBp: Int! } + input PartnerInput { code: String!, name: String!, kind: PartnerKind!, branchId: ID!, legalName: String, pan: String, gstin: String, stateCode: String, phone: String, email: String, address: AddressInput, tds: PartnerTdsInput } + input PartnerPatch { name: String, kind: PartnerKind, legalName: String, pan: String, gstin: String, stateCode: String, phone: String, email: String, address: AddressInput, "null = default TDS." tds: PartnerTdsInput, active: Boolean } + input ShareRateInput { code: String!, sharePaise: Int! } + input AgreementInput { partnerId: ID!, kind: AgreementKind!, shareBp: Int, rates: [ShareRateInput!], defaultSharePaise: Int, categories: [ItemCategory!], effectiveFrom: Day!, effectiveTo: Day, notes: String } + input SettlementDeductionInput { label: String!, amountPaise: Int!, ledgerId: ID } + input PaySettlementInput { settlementId: ID!, mode: PaymentMode!, date: DateTime, reference: String } + + input CashDenominationInput { valuePaise: Int!, count: Int! } + input CloseCashDayInput { branchId: ID!, day: Day!, countedPaise: Int!, denominations: [CashDenominationInput!], notes: String } + input BankImportInput { branchId: ID!, ledgerId: ID!, "The bank's CSV export, as text." csv: String!, fileName: String } + enum BankLineManualStatus { ignored unmatched } + + input LockDateInput { "Omit for the whole organisation." branchId: ID, "Last locked IST day; null removes the lock." upTo: Day, reason: String } + + extend type Mutation { + addTaxRule(input: TaxRuleInput!): TaxRule! + "finance-kit's starting rules. Every rate must be verified with the lab's CA." + seedTaxRules: [TaxRule!]! + + createCustomer(input: CustomerInput!): Customer! + updateCustomer(id: ID!, input: CustomerPatch!): Customer! + issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice! + issueCreditNote(input: CreditNoteInput!): CreditNoteResult! + + createPartner(input: PartnerInput!): Partner! + updatePartner(id: ID!, input: PartnerPatch!): Partner! + addAgreement(input: AgreementInput!): PartnerAgreement! + createSettlement(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): Settlement! + accrueSettlement(id: ID!): Settlement! + paySettlement(input: PaySettlementInput!): Settlement! + cancelSettlement(id: ID!): Settlement! + + closeCashDay(input: CloseCashDayInput!): CashClosing! + importBankStatement(input: BankImportInput!): BankImportResult! + reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult! + matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine! + setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine! + + setLockDate(input: LockDateInput!): PeriodLock! + "Closing vouchers on 31 March and a lock. fy: 2026-27." + closeFinancialYear(fy: String!): YearEndResult! + } +`; +``` + + +--- + +## File: src/schema/lab.graphql.ts + +```ts +/** Patients, doctors, bookings, samples and reports (db-kit workflows). */ +export const labTypeDefs = /* GraphQL */ ` + enum BookingStatus { booked sample_collected in_process completed cancelled } + enum CollectionType { walk_in home } + enum SampleStatus { pending collected received rejected } + enum ReportStatus { draft entered verified released amended } + enum ResultFlag { L H A NONE } + + type Patient { + id: ID! + uhid: String! + title: String + name: String! + sex: Sex! + dob: DateTime + ageYears: Int + phone: String + email: String + address: Address + branch: Branch + createdAt: DateTime! + bookings(limit: Int = 20): [Booking!]! + invoices(limit: Int = 20): [Invoice!]! + } + + type Doctor { id: ID!, name: String!, qualification: String, phone: String, clinic: String, active: Boolean! } + + type BookingTest { testCode: String!, name: String!, pricePaise: Int!, sampleType: String, "The catalog entry, when the code is in catalog-kit." catalog: CatalogTest } + type SampleCollection { type: CollectionType!, scheduledAt: DateTime, address: Address } + + type Booking { + id: ID! + bookingNo: String! + status: BookingStatus! + patient: Patient! + doctor: Doctor + branch: Branch! + tests: [BookingTest!]! + sampleCollection: SampleCollection! + samples: [Sample!]! + reports: [Report!]! + invoice: Invoice + notes: String + createdAt: DateTime! + } + + type Sample { + id: ID! + barcode: String! + sampleType: String! + status: SampleStatus! + collectedAt: DateTime + receivedAt: DateTime + rejectReason: String + booking: Booking! + } + + type ResultValue { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } + + type Report { + id: ID! + testCode: String! + testName: String! + status: ReportStatus! + results: [ResultValue!]! + version: Int! + verifiedAt: DateTime + releasedAt: DateTime + patient: Patient! + booking: Booking! + updatedAt: DateTime! + } + + type CreateBookingResult { booking: Booking!, samples: [Sample!]! } + + extend type Query { + patient(id: ID!): Patient + "Name, phone or UHID." + patients(search: String!, limit: Int = 20): [Patient!]! + doctors(activeOnly: Boolean = true): [Doctor!]! + booking(id: ID!): Booking + bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Booking!]! + sample(id: ID!): Sample + sampleByBarcode(barcode: String!): Sample + report(id: ID!): Report + } + + input RegisterPatientInput { branchId: ID!, title: String, name: String!, sex: Sex!, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } + input UpdatePatientInput { title: String, name: String, sex: Sex, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } + input DoctorInput { name: String!, qualification: String, phone: String, clinic: String, active: Boolean = true } + input DoctorPatch { name: String, qualification: String, phone: String, clinic: String, active: Boolean } + + "A test by catalog code: name, price and sample type come from catalog-kit unless given." + input BookingTestInput { testCode: String!, name: String, pricePaise: Int, sampleType: String } + + input CreateBookingInput { + branchId: ID! + patientId: ID! + doctorId: ID + tests: [BookingTestInput!]! + collectionType: CollectionType = walk_in + scheduledAt: DateTime + notes: String + } + + "Unit, range and flag are filled from catalog-kit when omitted and the test is in the catalog." + input ResultInput { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } + + extend type Mutation { + registerPatient(input: RegisterPatientInput!): Patient! + updatePatient(id: ID!, input: UpdatePatientInput!): Patient! + createDoctor(input: DoctorInput!): Doctor! + updateDoctor(id: ID!, input: DoctorPatch!): Doctor! + + createBooking(input: CreateBookingInput!): CreateBookingResult! + cancelBooking(id: ID!): Booking! + collectSample(id: ID!): Sample! + receiveSample(id: ID!): Sample! + rejectSample(id: ID!, reason: String!): Sample! + + createReport(bookingId: ID!, testCode: String!, testName: String): Report! + saveResults(reportId: ID!, results: [ResultInput!]!): Report! + verifyReport(id: ID!): Report! + releaseReport(id: ID!): Report! + amendReport(id: ID!): Report! + } +`; +``` + + +--- + +## File: src/schema/organization.graphql.ts + +```ts +/** The organisation, its branches and the audit trail. */ +export const organizationTypeDefs = /* GraphQL */ ` + enum BranchKind { lab collection_center } + + type Organization { + id: ID! + name: String! + code: String! + legalName: String + gstin: String + phone: String + email: String + address: Address + branches: [Branch!]! + } + + type Branch { id: ID!, code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, stateCode: String, address: Address, active: Boolean! } + + type AuditEntry { id: ID!, action: String!, entity: String, entityId: ID, branchId: ID, user: User, ip: String, data: JSON, at: DateTime! } + + extend type Query { + organization: Organization! + branches: [Branch!]! + branch(id: ID!): Branch + auditLog(entity: String, entityId: ID, limit: Int = 50, offset: Int = 0): [AuditEntry!]! + } + + input OrganizationInput { name: String, legalName: String, gstin: String, phone: String, email: String, address: AddressInput } + input BranchInput { code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, stateCode: String, address: AddressInput, active: Boolean } + input BranchPatch { name: String, kind: BranchKind, phone: String, gstin: String, stateCode: String, address: AddressInput, active: Boolean } + + extend type Mutation { + updateOrganization(input: OrganizationInput!): Organization! + createBranch(input: BranchInput!): Branch! + updateBranch(id: ID!, input: BranchPatch!): Branch! + } +`; +``` + + +--- + +## File: src/server.ts + +```ts +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; +import { NoSchemaIntrospectionCustomRule } from "graphql"; +import { createYoga, type Plugin } from "graphql-yoga"; +import type { Container } from "./container"; +import { createContext, type RequestContext } from "./graphql/context"; +import { depthLimit } from "./graphql/limits"; +import { buildSchema } from "./graphql/schema"; + +type ServerContext = { req: FastifyRequest; reply: FastifyReply }; + +/** Validation rules on every operation: depth limit, and no introspection unless enabled. */ +function rules(introspection: boolean): Plugin { + return { + onValidate({ addValidationRule }) { + addValidationRule(depthLimit(12)); + if (!introspection) addValidationRule(NoSchemaIntrospectionCustomRule); + }, + }; +} + +/** + * The HTTP server: ONE GraphQL endpoint (Yoga on Fastify) for everything, plus GET /health for load + * balancers. No other routes: PDFs and CSVs travel inside GraphQL responses. + */ +export async function buildServer(container: Container): Promise<FastifyInstance> { + const { config } = container; + const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: config.bodyLimit, trustProxy: config.trustProxy }); + + const yoga = createYoga<ServerContext, RequestContext>({ + schema: buildSchema(), + graphqlEndpoint: config.graphqlPath, + graphiql: config.graphiql ? { title: "MGPL Diagnostics API" } : false, + landingPage: false, + // domain errors carry their own codes (src/graphql/errors.ts); everything else becomes "Unexpected error" + maskedErrors: true, + cors: { origin: config.corsOrigins.includes("*") ? "*" : config.corsOrigins, methods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["content-type", "authorization"] }, + logging: config.env === "test" ? false : "warn", + plugins: [rules(config.introspection)], + context: ({ request, req }) => createContext(container, { headers: request.headers, ip: req.ip }), + }); + + app.route({ + url: config.graphqlPath, + method: ["GET", "POST", "OPTIONS"], + handler: async (req, reply) => { + const response = await yoga.handleNodeRequestAndResponse(req, reply, { req, reply }); + response.headers.forEach((value, key) => void reply.header(key, value)); + reply.status(response.status); + reply.send(response.body); + return reply; + }, + }); + + app.get("/health", async (_req, reply) => { + try { + await container.db.rawRepos.orgs.count(); + return { ok: true }; + } catch { + return reply.status(503).send({ ok: false }); + } + }); + + app.addHook("onClose", async () => container.close()); + return app; +} +``` + + +--- + +## File: src/services/auth.service.ts + +```ts +import { PERMISSIONS, SYSTEM_ROLES, type Auth, type PublicUser } from "@mgpl-diagnostics/auth-kit"; +import { ConflictError, createUser, NotFoundError, ValidationError, type Id, type Repositories, type Role, type RoleAssignment, type User } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +/** Sends password-reset links. Plug in email/SMS in the container; the API never returns reset tokens. */ +export interface Mailer { + passwordReset(to: { email: string; name: string }, link: string, expiresAt: Date): Promise<void>; +} + +const publicUser = ({ passwordHash: _p, ...u }: User): Omit<User, "passwordHash"> => u; + +/** Sign-in, sessions and passwords (auth-kit), plus users and roles (db-kit storage). */ +export class AuthService { + constructor( + private readonly repos: Repositories, + private readonly auth: Auth, + private readonly opts: { mailer?: Mailer; resetUrl: string | null }, + ) {} + + // ---------------------------------------------------------------- sessions + + login(input: { email: string; password: string; orgCode?: string | null; ip?: string; userAgent?: string }) { + return this.auth.login({ ...input, orgCode: input.orgCode ?? undefined }); + } + + logout(token: string) { + return this.auth.logout(token); + } + + /** Ends every other session of the user (keeps the current one). */ + logoutOthers(actor: Actor, currentToken: string) { + return this.auth.logoutAll(actor.userId, currentToken); + } + + sessions(actor: Actor) { + return this.auth.sessionsOf(actor.userId); + } + + revokeSession(actor: Actor, sessionId: string) { + return this.auth.revokeSession(actor.userId, sessionId); + } + + changePassword(actor: Actor, currentPassword: string, newPassword: string, currentToken?: string) { + return this.auth.changePassword({ userId: actor.userId, currentPassword, newPassword, currentToken }); + } + + /** Always resolves (never reveals whether the email exists). The link goes out through the mailer. */ + async requestPasswordReset(input: { email: string; orgCode?: string | null; ip?: string }): Promise<void> { + const r = await this.auth.requestPasswordReset({ email: input.email, orgCode: input.orgCode ?? undefined, ip: input.ip }); + if (!r || !this.opts.mailer) return; + const base = this.opts.resetUrl ?? "/reset-password"; + await this.opts.mailer.passwordReset({ email: r.user.email, name: r.user.name }, `${base}?token=${encodeURIComponent(r.token)}`, r.expiresAt); + } + + resetPassword(token: string, newPassword: string): Promise<PublicUser> { + return this.auth.resetPassword({ token, newPassword }); + } + + // ---------------------------------------------------------------- users + + async me(actor: Actor) { + const u = await this.repos.users.get(actor.userId); + if (!u) throw new NotFoundError("User", actor.userId); + return publicUser(u); + } + + async users(actor: Actor) { + return (await this.repos.users.find({ orgId: actor.orgId }, { sort: { field: "name" } })).map(publicUser); + } + + async user(actor: Actor, id: Id) { + const u = await this.repos.users.get(id); + return u && u.orgId === actor.orgId ? publicUser(u) : null; + } + + async createUser(actor: Actor, input: { name: string; email: string; phone?: string | null; password: string; roles: RoleAssignment[] }) { + await this.assertRoles(actor, input.roles); + const passwordHash = await this.auth.hashNewPassword(input.password, { email: input.email, name: input.name }); + const u = await createUser(this.repos, { orgId: actor.orgId, name: input.name, email: input.email, phone: input.phone ?? undefined, roles: input.roles, passwordHash }, actor.userId); + return publicUser(u); + } + + async setUserRoles(actor: Actor, userId: Id, roles: RoleAssignment[]) { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + await this.assertRoles(actor, roles); + const u = await this.repos.users.update(userId, { roles }); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "user.roles", entity: "user", entityId: userId }); + return publicUser(u!); + } + + /** Disabling ends every session at once (auth-kit re-reads the user on each request). */ + async setUserStatus(actor: Actor, userId: Id, status: "active" | "disabled") { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + if (userId === actor.userId && status === "disabled") throw new ConflictError("You cannot disable your own account"); + const u = await this.repos.users.update(userId, { status }); + if (status === "disabled") await this.auth.logoutAll(userId); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: `user.${status === "disabled" ? "disable" : "enable"}`, entity: "user", entityId: userId }); + return publicUser(u!); + } + + async setUserPassword(actor: Actor, userId: Id, newPassword: string) { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + await this.auth.setPassword(userId, newPassword, actor.userId); + } + + // ---------------------------------------------------------------- roles + + roles(actor: Actor): Promise<Role[]> { + return this.repos.roles.find({ orgId: actor.orgId }, { sort: { field: "key" } }); + } + + permissions(): readonly string[] { + return PERMISSIONS; + } + + /** Creates or replaces a custom role. System roles (seeded from auth-kit) cannot be changed. */ + async upsertRole(actor: Actor, input: { key: string; name: string; permissions: string[] }) { + const key = input.key.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{1,40}$/.test(key)) throw new ValidationError("Role key: lower-case letters, digits and _"); + for (const p of input.permissions) { + if (!/^([a-z_]+|\*):([a-z_]+|\*)$/.test(p)) throw new ValidationError(`Invalid permission "${p}"`); + } + const existing = await this.repos.roles.findOne({ orgId: actor.orgId, key }); + if (existing?.system) throw new ConflictError(`Role ${key} is a system role`); + const role = existing + ? await this.repos.roles.update(existing.id, { name: input.name, permissions: input.permissions }) + : await this.repos.roles.create({ orgId: actor.orgId, key, name: input.name, permissions: input.permissions, system: false }); + this.auth.invalidateRoles(actor.orgId); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "role.upsert", entity: "role", entityId: role!.id }); + return role!; + } + + /** Seeds auth-kit's SYSTEM_ROLES for an organisation that lacks some (idempotent). */ + async ensureSystemRoles(orgId: Id) { + for (const r of SYSTEM_ROLES) { + const existing = await this.repos.roles.findOne({ orgId, key: r.key }); + if (existing) await this.repos.roles.update(existing.id, { name: r.name, permissions: r.permissions, system: true }); + else await this.repos.roles.create({ orgId, key: r.key, name: r.name, permissions: r.permissions, system: true }); + } + this.auth.invalidateRoles(orgId); + } + + private async assertRoles(actor: Actor, roles: RoleAssignment[]) { + if (!roles.length) throw new ValidationError("Give the user at least one role"); + const keys = new Set((await this.roles(actor)).map((r) => r.key)); + for (const r of roles) { + if (!keys.has(r.role)) throw new ValidationError(`Unknown role "${r.role}"`); + if (r.branchId) mustOwn(await this.repos.branches.get(r.branchId), actor.orgId, "Branch", r.branchId); + } + } +} +``` + + +--- + +## File: src/services/billing.service.ts + +```ts +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { + cancelInvoice, issueInvoice, recordPayment, ValidationError, + type Id, type Invoice, type InvoiceStatus, type ItemCategory, type ItemInput, type PaymentMode, type Repositories, type Taxability, +} from "@mgpl-diagnostics/db-kit"; +import { applyTaxRules } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, scopedFind } from "./util"; + +export interface InvoiceItemInput { + code?: string | null; + name?: string | null; + category?: ItemCategory | null; + qty?: number | null; + ratePaise?: number | null; + discountPaise?: number | null; + taxPercent?: number | null; + hsnSac?: string | null; + taxability?: Taxability | null; +} + +export interface PaymentInput { + amountPaise: number; + mode: PaymentMode; + reference?: string | null; +} + +export interface IssueInvoiceInput { + branchId: Id; + patientId: Id; + bookingId?: Id | null; + items: InvoiceItemInput[]; + billDiscountPaise?: number | null; + payments?: PaymentInput[] | null; + /** Fill GST (HSN/SAC, taxability, rate) from finance-kit's tax rules. */ + applyTaxRules?: boolean | null; +} + +/** An invoice line; a catalog-kit test code alone is enough (name and MRP from the catalog). */ +export function invoiceItem(i: InvoiceItemInput): ItemInput { + const cat = i.code && (i.name == null || i.ratePaise == null) ? getTestByCode(i.code) : undefined; + const name = i.name ?? cat?.name; + const ratePaise = i.ratePaise ?? (cat?.mrp != null ? cat.mrp * 100 : undefined); + if (!name || ratePaise == null) throw new ValidationError(`Item ${i.code ?? "(no code)"} is not in the catalog; send its name and ratePaise`); + return clean({ + code: i.code, name, category: i.category ?? "test", qty: i.qty ?? 1, ratePaise, discountPaise: i.discountPaise ?? 0, + taxPercent: i.taxPercent ?? 0, hsnSac: i.hsnSac, taxability: i.taxability, + }) as ItemInput; +} + +const paymentOf = (p: PaymentInput) => clean({ amountPaise: p.amountPaise, mode: p.mode, reference: p.reference }) as { amountPaise: number; mode: PaymentMode; reference?: string }; + +/** Patient invoices and payments (db-kit workflows; posting to the books happens inside them). */ +export class BillingService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.invoices.get(id), actor.orgId, "Invoice", id); + } + + list(actor: Actor, q: { scope: "all" | Id[]; status?: InvoiceStatus | null; patientId?: Id | null; customerId?: Id | null; limit: number; offset: number }) { + return scopedFind(this.repos.invoices, clean({ orgId: actor.orgId, status: q.status, patientId: q.patientId, customerId: q.customerId }), q.scope, q.limit, q.offset, "createdAt"); + } + + payments(invoiceId: Id) { + return this.repos.payments.find({ invoiceId }, { sort: { field: "receivedAt" } }); + } + + vouchers(invoiceId: Id) { + return this.repos.vouchers.find({ invoiceId } as never, { sort: { field: "date" } }); + } + + creditNotes(invoiceId: Id) { + return this.repos.creditNotes.find({ invoiceId } as never, { sort: { field: "date" } }); + } + + async issue(actor: Actor, input: IssueInvoiceInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + mustOwn(await this.repos.patients.get(input.patientId), actor.orgId, "Patient", input.patientId); + if (input.bookingId) mustOwn(await this.repos.bookings.get(input.bookingId), actor.orgId, "Booking", input.bookingId); + let items = input.items.map(invoiceItem); + if (input.applyTaxRules) items = await applyTaxRules(this.repos, actor.orgId, items); + const { invoice } = await issueInvoice(this.repos, { + orgId: actor.orgId, branchId: input.branchId, patientId: input.patientId, bookingId: input.bookingId ?? null, + items, billDiscountPaise: input.billDiscountPaise ?? 0, payments: input.payments?.map(paymentOf), + }, actor.userId); + return invoice; + } + + async pay(actor: Actor, invoice: Invoice, payment: PaymentInput, kind: "payment" | "refund") { + return (await recordPayment(this.repos, { invoiceId: invoice.id, ...paymentOf(payment), kind }, actor.userId)).invoice; + } + + cancel(actor: Actor, invoice: Invoice, reason: string) { + if (!reason.trim()) throw new ValidationError("Give a reason"); + return cancelInvoice(this.repos, invoice.id, reason.trim(), actor.userId); + } + + /** Is any discount on this request? (Discounts need the invoice:discount permission.) */ + static discounted(input: Pick<IssueInvoiceInput, "billDiscountPaise" | "items">) { + return (input.billDiscountPaise ?? 0) > 0 || input.items.some((i) => (i.discountPaise ?? 0) > 0); + } +} +``` + + +--- + +## File: src/services/booking.service.ts + +```ts +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { ConflictError, createBooking, ValidationError, type BookingStatus, type BookingTest, type CollectionType, type Id, type Repositories, type Sample } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, owned, scopedFind } from "./util"; + +export interface BookingTestInput { + testCode: string; + name?: string | null; + pricePaise?: number | null; + sampleType?: string | null; +} + +export interface CreateBookingInput { + branchId: Id; + patientId: Id; + doctorId?: Id | null; + tests: BookingTestInput[]; + collectionType?: CollectionType | null; + scheduledAt?: Date | null; + notes?: string | null; +} + +/** + * A test line from catalog-kit when only the code is given: name, price (MRP, rupees → paise) and + * sample type come from the catalog; anything sent explicitly wins. + */ +export function bookingTest(t: BookingTestInput): BookingTest { + const cat = getTestByCode(t.testCode); + const name = t.name ?? cat?.name; + const pricePaise = t.pricePaise ?? (cat?.mrp != null ? cat.mrp * 100 : undefined); + if (!name || pricePaise == null) throw new ValidationError(`Test ${t.testCode} is not in the catalog; send its name and pricePaise`); + if (!Number.isInteger(pricePaise) || pricePaise < 0) throw new ValidationError(`Test ${t.testCode}: pricePaise must be a whole number >= 0`); + return clean({ testCode: t.testCode, name, pricePaise, sampleType: t.sampleType ?? cat?.sampleType ?? undefined }) as BookingTest; +} + +/** Bookings and their samples (barcodes). */ +export class BookingService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.bookings.get(id), actor.orgId, "Booking", id); + } + + list(actor: Actor, q: { scope: "all" | Id[]; status?: BookingStatus | null; patientId?: Id | null; limit: number; offset: number }) { + return scopedFind(this.repos.bookings, clean({ orgId: actor.orgId, status: q.status, patientId: q.patientId }), q.scope, q.limit, q.offset, "createdAt"); + } + + async create(actor: Actor, input: CreateBookingInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + mustOwn(await this.repos.patients.get(input.patientId), actor.orgId, "Patient", input.patientId); + if (input.doctorId) mustOwn(await this.repos.doctors.get(input.doctorId), actor.orgId, "Doctor", input.doctorId); + return createBooking(this.repos, { + orgId: actor.orgId, + branchId: input.branchId, + patientId: input.patientId, + doctorId: input.doctorId ?? null, + tests: input.tests.map(bookingTest), + sampleCollection: { type: input.collectionType ?? "walk_in", scheduledAt: input.scheduledAt ?? null }, + notes: input.notes ?? undefined, + }, actor.userId); + } + + async cancel(actor: Actor, id: Id) { + const b = await this.get(actor, id); + if (b.status === "cancelled") return b; + if (b.invoiceId) { + const inv = await this.repos.invoices.get(b.invoiceId); + if (inv && inv.status !== "cancelled") throw new ConflictError("Cancel the booking's invoice first"); + } + const out = await this.repos.bookings.update(id, { status: "cancelled" }); + await this.repos.audit.log({ orgId: b.orgId, branchId: b.branchId, userId: actor.userId, action: "booking.cancel", entity: "booking", entityId: id }); + return out!; + } + + // ---------------------------------------------------------------- samples + + samplesOf(bookingId: Id) { + return this.repos.samples.find({ bookingId }, { sort: { field: "barcode" } }); + } + + async sample(actor: Actor, id: Id) { + return mustOwn(await this.repos.samples.get(id), actor.orgId, "Sample", id); + } + + async sampleByBarcode(actor: Actor, barcode: string) { + return owned(await this.repos.samples.findOne({ orgId: actor.orgId, barcode: barcode.trim() }), actor.orgId); + } + + async collectSample(actor: Actor, s: Sample) { + if (s.status !== "pending") throw new ConflictError(`Sample is ${s.status}`); + const out = await this.repos.samples.update(s.id, { status: "collected", collectedAt: new Date(), collectedBy: actor.userId }); + const b = await this.repos.bookings.get(s.bookingId); + if (b?.status === "booked") await this.repos.bookings.update(b.id, { status: "sample_collected" }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.collect", entity: "sample", entityId: s.id }); + return out!; + } + + async receiveSample(actor: Actor, s: Sample) { + if (s.status !== "collected") throw new ConflictError(`Sample is ${s.status}`); + const out = await this.repos.samples.update(s.id, { status: "received", receivedAt: new Date() }); + await this.repos.bookings.update(s.bookingId, { status: "in_process" }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.receive", entity: "sample", entityId: s.id }); + return out!; + } + + async rejectSample(actor: Actor, s: Sample, reason: string) { + if (s.status === "rejected") throw new ConflictError("Sample is already rejected"); + if (!reason.trim()) throw new ValidationError("Give a reason"); + const out = await this.repos.samples.update(s.id, { status: "rejected", rejectReason: reason.trim() }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.reject", entity: "sample", entityId: s.id }); + return out!; + } +} +``` + + +--- + +## File: src/services/books.service.ts + +```ts +import { NotFoundError, type Id, type Repositories, type VoucherEntry, type VoucherType } from "@mgpl-diagnostics/db-kit"; +import { seedFinanceLedgers } from "@mgpl-diagnostics/finance-kit"; +import { + balanceSheet, cancelVoucher, cashBook, dayBook, gstSummary, ledgerBalance, ledgerStatement, outstandingReceivables, postVoucher, profitAndLoss, trialBalance, +} from "@mgpl-diagnostics/tally-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export interface PostVoucherInput { + branchId: Id; + type: VoucherType; + date?: Date | null; + narration?: string | null; + entries: { ledgerId: Id; debitPaise?: number | null; creditPaise?: number | null; invoiceId?: Id | null }[]; +} + +/** + * The books, through tally-kit (our accounting engine): chart of accounts, ledgers, manual vouchers + * and the books reports. Automatic posting of invoices and payments happens inside db-kit's workflows + * through finance-kit's posting hook (set in the container). + */ +export class BooksService { + constructor(private readonly repos: Repositories) {} + + groups(actor: Actor) { + return this.repos.accountGroups.find({ orgId: actor.orgId }, { sort: { field: "name" } }); + } + + ledgers(actor: Actor) { + return this.repos.ledgers.find({ orgId: actor.orgId }, { sort: { field: "name" } }); + } + + async ledger(actor: Actor, id: Id) { + return mustOwn(await this.repos.ledgers.get(id), actor.orgId, "Ledger", id); + } + + /** The standard chart (28 groups, default ledgers) plus finance-kit's ledgers. Idempotent. */ + setupChartOfAccounts(actor: Actor) { + return seedFinanceLedgers(this.repos, actor.orgId); + } + + async createLedger(actor: Actor, input: { name: string; groupId: Id; openingPaise?: number | null; gstin?: string | null }) { + mustOwn(await this.repos.accountGroups.get(input.groupId), actor.orgId, "Account group", input.groupId); + const l = await this.repos.ledgers.create({ orgId: actor.orgId, name: input.name.trim(), groupId: input.groupId, openingPaise: input.openingPaise ?? 0, gstin: input.gstin ?? undefined, systemKey: null, active: true } as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "ledger.create", entity: "ledger", entityId: l.id }); + return l; + } + + async postVoucher(actor: Actor, input: PostVoucherInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + await this.assertOwnLedgers(actor, input.entries.map((e) => e.ledgerId)); // no posting into another organisation's books + for (const e of input.entries) if (e.invoiceId) mustOwn(await this.repos.invoices.get(e.invoiceId), actor.orgId, "Invoice", e.invoiceId); + const entries: VoucherEntry[] = input.entries.map((e) => ({ ledgerId: e.ledgerId, debitPaise: e.debitPaise ?? 0, creditPaise: e.creditPaise ?? 0, invoiceId: e.invoiceId ?? null })); + return postVoucher(this.repos, { orgId: actor.orgId, branchId: input.branchId, type: input.type, date: input.date ?? undefined, narration: input.narration ?? undefined, entries }, actor.userId); + } + + async voucher(actor: Actor, id: Id) { + return mustOwn(await this.repos.vouchers.get(id), actor.orgId, "Voucher", id); + } + + cancelVoucher(actor: Actor, id: Id) { + return cancelVoucher(this.repos, id, actor.userId); + } + + // ---------------------------------------------------------------- reports (tally-kit) + + balance(orgId: Id, ledgerId: Id, q: { asOf?: Date | null; branchId?: Id | null }) { + return ledgerBalance(this.repos, { orgId, ledgerId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + async ledgerStatement(actor: Actor, q: { ledgerId: Id; from: Date; to: Date; branchId?: Id | null }) { + await this.assertOwnLedgers(actor, [q.ledgerId]); + return ledgerStatement(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + cashBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return cashBook(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + trialBalance(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null }) { + return trialBalance(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + profitAndLoss(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return profitAndLoss(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + balanceSheet(actor: Actor, q: { asOf: Date; branchId?: Id | null }) { + return balanceSheet(this.repos, { orgId: actor.orgId, asOf: q.asOf, branchId: q.branchId ?? undefined }); + } + + dayBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; type?: VoucherType | null }) { + return dayBook(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, type: q.type ?? undefined }); + } + + outstanding(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null }) { + return outstandingReceivables(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + gstSummary(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return gstSummary(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + private async assertOwnLedgers(actor: Actor, ids: Id[]) { + const unique = [...new Set(ids)]; + const found = await this.repos.ledgers.getMany(unique); + const bad = unique.find((id) => !found.some((l) => l.id === id && l.orgId === actor.orgId)); + if (bad) throw new NotFoundError("Ledger", bad); + } +} +``` + + +--- + +## File: src/services/cash-bank.service.ts + +```ts +import type { BankLineStatus, CashDenomination, Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { bankReconciliation, closeCashDay, importBankStatement, matchBankLine, reconcileBank, setBankLineStatus } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +/** Day-end cash closing and bank reconciliation, through finance-kit. */ +export class CashBankService { + constructor(private readonly repos: Repositories) {} + + closeCashDay(actor: Actor, input: { branchId: Id; day: string; countedPaise: number; denominations?: CashDenomination[] | null; notes?: string | null }) { + return closeCashDay(this.repos, { ...(clean(input) as typeof input), orgId: actor.orgId } as never, actor.userId); + } + + cashClosings(actor: Actor, q: { branchId: Id; limit: number; offset: number }) { + return this.repos.cashClosings.find({ orgId: actor.orgId, branchId: q.branchId }, { limit: q.limit, offset: q.offset, sort: { field: "day", dir: "desc" } }); + } + + importBankStatement(actor: Actor, input: { branchId: Id; ledgerId: Id; csv: string; fileName?: string | null }) { + return importBankStatement(this.repos, { orgId: actor.orgId, branchId: input.branchId, ledgerId: input.ledgerId, csv: input.csv, fileName: input.fileName ?? undefined }, actor.userId); + } + + async bankStatement(actor: Actor, id: Id) { + return mustOwn(await this.repos.bankStatements.get(id), actor.orgId, "Bank statement", id); + } + + bankStatements(actor: Actor, q: { ledgerId?: Id | null; limit: number; offset: number }) { + return this.repos.bankStatements.find(clean({ orgId: actor.orgId, ledgerId: q.ledgerId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "createdAt", dir: "desc" } }); + } + + bankLines(actor: Actor, q: { ledgerId?: Id | null; statementId?: Id | null; status?: BankLineStatus | null; limit: number; offset: number }) { + return this.repos.bankLines.find(clean({ orgId: actor.orgId, ledgerId: q.ledgerId, statementId: q.statementId, status: q.status }) as never, { limit: q.limit, offset: q.offset, sort: { field: "date", dir: "desc" } }); + } + + async bankLine(actor: Actor, id: Id) { + return mustOwn(await this.repos.bankLines.get(id), actor.orgId, "Bank line", id); + } + + reconcile(actor: Actor, q: { ledgerId: Id; statementId?: Id | null }) { + return reconcileBank(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, statementId: q.statementId ?? undefined }, actor.userId); + } + + matchLine(actor: Actor, q: { lineId: Id; voucherIds: Id[] }) { + return matchBankLine(this.repos, { orgId: actor.orgId, ...q }, actor.userId); + } + + setLineStatus(actor: Actor, q: { lineId: Id; status: "ignored" | "unmatched" }) { + return setBankLineStatus(this.repos, { orgId: actor.orgId, ...q }, actor.userId); + } + + reconciliation(actor: Actor, q: { ledgerId: Id; asOf: Date }) { + return bankReconciliation(this.repos, { orgId: actor.orgId, ...q }); + } +} +``` + + +--- + +## File: src/services/catalog.service.ts + +```ts +import * as catalog from "@mgpl-diagnostics/catalog-kit"; +import type { LabTest, Parameter } from "@mgpl-diagnostics/catalog-kit"; + +/** catalog-kit: the lab's tests, parameters, ranges, sample types and prices. Pure data, no tenant. */ +export class CatalogService { + search(query: string, opts: { limit: number; department?: string | null; sampleType?: string | null; fuzzy?: boolean | null }) { + return catalog.search(query, { limit: opts.limit, department: opts.department ?? undefined, sampleType: opts.sampleType ?? undefined, fuzzy: opts.fuzzy ?? undefined }); + } + + /** By numeric id or by code (case-insensitive). */ + test(key: { id?: number | null; code?: string | null }): LabTest | null { + if (key.id != null) return catalog.getTest(key.id) ?? null; + if (key.code) return catalog.getTestByCode(key.code) ?? null; + return null; + } + + tests(q: { department?: string | null; sampleType?: string | null; limit: number; offset: number }): LabTest[] { + let list: readonly LabTest[] = catalog.TESTS; + if (q.department) list = catalog.testsByDepartment(q.department); + if (q.sampleType) list = list.filter((t) => t.sampleType === q.sampleType || t.secondarySampleTypes?.includes(q.sampleType!)); + return list.slice(q.offset, q.offset + q.limit); + } + + count(): number { + return catalog.TESTS.length; + } + + departments(): readonly string[] { + return catalog.DEPARTMENTS; + } + + sampleTypes(): readonly string[] { + return catalog.SAMPLE_TYPES; + } + + isProfile(t: LabTest): boolean { + return catalog.isProfile(t); + } + + formatRange(p: Parameter, sex?: catalog.Sex | null): string { + return catalog.formatRange(p, sex ?? undefined); + } + + /** "L", "H" or "" for a numeric result against the parameter's range. */ + flag(p: Parameter, value: number, sex?: catalog.Sex | null): string { + return catalog.flagResult(p, value, sex ?? undefined); + } + + /** Price quote for test ids or codes: total in rupees, the tests found and the keys not found. */ + quote(items: (string | number)[]) { + return catalog.totalPrice(items); + } +} +``` + + +--- + +## File: src/services/document.service.ts + +```ts +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { + ConflictError, NotFoundError, ValidationError, + type Id, type Invoice as DbInvoice, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, +} from "@mgpl-diagnostics/db-kit"; +import { + computeInvoice, INVOICE_SAMPLES, invoiceFromRequest, invoiceTotalsJson, PRICE_LIST, renderInvoiceHtml, renderInvoicePdf, + type Invoice as PrintInvoice, type InvoiceItem as PrintItem, type InvoiceRequest, +} from "@mgpl-diagnostics/invoice-kit"; +import { + buildReport, COLS_STD, defaultLab, renderLetterheadHtml, renderLetterheadPdf, renderReportHtml, renderReportPdf, reportFromRequest, SAMPLES, templateCatalog, TEMPLATES, + type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportVariant, type Section, type TestRow, +} from "@mgpl-diagnostics/report-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export type DocumentFormat = "pdf" | "html"; + +/** A rendered document. PDFs travel as base64 in `base64`; HTML (self-contained, fonts inlined) in `html`. */ +export interface RenderedDocument { + format: DocumentFormat; + fileName: string; + mimeType: string; + byteLength: number; + base64: string | null; + html: string | null; +} + +export interface RenderOptions { + format: DocumentFormat; + variant: ReportVariant; + design?: ReportDesign; + fileName?: string | null; +} + +const MODE_LABEL: Record<PaymentMode, string> = { cash: "Cash", upi: "UPI", card: "Card", netbanking: "Online", wallet: "Wallet", cheque: "Cheque", insurance: "Insurance" }; +const rupees = (paise: number) => paise / 100; +const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +function ageOf(p: DbPatient, at: Date): number | string { + if (p.ageYears != null) return p.ageYears; + if (!p.dob) return ""; + const dob = new Date(p.dob); + let years = at.getUTCFullYear() - dob.getUTCFullYear(); + if (at.getUTCMonth() < dob.getUTCMonth() || (at.getUTCMonth() === dob.getUTCMonth() && at.getUTCDate() < dob.getUTCDate())) years--; + return Math.max(0, years); +} + +/** db-kit patient → the printed patient block. */ +export function printPatient(p: DbPatient, at: Date, extra: Partial<PrintPatient> = {}): PrintPatient { + return { + title: p.title, name: p.name, age: ageOf(p, at), sex: titleCase(p.sex), patientId: p.uhid, phone: p.phone, + address: [p.address?.line1, p.address?.city].filter(Boolean).join(", ") || undefined, ...extra, + }; +} + +/** + * report-kit (lab reports, letterhead) and invoice-kit (invoices) used as internal libraries: render + * from a request (any data) or from what is stored in db-kit (an invoice, a booking's reports). + */ +export class DocumentService { + private readonly lab: LabProfile; + private readonly samples: boolean; + + constructor(private readonly repos: Repositories, opts: { lab?: LabProfile; samples: boolean }) { + this.lab = opts.lab ?? defaultLab(); + this.samples = opts.samples; + } + + // ---------------------------------------------------------------- catalogs + + templates() { + return templateCatalog(); + } + + priceList() { + return Object.entries(PRICE_LIST).map(([code, e]) => ({ code, ...e })); + } + + reportSamples() { + return this.samples ? SAMPLES.map((s) => ({ id: s.id, label: s.label, group: s.group })) : []; + } + + invoiceSamples() { + return this.samples ? INVOICE_SAMPLES.map((s) => ({ id: s.id, label: s.label, invoiceNo: s.invoice.invoiceNo })) : []; + } + + // ---------------------------------------------------------------- from a request + + renderReportRequest(request: ReportRequest, o: RenderOptions) { + return this.renderReport(reportFromRequest(request, { samples: this.samples }), o); + } + + renderInvoiceRequest(request: InvoiceRequest, o: RenderOptions) { + return this.renderInvoice(invoiceFromRequest(request, { samples: this.samples }), o); + } + + /** invoice-kit's calculation (integer paise inside, rupees out) without rendering. */ + invoiceTotals(request: InvoiceRequest) { + return invoiceTotalsJson(invoiceFromRequest(request, { samples: this.samples })); + } + + async letterhead(format: DocumentFormat): Promise<RenderedDocument> { + if (format === "html") return htmlDoc("letterhead.html", await renderLetterheadHtml({ lab: this.lab, inline: true })); + return pdfDoc("letterhead.pdf", await renderLetterheadPdf({ lab: this.lab })); + } + + // ---------------------------------------------------------------- from stored data + + /** The printed form of a stored invoice (with its payments). */ + async storedInvoice(actor: Actor, invoice: DbInvoice): Promise<PrintInvoice> { + const patient = mustOwn(await this.repos.patients.get(invoice.patientId), actor.orgId, "Patient", invoice.patientId); + const payments = await this.repos.payments.find({ invoiceId: invoice.id }, { sort: { field: "receivedAt" } }); + const at = invoice.issuedAt ?? invoice.createdAt; + const lineDiscounts = invoice.items.reduce((s, i) => s + i.discountPaise, 0); + const billDiscount = invoice.discountPaise - lineDiscounts; + const refunded = payments.filter((p) => p.kind === "refund").reduce((s, p) => s + p.amountPaise, 0); + const items: PrintItem[] = invoice.items.map((i) => ({ + code: i.code, name: i.name, category: i.category === "package" ? "profile" : i.category, qty: i.qty, rate: rupees(i.ratePaise), + discountAmount: i.discountPaise ? rupees(i.discountPaise) : undefined, taxPercent: i.taxPercent || undefined, hsn: i.hsnSac, + })); + const notes = [invoice.status === "cancelled" ? `CANCELLED: ${invoice.cancelReason ?? ""}`.trim() : "", refunded ? `Refunded Rs. ${rupees(refunded).toFixed(2)}` : ""].filter(Boolean); + return { + invoiceNo: invoice.invoiceNo, + date: at, + patient: printPatient(patient, at), + items, + discount: billDiscount > 0 ? { amount: rupees(billDiscount) } : undefined, + payments: payments.filter((p) => p.kind === "payment").map((p) => ({ mode: MODE_LABEL[p.mode], amount: rupees(p.amountPaise), date: p.receivedAt, reference: p.reference })), + notes: notes.length ? notes : undefined, + roundOff: true, + }; + } + + async renderStoredInvoice(actor: Actor, invoice: DbInvoice, o: RenderOptions) { + const printed = await this.storedInvoice(actor, invoice); + // the printed total must equal the stored one (both are integer-paise maths with the same rules) + const t = computeInvoice(printed); + if (Math.round(t.total * 100) !== invoice.totalPaise) throw new ConflictError(`Invoice ${invoice.invoiceNo}: printed total ${t.total} differs from the stored total`); + return this.renderInvoice(printed, { ...o, fileName: o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}` }); + } + + /** + * A booking's reports as one printed report. Only verified or released reports print. A report + * whose test code is a report-kit template uses that template (results keyed by its field keys); + * any other test prints as a standard Test | Result | Unit | Range table. + */ + async renderBookingReports(actor: Actor, bookingId: Id, reportIds: Id[] | null, o: RenderOptions) { + const booking = mustOwn(await this.repos.bookings.get(bookingId), actor.orgId, "Booking", bookingId); + let reports = await this.repos.reports.find({ bookingId }); + if (reportIds?.length) reports = reports.filter((r) => reportIds.includes(r.id)); + if (!reports.length) throw new NotFoundError("Report", reportIds?.join(",") ?? bookingId); + const unready = reports.find((r) => r.status !== "verified" && r.status !== "released"); + if (unready) throw new ConflictError(`Report ${unready.testName} is ${unready.status}; verify it before printing`); + const patient = mustOwn(await this.repos.patients.get(booking.patientId), actor.orgId, "Patient", booking.patientId); + const doctor = booking.doctorId ? await this.repos.doctors.get(booking.doctorId) : null; + const samples = await this.repos.samples.find({ bookingId }); + const collected = samples.map((s) => s.collectedAt).filter(Boolean).sort()[0] ?? booking.createdAt; + const reportedAt = reports.map((r) => r.verifiedAt ?? r.updatedAt).filter(Boolean).sort().at(-1) ?? new Date(); + const printed = printPatient(patient, reportedAt, { + refDoctor: doctor?.name ?? "Self", sampleId: booking.bookingNo, sampleCollectionDate: collected, collectedAt: collected, registeredAt: booking.createdAt, reportedAt, + }); + const report: PrintReport = { patient: printed, sections: reports.flatMap((r) => this.sections(r, printed)) }; + return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` }); + } + + private sections(r: DbReport, patient: PrintPatient): Section[] { + const template = (TEMPLATES as Record<string, (typeof TEMPLATES)[keyof typeof TEMPLATES]>)[r.testCode.toLowerCase()]; + const flag = (f: string | undefined): TestRow["flag"] => (f === "H" ? "high" : f === "L" ? "low" : f === "A" ? "abnormal" : f === "" ? "normal" : "auto"); + if (template) { + // results match a template row by its key ("hb") or its label ("Haemoglobin (HB)"), ignoring case and punctuation + const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, ""); + const keyOf = new Map<string, string>(); + for (const f of template.fields) { + keyOf.set(norm(f.key), f.key); + keyOf.set(norm(f.label), f.key); + } + const matched = r.results.flatMap((x) => { + const key = keyOf.get(norm(x.parameter)); + return key ? [[key, { value: x.value, flag: flag(x.flag), unit: x.unit, range: x.range }] as const] : []; + }); + if (matched.length === r.results.length && matched.length) return buildReport(patient, [{ template: template.id as never, results: Object.fromEntries(matched) }]).sections; + } + const cat = getTestByCode(r.testCode); + return [{ + title: (cat?.department ?? "LABORATORY").toUpperCase(), + subtitle: r.testName, + specimen: cat?.sampleType ?? undefined, + columns: COLS_STD, + rows: r.results.map((x): TestRow => ({ kind: "test", name: x.parameter, result: x.value, unit: x.unit, range: x.range, flag: flag(x.flag) })), + }]; + } + + // ---------------------------------------------------------------- rendering + + private async renderReport(report: PrintReport, o: RenderOptions): Promise<RenderedDocument> { + const name = o.fileName ?? `${report.patient.patientId ?? "report"}-${o.variant}.${o.format}`; + if (o.format === "html") return htmlDoc(name, await renderReportHtml({ report, variant: o.variant, design: o.design, lab: this.lab, inline: true })); + return pdfDoc(name, await renderReportPdf({ report, variant: o.variant, design: o.design, lab: this.lab })); + } + + private async renderInvoice(invoice: PrintInvoice, o: RenderOptions): Promise<RenderedDocument> { + const name = o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}`; + if (o.format === "html") return htmlDoc(name, await renderInvoiceHtml({ invoice, variant: o.variant, lab: this.lab, inline: true })); + return pdfDoc(name, await renderInvoicePdf({ invoice, variant: o.variant, lab: this.lab })); + } +} + +function pdfDoc(fileName: string, pdf: Buffer): RenderedDocument { + return { format: "pdf", fileName, mimeType: "application/pdf", byteLength: pdf.byteLength, base64: pdf.toString("base64"), html: null }; +} + +function htmlDoc(fileName: string, html: string): RenderedDocument { + return { format: "html", fileName, mimeType: "text/html; charset=utf-8", byteLength: Buffer.byteLength(html), base64: null, html }; +} + +export const assertFormat = (f: string): DocumentFormat => { + if (f !== "pdf" && f !== "html") throw new ValidationError(`format must be pdf or html`); + return f; +}; +``` + + +--- + +## File: src/services/export.service.ts + +```ts +import type { Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { + balanceSheetCsv, dayBookCsv, gstr1, gstr1Csv, gstr3b, gstr3bCsv, ledgerCsv, profitAndLossCsv, settlementCsv, trialBalanceCsv, +} from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +export interface CsvFile { + fileName: string; + mimeType: string; + content: string; +} + +const csv = (fileName: string, content: string): CsvFile => ({ fileName, mimeType: "text/csv; charset=utf-8", content }); +const day = (d: Date) => d.toISOString().slice(0, 10); + +/** CSV exports (finance-kit): every file ends with a TOTAL row equal to its report. */ +export class ExportService { + constructor(private readonly repos: Repositories) {} + + async dayBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`day-book-${day(q.from)}-${day(q.to)}.csv`, await dayBookCsv(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async ledger(actor: Actor, q: { ledgerId: Id; from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`ledger-${day(q.from)}-${day(q.to)}.csv`, await ledgerCsv(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async trialBalance(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null; bom: boolean }) { + return csv(`trial-balance${q.asOf ? `-${day(q.asOf)}` : ""}.csv`, await trialBalanceCsv(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async profitAndLoss(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`profit-and-loss-${day(q.from)}-${day(q.to)}.csv`, await profitAndLossCsv(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async balanceSheet(actor: Actor, q: { asOf: Date; branchId?: Id | null; bom: boolean }) { + return csv(`balance-sheet-${day(q.asOf)}.csv`, await balanceSheetCsv(this.repos, { orgId: actor.orgId, asOf: q.asOf, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async settlement(actor: Actor, q: { settlementId: Id; bom: boolean }) { + return csv(`settlement-${q.settlementId}.csv`, await settlementCsv(this.repos, { orgId: actor.orgId, settlementId: q.settlementId, bom: q.bom })); + } + + /** One file per GSTR-1 section (b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs). */ + async gstr1(actor: Actor, q: { from: Date; to: Date; gstin?: string | null; bom: boolean }) { + const r = await gstr1(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + return Object.entries(gstr1Csv(r, { bom: q.bom })).map(([section, content]) => csv(`gstr1-${section}-${day(q.from)}-${day(q.to)}.csv`, content)); + } + + async gstr3b(actor: Actor, q: { from: Date; to: Date; gstin?: string | null; bom: boolean }) { + const r = await gstr3b(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + return csv(`gstr3b-${day(q.from)}-${day(q.to)}.csv`, gstr3bCsv(r, { bom: q.bom })); + } +} +``` + + +--- + +## File: src/services/gst.service.ts + +```ts +import type { Id, ItemCategory, Repositories, Taxability } from "@mgpl-diagnostics/db-kit"; +import { addTaxRule, DEFAULT_TAX_RULES, financeConfig, gstr1, gstr3b, seedTaxRules, VERIFY_WITH_CA } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +export interface TaxRuleInput { + code?: string | null; + category?: ItemCategory | null; + hsnSac: string; + taxability: Taxability; + ratePercent: number; + /** IST day, "2026-04-01". */ + effectiveFrom: string; + note?: string | null; +} + +/** + * GST through finance-kit: tax rules (configuration, to be verified with the lab's CA), GSTR-1 and + * GSTR-3B data. Nothing here is legal advice; `verifyWithCA` travels with every return. + */ +export class GstService { + constructor(private readonly repos: Repositories) {} + + taxRules(actor: Actor) { + return this.repos.taxRules.find({ orgId: actor.orgId }, { sort: { field: "effectiveFrom", dir: "desc" } }); + } + + addTaxRule(actor: Actor, input: TaxRuleInput) { + return addTaxRule(this.repos, actor.orgId, { ...input, code: input.code ?? null, category: input.category ?? null, note: input.note ?? undefined }, actor.userId); + } + + /** finance-kit's starting rules (DEFAULT_TAX_RULES). Every rate is marked "verify with CA". */ + seedTaxRules(actor: Actor) { + return seedTaxRules(this.repos, actor.orgId, DEFAULT_TAX_RULES, actor.userId); + } + + config(actor: Actor) { + return financeConfig(this.repos, actor.orgId); + } + + verifyWithCA(): readonly string[] { + return VERIFY_WITH_CA; + } + + gstr1(actor: Actor, q: { from: Date; to: Date; gstin?: string | null }) { + return gstr1(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + } + + gstr3b(actor: Actor, q: { from: Date; to: Date; gstin?: string | null }) { + return gstr3b(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + } +} +``` + + +--- + +## File: src/services/index.ts + +```ts +import type { Auth } from "@mgpl-diagnostics/auth-kit"; +import type { Repositories } from "@mgpl-diagnostics/db-kit"; +import type { LabProfile } from "@mgpl-diagnostics/report-kit"; +import { AuthService, type Mailer } from "./auth.service"; +import { BillingService } from "./billing.service"; +import { BookingService } from "./booking.service"; +import { BooksService } from "./books.service"; +import { CashBankService } from "./cash-bank.service"; +import { CatalogService } from "./catalog.service"; +import { DocumentService } from "./document.service"; +import { ExportService } from "./export.service"; +import { GstService } from "./gst.service"; +import { OrganizationService } from "./organization.service"; +import { PatientService } from "./patient.service"; +import { PeriodService } from "./period.service"; +import { ReceivablesService } from "./receivables.service"; +import { ReportService } from "./report.service"; +import { SettlementService } from "./settlement.service"; + +export interface ServiceDeps { + repos: Repositories; + auth: Auth; + mailer?: Mailer; + resetUrl: string | null; + lab?: LabProfile; + samples: boolean; +} + +/** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */ +export function createServices(d: ServiceDeps) { + return { + auth: new AuthService(d.repos, d.auth, { mailer: d.mailer, resetUrl: d.resetUrl }), + organization: new OrganizationService(d.repos), + catalog: new CatalogService(), + patients: new PatientService(d.repos), + bookings: new BookingService(d.repos), + reports: new ReportService(d.repos), + billing: new BillingService(d.repos), + books: new BooksService(d.repos), + gst: new GstService(d.repos), + receivables: new ReceivablesService(d.repos), + settlements: new SettlementService(d.repos), + cashBank: new CashBankService(d.repos), + periods: new PeriodService(d.repos), + exports: new ExportService(d.repos), + documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }), + }; +} + +export type Services = ReturnType<typeof createServices>; +export type { Mailer }; +``` + + +--- + +## File: src/services/organization.service.ts + +```ts +import { ConflictError, NotFoundError, ValidationError, type Address, type Branch, type BranchKind, type Id, type Repositories } from "@mgpl-diagnostics/db-kit"; +import { isValidGstin } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +export interface BranchInput { + code: string; + name: string; + kind: BranchKind; + phone?: string | null; + gstin?: string | null; + stateCode?: string | null; + address?: Address | null; + active?: boolean | null; +} + +export interface OrganizationPatch { + name?: string | null; + legalName?: string | null; + gstin?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; +} + +const checkGstin = (g?: string | null) => { + if (g && !isValidGstin(g)) throw new ValidationError(`Invalid GSTIN ${g}`); +}; + +/** The signed-in user's organisation and its branches. */ +export class OrganizationService { + constructor(private readonly repos: Repositories) {} + + async organization(actor: Actor) { + const org = await this.repos.orgs.get(actor.orgId); + if (!org) throw new NotFoundError("Organization", actor.orgId); + return org; + } + + async updateOrganization(actor: Actor, patch: OrganizationPatch) { + checkGstin(patch.gstin); + const org = await this.repos.orgs.update(actor.orgId, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "org.update", entity: "organization", entityId: actor.orgId }); + return org!; + } + + branches(actor: Actor): Promise<Branch[]> { + return this.repos.branches.find({ orgId: actor.orgId }, { sort: { field: "code" } }); + } + + async branch(actor: Actor, id: Id) { + return mustOwn(await this.repos.branches.get(id), actor.orgId, "Branch", id); + } + + async createBranch(actor: Actor, input: BranchInput) { + checkGstin(input.gstin); + const code = input.code.trim().toUpperCase(); + if (!/^[A-Z0-9]{2,12}$/.test(code)) throw new ValidationError("Branch code: 2-12 letters or digits"); + if (await this.repos.branches.findOne({ orgId: actor.orgId, code })) throw new ConflictError(`Branch ${code} already exists`); + const b = await this.repos.branches.create({ ...(clean(input) as BranchInput), orgId: actor.orgId, code, active: input.active ?? true } as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: b.id, userId: actor.userId, action: "branch.create", entity: "branch", entityId: b.id }); + return b as Branch; + } + + async updateBranch(actor: Actor, id: Id, patch: Partial<Omit<BranchInput, "code">>) { + mustOwn(await this.repos.branches.get(id), actor.orgId, "Branch", id); + checkGstin(patch.gstin); + const b = await this.repos.branches.update(id, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: id, userId: actor.userId, action: "branch.update", entity: "branch", entityId: id }); + return b!; + } + + /** Audit trail, newest first. */ + audit(actor: Actor, q: { entity?: string | null; entityId?: Id | null; limit: number; offset: number }) { + return this.repos.audit.find(clean({ orgId: actor.orgId, entity: q.entity, entityId: q.entityId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "at", dir: "desc" } }); + } +} +``` + + +--- + +## File: src/services/patient.service.ts + +```ts +import { registerPatient, type Address, type Doctor, type Id, type Patient, type Repositories, type Sex } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, owned } from "./util"; + +export interface RegisterPatientInput { + branchId: Id; + title?: string | null; + name: string; + sex: Sex; + dob?: Date | null; + ageYears?: number | null; + phone?: string | null; + email?: string | null; + address?: Address | null; +} + +export interface DoctorInput { + name: string; + qualification?: string | null; + phone?: string | null; + clinic?: string | null; + active?: boolean | null; +} + +/** Patients (UHID per organisation) and referring doctors. */ +export class PatientService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id): Promise<Patient | null> { + return owned(await this.repos.patients.get(id), actor.orgId); + } + + search(actor: Actor, text: string, limit: number) { + return this.repos.patients.search(actor.orgId, text, limit); + } + + async register(actor: Actor, input: RegisterPatientInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + return registerPatient(this.repos, { ...(clean(input) as RegisterPatientInput), orgId: actor.orgId } as never, actor.userId); + } + + async update(actor: Actor, id: Id, patch: Partial<Omit<RegisterPatientInput, "branchId">>) { + mustOwn(await this.repos.patients.get(id), actor.orgId, "Patient", id); + const p = await this.repos.patients.update(id, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "patient.update", entity: "patient", entityId: id }); + return p!; + } + + doctors(actor: Actor, activeOnly: boolean): Promise<Doctor[]> { + return this.repos.doctors.find(activeOnly ? { orgId: actor.orgId, active: true } : { orgId: actor.orgId }, { sort: { field: "name" } }); + } + + async createDoctor(actor: Actor, input: DoctorInput) { + return this.repos.doctors.create({ ...(clean(input) as DoctorInput), orgId: actor.orgId, active: input.active ?? true } as never); + } + + async updateDoctor(actor: Actor, id: Id, patch: Partial<DoctorInput>) { + mustOwn(await this.repos.doctors.get(id), actor.orgId, "Doctor", id); + return (await this.repos.doctors.update(id, clean(patch) as never))!; + } +} +``` + + +--- + +## File: src/services/period.service.ts + +```ts +import type { Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { closeFinancialYear, listLocks, setLockDate } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +/** Lock dates and the year-end close, through finance-kit. */ +export class PeriodService { + constructor(private readonly repos: Repositories) {} + + locks(actor: Actor) { + return listLocks(this.repos, actor.orgId); + } + + setLockDate(actor: Actor, input: { branchId?: Id | null; upTo: string | null; reason?: string | null }) { + return setLockDate(this.repos, { orgId: actor.orgId, branchId: input.branchId ?? null, upTo: input.upTo, reason: input.reason ?? undefined }, actor.userId); + } + + closeFinancialYear(actor: Actor, fy: string) { + return closeFinancialYear(this.repos, { orgId: actor.orgId, fy }, actor.userId); + } +} +``` + + +--- + +## File: src/services/receivables.service.ts + +```ts +import type { Address, CustomerKind, Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { createCustomer, customerStatement, issueCreditNote, issueCustomerInvoice, receivablesSummary, updateCustomer } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { invoiceItem, type IssueInvoiceInput } from "./billing.service"; +import { clean, mustOwn } from "./util"; + +export interface CustomerInput { + code: string; + name: string; + kind: CustomerKind; + legalName?: string | null; + gstin?: string | null; + stateCode?: string | null; + pan?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; + creditLimitPaise?: number | null; + creditDays?: number | null; +} + +export interface CustomerInvoiceInput extends Omit<IssueInvoiceInput, "applyTaxRules"> { + customerId: Id; + placeOfSupply?: string | null; + /** Default true: GST from the tax rules. */ + applyTaxRules?: boolean | null; +} + +/** B2B receivables (hospitals, insurers, TPAs, corporates) and credit notes, through finance-kit. */ +export class ReceivablesService { + constructor(private readonly repos: Repositories) {} + + customers(actor: Actor, activeOnly: boolean) { + return this.repos.customers.find(activeOnly ? { orgId: actor.orgId, active: true } : { orgId: actor.orgId }, { sort: { field: "code" } }); + } + + async customer(actor: Actor, id: Id) { + return mustOwn(await this.repos.customers.get(id), actor.orgId, "Customer", id); + } + + createCustomer(actor: Actor, input: CustomerInput) { + return createCustomer(this.repos, { ...(clean(input) as CustomerInput), orgId: actor.orgId } as never, actor.userId); + } + + updateCustomer(actor: Actor, id: Id, patch: Partial<Omit<CustomerInput, "code">> & { active?: boolean | null }) { + // creditLimitPaise: null means "no limit", so it is passed through when present + const p = clean(patch) as Record<string, unknown>; + if (patch.creditLimitPaise === null) p.creditLimitPaise = null; + return updateCustomer(this.repos, { ...p, orgId: actor.orgId, customerId: id } as never, actor.userId); + } + + async issueInvoice(actor: Actor, input: CustomerInvoiceInput) { + const { invoice } = await issueCustomerInvoice(this.repos, { + orgId: actor.orgId, branchId: input.branchId, patientId: input.patientId, bookingId: input.bookingId ?? null, customerId: input.customerId, + items: input.items.map(invoiceItem), billDiscountPaise: input.billDiscountPaise ?? 0, + payments: input.payments?.map((p) => clean(p)) as never, placeOfSupply: input.placeOfSupply ?? undefined, applyTaxRules: input.applyTaxRules ?? true, + }, actor.userId); + return invoice; + } + + statement(actor: Actor, q: { customerId: Id; from: Date; to: Date }) { + return customerStatement(this.repos, { orgId: actor.orgId, ...q }); + } + + summary(actor: Actor, asOf: Date) { + return receivablesSummary(this.repos, { orgId: actor.orgId, asOf }); + } + + async creditNote(actor: Actor, id: Id) { + return mustOwn(await this.repos.creditNotes.get(id), actor.orgId, "Credit note", id); + } + + creditNotes(actor: Actor, q: { invoiceId?: Id | null; customerId?: Id | null; limit: number; offset: number }) { + return this.repos.creditNotes.find(clean({ orgId: actor.orgId, invoiceId: q.invoiceId, customerId: q.customerId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "date", dir: "desc" } }); + } + + issueCreditNote(actor: Actor, input: { invoiceId: Id; reason: string; lines?: { itemIndex: number; taxablePaise?: number | null }[] | null; date?: Date | null }) { + return issueCreditNote(this.repos, { + orgId: actor.orgId, invoiceId: input.invoiceId, reason: input.reason, date: input.date ?? undefined, + lines: input.lines?.map((l) => clean(l) as { itemIndex: number; taxablePaise?: number }), + }, actor.userId); + } +} +``` + + +--- + +## File: src/services/report.service.ts + +```ts +import { flagResult, formatRange, getTestByCode, type Sex as CatalogSex } from "@mgpl-diagnostics/catalog-kit"; +import { ConflictError, saveResults, transitionReport, ValidationError, type Booking, type Id, type Report, type Repositories, type ResultValue, type Sex } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +const catalogSex = (s: Sex): CatalogSex | undefined => (s === "male" || s === "female" ? s : undefined); + +/** + * Fills unit, range and flag from catalog-kit when the report's test is in the catalog and the + * result does not carry them. Values sent explicitly are kept (the pathologist has the last word). + */ +export function withCatalogDefaults(testCode: string, results: ResultValue[], sex: Sex): ResultValue[] { + const test = getTestByCode(testCode); + if (!test) return results; + const byName = new Map(test.parameters.filter((p) => p.name).map((p) => [p.name!.trim().toLowerCase(), p])); + return results.map((r) => { + const p = byName.get(r.parameter.trim().toLowerCase()); + if (!p) return r; + const out: ResultValue = { ...r }; + if (out.unit == null && p.unit) out.unit = p.unit; + if (out.range == null) { + const range = formatRange(p, catalogSex(sex)); + if (range) out.range = range; + } + const n = Number(r.value); + if (out.flag == null && p.type === "numeric" && r.value.trim() !== "" && Number.isFinite(n)) out.flag = flagResult(p, n, catalogSex(sex)); + return out; + }); +} + +/** Results entry and the report workflow: draft → entered → verified → released (→ amended → verified). */ +export class ReportService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.reports.get(id), actor.orgId, "Report", id); + } + + ofBooking(bookingId: Id) { + return this.repos.reports.find({ bookingId }); + } + + async create(actor: Actor, booking: Booking, testCode: string, testName?: string | null) { + const t = booking.tests.find((x) => x.testCode === testCode); + if (!t) throw new ValidationError(`Test ${testCode} is not in booking ${booking.bookingNo}`); + const existing = await this.repos.reports.findOne({ bookingId: booking.id, testCode }); + if (existing) throw new ConflictError(`Booking ${booking.bookingNo} already has a ${testCode} report`); + const r = await this.repos.reports.create({ + orgId: booking.orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId, + testCode, testName: testName ?? t.name, results: [], status: "draft", version: 1, + } as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "report.create", entity: "report", entityId: r.id }); + return r as Report; + } + + async saveResults(actor: Actor, report: Report, results: ResultValue[]) { + const patient = await this.repos.patients.get(report.patientId); + return saveResults(this.repos, report.id, withCatalogDefaults(report.testCode, results, patient?.sex ?? "other"), actor.userId); + } + + verify(actor: Actor, report: Report) { + return transitionReport(this.repos, report.id, "verified", actor.userId); + } + + /** Releasing the last report of a booking completes the booking. */ + async release(actor: Actor, report: Report) { + const out = await transitionReport(this.repos, report.id, "released", actor.userId); + const all = await this.repos.reports.find({ bookingId: report.bookingId }); + if (all.every((x) => x.status === "released")) await this.repos.bookings.update(report.bookingId, { status: "completed" }); + return out; + } + + amend(actor: Actor, report: Report) { + return transitionReport(this.repos, report.id, "amended", actor.userId); + } +} +``` + + +--- + +## File: src/services/settlement.service.ts + +```ts +import type { Address, AgreementKind, Id, ItemCategory, PartnerKind, PaymentMode, Repositories, SettlementDeduction, SettlementStatus } from "@mgpl-diagnostics/db-kit"; +import { + accrueSettlement, addAgreement, cancelSettlement, computeSettlement, createPartner, createSettlement, paySettlement, updatePartner, +} from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +export interface PartnerInput { + code: string; + name: string; + kind: PartnerKind; + branchId: Id; + legalName?: string | null; + pan?: string | null; + gstin?: string | null; + stateCode?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; + /** Overrides the default TDS (e.g. a lower-deduction certificate). Verify with CA. */ + tds?: { section: string; rateBp: number } | null; +} + +export interface AgreementInput { + partnerId: Id; + kind: AgreementKind; + shareBp?: number | null; + rates?: { code: string; sharePaise: number }[] | null; + defaultSharePaise?: number | null; + categories?: ItemCategory[] | null; + effectiveFrom: string; + effectiveTo?: string | null; + notes?: string | null; +} + +/** + * Collection-centre and franchise settlements with TDS, through finance-kit. No doctor referral + * commissions: settlements are for collection centres and franchises only. + */ +export class SettlementService { + constructor(private readonly repos: Repositories) {} + + partners(actor: Actor, q: { branchIds: "all" | Id[] }) { + return this.repos.partners.find({ orgId: actor.orgId }, { sort: { field: "code" } }).then((all) => (q.branchIds === "all" ? all : all.filter((p) => (q.branchIds as Id[]).includes(p.branchId)))); + } + + async partner(actor: Actor, id: Id) { + return mustOwn(await this.repos.partners.get(id), actor.orgId, "Partner", id); + } + + createPartner(actor: Actor, input: PartnerInput) { + return createPartner(this.repos, { ...(clean(input) as PartnerInput), tds: input.tds ?? undefined, orgId: actor.orgId } as never, actor.userId); + } + + updatePartner(actor: Actor, id: Id, patch: Partial<Omit<PartnerInput, "code" | "branchId">> & { active?: boolean | null }) { + const p = clean(patch) as Record<string, unknown>; + if (patch.tds === null) p.tds = null; // back to the default TDS + return updatePartner(this.repos, { ...p, orgId: actor.orgId, partnerId: id } as never, actor.userId); + } + + agreements(partnerId: Id) { + return this.repos.partnerAgreements.find({ partnerId }, { sort: { field: "effectiveFrom", dir: "desc" } }); + } + + addAgreement(actor: Actor, input: AgreementInput) { + return addAgreement(this.repos, { ...(clean(input) as AgreementInput), orgId: actor.orgId } as never, actor.userId); + } + + compute(actor: Actor, q: { partnerId: Id; period: string; deductions?: SettlementDeduction[] | null }) { + return computeSettlement(this.repos, { orgId: actor.orgId, partnerId: q.partnerId, period: q.period, deductions: q.deductions ?? undefined }); + } + + async settlement(actor: Actor, id: Id) { + return mustOwn(await this.repos.settlements.get(id), actor.orgId, "Settlement", id); + } + + settlements(actor: Actor, q: { partnerId?: Id | null; status?: SettlementStatus | null; branchIds: "all" | Id[]; limit: number; offset: number }) { + const filter = clean({ orgId: actor.orgId, partnerId: q.partnerId, status: q.status }) as never; + return this.repos.settlements.find(filter, { limit: q.limit + q.offset, sort: { field: "createdAt", dir: "desc" } }).then((all) => + (q.branchIds === "all" ? all : all.filter((s) => (q.branchIds as Id[]).includes(s.branchId))).slice(q.offset, q.offset + q.limit), + ); + } + + create(actor: Actor, q: { partnerId: Id; period: string; deductions?: SettlementDeduction[] | null }) { + return createSettlement(this.repos, { orgId: actor.orgId, partnerId: q.partnerId, period: q.period, deductions: q.deductions ?? undefined }, actor.userId); + } + + accrue(actor: Actor, settlementId: Id) { + return accrueSettlement(this.repos, { orgId: actor.orgId, settlementId }, actor.userId); + } + + pay(actor: Actor, q: { settlementId: Id; mode: PaymentMode; date?: Date | null; reference?: string | null }) { + return paySettlement(this.repos, { orgId: actor.orgId, settlementId: q.settlementId, mode: q.mode, date: q.date ?? undefined, reference: q.reference ?? undefined }, actor.userId); + } + + cancel(actor: Actor, settlementId: Id) { + return cancelSettlement(this.repos, { orgId: actor.orgId, settlementId }, actor.userId); + } +} +``` + + +--- + +## File: src/services/util.ts + +```ts +import { NotFoundError, type Id, type Repo } from "@mgpl-diagnostics/db-kit"; + +/** The entity when it belongs to `orgId`, otherwise null (tenant isolation). */ +export const owned = <T extends { orgId: Id }>(entity: T | null | undefined, orgId: Id): T | null => (entity && entity.orgId === orgId ? entity : null); + +/** The entity when it belongs to `orgId`; NotFoundError otherwise (another organisation's data looks absent). */ +export function mustOwn<T extends { orgId: Id }>(entity: T | null | undefined, orgId: Id, what: string, id: Id): T { + const e = owned(entity, orgId); + if (!e) throw new NotFoundError(what, id); + return e; +} + +/** Drops undefined and null values (partial updates). */ +export const clean = <T extends object>(o: T): Partial<T> => + Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null)) as Partial<T>; + +/** Branch-aware list: all branches, or a merge of each allowed branch, newest first. */ +export async function scopedFind<T extends { id: Id }>( + repo: Repo<T>, + filter: Record<string, unknown>, + scope: "all" | Id[], + limit: number, + offset: number, + sortField: string, +): Promise<T[]> { + const sort = { field: sortField as keyof T & string, dir: "desc" as const }; + if (scope === "all") return repo.find(filter as never, { limit, offset, sort }); + const lists = await Promise.all(scope.map((b) => repo.find({ ...filter, branchId: b } as never, { limit: limit + offset, sort }))); + const key = (x: T) => +((x as Record<string, unknown>)[sortField] as Date); + return lists.flat().sort((a, b) => key(b) - key(a)).slice(offset, offset + limit); +} +``` + + +--- + +## File: test/run.ts + +```ts +// End-to-end tests: the real server (Fastify + Yoga) over HTTP inject, real auth-kit sessions, real +// databases started in-process: PostgreSQL always, MongoDB (replica set) unless TEST_BACKENDS=pg. +// npm test both backends (first run downloads the database binaries) +// TEST_BACKENDS=pg npm test PostgreSQL only (when MongoDB binaries cannot be downloaded) +import { readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; +import { createDb, seedOrganization, type Db, type Repositories } from "@mgpl-diagnostics/db-kit"; +import { financePosting, istDay } from "@mgpl-diagnostics/finance-kit"; +import { parse, validate } from "graphql"; +import { loadConfig } from "../src/config/env"; +import { buildSchema } from "../src/graphql/schema"; +import { createContainer } from "../src/container"; +import { buildServer } from "../src/server"; +import type { Mailer } from "../src/services/auth.service"; + +let passed = 0; +let failed = 0; +const ok = (cond: unknown, msg: string) => { + if (cond) passed++; + else failed++; + console.log(`${cond ? "✓" : "✗"} ${msg}`); +}; + +type Gql = { data?: any; errors?: { message: string; extensions?: { code?: string } }[] }; +const code = (r: Gql) => r.errors?.[0]?.extensions?.code; + +async function suite(db: Db, tag: string) { + console.log(`\n[${tag}]`); + // count batched patient lookups (N+1 check) on the repositories the services use + let patientBatches = 0; + const repos: Repositories = { ...db.repos, patients: { ...db.repos.patients, getMany: (ids) => { patientBatches++; return db.repos.patients.getMany(ids); } } }; + const mails: { to: string; link: string }[] = []; + const mailer: Mailer = { passwordReset: async (to, link) => void mails.push({ to: to.email, link }) }; + const config = { ...loadConfig({ NODE_ENV: "test", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused" }) }; + const container = await createContainer({ config, db: { ...db, repos }, mailer }); + const app = await buildServer(container); + + const gql = async (token: string | null, query: string, variables?: Record<string, unknown>): Promise<Gql> => { + const res = await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, payload: JSON.stringify({ query, variables }) }); + const out = res.json() as Gql; + if (process.env.DEBUG && out.errors) console.log(" ", JSON.stringify(out.errors).slice(0, 400)); + return out; + }; + + // ---------------------------------------------------------------- setup: two organisations, auth-kit roles + const pw = "Lab-Owner-2026!"; + const a = await seedOrganization(db.rawRepos, { org: { name: "API Lab", code: "APL", gstin: "22AAAAA0000A1Z5" }, branch: { code: "A01", name: "Branch A", stateCode: "22" }, owner: { name: "Owner", email: "owner@apl.test", passwordHash: await hashPassword(pw) }, roles: SYSTEM_ROLES }); + const other = await seedOrganization(db.rawRepos, { org: { name: "Other Lab", code: "OTH" }, branch: { code: "Z01", name: "Z", stateCode: "22" }, owner: { name: "X", email: "x@oth.test", passwordHash: await hashPassword(pw) }, roles: SYSTEM_ROLES }); + + const health = await app.inject({ url: "/health" }); + ok(health.statusCode === 200 && (await gql(null, "{ health { ok database } }")).data?.health.ok, `[${tag}] GET /health and { health } report the database`); + ok(code(await gql(null, "{ organization { name } }")) === "UNAUTHENTICATED", `[${tag}] no token → UNAUTHENTICATED`); + ok(code(await gql(null, 'mutation { login(input: { email: "owner@apl.test", password: "wrong-password" }) { token } }')) === "INVALID_CREDENTIALS", `[${tag}] wrong password → INVALID_CREDENTIALS`); + const login = async (email: string, password = pw) => (await gql(null, "mutation($i: LoginInput!) { login(input: $i) { token user { email } } }", { i: { email, password } })).data?.login.token as string; + const owner = await login("owner@apl.test"); + const otherOwner = await login("x@oth.test"); + ok(typeof owner === "string" && owner.length > 20, `[${tag}] login → bearer token`); + const viewer = await gql(owner, '{ viewer { user { email } organization { code } can(permission: "accounts:post") branchesFor(permission: "invoice:read") } }'); + ok(viewer.data?.viewer.user.email === "owner@apl.test" && viewer.data.viewer.organization.code === "APL" && viewer.data.viewer.can && viewer.data.viewer.branchesFor === null, `[${tag}] viewer: user, organisation, permissions`); + ok((await gql(null, "{ viewer { user { id } } }")).data?.viewer === null, `[${tag}] viewer is null without a token`); + + const chart = await gql(owner, "mutation { setupChartOfAccounts { name systemKey } }"); + ok(chart.data?.setupChartOfAccounts.length > 10, `[${tag}] setupChartOfAccounts (tally-kit chart + finance-kit ledgers): ${chart.data?.setupChartOfAccounts.length} ledgers`); + await gql(otherOwner, "mutation { setupChartOfAccounts { id } }"); + const branchB = (await gql(owner, 'mutation { createBranch(input: { code: "b01", name: "Branch B", kind: collection_center, stateCode: "22" }) { id code } }')).data?.createBranch; + ok(branchB?.code === "B01", `[${tag}] createBranch (code upper-cased)`); + const deskUser = await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id roles { role branch { code } } } }", { + i: { name: "Desk", email: "desk@apl.test", password: "Counter-Shift-7391", roles: [{ role: "front_desk", branchId: a.branch.id }] }, + }); + ok(deskUser.data?.createUser.roles[0].branch.code === "A01", `[${tag}] createUser with a branch role (auth-kit hashes the password)`); + ok(code(await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id } }", { i: { name: "W", email: "w@apl.test", password: "password", roles: [{ role: "front_desk" }] } })) === "WEAK_PASSWORD", `[${tag}] weak password → WEAK_PASSWORD`); + const desk = await login("desk@apl.test", "Counter-Shift-7391"); + + // ---------------------------------------------------------------- catalog (public) + const cat = await gql(null, '{ catalogSearch(query: "cbc", limit: 3) { test { code name pricePaise sampleType parameters { name unit range(sex: male) } } } priceQuote(tests: ["CBC", "NOPE"]) { total totalPaise unknown } }'); + const cbc = cat.data?.catalogSearch[0]?.test; + ok(cbc?.code === "CBC" && cbc.pricePaise === 39900 && cat.data.priceQuote.unknown[0] === "NOPE", `[${tag}] catalog search + price quote (catalog-kit, no sign-in)`); + + // ---------------------------------------------------------------- lab workflow + const reg = await gql(owner, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id uhid name } }", { i: { branchId: a.branch.id, name: "Asha Patel", sex: "female", ageYears: 29, phone: "9111111111" } }); + const patientId = reg.data?.registerPatient.id; + ok(reg.data?.registerPatient.uhid === "APL-000001", `[${tag}] registerPatient → ${reg.data?.registerPatient.uhid}`); + const book = await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo tests { testCode name pricePaise sampleType catalog { department } } } samples { id barcode sampleType } } }", { + i: { branchId: a.branch.id, patientId, tests: [{ testCode: "CBC" }] }, + }); + const booking = book.data?.createBooking; + ok(booking?.booking.tests[0].name === "Complete Blood Count (CBC)" && booking.booking.tests[0].pricePaise === 39900 && booking.samples.length === 1, `[${tag}] createBooking by catalog code: name, price and sample type from catalog-kit`); + ok((await gql(owner, "mutation($id: ID!) { collectSample(id: $id) { status } }", { id: booking.samples[0].id })).data?.collectSample.status === "collected", `[${tag}] collectSample`); + ok((await gql(owner, "mutation($id: ID!) { receiveSample(id: $id) { status booking { status } } }", { id: booking.samples[0].id })).data?.receiveSample.booking.status === "in_process", `[${tag}] receiveSample → booking in process`); + const rep = await gql(owner, 'mutation($b: ID!) { createReport(bookingId: $b, testCode: "CBC") { id status testName } }', { b: booking.booking.id }); + const reportId = rep.data?.createReport.id; + const saved = await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { status results { parameter value unit flag range } } }", { + r: reportId, x: [{ parameter: "Hemoglobin", value: "10.2" }, { parameter: "Total Leucocytes Count", value: "7.5" }], + }); + const hb = saved.data?.saveResults.results[0]; + ok(hb?.unit === "g/dL" && hb.flag === "L" && saved.data.saveResults.results[1].flag === "NONE", `[${tag}] saveResults: unit, range and flag filled from catalog-kit (Hb 10.2 → L)`); + ok(code(await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b) { fileName } }", { b: booking.booking.id })) === "CONFLICT", `[${tag}] an unverified report does not print → CONFLICT`); + await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { status } }", { r: reportId }); + const released = await gql(owner, "mutation($r: ID!) { releaseReport(id: $r) { status booking { status } } }", { r: reportId }); + ok(released.data?.releaseReport.booking.status === "completed", `[${tag}] verify + release → booking completed`); + const doc = await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b) { format fileName mimeType byteLength base64 } }", { b: booking.booking.id }); + const pdf = Buffer.from(doc.data?.bookingReportDocument.base64 ?? "", "base64"); + ok(pdf.subarray(0, 5).toString() === "%PDF-" && pdf.byteLength === doc.data.bookingReportDocument.byteLength, `[${tag}] bookingReportDocument: report-kit PDF (${(pdf.byteLength / 1024).toFixed(0)} KB, base64)`); + const html = await gql(owner, 'query($b: ID!) { bookingReportDocument(bookingId: $b, format: html, variant: non_header) { html } }', { b: booking.booking.id }); + ok(/Asha Patel/.test(html.data?.bookingReportDocument.html ?? "") && /10\.2/.test(html.data.bookingReportDocument.html), `[${tag}] bookingReportDocument html (non-header) carries the patient and results`); + + // ---------------------------------------------------------------- billing + automatic posting (finance-kit → tally-kit) + const inv = await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id invoiceNo status totalPaise duePaise items { name ratePaise } } }", { + i: { branchId: a.branch.id, patientId, bookingId: booking.booking.id, items: [{ code: "CBC" }, { code: "HOME", name: "Home collection", category: "service", ratePaise: 10000 }], payments: [{ amountPaise: 20000, mode: "cash" }] }, + }); + const invoiceId = inv.data?.issueInvoice.id; + ok(inv.data?.issueInvoice.status === "partially_paid" && inv.data.issueInvoice.totalPaise === 49900, `[${tag}] issueInvoice ${inv.data?.issueInvoice.invoiceNo}: CBC price from the catalog, Rs 499, partially paid`); + ok(code(await gql(owner, "mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 999999, mode: upi }) { id } }", { id: invoiceId })) === "CONFLICT", `[${tag}] overpayment → CONFLICT`); + const paid = await gql(owner, 'mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 29900, mode: upi, reference: "UPI9" }) { status payments { mode } vouchers { type entries { ledger { name } debitPaise creditPaise } } } }', { id: invoiceId }); + ok(paid.data?.recordPayment.status === "paid" && paid.data.recordPayment.vouchers.length === 3, `[${tag}] recordPayment → paid; sales + 2 receipt vouchers posted through finance-kit`); + const tb = await gql(owner, "{ trialBalance { differencePaise totalDebitPaise } outstandingReceivables { totalDuePaise } }"); + ok(tb.data?.trialBalance.differencePaise === 0 && tb.data.trialBalance.totalDebitPaise > 0 && tb.data.outstandingReceivables.totalDuePaise === 0, `[${tag}] trial balance balances (tally-kit); nothing outstanding`); + const invDoc = await gql(owner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { base64 fileName } h: invoiceDocument(invoiceId: $id, format: html) { html } }", { id: invoiceId }); + ok(Buffer.from(invDoc.data?.invoiceDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && invDoc.data.h.html.includes(inv.data.issueInvoice.invoiceNo), `[${tag}] invoiceDocument: invoice-kit PDF + HTML of the stored invoice (totals match)`); + const anyDoc = await gql(owner, '{ renderReport(request: { sample: "cbc" }, format: html) { html } invoiceTotals(request: { invoice: { invoiceNo: "T-1", patient: { name: "Kavita Sharma", age: 32, sex: "Female" }, items: ["cbc"] } }) reportTemplates { templates { id } } }'); + ok(anyDoc.data?.renderReport.html.includes("<html") && anyDoc.data.invoiceTotals.total > 0 && anyDoc.data.reportTemplates.templates.length > 20, `[${tag}] renderReport / invoiceTotals / reportTemplates from request data`); + ok(code(await gql(owner, "{ renderReport(request: { patient: { name: \"X\" } }) { fileName } }")) === "BAD_USER_INPUT", `[${tag}] bad report request → BAD_USER_INPUT`); + + const cash = (await db.rawRepos.ledgers.findBySystemKey(a.org.id, "cash"))!; + const capital = (await db.rawRepos.ledgers.findBySystemKey(a.org.id, "capital"))!; + ok(code(await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 50 }] } })) === "BAD_USER_INPUT", `[${tag}] unbalanced voucher → BAD_USER_INPUT`); + const journal = await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id voucherNo } }", { i: { branchId: a.branch.id, type: "journal", narration: "Capital", entries: [{ ledgerId: cash.id, debitPaise: 100000 }, { ledgerId: capital.id, creditPaise: 100000 }] } }); + ok(/^JRN\//.test(journal.data?.postVoucher.voucherNo ?? ""), `[${tag}] postVoucher (tally-kit): ${journal.data?.postVoucher.voucherNo}`); + ok(code(await gql(otherOwner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: other.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] } })) === "NOT_FOUND", `[${tag}] cannot post into another organisation's ledgers`); + ok(code(await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", qty: 0, ratePaise: 100 }] } })) === "BAD_USER_INPUT", `[${tag}] qty 0 → BAD_USER_INPUT`); + + // ---------------------------------------------------------------- permissions (front desk, branch A) + ok(code(await gql(desk, "{ trialBalance { differencePaise } }")) === "FORBIDDEN", `[${tag}] front desk cannot read the books → FORBIDDEN`); + const deskA = await gql(desk, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: a.branch.id, name: "Walk In", sex: "male" } }); + const deskB = await gql(desk, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: branchB.id, name: "Walk In", sex: "male" } }); + ok(deskA.data?.registerPatient.uhid && code(deskB) === "FORBIDDEN", `[${tag}] front desk registers in branch A, not branch B`); + await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: branchB.id, patientId, items: [{ code: "CBC" }] } }); + const deskList = await gql(desk, "{ invoices { branch { code } } }"); + ok(deskList.data?.invoices.length > 0 && deskList.data.invoices.every((i: any) => i.branch.code === "A01"), `[${tag}] lists only show the viewer's branches`); + ok(code(await gql(desk, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ code: "CBC" }], billDiscountPaise: 100 } })) === "FORBIDDEN", `[${tag}] discounts need invoice:discount`); + ok((await gql(desk, "query($id: ID!) { invoice(id: $id) { vouchers { id } } }", { id: invoiceId })).data?.invoice.vouchers.length === 0, `[${tag}] nested vouchers hidden without accounts:read`); + + // ---------------------------------------------------------------- tenant isolation + ok(code(await gql(otherOwner, "query($id: ID!) { invoice(id: $id) { id } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] another organisation cannot read this invoice`); + ok((await gql(otherOwner, "query($id: ID!) { patient(id: $id) { id } }", { id: patientId })).data?.patient === null, `[${tag}] …or this patient`); + ok(code(await gql(otherOwner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { fileName } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] …or print it`); + + // ---------------------------------------------------------------- N+1 + for (let i = 0; i < 8; i++) await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "T", ratePaise: 1000 }] } }); + patientBatches = 0; + const list = await gql(owner, "{ invoices(limit: 10) { invoiceNo patient { name } } }"); + ok(list.data?.invoices.length === 10 && patientBatches === 1, `[${tag}] 10 invoices → ${patientBatches} batched patient query (no N+1)`); + + // ---------------------------------------------------------------- finance-kit + const rules = await gql(owner, "mutation { seedTaxRules { hsnSac ratePercent } }"); + ok(rules.data?.seedTaxRules.length > 0, `[${tag}] seedTaxRules (finance-kit defaults, verify with CA)`); + const cust = await gql(owner, 'mutation { createCustomer(input: { code: "city-hosp", name: "City Hospital", kind: hospital, stateCode: "22", creditLimitPaise: 100000, creditDays: 30 }) { id code ledger { name } } }'); + const customerId = cust.data?.createCustomer.id; + ok(cust.data?.createCustomer.code === "CITY-HOSP" && cust.data.createCustomer.ledger.name, `[${tag}] createCustomer with its receivable ledger`); + const b2b = await gql(owner, "mutation($i: CustomerInvoiceInput!) { issueCustomerInvoice(input: $i) { id totalPaise customer { code } } }", { i: { branchId: a.branch.id, patientId, customerId, items: [{ code: "CBC" }] } }); + ok(b2b.data?.issueCustomerInvoice.customer.code === "CITY-HOSP", `[${tag}] issueCustomerInvoice (tax rules applied)`); + ok(code(await gql(owner, "mutation($i: CustomerInvoiceInput!) { issueCustomerInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, customerId, items: [{ name: "Big", ratePaise: 200000 }] } })) === "CREDIT_LIMIT_EXCEEDED", `[${tag}] over the credit limit → CREDIT_LIMIT_EXCEEDED`); + const yearRange = { from: new Date(Date.now() - 86400_000 * 30), to: new Date(Date.now() + 86400_000) }; + const stmt = await gql(owner, "query($c: ID!, $f: DateTime!, $t: DateTime!) { customerStatement(customerId: $c, from: $f, to: $t) { closingPaise open { bucket } ageing { notDue } } }", { c: customerId, f: yearRange.from, t: yearRange.to }); + ok(stmt.data?.customerStatement.closingPaise === b2b.data.issueCustomerInvoice.totalPaise && stmt.data.customerStatement.open[0].bucket === "not_due", `[${tag}] customerStatement: closing = billed, not yet due`); + const cn = await gql(owner, 'mutation($i: CreditNoteInput!) { issueCreditNote(input: $i) { creditNote { noteNo totalPaise } invoice { status creditedPaise } } }', { i: { invoiceId: b2b.data.issueCustomerInvoice.id, reason: "Test not done" } }); + ok(cn.data?.issueCreditNote.invoice.status === "credited", `[${tag}] issueCreditNote ${cn.data?.issueCreditNote.creditNote.noteNo} → invoice credited`); + const returns = await gql(owner, "query($f: DateTime!, $t: DateTime!) { gstr3b(from: $f, to: $t) { outwardNilExemptPaise verifyWithCA } gstr1(from: $f, to: $t) { docs { nature total } } }", { f: yearRange.from, t: yearRange.to }); + ok(returns.data?.gstr3b.verifyWithCA.length > 0 && Array.isArray(returns.data.gstr1.docs), `[${tag}] gstr1 + gstr3b data, with the verify-with-CA list`); + const csv = await gql(owner, "{ exportTrialBalance { fileName content } }"); + ok(/TOTAL/.test(csv.data?.exportTrialBalance.content ?? ""), `[${tag}] exportTrialBalance CSV ends with a TOTAL row`); + const partner = await gql(owner, 'mutation($b: ID!) { createPartner(input: { code: "cc1", name: "Collection Centre One", kind: collection_centre, branchId: $b, pan: "ABCDE1234F" }) { id branch { code } } }', { b: branchB.id }); + ok(partner.data?.createPartner.branch.code === "B01", `[${tag}] createPartner on branch B`); + const agr = await gql(owner, 'mutation($p: ID!, $d: Day!) { addAgreement(input: { partnerId: $p, kind: revenue_share, shareBp: 2500, effectiveFrom: $d }) { id shareBp } }', { p: partner.data?.createPartner.id, d: "2026-04-01" }); + ok(agr.data?.addAgreement.shareBp === 2500, `[${tag}] addAgreement (25% revenue share)`); + const period = istDay(new Date()).slice(0, 7); + const prev = await gql(owner, "query($p: ID!, $m: String!) { settlementPreview(partnerId: $p, period: $m) { grossPaise sharePaise netPaise tds { tdsPaise } } }", { p: partner.data?.createPartner.id, m: period }); + ok(prev.data?.settlementPreview.sharePaise === Math.round(prev.data.settlementPreview.grossPaise / 4) && prev.data.settlementPreview.grossPaise > 0, `[${tag}] settlementPreview: share 25% of branch B's billing`); + + // ---------------------------------------------------------------- lock dates + const today = istDay(new Date()); + await gql(owner, "mutation($d: Day!) { setLockDate(input: { upTo: $d, reason: \"test\" }) { lockedUpTo } }", { d: today }); + ok(code(await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] } })) === "PERIOD_LOCKED", `[${tag}] posting into a locked period → PERIOD_LOCKED`); + await gql(owner, "mutation { setLockDate(input: { upTo: null }) { id } }"); + + // ---------------------------------------------------------------- sessions and passwords + ok((await gql(owner, "mutation { requestPasswordReset(email: \"desk@apl.test\") }")).data?.requestPasswordReset === true && mails.length === 1, `[${tag}] requestPasswordReset sends a link through the mailer`); + ok((await gql(null, "mutation { requestPasswordReset(email: \"nobody@apl.test\") }")).data?.requestPasswordReset === true && mails.length === 1, `[${tag}] …and does not reveal unknown emails`); + const resetToken = new URL(mails[0]!.link, "http://x").searchParams.get("token"); + ok((await gql(null, "mutation($t: String!) { resetPassword(token: $t, newPassword: \"Quiet-River-5821\") }", { t: resetToken })).data?.resetPassword === true, `[${tag}] resetPassword with the emailed token`); + ok(code(await gql(desk, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] a password reset ends the old sessions`); + const desk2 = await login("desk@apl.test", "Quiet-River-5821"); + ok(!!desk2, `[${tag}] login with the new password`); + await gql(owner, "mutation($u: ID!) { disableUser(userId: $u) { status } }", { u: deskUser.data.createUser.id }); + ok(code(await gql(desk2, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] disableUser signs the user out at once`); + const sessions = await gql(owner, "{ mySessions { current } }"); + ok(sessions.data?.mySessions.some((s: any) => s.current), `[${tag}] mySessions marks the current session`); + await gql(owner, "mutation { logout }"); + ok(code(await gql(owner, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] logout ends the session`); + + // ---------------------------------------------------------------- server guards + ok(code(await gql(null, "{ __schema { types { name } } }")) === "GRAPHQL_VALIDATION_FAILED", `[${tag}] introspection disabled when INTROSPECTION=0`); + const deep = `{ invoices ${"{ booking { invoice ".repeat(7)}{ id }${" } }".repeat(7)} }`; + ok(/levels deep/.test((await gql(otherOwner, deep)).errors?.[0]?.message ?? ""), `[${tag}] queries deeper than 12 levels are refused`); + + await app.close(); +} + +/** The production path: the container builds its own database and auth from the environment. */ +async function fromConfig(url: string) { + console.log("\n[config]"); + const container = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url }) }); + const app = await buildServer(container); + const post = async (payload: unknown, token?: string) => + (await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, payload: JSON.stringify(payload) })).json() as Gql; + const login = await post({ query: 'mutation { login(input: { email: "owner@apl.test", password: "Lab-Owner-2026!" }) { token } }' }); + const org = await post({ query: "{ organization { code } }" }, login.data?.login.token); + ok(org.data?.organization.code === "APL", "[config] createContainer from env: own db-kit + auth-kit (memory sessions), login works"); + const pre = await app.inject({ method: "OPTIONS", url: "/graphql", headers: { origin: "http://localhost:3000", "access-control-request-method": "POST" } }); + ok(pre.statusCode < 300 && pre.headers["access-control-allow-origin"] !== undefined, "[config] CORS preflight answered"); + await app.close(); +} + +/** Static checks: the documented example operations stay valid against the schema. */ +function staticChecks() { + console.log("\n[static]"); + const schema = buildSchema(); + const dir = new URL("../docs/examples/", import.meta.url); + for (const f of readdirSync(dir).filter((n) => n.endsWith(".graphql"))) { + const errs = validate(schema, parse(readFileSync(new URL(f, dir), "utf8"))); + ok(errs.length === 0, `docs/examples/${f} is valid${errs.length ? `: ${errs.map((e) => e.message).join("; ")}` : ""}`); + } +} + +async function main() { + const t0 = Date.now(); + staticChecks(); + const backends = (process.env.TEST_BACKENDS ?? "pg,mongo").split(",").map((s) => s.trim()); + + if (backends.includes("pg")) { + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + // in the temp folder: as root, embedded-postgres runs initdb as the "postgres" user, who may not write here + const dir = join(tmpdir(), `mgpl-api-pg-${process.pid}`); + rmSync(dir, { recursive: true, force: true }); + const pg = new EmbeddedPostgres({ databaseDir: dir, user: "postgres", password: "postgres", port: 54331, persistent: false }); + await pg.initialise(); + await pg.start(); + await pg.createDatabase("mgpl_api_test"); + const url = "postgres://postgres:postgres@localhost:54331/mgpl_api_test"; + const db = await createDb({ backend: "pg", pg: { url }, posting: financePosting }); + try { + await suite(db, "pg"); + await fromConfig(url); + } finally { + await db.close(); + await pg.stop(); + rmSync(dir, { recursive: true, force: true }); + } + } + + if (backends.includes("mongo")) { + const { MongoMemoryReplSet } = await import("mongodb-memory-server"); + const rs = await MongoMemoryReplSet.create({ replSet: { count: 1, storageEngine: "wiredTiger" } }); + const db = await createDb({ backend: "mongo", mongo: { uri: rs.getUri("mgpl_api_test") }, posting: financePosting }); + try { + await suite(db, "mongo"); + } finally { + await db.close(); + await rs.stop(); + } + } + + console.log(`\n${passed} passed, ${failed} failed in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); +``` diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..a40b0c5 --- /dev/null +++ b/llms.txt @@ -0,0 +1,32 @@ +# @mgpl-diagnostics/api + +> The MGPL Diagnostics backend (Node.js ≥ 20, TypeScript): one GraphQL endpoint, `POST /graphql` (GraphQL Yoga on Fastify), for sign-in, users and roles, the test catalog, patients, bookings, samples, results and reports, billing, double-entry books, GST returns, B2B receivables, partner settlements, cash and bank, lock dates, CSV exports, and rendered report/invoice documents (base64 PDF or HTML). The kits (auth, db, catalog, report, invoice, tally, finance) are internal libraries behind a controller → service layer. PostgreSQL or MongoDB; Redis optional. + +Key facts for agents: +- Layers: `src/schema` (SDL) → `src/controllers` (authorize, map) → `src/services` (kit calls) → kits. No REST routes except `GET /health`. +- Auth: `login` returns a bearer token (auth-kit session); every request re-reads the user. Permissions are `resource:action`, checked per branch with `authorize(ctx, perm, branchId)`. +- Tenant isolation: orgId and actorId come from the session, never from arguments; every id is checked against the organisation. +- Errors carry `extensions.code`: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT, CONFLICT, PERIOD_LOCKED, CREDIT_LIMIT_EXCEEDED, plus auth-kit codes. Unknown errors are masked. +- Money is integer paise; days are IST "YYYY-MM-DD"; the financial year is April–March. Billing needs the branch's GST stateCode; GSTR-1/3B need the organisation's GSTIN. +- Frontends use `createMgplGraphQLClient` from `@mgpl-diagnostics/client`. +- Generated files (CI-checked): schema.graphql (`npm run schema`), docs/04-api-reference.md (`npm run docs:reference`), llms-full.txt (`npm run llms`). + +## Docs + +- [README](README.md): what it is, quick start +- [docs/README.md](docs/README.md): index of the A-to-Z docs (architecture, getting started, API reference, errors, auth, flows, documents, configuration, data and money, testing, deployment, adding a feature, client, glossary) +- [docs/04-api-reference.md](docs/04-api-reference.md): every operation with its permission +- [docs/LOCAL-AGENT-BRIEF.md](docs/LOCAL-AGENT-BRIEF.md): releases, org settings and deployment to do outside this repo +- [AGENTS.md](AGENTS.md): map and rules +- [schema.graphql](schema.graphql): the SDL +- [llms-full.txt](llms-full.txt): everything above plus the source + +## Source + +- [src/server.ts](src/server.ts): Fastify + Yoga, one endpoint +- [src/container.ts](src/container.ts): db, auth, services +- [src/graphql/](src/graphql/): context, guards, errors, scalars, loaders, schema assembly +- [src/schema/](src/schema/): SDL per module +- [src/controllers/](src/controllers/): resolvers per module +- [src/services/](src/services/): business logic on the kits +- [test/run.ts](test/run.ts): end-to-end suite diff --git a/package.json b/package.json new file mode 100644 index 0000000..92515a1 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "@mgpl-diagnostics/api", + "version": "0.1.0", + "private": true, + "description": "MGPL Diagnostics backend: one GraphQL endpoint (GraphQL Yoga on Fastify) over the kits (auth, db, catalog, report, invoice, finance, tally) used as internal libraries.", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "repository": "github:mgpl-diagnostics/api", + "main": "./dist/main.js", + "scripts": { + "kits": "node scripts/kits.mjs", + "dev": "tsx watch src/main.ts", + "start": "node dist/main.js", + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "tsx test/run.ts", + "schema": "tsx scripts/print-schema.ts", + "seed": "tsx scripts/seed.ts", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/api README.md AGENTS.md llms.txt docs package.json kits.json schema.graphql src scripts test", + "docs:reference": "tsx scripts/api-reference.ts" + }, + "dependencies": { + "@graphql-tools/schema": "^10.1.4", + "@mgpl-diagnostics/auth-kit": "^1.0.0", + "@mgpl-diagnostics/catalog-kit": "^1.0.0", + "@mgpl-diagnostics/db-kit": "^1.1.0", + "@mgpl-diagnostics/finance-kit": "^1.0.0", + "@mgpl-diagnostics/invoice-kit": "^2.0.0", + "@mgpl-diagnostics/report-kit": "^2.0.0", + "@mgpl-diagnostics/tally-kit": "^0.1.0", + "fastify": "^5.12.7", + "graphql": "^16.14.0", + "graphql-yoga": "^5.24.5", + "ioredis": "^5.11.1", + "mongoose": "^9.11.2", + "pg": "^8.23.1", + "pg-hstore": "^2.3.4", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sequelize": "^6.37.8" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "embedded-postgres": "^18.4.0-beta.17", + "ioredis-mock": "^8.13.1", + "mongodb-memory-server": "^11.3.0", + "tsup": "^8.5.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/schema.graphql b/schema.graphql new file mode 100644 index 0000000..11e7d9c --- /dev/null +++ b/schema.graphql @@ -0,0 +1,1755 @@ +# Generated by `npm run schema` from src/schema. Do not edit by hand. + +type AccountGroup { + affectsGrossProfit: Boolean! + id: ID! + name: String! + nature: AccountNature! + parent: AccountGroup +} + +enum AccountNature { + assets + expenses + income + liabilities +} + +type Address { + city: String + district: String + line1: String + line2: String + pincode: String + state: String +} + +input AddressInput { + city: String + district: String + line1: String + line2: String + pincode: String + state: String +} + +enum AgeBucket { + D0_30 + D31_60 + D61_90 + D90_PLUS +} + +type Ageing { + d1to30: Int! + d31to60: Int! + d61to90: Int! + d90plus: Int! + notDue: Int! +} + +input AgreementInput { + categories: [ItemCategory!] + defaultSharePaise: Int + effectiveFrom: Day! + effectiveTo: Day + kind: AgreementKind! + notes: String + partnerId: ID! + rates: [ShareRateInput!] + shareBp: Int +} + +enum AgreementKind { + fixed_rate + revenue_share +} + +type AuditEntry { + action: String! + at: DateTime! + branchId: ID + data: JSON + entity: String + entityId: ID + id: ID! + ip: String + user: User +} + +type B2csRow { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + placeOfSupply: String! + ratePercent: Float! + sgstPaise: Int! + supplyType: String! + taxablePaise: Int! +} + +type BalanceSheet { + assets: [GroupLine!]! + differencePaise: Int! + liabilities: [GroupLine!]! + profitPaise: Int! + totalAssetsPaise: Int! + totalLiabilitiesPaise: Int! +} + +input BankImportInput { + branchId: ID! + + """The bank's CSV export, as text.""" + csv: String! + fileName: String + ledgerId: ID! +} + +type BankImportResult { + added: Int! + skipped: Int! + statement: BankStatement! +} + +type BankLine { + balancePaise: Int + creditPaise: Int! + date: DateTime! + debitPaise: Int! + description: String! + id: ID! + matchKind: String + matchedAt: DateTime + reference: String + settlement: BankSettlementMatch + statementId: ID! + status: BankLineStatus! + voucherIds: [ID!]! +} + +enum BankLineManualStatus { + ignored + unmatched +} + +enum BankLineStatus { + ignored + matched + unmatched +} + +type BankReconciliation { + bankCreditsNotInBooksPaise: Int! + bankDebitsNotInBooksPaise: Int! + bankPaise: Int + bookPaise: Int! + depositsNotCreditedPaise: Int! + differencePaise: Int + expectedBankPaise: Int! + paymentsNotPresentedPaise: Int! + reconciled: ReconciledTotals! + unmatchedLines: [BankLine!]! + unreconciledEntries: [LedgerEntryRow!]! +} + +type BankSettlementMatch { + day: Day! + feePaise: Int! + grossPaise: Int! + ledgerId: ID! +} + +type BankStatement { + branch: Branch! + closingPaise: Int + createdAt: DateTime! + fileName: String + from: DateTime + id: ID! + ledger: Ledger! + lineCount: Int! + openingPaise: Int + to: DateTime +} + +type Booking { + bookingNo: String! + branch: Branch! + createdAt: DateTime! + doctor: Doctor + id: ID! + invoice: Invoice + notes: String + patient: Patient! + reports: [Report!]! + sampleCollection: SampleCollection! + samples: [Sample!]! + status: BookingStatus! + tests: [BookingTest!]! +} + +enum BookingStatus { + booked + cancelled + completed + in_process + sample_collected +} + +type BookingTest { + """The catalog entry, when the code is in catalog-kit.""" + catalog: CatalogTest + name: String! + pricePaise: Int! + sampleType: String + testCode: String! +} + +""" +A test by catalog code: name, price and sample type come from catalog-kit unless given. +""" +input BookingTestInput { + name: String + pricePaise: Int + sampleType: String + testCode: String! +} + +type Branch { + active: Boolean! + address: Address + code: String! + gstin: String + id: ID! + kind: BranchKind! + name: String! + phone: String + stateCode: String +} + +input BranchInput { + active: Boolean + address: AddressInput + code: String! + gstin: String + kind: BranchKind! + name: String! + phone: String + stateCode: String +} + +enum BranchKind { + collection_center + lab +} + +input BranchPatch { + active: Boolean + address: AddressInput + gstin: String + kind: BranchKind + name: String + phone: String + stateCode: String +} + +type BucketTotals { + d0to30: Int! + d31to60: Int! + d61to90: Int! + d90plus: Int! +} + +type CashClosing { + bookPaise: Int! + branch: Branch! + countedPaise: Int! + createdAt: DateTime! + day: Day! + denominations: [CashDenomination!] + + """counted − book; negative = short.""" + differencePaise: Int! + id: ID! + notes: String + voucher: Voucher +} + +type CashDenomination { + count: Int! + valuePaise: Int! +} + +input CashDenominationInput { + count: Int! + valuePaise: Int! +} + +type CatalogHit { + score: Float! + test: CatalogTest! +} + +type CatalogInterval { + high: String + low: String +} + +type CatalogParameter { + calculated: Boolean! + female: CatalogInterval + + """Readable formula of a calculated parameter.""" + formulaText: String + male: CatalogInterval + method: String + name: String + options: [String!] + + """The printed range for a sex (or both).""" + range(sex: Sex): String! + referenceText: String + type: ParameterType! + unit: String +} + +type CatalogTest { + code: String + department: String! + id: Int! + isProfile: Boolean! + + """MRP in rupees.""" + mrp: Int + name: String! + parameterCount: Int! + parameters: [CatalogParameter!]! + + """MRP in paise (what bookings and invoices use).""" + pricePaise: Int + sampleType: String + secondarySampleTypes: [String!] + shortCode: String +} + +input CloseCashDayInput { + branchId: ID! + countedPaise: Int! + day: Day! + denominations: [CashDenominationInput!] + notes: String +} + +enum CollectionType { + home + walk_in +} + +input CreateBookingInput { + branchId: ID! + collectionType: CollectionType = walk_in + doctorId: ID + notes: String + patientId: ID! + scheduledAt: DateTime + tests: [BookingTestInput!]! +} + +type CreateBookingResult { + booking: Booking! + samples: [Sample!]! +} + +input CreateUserInput { + email: String! + name: String! + password: String! + phone: String + roles: [RoleAssignmentInput!]! +} + +type CreditNote { + buyerGstin: String + cgstPaise: Int! + customer: Customer + date: DateTime! + id: ID! + igstPaise: Int! + invoice: Invoice! + lines: [CreditNoteLine!]! + noteNo: String! + placeOfSupply: String + reason: String! + roundOffPaise: Int! + sgstPaise: Int! + status: String! + taxPaise: Int! + taxablePaise: Int! + totalPaise: Int! +} + +input CreditNoteInput { + date: DateTime + invoiceId: ID! + + """Default: everything not yet credited.""" + lines: [CreditNoteLineInput!] + reason: String! +} + +type CreditNoteLine { + cgstPaise: Int! + code: String + hsnSac: String + igstPaise: Int! + itemIndex: Int! + name: String! + sgstPaise: Int! + taxPaise: Int! + taxPercent: Float! + taxability: Taxability + taxablePaise: Int! +} + +input CreditNoteLineInput { + itemIndex: Int! + taxablePaise: Int +} + +type CreditNoteResult { + creditNote: CreditNote! + invoice: Invoice! + + """Refund with refundPayment.""" + refundablePaise: Int! +} + +type CsvFile { + content: String! + fileName: String! + mimeType: String! +} + +type Customer { + active: Boolean! + address: Address + code: String! + creditDays: Int! + + """null = no limit.""" + creditLimitPaise: Int + email: String + gstin: String + id: ID! + kind: CustomerKind! + ledger: Ledger! + legalName: String + name: String! + pan: String + phone: String + stateCode: String +} + +input CustomerInput { + address: AddressInput + code: String! + creditDays: Int + creditLimitPaise: Int + email: String + gstin: String + kind: CustomerKind! + legalName: String + name: String! + pan: String + phone: String + stateCode: String +} + +input CustomerInvoiceInput { + applyTaxRules: Boolean = true + billDiscountPaise: Int = 0 + bookingId: ID + branchId: ID! + customerId: ID! + items: [InvoiceItemInput!]! + patientId: ID! + payments: [PaymentInput!] + + """State code override.""" + placeOfSupply: String +} + +enum CustomerKind { + corporate + hospital + insurer + other + tpa +} + +input CustomerPatch { + active: Boolean + address: AddressInput + creditDays: Int + + """null removes the limit.""" + creditLimitPaise: Int + email: String + gstin: String + kind: CustomerKind + legalName: String + name: String + pan: String + phone: String + stateCode: String +} + +type CustomerStatement { + ageing: Ageing! + availableCreditPaise: Int + closingPaise: Int! + customer: Customer! + open: [OpenItem!]! + openTotalPaise: Int! + openingPaise: Int! + rows: [StatementRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +"""ISO-8601 date-time (UTC), e.g. 2026-10-14T09:30:00.000Z""" +scalar DateTime + +"""Calendar day in IST, YYYY-MM-DD""" +scalar Day + +type DocSeries { + cancelled: Int! + from: String! + nature: String! + net: Int! + series: String! + to: String! + total: Int! +} + +type Doctor { + active: Boolean! + clinic: String + id: ID! + name: String! + phone: String + qualification: String +} + +input DoctorInput { + active: Boolean = true + clinic: String + name: String! + phone: String + qualification: String +} + +input DoctorPatch { + active: Boolean + clinic: String + name: String + phone: String + qualification: String +} + +type Document { + """PDF bytes, base64 (null for HTML).""" + base64: String + byteLength: Int! + fileName: String! + format: DocumentFormat! + + """Self-contained HTML (null for PDF).""" + html: String + mimeType: String! +} + +enum DocumentFormat { + html + pdf +} + +type DocumentSample { + group: String + id: String! + label: String! +} + +type FinanceConfig { + b2clThresholdPaise: Int! + bankMaxFeeBp: Int! + bankToleranceDays: Int! + clearingLedgerKeys: [String!]! + tds: TdsConfig! +} + +type GroupLine { + amountPaise: Int! + group: AccountGroup! + ledgers: [LedgerAmount!]! +} + +type GstSummary { + cgstPaise: Int! + igstPaise: Int! + sgstPaise: Int! + taxableValuePaise: Int! + totalTaxPaise: Int! +} + +type Gstr1 { + b2b: [Gstr1Invoice!]! + b2cl: [Gstr1Invoice!]! + b2cs: [B2csRow!]! + cdnr: [Gstr1Note!]! + cdnur: [Gstr1Note!]! + docs: [DocSeries!]! + gstin: String! + hsn: HsnSummary! + nil: [NilRow!]! + period: Period! + totals: TaxTotals! + verifyWithCA: [String!]! +} + +type Gstr1Invoice { + ctin: String + day: Day! + invoiceId: ID! + invoiceNo: String! + items: [RateTotals!]! + placeOfSupply: String! + reverseCharge: String! + valuePaise: Int! +} + +type Gstr1Note { + ctin: String + day: Day! + invoiceDay: Day! + invoiceNo: String! + items: [RateTotals!]! + noteId: ID! + noteNo: String! + noteType: String! + placeOfSupply: String! + type: String + valuePaise: Int! +} + +type Gstr3b { + gstin: String! + + """3.2""" + interStateUnregistered: [InterStateRow!]! + + """3.1(c)""" + outwardNilExemptPaise: Int! + + """3.1(e)""" + outwardNonGstPaise: Int! + + """3.1(a)""" + outwardTaxable: TaxTotals! + period: Period! + verifyWithCA: [String!]! +} + +"""Service health: database reachable, versions.""" +type Health { + database: String! + ok: Boolean! + service: String! + time: DateTime! + version: String! +} + +type HsnRow { + cessPaise: Int! + cgstPaise: Int! + hsnSac: String! + igstPaise: Int! + qty: Float! + ratePercent: Float! + sgstPaise: Int! + taxability: Taxability! + taxablePaise: Int! + totalValuePaise: Int! +} + +type HsnSummary { + b2b: [HsnRow!]! + b2c: [HsnRow!]! +} + +type InterStateRow { + igstPaise: Int! + placeOfSupply: String! + taxablePaise: Int! +} + +type Invoice { + booking: Booking + branch: Branch! + buyerGstin: String + cancelReason: String + createdAt: DateTime! + creditNotes: [CreditNote!]! + creditedPaise: Int! + customer: Customer + discountPaise: Int! + duePaise: Int! + id: ID! + invoiceNo: String! + issuedAt: DateTime + items: [InvoiceItem!]! + paidPaise: Int! + patient: Patient! + payments: [Payment!]! + placeOfSupply: String + roundOffPaise: Int! + status: InvoiceStatus! + subtotalPaise: Int! + taxPaise: Int! + totalPaise: Int! + vouchers: [Voucher!]! +} + +type InvoiceItem { + amountPaise: Int! + category: ItemCategory! + code: String + discountPaise: Int! + hsnSac: String + name: String! + qty: Float! + ratePaise: Int! + taxPercent: Float! + taxability: Taxability +} + +""" +A catalog-kit test code alone is enough: name and MRP come from the catalog. +""" +input InvoiceItemInput { + category: ItemCategory = test + code: String + discountPaise: Int = 0 + hsnSac: String + name: String + qty: Float = 1 + ratePaise: Int + taxPercent: Float = 0 + taxability: Taxability +} + +enum InvoiceStatus { + cancelled + credited + draft + issued + paid + partially_paid + refunded +} + +input IssueInvoiceInput { + """Fill HSN/SAC, taxability and GST rate from the tax rules.""" + applyTaxRules: Boolean = false + billDiscountPaise: Int = 0 + bookingId: ID + branchId: ID! + items: [InvoiceItemInput!]! + patientId: ID! + + """Taken at the counter with the invoice.""" + payments: [PaymentInput!] +} + +enum ItemCategory { + consumable + medicine + other + package + service + test +} + +"""Any JSON value""" +scalar JSON + +type Ledger { + active: Boolean! + + """Closing balance, positive = Debit.""" + balancePaise(asOf: DateTime, branchId: ID): Int! + group: AccountGroup! + gstin: String + id: ID! + name: String! + openingPaise: Int! + systemKey: String +} + +type LedgerAmount { + amountPaise: Int! + ledger: Ledger! +} + +type LedgerEntryRow { + creditPaise: Int! + date: DateTime! + debitPaise: Int! + id: ID! + invoiceId: ID + voucherId: ID! + voucherNo: String! + voucherType: VoucherType! +} + +input LedgerInput { + groupId: ID! + gstin: String + name: String! + openingPaise: Int = 0 +} + +type LedgerStatement { + closingPaise: Int! + ledger: Ledger! + openingPaise: Int! + rows: [StatementRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +input LockDateInput { + """Omit for the whole organisation.""" + branchId: ID + reason: String + + """Last locked IST day; null removes the lock.""" + upTo: Day +} + +input LoginInput { + email: String! + + """Only when the same email exists in several organisations.""" + orgCode: String + password: String! +} + +type LoginResult { + expiresAt: DateTime! + + """ + Send as 'Authorization: Bearer <token>'. Shown once; the server stores only its SHA-256. + """ + token: String! + user: User! +} + +type Mutation { + accrueSettlement(id: ID!): Settlement! + addAgreement(input: AgreementInput!): PartnerAgreement! + addTaxRule(input: TaxRuleInput!): TaxRule! + amendReport(id: ID!): Report! + cancelBooking(id: ID!): Booking! + cancelInvoice(id: ID!, reason: String!): Invoice! + cancelSettlement(id: ID!): Settlement! + cancelVoucher(id: ID!): Voucher! + changePassword(currentPassword: String!, newPassword: String!): Boolean! + closeCashDay(input: CloseCashDayInput!): CashClosing! + + """Closing vouchers on 31 March and a lock. fy: 2026-27.""" + closeFinancialYear(fy: String!): YearEndResult! + collectSample(id: ID!): Sample! + createBooking(input: CreateBookingInput!): CreateBookingResult! + createBranch(input: BranchInput!): Branch! + createCustomer(input: CustomerInput!): Customer! + createDoctor(input: DoctorInput!): Doctor! + createLedger(input: LedgerInput!): Ledger! + createPartner(input: PartnerInput!): Partner! + createReport(bookingId: ID!, testCode: String!, testName: String): Report! + createSettlement(deductions: [SettlementDeductionInput!], partnerId: ID!, period: String!): Settlement! + createUser(input: CreateUserInput!): User! + disableUser(userId: ID!): User! + enableUser(userId: ID!): User! + importBankStatement(input: BankImportInput!): BankImportResult! + issueCreditNote(input: CreditNoteInput!): CreditNoteResult! + issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice! + issueInvoice(input: IssueInvoiceInput!): Invoice! + login(input: LoginInput!): LoginResult! + + """Ends this session.""" + logout: Boolean! + + """Ends every other session of the signed-in user; returns how many.""" + logoutOtherSessions: Int! + matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine! + paySettlement(input: PaySettlementInput!): Settlement! + + """Does nothing; lets clients check that mutations reach the server.""" + ping: String! + postVoucher(input: PostVoucherInput!): Voucher! + receiveSample(id: ID!): Sample! + reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult! + recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + registerPatient(input: RegisterPatientInput!): Patient! + rejectSample(id: ID!, reason: String!): Sample! + releaseReport(id: ID!): Report! + + """ + Always true (never reveals whether the email exists). The link is sent by the server's mailer. + """ + requestPasswordReset(email: String!, orgCode: String): Boolean! + resetPassword(newPassword: String!, token: String!): Boolean! + revokeSession(id: ID!): Boolean! + saveResults(reportId: ID!, results: [ResultInput!]!): Report! + + """ + finance-kit's starting rules. Every rate must be verified with the lab's CA. + """ + seedTaxRules: [TaxRule!]! + setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine! + setLockDate(input: LockDateInput!): PeriodLock! + setUserPassword(newPassword: String!, userId: ID!): Boolean! + setUserRoles(roles: [RoleAssignmentInput!]!, userId: ID!): User! + + """ + Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent. + """ + setupChartOfAccounts: [Ledger!]! + updateBranch(id: ID!, input: BranchPatch!): Branch! + updateCustomer(id: ID!, input: CustomerPatch!): Customer! + updateDoctor(id: ID!, input: DoctorPatch!): Doctor! + updateOrganization(input: OrganizationInput!): Organization! + updatePartner(id: ID!, input: PartnerPatch!): Partner! + updatePatient(id: ID!, input: UpdatePatientInput!): Patient! + upsertRole(input: RoleInput!): Role! + verifyReport(id: ID!): Report! +} + +type NilRow { + description: String! + exemptedPaise: Int! + nilRatedPaise: Int! + nonGstPaise: Int! +} + +type OpenItem { + bucket: OpenItemBucket! + creditedPaise: Int! + day: Day! + dueDay: Day! + duePaise: Int! + invoiceId: ID! + invoiceNo: String! + overdueDays: Int! + paidPaise: Int! + totalPaise: Int! +} + +enum OpenItemBucket { + D1_30 + D31_60 + D61_90 + D90_PLUS + not_due +} + +type Organization { + address: Address + branches: [Branch!]! + code: String! + email: String + gstin: String + id: ID! + legalName: String + name: String! + phone: String +} + +input OrganizationInput { + address: AddressInput + email: String + gstin: String + legalName: String + name: String + phone: String +} + +type Outstanding { + buckets: BucketTotals! + rows: [OutstandingRow!]! + totalDuePaise: Int! +} + +type OutstandingRow { + ageDays: Int! + bucket: AgeBucket! + invoice: Invoice! +} + +enum ParameterType { + image + numeric + options + text + title +} + +type Partner { + active: Boolean! + address: Address + agreements: [PartnerAgreement!]! + branch: Branch! + code: String! + email: String + gstin: String + id: ID! + kind: PartnerKind! + ledger: Ledger! + legalName: String + name: String! + pan: String + phone: String + stateCode: String + + """Overrides the default TDS (verify with CA).""" + tds: PartnerTds +} + +type PartnerAgreement { + categories: [ItemCategory!] + defaultSharePaise: Int + effectiveFrom: DateTime! + effectiveTo: DateTime + id: ID! + kind: AgreementKind! + notes: String + rates: [ShareRate!] + shareBp: Int +} + +input PartnerInput { + address: AddressInput + branchId: ID! + code: String! + email: String + gstin: String + kind: PartnerKind! + legalName: String + name: String! + pan: String + phone: String + stateCode: String + tds: PartnerTdsInput +} + +enum PartnerKind { + collection_centre + franchise +} + +input PartnerPatch { + active: Boolean + address: AddressInput + email: String + gstin: String + kind: PartnerKind + legalName: String + name: String + pan: String + phone: String + stateCode: String + + """null = default TDS.""" + tds: PartnerTdsInput +} + +type PartnerTds { + rateBp: Int! + section: String! +} + +input PartnerTdsInput { + rateBp: Int! + section: String! +} + +type Patient { + address: Address + ageYears: Int + bookings(limit: Int = 20): [Booking!]! + branch: Branch + createdAt: DateTime! + dob: DateTime + email: String + id: ID! + invoices(limit: Int = 20): [Invoice!]! + name: String! + phone: String + sex: Sex! + title: String + uhid: String! +} + +input PaySettlementInput { + date: DateTime + mode: PaymentMode! + reference: String + settlementId: ID! +} + +type Payment { + amountPaise: Int! + id: ID! + kind: PaymentKind! + mode: PaymentMode! + receivedAt: DateTime! + reference: String +} + +input PaymentInput { + amountPaise: Int! + mode: PaymentMode! + reference: String +} + +enum PaymentKind { + payment + refund +} + +enum PaymentMode { + card + cash + cheque + insurance + netbanking + upi + wallet +} + +type Period { + from: DateTime! + to: DateTime! +} + +type PeriodLock { + branch: Branch + id: ID! + lockedUpTo: DateTime + reason: String + scope: String! + updatedAt: DateTime! +} + +input PostVoucherInput { + branchId: ID! + date: DateTime + entries: [VoucherEntryInput!]! + narration: String + type: VoucherType! +} + +type PriceListEntry { + category: String! + code: String! + hsn: String + name: String! + + """rupees""" + rate: Float! + taxPercent: Float +} + +type PriceQuote { + tests: [CatalogTest!]! + + """Rupees.""" + total: Int! + totalPaise: Int! + unknown: [String!]! +} + +type ProfitAndLoss { + expenses: [GroupLine!]! + grossProfitPaise: Int! + income: [GroupLine!]! + netProfitPaise: Int! +} + +type Query { + accountGroups: [AccountGroup!]! + auditLog(entity: String, entityId: ID, limit: Int = 50, offset: Int = 0): [AuditEntry!]! + balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! + bankLines(ledgerId: ID, limit: Int = 100, offset: Int = 0, statementId: ID, status: BankLineStatus): [BankLine!]! + bankReconciliation(asOf: DateTime!, ledgerId: ID!): BankReconciliation! + bankStatements(ledgerId: ID, limit: Int = 50, offset: Int = 0): [BankStatement!]! + booking(id: ID!): Booking + + """ + A booking's verified or released reports, printed together (or only reportIds). + """ + bookingReportDocument(bookingId: ID!, design: ReportDesign = sidebar, format: DocumentFormat = pdf, reportIds: [ID!], variant: ReportVariant = header): Document! + bookings(branchId: ID, limit: Int = 50, offset: Int = 0, patientId: ID, status: BookingStatus): [Booking!]! + branch(id: ID!): Branch + branches: [Branch!]! + cashBook(branchId: ID, from: DateTime!, to: DateTime!): LedgerStatement! + cashClosings(branchId: ID!, limit: Int = 31, offset: Int = 0): [CashClosing!]! + catalogDepartments: [String!]! + catalogSampleTypes: [String!]! + + """Typo-tolerant search with lab shorthand (cbc, lft, vit d…).""" + catalogSearch(department: String, fuzzy: Boolean = true, limit: Int = 20, query: String!, sampleType: String): [CatalogHit!]! + catalogTest(code: String, id: Int): CatalogTest + catalogTests(department: String, limit: Int = 50, offset: Int = 0, sampleType: String): [CatalogTest!]! + creditNote(id: ID!): CreditNote + creditNotes(customerId: ID, invoiceId: ID, limit: Int = 50, offset: Int = 0): [CreditNote!]! + customer(id: ID!): Customer + customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement! + customers(activeOnly: Boolean = true): [Customer!]! + dayBook(branchId: ID, from: DateTime!, to: DateTime!, type: VoucherType): [Voucher!]! + doctors(activeOnly: Boolean = true): [Doctor!]! + + """Demo samples (empty unless the server enables them).""" + documentSamples: [DocumentSample!]! + exportBalanceSheet(asOf: DateTime!, bom: Boolean = false, branchId: ID): CsvFile! + exportDayBook(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile! + + """One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs.""" + exportGstr1(bom: Boolean = false, from: DateTime!, gstin: String, to: DateTime!): [CsvFile!]! + exportGstr3b(bom: Boolean = false, from: DateTime!, gstin: String, to: DateTime!): CsvFile! + exportLedger(bom: Boolean = false, branchId: ID, from: DateTime!, ledgerId: ID!, to: DateTime!): CsvFile! + exportProfitAndLoss(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile! + exportSettlement(bom: Boolean = false, settlementId: ID!): CsvFile! + exportTrialBalance(asOf: DateTime, bom: Boolean = false, branchId: ID): CsvFile! + financeConfig: FinanceConfig! + gstSummary(branchId: ID, from: DateTime!, to: DateTime!): GstSummary! + gstr1(from: DateTime!, gstin: String, to: DateTime!): Gstr1! + gstr3b(from: DateTime!, gstin: String, to: DateTime!): Gstr3b! + health: Health! + invoice(id: ID!): Invoice + + """A stored invoice, printed.""" + invoiceDocument(format: DocumentFormat = pdf, invoiceId: ID!, variant: ReportVariant = header): Document! + invoiceSamples: [DocumentSample!]! + + """ + invoice-kit's calculation of an InvoiceRequest, without rendering (rupees). + """ + invoiceTotals(request: JSON!): JSON! + invoices(branchId: ID, customerId: ID, limit: Int = 50, offset: Int = 0, patientId: ID, status: InvoiceStatus): [Invoice!]! + ledger(id: ID!): Ledger + ledgerStatement(branchId: ID, from: DateTime!, ledgerId: ID!, to: DateTime!): LedgerStatement! + ledgers: [Ledger!]! + letterhead(format: DocumentFormat = pdf): Document! + lockDates: [PeriodLock!]! + mySessions: [Session!]! + organization: Organization! + outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! + partner(id: ID!): Partner + partners(branchId: ID): [Partner!]! + patient(id: ID!): Patient + + """Name, phone or UHID.""" + patients(limit: Int = 20, search: String!): [Patient!]! + + """Every permission name the API checks.""" + permissions: [String!]! + + """ + invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables). + """ + priceList: [PriceListEntry!]! + + """Total MRP for test codes (or numeric ids as strings).""" + priceQuote(tests: [String!]!): PriceQuote! + profitAndLoss(branchId: ID, from: DateTime!, to: DateTime!): ProfitAndLoss! + receivablesSummary(asOf: DateTime!): ReceivablesSummary! + + """ + Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample }). + """ + renderInvoice(fileName: String, format: DocumentFormat = pdf, request: JSON!, variant: ReportVariant = header): Document! + + """ + Any report: request = report-kit's ReportRequest JSON (patient + tests | profile | report | sample). + """ + renderReport(design: ReportDesign = sidebar, fileName: String, format: DocumentFormat = pdf, request: JSON!, variant: ReportVariant = header): Document! + report(id: ID!): Report + reportTemplates: TemplateCatalog! + roles: [Role!]! + sample(id: ID!): Sample + sampleByBarcode(barcode: String!): Sample + settlement(id: ID!): Settlement + + """Computes a month's settlement without saving it. period: YYYY-MM.""" + settlementPreview(deductions: [SettlementDeductionInput!], partnerId: ID!, period: String!): SettlementPreview! + settlements(branchId: ID, limit: Int = 50, offset: Int = 0, partnerId: ID, status: SettlementStatus): [Settlement!]! + taxRules: [TaxRule!]! + trialBalance(asOf: DateTime, branchId: ID): TrialBalance! + user(id: ID!): User + users: [User!]! + + """Items the lab's CA must confirm before these values are relied on.""" + verifyWithCA: [String!]! + + """null when not signed in.""" + viewer: Viewer + voucher(id: ID!): Voucher +} + +type RateTotals { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + ratePercent: Float! + sgstPaise: Int! + taxablePaise: Int! +} + +type ReceivablesRow { + ageing: Ageing! + balancePaise: Int! + customer: Customer! + overduePaise: Int! +} + +type ReceivablesSummary { + rows: [ReceivablesRow!]! + totalPaise: Int! +} + +type ReconcileResult { + matched: Int! + settlements: Int! + unmatched: Int! +} + +type ReconciledTotals { + amountPaise: Int! + lines: Int! +} + +input RegisterPatientInput { + address: AddressInput + ageYears: Int + branchId: ID! + dob: DateTime + email: String + name: String! + phone: String + sex: Sex! + title: String +} + +type Report { + booking: Booking! + id: ID! + patient: Patient! + releasedAt: DateTime + results: [ResultValue!]! + status: ReportStatus! + testCode: String! + testName: String! + updatedAt: DateTime! + verifiedAt: DateTime + version: Int! +} + +enum ReportDesign { + classic + sidebar +} + +type ReportProfile { + id: String! + name: String! + tests: [String!]! +} + +enum ReportStatus { + amended + draft + entered + released + verified +} + +type ReportTemplate { + category: String + fields: [TemplateField!]! + id: String! + method: String + name: String! + specimen: String +} + +enum ReportVariant { + header + non_header +} + +enum ResultFlag { + A + H + L + NONE +} + +""" +Unit, range and flag are filled from catalog-kit when omitted and the test is in the catalog. +""" +input ResultInput { + flag: ResultFlag + parameter: String! + range: String + unit: String + value: String! +} + +type ResultValue { + flag: ResultFlag + parameter: String! + range: String + unit: String + value: String! +} + +type Role { + id: ID! + key: String! + name: String! + permissions: [String!]! + system: Boolean! +} + +type RoleAssignment { + branch: Branch + branchId: ID + role: String! +} + +input RoleAssignmentInput { + branchId: ID + role: String! +} + +input RoleInput { + key: String! + name: String! + permissions: [String!]! +} + +type Sample { + barcode: String! + booking: Booking! + collectedAt: DateTime + id: ID! + receivedAt: DateTime + rejectReason: String + sampleType: String! + status: SampleStatus! +} + +type SampleCollection { + address: Address + scheduledAt: DateTime + type: CollectionType! +} + +enum SampleStatus { + collected + pending + received + rejected +} + +type Session { + createdAt: DateTime! + current: Boolean! + expiresAt: DateTime! + id: ID! + ip: String + lastSeenAt: DateTime! + userAgent: String +} + +type Settlement { + accrualVoucher: Voucher + branch: Branch! + createdAt: DateTime! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + from: DateTime! + grossPaise: Int! + id: ID! + lines: [SettlementLine!]! + netPaise: Int! + paidAt: DateTime + partner: Partner! + payoutVoucher: Voucher + period: String! + sharePaise: Int! + status: SettlementStatus! + tdsBasePaise: Int! + tdsPaise: Int! + tdsRateBp: Int! + tdsSection: String + to: DateTime! +} + +type SettlementDeduction { + amountPaise: Int! + label: String! + ledgerId: ID +} + +input SettlementDeductionInput { + amountPaise: Int! + label: String! + ledgerId: ID +} + +type SettlementLine { + agreementId: ID! + code: String + creditNoteId: ID + day: Day! + invoiceId: ID! + invoiceNo: String! + name: String! + netPaise: Int! + sharePaise: Int! +} + +"""A settlement computed but not saved.""" +type SettlementPreview { + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + from: DateTime! + grossPaise: Int! + lines: [SettlementLine!]! + netPaise: Int! + partner: Partner! + period: String! + sharePaise: Int! + tds: Tds! + to: DateTime! + + """Invoice numbers with no agreement in force on their date.""" + uncoveredInvoices: [String!]! +} + +enum SettlementStatus { + accrued + cancelled + draft + paid +} + +enum Sex { + female + male + other +} + +type ShareRate { + code: String! + sharePaise: Int! +} + +input ShareRateInput { + code: String! + sharePaise: Int! +} + +type StatementRow { + balancePaise: Int! + creditPaise: Int! + date: DateTime! + debitPaise: Int! + invoiceId: ID + voucherNo: String! + voucherType: VoucherType! +} + +type TaxRule { + category: ItemCategory + code: String + effectiveFrom: DateTime! + effectiveTo: DateTime + hsnSac: String! + id: ID! + note: String + ratePercent: Float! + taxability: Taxability! +} + +input TaxRuleInput { + category: ItemCategory + code: String + effectiveFrom: Day! + hsnSac: String! + note: String + ratePercent: Float! + taxability: Taxability! +} + +type TaxTotals { + cessPaise: Int! + cgstPaise: Int! + igstPaise: Int! + sgstPaise: Int! + taxablePaise: Int! +} + +enum Taxability { + exempt + nil_rated + non_gst + taxable +} + +type Tds { + basePaise: Int! + rateBp: Int! + section: String + tdsPaise: Int! +} + +type TdsConfig { + noPanRateBp: Int! + rateBp: Int! + section: String! + thresholdPaise: Int +} + +type TemplateCatalog { + profiles: [ReportProfile!]! + templates: [ReportTemplate!]! +} + +type TemplateField { + key: String! + label: String! + range: [String!] + unit: String +} + +type TrialBalance { + differencePaise: Int! + rows: [TrialBalanceRow!]! + totalCreditPaise: Int! + totalDebitPaise: Int! +} + +type TrialBalanceRow { + creditPaise: Int! + debitPaise: Int! + group: AccountGroup! + ledger: Ledger! + primaryGroup: AccountGroup! +} + +input UpdatePatientInput { + address: AddressInput + ageYears: Int + dob: DateTime + email: String + name: String + phone: String + sex: Sex + title: String +} + +type User { + createdAt: DateTime! + email: String! + id: ID! + lastLoginAt: DateTime + name: String! + phone: String + roles: [RoleAssignment!]! + status: UserStatus! +} + +enum UserStatus { + active + disabled + invited +} + +"""The signed-in user and what they may do.""" +type Viewer { + """Branches where the user has this permission; null means every branch.""" + branchesFor(permission: String!): [ID!] + can(branchId: ID, permission: String!): Boolean! + organization: Organization! + + """ + Permissions held in this branch (or organisation-wide when branchId is omitted). + """ + permissions(branchId: ID): [String!]! + user: User! +} + +type Voucher { + branch: Branch! + date: DateTime! + entries: [VoucherEntry!]! + id: ID! + invoiceId: ID + narration: String + status: VoucherStatus! + type: VoucherType! + voucherNo: String! +} + +type VoucherEntry { + creditPaise: Int! + debitPaise: Int! + invoiceId: ID + ledger: Ledger! +} + +input VoucherEntryInput { + creditPaise: Int = 0 + debitPaise: Int = 0 + invoiceId: ID + ledgerId: ID! +} + +enum VoucherStatus { + cancelled + posted +} + +enum VoucherType { + closing + contra + credit_note + debit_note + journal + payment + purchase + receipt + sales +} + +type YearEndResult { + netProfitPaise: Int! + vouchers: [Voucher!]! +} diff --git a/scripts/api-reference.ts b/scripts/api-reference.ts new file mode 100644 index 0000000..b6427b2 --- /dev/null +++ b/scripts/api-reference.ts @@ -0,0 +1,75 @@ +// Writes docs/04-api-reference.md from the schema (operations, arguments, return types, descriptions) +// and the controllers (the permission each resolver checks). Run `npm run docs:reference`; CI fails when it is stale. +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { GraphQLObjectType, type GraphQLField } from "graphql"; +import { buildSchema, MODULES } from "../src/graphql/schema"; + +const root = new URL("..", import.meta.url).pathname; +const schema = buildSchema(); + +/** field → permissions found in its resolver body, per root type. */ +function permissionsByField(): Record<"Query" | "Mutation", Map<string, Set<string>>> { + const out = { Query: new Map<string, Set<string>>(), Mutation: new Map<string, Set<string>>() }; + for (const file of readdirSync(`${root}src/controllers`)) { + const src = readFileSync(`${root}src/controllers/${file}`, "utf8"); + // helpers like `reportFor(ctx, id, "report:read")` pass the permission literally, so a literal scan is enough + for (const kind of ["Query", "Mutation"] as const) { + const start = src.indexOf(`\n ${kind}: {`); + if (start < 0) continue; + const end = src.indexOf("\n },", start); + const block = src.slice(start, end); + const keys = [...block.matchAll(/^ {4}(?:async )?(\w+)(?=\s*[:(])/gm)]; + keys.forEach((k, i) => { + const body = block.slice(k.index!, keys[i + 1]?.index ?? block.length); + const perms = new Set([...body.matchAll(/"([a-z]+:[a-z]+)"/g)].map((m) => m[1]!)); + const signedIn = /actorOf|authorize|viewerOrThrow|requireViewer/.test(body); + if (!perms.size && signedIn) perms.add("(signed in)"); + out[kind].set(k[1]!, perms); + }); + } + } + return out; +} + +const perms = permissionsByField(); +const moduleOf = new Map<string, string>(); +for (const { name, typeDefs } of MODULES) { + const sdl = String(typeDefs).replace(/"""[\s\S]*?"""/g, ""); + for (const kind of ["Query", "Mutation"]) { + for (const m of sdl.matchAll(new RegExp(`type ${kind} \\{([\\s\\S]*?)\\n\\s*\\}`, "g"))) + for (const f of m[1]!.matchAll(/^\s+(\w+)\s*[(:]/gm)) moduleOf.set(`${kind}.${f[1]}`, name); + } +} + +const sig = (f: GraphQLField<unknown, unknown>) => + `${f.name}${f.args.length ? `(${f.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""}: ${f.type}`; +const one = (s?: string | null) => (s ?? "").replace(/\s+/g, " ").replace(/\|/g, "\\|").trim(); + +const lines: string[] = [ + "# 04 · API reference", + "", + "<!-- Generated by `npm run docs:reference` (scripts/api-reference.ts). Do not edit by hand. -->", + "", + "Every operation of `POST /graphql`, grouped by module, with the permission its resolver checks.", + "**Permission** lists what `authorize()` requires; a branch-scoped permission is checked against the", + "record's branch (see [06-auth-and-permissions.md](06-auth-and-permissions.md)). `public` means no", + "sign-in is needed; `(signed in)` means any signed-in user. The full SDL is [schema.graphql](../schema.graphql),", + "and runnable examples are in [examples/](examples/).", + "", +]; +for (const { name } of MODULES) { + const rows: string[] = []; + for (const kind of ["Query", "Mutation"] as const) { + const type = (kind === "Query" ? schema.getQueryType() : schema.getMutationType()) as GraphQLObjectType; + for (const f of Object.values(type.getFields()).sort((a, b) => a.name.localeCompare(b.name))) { + if (moduleOf.get(`${kind}.${f.name}`) !== name) continue; + const p = perms[kind].get(f.name); + const perm = p && p.size ? [...p].map((x) => (x.startsWith("(") ? x : `\`${x}\``)).join(", ") : "public"; + rows.push(`| ${kind === "Query" ? "query" : "mutation"} | \`${sig(f)}\` | ${perm} | ${one(f.description)} |`); + } + } + if (!rows.length) continue; + lines.push(`## ${name}`, "", "| Kind | Operation | Permission | Notes |", "|---|---|---|---|", ...rows, ""); +} +writeFileSync(`${root}docs/04-api-reference.md`, lines.join("\n")); +console.log(`docs/04-api-reference.md: ${lines.filter((l) => l.startsWith("| q") || l.startsWith("| m")).length} operations`); diff --git a/scripts/kits.mjs b/scripts/kits.mjs new file mode 100644 index 0000000..3c1ce5d --- /dev/null +++ b/scripts/kits.mjs @@ -0,0 +1,64 @@ +// Builds every @mgpl-diagnostics kit from source into .kits/*.tgz and installs them with this repo's +// dependencies. Needed because not every kit is on GitHub Packages yet (finance-kit), and this repo's +// CI token has no package access yet. See docs/12-local-agent-brief.md for the switch to the registry. +// +// npm run kits # clone each kit at the ref in kits.json (public repos, no token) +// KITS_SRC=.. npm run kits # use local checkouts next to this repo instead (their working tree) +// npm run kits -- --no-install # only build the tarballs +// +// Each kit is copied to .kits/src/<name>, its @mgpl-diagnostics dependencies are pointed at the +// tarballs built before it, it is built with tsup and packed with its ORIGINAL package.json. +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const out = join(root, ".kits"); +const { kits } = JSON.parse(readFileSync(join(root, "kits.json"), "utf8")); +const local = process.env.KITS_SRC ? resolve(root, process.env.KITS_SRC) : null; +const install = !process.argv.includes("--no-install"); +const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, npm_config_audit: "false", npm_config_fund: "false" } }); +const tgz = (name) => join(out, `${name}.tgz`); + +rmSync(out, { recursive: true, force: true }); +mkdirSync(join(out, "src"), { recursive: true }); + +for (const { name, ref } of kits) { + const dir = join(out, "src", name); + console.log(`\n▶ ${name} (${local ? `local ${join(local, name)}` : ref})`); + if (local) { + cpSync(join(local, name), dir, { recursive: true, filter: (p) => !/[\\/](node_modules|dist|\.git)$/.test(p) }); + } else { + mkdirSync(dir); + run("git", ["init", "-q"], dir); + run("git", ["fetch", "-q", "--depth", "1", `https://github.com/mgpl-diagnostics/${name}.git`, ref], dir); + run("git", ["checkout", "-q", "FETCH_HEAD"], dir); + } + const pkgFile = join(dir, "package.json"); + const original = readFileSync(pkgFile, "utf8"); + const pkg = JSON.parse(original); + // build-time view of the package: our kits come from the tarballs built so far + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + for (const dep of Object.keys(pkg[field] ?? {})) { + if (!dep.startsWith("@mgpl-diagnostics/")) continue; + const file = tgz(dep.split("/")[1]); + if (!existsSync(file)) throw new Error(`${name} needs ${dep}; list it earlier in kits.json`); + pkg[field][dep] = `file:${file}`; // same field, so tsup still treats peers as external + } + } + writeFileSync(pkgFile, JSON.stringify(pkg, null, 2)); + run("npm", ["install", "--no-package-lock", "--ignore-scripts", "--loglevel=error"], dir); + run("npm", ["run", "prepare"], dir); + writeFileSync(pkgFile, original); + run("npm", ["pack", "--ignore-scripts", "--silent", "--pack-destination", out], dir); + const packed = readdirSync(out).find((f) => f.startsWith(`mgpl-diagnostics-${name}-`) && f.endsWith(".tgz")); + if (!packed) throw new Error(`npm pack produced no tarball for ${name}`); + cpSync(join(out, packed), tgz(name)); + rmSync(join(out, packed)); +} + +if (install) { + console.log("\n▶ installing this repo's dependencies with the kits"); + run("npm", ["install", "--no-package-lock", "--no-save", "--loglevel=error", ...kits.map((k) => tgz(k.name))], root); +} +console.log("\n✓ kits ready"); diff --git a/scripts/llms-full.mjs b/scripts/llms-full.mjs new file mode 100644 index 0000000..77ff18a --- /dev/null +++ b/scripts/llms-full.mjs @@ -0,0 +1,53 @@ +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "<title>" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`); diff --git a/scripts/print-schema.ts b/scripts/print-schema.ts new file mode 100644 index 0000000..86c636b --- /dev/null +++ b/scripts/print-schema.ts @@ -0,0 +1,8 @@ +// Writes schema.graphql (the whole API in SDL) for clients, codegen and review. CI checks it is current. +import { writeFileSync } from "node:fs"; +import { lexicographicSortSchema, printSchema } from "graphql"; +import { buildSchema } from "../src/graphql/schema"; + +const sdl = `# Generated by \`npm run schema\` from src/schema. Do not edit by hand.\n\n${printSchema(lexicographicSortSchema(buildSchema()))}\n`; +writeFileSync("schema.graphql", sdl); +console.log(`schema.graphql: ${sdl.split("\n").length} lines`); diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..9e54954 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,40 @@ +// Creates the first organisation, its first branch, the owner account, auth-kit's system roles and +// the chart of accounts. Run once per new database: +// SEED_ORG_NAME="Maa Gayatri Path Lab" SEED_ORG_CODE=MGPL SEED_BRANCH_CODE=ARG01 SEED_BRANCH_NAME=Arang \ +// SEED_BRANCH_STATE=22 SEED_OWNER_NAME="…" SEED_OWNER_EMAIL=… SEED_OWNER_PASSWORD=… npm run seed +// The password is read from the environment and never printed. Change it after the first sign-in. +import { assertPasswordPolicy, hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; +import { createDb, seedOrganization } from "@mgpl-diagnostics/db-kit"; +import { financePosting, seedFinanceLedgers } from "@mgpl-diagnostics/finance-kit"; +import { loadConfig } from "../src/config/env"; + +const need = (k: string) => { + const v = process.env[k]?.trim(); + if (!v) throw new Error(`Set ${k}`); + return v; +}; + +const config = loadConfig(); +const org = { name: need("SEED_ORG_NAME"), code: need("SEED_ORG_CODE").toUpperCase(), gstin: process.env.SEED_ORG_GSTIN || undefined }; +const branch = { code: need("SEED_BRANCH_CODE").toUpperCase(), name: need("SEED_BRANCH_NAME"), stateCode: need("SEED_BRANCH_STATE") }; +const owner = { name: need("SEED_OWNER_NAME"), email: need("SEED_OWNER_EMAIL"), password: need("SEED_OWNER_PASSWORD") }; +assertPasswordPolicy(owner.password, { email: owner.email, name: owner.name }); + +const db = await createDb({ + backend: config.db.backend, + ...(config.db.backend === "pg" ? { pg: { url: config.db.url } } : { mongo: { uri: config.db.url } }), + posting: financePosting, +} as Parameters<typeof createDb>[0]); +try { + if (await db.rawRepos.orgs.findOne({ code: org.code })) throw new Error(`Organisation ${org.code} already exists`); + const seeded = await seedOrganization(db.rawRepos, { + org, + branch, + owner: { name: owner.name, email: owner.email, passwordHash: await hashPassword(owner.password) }, + roles: SYSTEM_ROLES, + }); + const ledgers = await seedFinanceLedgers(db.rawRepos, seeded.org.id); + console.log(`Organisation ${seeded.org.code} (${seeded.org.id}), branch ${seeded.branch.code}, owner ${seeded.owner.email}, ${ledgers.length} ledgers.`); +} finally { + await db.close(); +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..09ca6aa --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,66 @@ +// Runtime configuration, read once from the environment. Every value has a safe development default; +// production sets them explicitly (see docs/09-configuration.md). + +export type DbBackend = "pg" | "mongo"; + +export interface Config { + env: "development" | "production" | "test"; + host: string; + port: number; + /** URL path of the one GraphQL endpoint. */ + graphqlPath: string; + db: { backend: DbBackend; url: string }; + /** Sessions, reset tokens, rate limits and the read cache. Without it they live in process memory (one server only). */ + redisUrl: string | null; + /** Origins allowed to call the API from a browser; "*" only in development. */ + corsOrigins: string[]; + /** GraphiQL in the browser at the GraphQL path (development only by default). */ + graphiql: boolean; + /** Enables introspection (schema download). Development only by default. */ + introspection: boolean; + /** Largest request body, bytes. */ + bodyLimit: number; + /** Behind a proxy (nginx, a load balancer): read the client IP from X-Forwarded-For. */ + trustProxy: boolean; + /** Demo report and invoice samples (report-kit / invoice-kit) in `documents`. */ + samples: boolean; + /** Session lifetime knobs, passed to auth-kit. */ + session: { idleMs: number; maxAgeMs: number }; + /** Password-reset links point here: `${resetUrl}?token=…`. */ + resetUrl: string | null; +} + +const bool = (v: string | undefined, d: boolean) => (v == null || v === "" ? d : ["1", "true", "yes", "on"].includes(v.toLowerCase())); +const int = (v: string | undefined, d: number) => { + if (v == null || v === "") return d; + const n = Number(v); + if (!Number.isInteger(n) || n < 0) throw new Error(`Expected a whole number, got "${v}"`); + return n; +}; + +export function loadConfig(env: Record<string, string | undefined> = process.env): Config { + const mode = env.NODE_ENV === "production" ? "production" : env.NODE_ENV === "test" ? "test" : "development"; + const dev = mode !== "production"; + const backend = (env.DB_BACKEND ?? "pg") as DbBackend; + if (backend !== "pg" && backend !== "mongo") throw new Error(`DB_BACKEND must be "pg" or "mongo", got "${backend}"`); + const url = env.DATABASE_URL ?? (dev ? (backend === "pg" ? "postgres://postgres:postgres@localhost:5432/mgpl" : "mongodb://localhost:27017/mgpl?replicaSet=rs0") : ""); + if (!url) throw new Error("DATABASE_URL is required in production"); + const corsOrigins = (env.CORS_ORIGINS ?? (dev ? "*" : "")).split(",").map((s) => s.trim()).filter(Boolean); + if (!dev && corsOrigins.includes("*")) throw new Error('CORS_ORIGINS cannot be "*" in production'); + return { + env: mode, + host: env.HOST ?? "0.0.0.0", + port: int(env.PORT, 4000), + graphqlPath: env.GRAPHQL_PATH ?? "/graphql", + db: { backend, url }, + redisUrl: env.REDIS_URL || null, + corsOrigins, + graphiql: bool(env.GRAPHIQL, dev), + introspection: bool(env.INTROSPECTION, dev), + bodyLimit: int(env.BODY_LIMIT_BYTES, 2_000_000), + trustProxy: bool(env.TRUST_PROXY, false), + samples: bool(env.MGPL_SAMPLES, dev), + session: { idleMs: int(env.SESSION_IDLE_MS, 12 * 3600_000), maxAgeMs: int(env.SESSION_MAX_AGE_MS, 30 * 86400_000) }, + resetUrl: env.PASSWORD_RESET_URL || null, + }; +} diff --git a/src/container.ts b/src/container.ts new file mode 100644 index 0000000..7c5dbd9 --- /dev/null +++ b/src/container.ts @@ -0,0 +1,69 @@ +import { createAuth, memoryKeyValueStore, memoryRateLimiter, memorySessionStore, type Auth } from "@mgpl-diagnostics/auth-kit"; +import { createDb, type Db } from "@mgpl-diagnostics/db-kit"; +import { financePosting } from "@mgpl-diagnostics/finance-kit"; +import type { LabProfile } from "@mgpl-diagnostics/report-kit"; +import type { Config } from "./config/env"; +import { createServices, type Mailer, type Services } from "./services"; + +export interface ContainerOptions { + config: Config; + /** Inject a database (tests). Default: createDb from config, posting through finance-kit. */ + db?: Db; + /** Inject auth (tests). Default: auth-kit on db-kit's users/orgs/roles/audit, sessions in Redis or memory. */ + auth?: Auth; + mailer?: Mailer; + /** Lab branding and signatures for documents. Default: report-kit's defaultLab() (MGPL_SIGNATURE_* files). */ + lab?: LabProfile; +} + +/** The composition root: one database, one auth, one set of services, shared by every request. */ +export interface Container { + config: Config; + db: Db; + auth: Auth; + services: Services; + close(): Promise<void>; +} + +export async function createContainer(o: ContainerOptions): Promise<Container> { + const { config } = o; + const ownsDb = !o.db; + const db = + o.db ?? + (await createDb({ + backend: config.db.backend, + ...(config.db.backend === "pg" ? { pg: { url: config.db.url } } : { mongo: { uri: config.db.url } }), + redis: config.redisUrl ?? undefined, + // invoices and payments post to the books through finance-kit → tally-kit + posting: financePosting, + } as Parameters<typeof createDb>[0])); + + let auth = o.auth; + if (!auth) { + const redis = db.redis; + const stores = redis + ? await (async () => { + const r = await import("@mgpl-diagnostics/auth-kit/redis"); + return { + sessions: r.redisSessionStore(redis), + kv: r.redisKeyValueStore(redis), + limiter: r.redisRateLimiter(redis, { limit: 10, windowMs: 15 * 60_000 }), + }; + })() + : { sessions: memorySessionStore(), kv: memoryKeyValueStore(), limiter: memoryRateLimiter({ limit: 10, windowMs: 15 * 60_000 }) }; + // auth reads users on every request: use the raw (uncached) repositories so a disabled user is out at once + const r = db.rawRepos; + auth = createAuth({ users: r.users, orgs: r.orgs, roles: r.roles, audit: r.audit, ...stores, session: config.session }); + } + + const services = createServices({ repos: db.repos, auth, mailer: o.mailer, resetUrl: config.resetUrl, lab: o.lab, samples: config.samples }); + return { + config, + db, + auth, + services, + async close() { + if (ownsDb) await db.close(); + }, + }; +} diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 0000000..2fd779c --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,75 @@ +import type { RoleAssignment, User } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { unauthenticated, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; + +/** auth-kit: sign-in, sessions, passwords; users and roles. */ +export const authController = withErrorCodes({ + Query: { + viewer: (_: unknown, __: unknown, ctx: Ctx) => (ctx.viewer ? {} : null), + mySessions: async (_: unknown, __: unknown, ctx: Ctx) => { + const sessions = await ctx.services.auth.sessions(actorOf(ctx)); + return sessions.map((s) => ({ ...s, current: s.id === ctx.viewer!.sessionId, createdAt: new Date(s.createdAt), lastSeenAt: new Date(s.lastSeenAt), expiresAt: new Date(s.expiresAt) })); + }, + users: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.users(authorize(ctx, "user:read")), + user: (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.auth.user(authorize(ctx, "user:read"), a.id), + roles: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.roles(authorize(ctx, "role:read")), + permissions: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.auth.permissions()), + }, + + Viewer: { + user: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.me(actorOf(ctx)), + organization: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.organization(actorOf(ctx)), + permissions: (_: unknown, a: { branchId?: string | null }, ctx: Ctx) => ctx.permissions(a.branchId), + branchesFor: (_: unknown, a: { permission: string }, ctx: Ctx) => { + const b = ctx.branchesFor(a.permission); + return b === "all" ? null : b; + }, + can: (_: unknown, a: { permission: string; branchId?: string | null }, ctx: Ctx) => ctx.can(a.permission, a.branchId), + }, + + RoleAssignment: { + branch: (r: RoleAssignment, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(r.branchId), + }, + + User: { + createdAt: (u: User) => u.createdAt, + }, + + Mutation: { + login: (_: unknown, { input }: { input: { email: string; password: string; orgCode?: string | null } }, ctx: Ctx) => + ctx.services.auth.login({ ...input, ip: ctx.ip, userAgent: ctx.userAgent }), + logout: async (_: unknown, __: unknown, ctx: Ctx) => { + if (!ctx.token) throw unauthenticated(); + await ctx.services.auth.logout(ctx.token); + return true; + }, + logoutOtherSessions: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.auth.logoutOthers(actorOf(ctx), ctx.token!), + revokeSession: (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.auth.revokeSession(actorOf(ctx), a.id), + changePassword: async (_: unknown, a: { currentPassword: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.changePassword(actorOf(ctx), a.currentPassword, a.newPassword, ctx.token ?? undefined); + return true; + }, + requestPasswordReset: async (_: unknown, a: { email: string; orgCode?: string | null }, ctx: Ctx) => { + await ctx.services.auth.requestPasswordReset({ ...a, ip: ctx.ip }); + return true; + }, + resetPassword: async (_: unknown, a: { token: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.resetPassword(a.token, a.newPassword); + return true; + }, + + createUser: (_: unknown, { input }: { input: { name: string; email: string; phone?: string | null; password: string; roles: RoleAssignment[] } }, ctx: Ctx) => + ctx.services.auth.createUser(authorize(ctx, "user:create"), { ...input, roles: input.roles.map((r) => ({ role: r.role, branchId: r.branchId ?? null })) }), + setUserRoles: (_: unknown, a: { userId: string; roles: RoleAssignment[] }, ctx: Ctx) => + ctx.services.auth.setUserRoles(authorize(ctx, "user:update"), a.userId, a.roles.map((r) => ({ role: r.role, branchId: r.branchId ?? null }))), + disableUser: (_: unknown, a: { userId: string }, ctx: Ctx) => ctx.services.auth.setUserStatus(authorize(ctx, "user:disable"), a.userId, "disabled"), + enableUser: (_: unknown, a: { userId: string }, ctx: Ctx) => ctx.services.auth.setUserStatus(authorize(ctx, "user:disable"), a.userId, "active"), + setUserPassword: async (_: unknown, a: { userId: string; newPassword: string }, ctx: Ctx) => { + await ctx.services.auth.setUserPassword(authorize(ctx, "user:update"), a.userId, a.newPassword); + return true; + }, + upsertRole: (_: unknown, { input }: { input: { key: string; name: string; permissions: string[] } }, ctx: Ctx) => + ctx.services.auth.upsertRole(authorize(ctx, "role:manage"), input), + }, +}); diff --git a/src/controllers/base.controller.ts b/src/controllers/base.controller.ts new file mode 100644 index 0000000..9e5dfcc --- /dev/null +++ b/src/controllers/base.controller.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import type { RequestContext } from "../graphql/context"; +import { Day, DateTime, JSONScalar } from "../graphql/scalars"; + +const VERSION = (() => { + try { + return (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version; + } catch { + return "0.0.0"; + } +})(); + +export const baseController = { + DateTime, + Day, + JSON: JSONScalar, + Query: { + health: async (_: unknown, __: unknown, ctx: RequestContext) => { + let database = "ok"; + try { + await ctx.repos.orgs.count(); + } catch { + database = "unreachable"; + } + return { ok: database === "ok", service: "@mgpl-diagnostics/api", version: VERSION, database, time: new Date() }; + }, + }, + Mutation: { + ping: () => "pong", + }, +}; diff --git a/src/controllers/billing.controller.ts b/src/controllers/billing.controller.ts new file mode 100644 index 0000000..a00140e --- /dev/null +++ b/src/controllers/billing.controller.ts @@ -0,0 +1,44 @@ +import type { Invoice, InvoiceStatus } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards"; +import { BillingService, type IssueInvoiceInput, type PaymentInput } from "../services/billing.service"; + +async function invoiceFor(ctx: Ctx, id: string, permission: string) { + const inv = await ctx.services.billing.get(actorOf(ctx), id); + authorize(ctx, permission, inv.branchId); + return inv; +} + +/** Invoices and payments. */ +export const billingController = withErrorCodes({ + Query: { + invoice: (_: unknown, a: { id: string }, ctx: Ctx) => invoiceFor(ctx, a.id, "invoice:read"), + invoices: (_: unknown, a: { branchId?: string | null; status?: InvoiceStatus | null; patientId?: string | null; customerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.billing.list(actorOf(ctx), { scope: branchScope(ctx, "invoice:read", a.branchId), status: a.status, patientId: a.patientId, customerId: a.customerId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + }, + Invoice: { + patient: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(i.patientId), + branch: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(i.branchId), + booking: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(i.bookingId), + customer: (i: Invoice, _: unknown, ctx: Ctx) => ctx.loaders.customer.load(i.customerId), + creditedPaise: (i: Invoice) => i.creditedPaise ?? 0, + payments: (i: Invoice, _: unknown, ctx: Ctx) => ctx.services.billing.payments(i.id), + vouchers: (i: Invoice, _: unknown, ctx: Ctx) => (canSee(ctx, "accounts:read", i.branchId) ? ctx.services.billing.vouchers(i.id) : []), + creditNotes: (i: Invoice, _: unknown, ctx: Ctx) => ctx.services.billing.creditNotes(i.id), + }, + Mutation: { + issueInvoice: (_: unknown, { input }: { input: IssueInvoiceInput }, ctx: Ctx) => { + const actor = authorize(ctx, "invoice:create", input.branchId); + if (BillingService.discounted(input)) authorize(ctx, "invoice:discount", input.branchId); + if (input.payments?.length) authorize(ctx, "payment:create", input.branchId); + return ctx.services.billing.issue(actor, input); + }, + recordPayment: async (_: unknown, a: { invoiceId: string; payment: PaymentInput }, ctx: Ctx) => + ctx.services.billing.pay(actorOf(ctx), await invoiceFor(ctx, a.invoiceId, "payment:create"), a.payment, "payment"), + refundPayment: async (_: unknown, a: { invoiceId: string; payment: PaymentInput }, ctx: Ctx) => + ctx.services.billing.pay(actorOf(ctx), await invoiceFor(ctx, a.invoiceId, "payment:refund"), a.payment, "refund"), + cancelInvoice: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => + ctx.services.billing.cancel(actorOf(ctx), await invoiceFor(ctx, a.id, "invoice:cancel"), a.reason), + }, +}); diff --git a/src/controllers/books.controller.ts b/src/controllers/books.controller.ts new file mode 100644 index 0000000..79d3ded --- /dev/null +++ b/src/controllers/books.controller.ts @@ -0,0 +1,64 @@ +import type { AccountGroup, Ledger, Voucher, VoucherEntry, VoucherType } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; +import type { PostVoucherInput } from "../services/books.service"; + +const BUCKET = { "0-30": "D0_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" } as const; + +type Range = { from: Date; to: Date; branchId?: string | null }; + +/** The books (tally-kit). Reads need accounts:read, manual vouchers accounts:post, setup accounts:manage. */ +export const booksController = withErrorCodes({ + Query: { + accountGroups: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.groups(authorize(ctx, "accounts:read")), + ledgers: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.ledgers(authorize(ctx, "accounts:read")), + ledger: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const l = await ctx.loaders.ledger.load(a.id); + return l && l.orgId === authorize(ctx, "accounts:read").orgId ? l : null; + }, + voucher: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const v = await ctx.services.books.voucher(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", v.branchId); + return v; + }, + ledgerStatement: (_: unknown, a: Range & { ledgerId: string }, ctx: Ctx) => ctx.services.books.ledgerStatement(authorize(ctx, "accounts:read", a.branchId), a), + cashBook: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.cashBook(authorize(ctx, "accounts:read", a.branchId), a), + trialBalance: (_: unknown, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => ctx.services.books.trialBalance(authorize(ctx, "accounts:read", a.branchId), a), + profitAndLoss: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.profitAndLoss(authorize(ctx, "accounts:read", a.branchId), a), + balanceSheet: (_: unknown, a: { asOf: Date; branchId?: string | null }, ctx: Ctx) => ctx.services.books.balanceSheet(authorize(ctx, "accounts:read", a.branchId), a), + dayBook: (_: unknown, a: Range & { type?: VoucherType | null }, ctx: Ctx) => ctx.services.books.dayBook(authorize(ctx, "accounts:read", a.branchId), a), + outstandingReceivables: async (_: unknown, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => { + const out = await ctx.services.books.outstanding(authorize(ctx, "accounts:read", a.branchId), a); + return { + rows: out.rows.map((r) => ({ ...r, bucket: BUCKET[r.bucket] })), + buckets: { d0to30: out.buckets["0-30"], d31to60: out.buckets["31-60"], d61to90: out.buckets["61-90"], d90plus: out.buckets["90+"] }, + totalDuePaise: out.totalDuePaise, + }; + }, + gstSummary: (_: unknown, a: Range, ctx: Ctx) => ctx.services.books.gstSummary(authorize(ctx, "accounts:read", a.branchId), a), + }, + AccountGroup: { + parent: (g: AccountGroup, _: unknown, ctx: Ctx) => ctx.loaders.group.load(g.parentId), + }, + Ledger: { + group: (l: Ledger, _: unknown, ctx: Ctx) => ctx.loaders.group.load(l.groupId), + balancePaise: (l: Ledger, a: { asOf?: Date | null; branchId?: string | null }, ctx: Ctx) => ctx.services.books.balance(l.orgId, l.id, a), + }, + Voucher: { + branch: (v: Voucher, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(v.branchId), + }, + VoucherEntry: { + ledger: (e: VoucherEntry, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(e.ledgerId), + }, + Mutation: { + setupChartOfAccounts: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.books.setupChartOfAccounts(authorize(ctx, "accounts:manage")), + createLedger: (_: unknown, { input }: { input: { name: string; groupId: string; openingPaise?: number | null; gstin?: string | null } }, ctx: Ctx) => + ctx.services.books.createLedger(authorize(ctx, "accounts:manage"), input), + postVoucher: (_: unknown, { input }: { input: PostVoucherInput }, ctx: Ctx) => ctx.services.books.postVoucher(authorize(ctx, "accounts:post", input.branchId), input), + cancelVoucher: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const v = await ctx.services.books.voucher(actorOf(ctx), a.id); + return ctx.services.books.cancelVoucher(authorize(ctx, "accounts:post", v.branchId), a.id); + }, + }, +}); diff --git a/src/controllers/catalog.controller.ts b/src/controllers/catalog.controller.ts new file mode 100644 index 0000000..4f69e63 --- /dev/null +++ b/src/controllers/catalog.controller.ts @@ -0,0 +1,32 @@ +import type { Interval, LabTest, Parameter } from "@mgpl-diagnostics/catalog-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { cap } from "../graphql/guards"; + +const bound = (b: Interval["low"]) => (b == null ? null : String(b)); + +/** catalog-kit. Public: the same tests and prices are on the website. */ +export const catalogController = withErrorCodes({ + Query: { + catalogSearch: (_: unknown, a: { query: string; limit?: number; department?: string | null; sampleType?: string | null; fuzzy?: boolean | null }, ctx: Ctx) => + ctx.services.catalog.search(a.query, { limit: cap(a.limit, 20), department: a.department, sampleType: a.sampleType, fuzzy: a.fuzzy }), + catalogTest: (_: unknown, a: { id?: number | null; code?: string | null }, ctx: Ctx) => ctx.services.catalog.test(a), + catalogTests: (_: unknown, a: { department?: string | null; sampleType?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.catalog.tests({ department: a.department, sampleType: a.sampleType, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + catalogDepartments: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.catalog.departments(), + catalogSampleTypes: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.catalog.sampleTypes(), + priceQuote: (_: unknown, a: { tests: string[] }, ctx: Ctx) => { + const q = ctx.services.catalog.quote(a.tests.slice(0, 200).map((t) => (/^\d+$/.test(t) ? Number(t) : t))); + return { total: q.total, totalPaise: q.total * 100, tests: q.tests, unknown: q.unknown.map(String) }; + }, + }, + CatalogTest: { + pricePaise: (t: LabTest) => (t.mrp == null ? null : t.mrp * 100), + isProfile: (t: LabTest, _: unknown, ctx: Ctx) => ctx.services.catalog.isProfile(t), + }, + CatalogParameter: { + male: (p: Parameter) => (p.male ? { low: bound(p.male.low), high: bound(p.male.high) } : null), + female: (p: Parameter) => (p.female ? { low: bound(p.female.low), high: bound(p.female.high) } : null), + range: (p: Parameter, a: { sex?: "male" | "female" | "other" | null }, ctx: Ctx) => ctx.services.catalog.formatRange(p, a.sex === "other" ? null : a.sex), + }, +}); diff --git a/src/controllers/documents.controller.ts b/src/controllers/documents.controller.ts new file mode 100644 index 0000000..a1c30cd --- /dev/null +++ b/src/controllers/documents.controller.ts @@ -0,0 +1,54 @@ +import type { InvoiceRequest } from "@mgpl-diagnostics/invoice-kit"; +import type { ReportDesign, ReportRequest, ReportVariant } from "@mgpl-diagnostics/report-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { badInput, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; +import type { DocumentFormat } from "../services/document.service"; + +type Opts = { format?: DocumentFormat; variant?: "header" | "non_header"; design?: ReportDesign; fileName?: string | null }; + +const variantOf = (v?: string): ReportVariant => (v === "non_header" ? "non-header" : "header"); +const options = (a: Opts) => ({ format: a.format ?? "pdf", variant: variantOf(a.variant), design: a.design ?? "sidebar", fileName: a.fileName }); +const objectOf = <T>(v: unknown, what: string): T => { + if (!v || typeof v !== "object" || Array.isArray(v)) throw badInput(`${what} must be a JSON object`); + return v as T; +}; + +/** report-kit and invoice-kit, used as internal libraries behind this one endpoint. */ +export const documentsController = withErrorCodes({ + Query: { + reportTemplates: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.templates()), + priceList: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.priceList()), + documentSamples: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.reportSamples()), + invoiceSamples: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.documents.invoiceSamples()), + + renderReport: (_: unknown, a: Opts & { request: unknown }, ctx: Ctx) => { + authorize(ctx, "report:read"); + return ctx.services.documents.renderReportRequest(objectOf<ReportRequest>(a.request, "request"), options(a)); + }, + renderInvoice: (_: unknown, a: Opts & { request: unknown }, ctx: Ctx) => { + authorize(ctx, "invoice:read"); + return ctx.services.documents.renderInvoiceRequest(objectOf<InvoiceRequest>(a.request, "request"), options(a)); + }, + invoiceTotals: (_: unknown, a: { request: unknown }, ctx: Ctx) => { + authorize(ctx, "invoice:read"); + return ctx.services.documents.invoiceTotals(objectOf<InvoiceRequest>(a.request, "request")); + }, + letterhead: (_: unknown, a: { format?: DocumentFormat }, ctx: Ctx) => { + authorize(ctx, "report:read"); + return ctx.services.documents.letterhead(a.format ?? "pdf"); + }, + + invoiceDocument: async (_: unknown, a: Opts & { invoiceId: string }, ctx: Ctx) => { + const inv = await ctx.services.billing.get(actorOf(ctx), a.invoiceId); + return ctx.services.documents.renderStoredInvoice(authorize(ctx, "invoice:read", inv.branchId), inv, options(a)); + }, + bookingReportDocument: async (_: unknown, a: Opts & { bookingId: string; reportIds?: string[] | null }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.bookingId); + return ctx.services.documents.renderBookingReports(authorize(ctx, "report:read", b.branchId), a.bookingId, a.reportIds ?? null, options(a)); + }, + }, + TemplateField: { + range: (f: { range?: string | string[] }) => (f.range == null ? null : Array.isArray(f.range) ? f.range : [f.range]), + }, +}); diff --git a/src/controllers/finance.controller.ts b/src/controllers/finance.controller.ts new file mode 100644 index 0000000..4b11593 --- /dev/null +++ b/src/controllers/finance.controller.ts @@ -0,0 +1,187 @@ +import type { + BankLineStatus, BankStatement, CashClosing, CreditNote, Customer, Partner, PeriodLock, Settlement, SettlementDeduction, SettlementStatus, +} from "@mgpl-diagnostics/db-kit"; +import type { AgeBucket, FinanceConfig } from "@mgpl-diagnostics/finance-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, cap } from "../graphql/guards"; +import type { CustomerInput, CustomerInvoiceInput } from "../services/receivables.service"; +import type { AgreementInput, PartnerInput } from "../services/settlement.service"; +import type { TaxRuleInput } from "../services/gst.service"; + +const OPEN_BUCKET: Record<AgeBucket, string> = { not_due: "not_due", "1-30": "D1_30", "31-60": "D31_60", "61-90": "D61_90", "90+": "D90_PLUS" }; +const ageing = (a: Record<AgeBucket, number>) => ({ notDue: a.not_due, d1to30: a["1-30"], d31to60: a["31-60"], d61to90: a["61-90"], d90plus: a["90+"] }); +const voucherById = (ctx: Ctx, id?: string | null) => (id ? ctx.repos.vouchers.get(id) : null); + +type Range = { from: Date; to: Date; branchId?: string | null; bom?: boolean }; +type Gst = { from: Date; to: Date; gstin?: string | null; bom?: boolean }; + +async function settlementFor(ctx: Ctx, id: string, permission: string) { + const s = await ctx.services.settlements.settlement(actorOf(ctx), id); + authorize(ctx, permission, s.branchId); + return s; +} + +/** + * finance-kit. Permissions as in finance-kit's docs/api.md: accounts:read for returns, statements and + * exports; accounts:post for invoices, credit notes, settlements, cash and bank; accounts:manage for + * tax rules, customers, partners, lock dates and the year-end close. + */ +export const financeController = withErrorCodes({ + Query: { + taxRules: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.gst.taxRules(authorize(ctx, "accounts:read")), + financeConfig: async (_: unknown, __: unknown, ctx: Ctx) => { + const c: FinanceConfig = await ctx.services.gst.config(authorize(ctx, "accounts:read")); + return { b2clThresholdPaise: c.gst.b2clThresholdPaise, tds: c.tds, bankToleranceDays: c.bank.toleranceDays, bankMaxFeeBp: c.bank.maxFeeBp, clearingLedgerKeys: c.bank.clearingLedgerKeys }; + }, + verifyWithCA: (_: unknown, __: unknown, ctx: Ctx) => (actorOf(ctx), ctx.services.gst.verifyWithCA()), + gstr1: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.gst.gstr1(authorize(ctx, "accounts:read"), a), + gstr3b: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.gst.gstr3b(authorize(ctx, "accounts:read"), a), + + customers: (_: unknown, a: { activeOnly?: boolean }, ctx: Ctx) => ctx.services.receivables.customers(authorize(ctx, "accounts:read"), a.activeOnly ?? true), + customer: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const c = await ctx.loaders.customer.load(a.id); + return c && c.orgId === authorize(ctx, "accounts:read").orgId ? c : null; + }, + customerStatement: async (_: unknown, a: { customerId: string; from: Date; to: Date }, ctx: Ctx) => { + const s = await ctx.services.receivables.statement(authorize(ctx, "accounts:read"), a); + return { ...s, open: s.open.map((o) => ({ ...o, bucket: OPEN_BUCKET[o.bucket] })), ageing: ageing(s.ageing) }; + }, + receivablesSummary: async (_: unknown, a: { asOf: Date }, ctx: Ctx) => { + const s = await ctx.services.receivables.summary(authorize(ctx, "accounts:read"), a.asOf); + return { ...s, rows: s.rows.map((r) => ({ ...r, ageing: ageing(r.ageing) })) }; + }, + creditNote: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const n = await ctx.services.receivables.creditNote(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", n.branchId); + return n; + }, + creditNotes: (_: unknown, a: { invoiceId?: string | null; customerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.receivables.creditNotes(authorize(ctx, "accounts:read"), { ...a, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + + partners: (_: unknown, a: { branchId?: string | null }, ctx: Ctx) => ctx.services.settlements.partners(actorOf(ctx), { branchIds: branchScope(ctx, "accounts:read", a.branchId) }), + partner: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.id); + authorize(ctx, "accounts:read", p.branchId); + return p; + }, + settlementPreview: async (_: unknown, a: { partnerId: string; period: string; deductions?: SettlementDeduction[] | null }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.partnerId); + return ctx.services.settlements.compute(authorize(ctx, "accounts:read", p.branchId), a); + }, + settlements: (_: unknown, a: { partnerId?: string | null; status?: SettlementStatus | null; branchId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.settlements.settlements(actorOf(ctx), { ...a, branchIds: branchScope(ctx, "accounts:read", a.branchId), limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + settlement: (_: unknown, a: { id: string }, ctx: Ctx) => settlementFor(ctx, a.id, "accounts:read"), + + cashClosings: (_: unknown, a: { branchId: string; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.cashClosings(authorize(ctx, "accounts:read", a.branchId), { branchId: a.branchId, limit: cap(a.limit, 31), offset: a.offset ?? 0 }), + bankStatements: (_: unknown, a: { ledgerId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.bankStatements(authorize(ctx, "accounts:read"), { ledgerId: a.ledgerId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + bankLines: (_: unknown, a: { ledgerId?: string | null; statementId?: string | null; status?: BankLineStatus | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.cashBank.bankLines(authorize(ctx, "accounts:read"), { ...a, limit: cap(a.limit, 100), offset: a.offset ?? 0 }), + bankReconciliation: (_: unknown, a: { ledgerId: string; asOf: Date }, ctx: Ctx) => ctx.services.cashBank.reconciliation(authorize(ctx, "accounts:read"), a), + + lockDates: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.periods.locks(authorize(ctx, "accounts:read")), + + exportDayBook: (_: unknown, a: Range, ctx: Ctx) => ctx.services.exports.dayBook(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportLedger: async (_: unknown, a: Range & { ledgerId: string }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:read", a.branchId); + await ctx.services.books.ledger(actor, a.ledgerId); + return ctx.services.exports.ledger(actor, { ...a, bom: !!a.bom }); + }, + exportTrialBalance: (_: unknown, a: { asOf?: Date | null; branchId?: string | null; bom?: boolean }, ctx: Ctx) => + ctx.services.exports.trialBalance(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportProfitAndLoss: (_: unknown, a: Range, ctx: Ctx) => ctx.services.exports.profitAndLoss(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportBalanceSheet: (_: unknown, a: { asOf: Date; branchId?: string | null; bom?: boolean }, ctx: Ctx) => + ctx.services.exports.balanceSheet(authorize(ctx, "accounts:read", a.branchId), { ...a, bom: !!a.bom }), + exportSettlement: async (_: unknown, a: { settlementId: string; bom?: boolean }, ctx: Ctx) => { + await settlementFor(ctx, a.settlementId, "accounts:read"); + return ctx.services.exports.settlement(actorOf(ctx), { settlementId: a.settlementId, bom: !!a.bom }); + }, + exportGstr1: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.exports.gstr1(authorize(ctx, "accounts:read"), { ...a, bom: !!a.bom }), + exportGstr3b: (_: unknown, a: Gst, ctx: Ctx) => ctx.services.exports.gstr3b(authorize(ctx, "accounts:read"), { ...a, bom: !!a.bom }), + }, + + Customer: { + ledger: (c: Customer, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(c.ledgerId), + }, + CreditNote: { + invoice: (n: CreditNote, _: unknown, ctx: Ctx) => ctx.loaders.invoice.load(n.invoiceId), + customer: (n: CreditNote, _: unknown, ctx: Ctx) => ctx.loaders.customer.load(n.customerId), + }, + Partner: { + branch: (p: Partner, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(p.branchId), + ledger: (p: Partner, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(p.ledgerId), + agreements: (p: Partner, _: unknown, ctx: Ctx) => ctx.services.settlements.agreements(p.id), + }, + Settlement: { + partner: (s: Settlement, _: unknown, ctx: Ctx) => ctx.loaders.partner.load(s.partnerId), + branch: (s: Settlement, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(s.branchId), + accrualVoucher: (s: Settlement, _: unknown, ctx: Ctx) => voucherById(ctx, s.accrualVoucherId), + payoutVoucher: (s: Settlement, _: unknown, ctx: Ctx) => voucherById(ctx, s.payoutVoucherId), + }, + CashClosing: { + branch: (c: CashClosing, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(c.branchId), + voucher: (c: CashClosing, _: unknown, ctx: Ctx) => voucherById(ctx, c.voucherId), + }, + BankStatement: { + branch: (s: BankStatement, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(s.branchId), + ledger: (s: BankStatement, _: unknown, ctx: Ctx) => ctx.loaders.ledger.load(s.ledgerId), + }, + PeriodLock: { + branch: (l: PeriodLock, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(l.branchId), + }, + + Mutation: { + addTaxRule: (_: unknown, { input }: { input: TaxRuleInput }, ctx: Ctx) => ctx.services.gst.addTaxRule(authorize(ctx, "accounts:manage"), input), + seedTaxRules: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.gst.seedTaxRules(authorize(ctx, "accounts:manage")), + + createCustomer: (_: unknown, { input }: { input: CustomerInput }, ctx: Ctx) => ctx.services.receivables.createCustomer(authorize(ctx, "accounts:manage"), input), + updateCustomer: (_: unknown, a: { id: string; input: Partial<CustomerInput> & { active?: boolean | null } }, ctx: Ctx) => + ctx.services.receivables.updateCustomer(authorize(ctx, "accounts:manage"), a.id, a.input), + issueCustomerInvoice: (_: unknown, { input }: { input: CustomerInvoiceInput }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:post", input.branchId); + authorize(ctx, "invoice:create", input.branchId); + return ctx.services.receivables.issueInvoice(actor, input); + }, + issueCreditNote: async (_: unknown, { input }: { input: { invoiceId: string; reason: string; lines?: { itemIndex: number; taxablePaise?: number | null }[] | null; date?: Date | null } }, ctx: Ctx) => { + const inv = await ctx.services.billing.get(actorOf(ctx), input.invoiceId); + return ctx.services.receivables.issueCreditNote(authorize(ctx, "accounts:post", inv.branchId), input); + }, + + createPartner: (_: unknown, { input }: { input: PartnerInput }, ctx: Ctx) => ctx.services.settlements.createPartner(authorize(ctx, "accounts:manage", input.branchId), input), + updatePartner: async (_: unknown, a: { id: string; input: Partial<PartnerInput> & { active?: boolean | null } }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.id); + return ctx.services.settlements.updatePartner(authorize(ctx, "accounts:manage", p.branchId), a.id, a.input); + }, + addAgreement: async (_: unknown, { input }: { input: AgreementInput }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), input.partnerId); + return ctx.services.settlements.addAgreement(authorize(ctx, "accounts:manage", p.branchId), input); + }, + createSettlement: async (_: unknown, a: { partnerId: string; period: string; deductions?: SettlementDeduction[] | null }, ctx: Ctx) => { + const p = await ctx.services.settlements.partner(actorOf(ctx), a.partnerId); + return ctx.services.settlements.create(authorize(ctx, "accounts:post", p.branchId), a); + }, + accrueSettlement: async (_: unknown, a: { id: string }, ctx: Ctx) => (await settlementFor(ctx, a.id, "accounts:post"), ctx.services.settlements.accrue(actorOf(ctx), a.id)), + paySettlement: async (_: unknown, { input }: { input: { settlementId: string; mode: never; date?: Date | null; reference?: string | null } }, ctx: Ctx) => { + await settlementFor(ctx, input.settlementId, "accounts:post"); + return ctx.services.settlements.pay(actorOf(ctx), input); + }, + cancelSettlement: async (_: unknown, a: { id: string }, ctx: Ctx) => (await settlementFor(ctx, a.id, "accounts:post"), ctx.services.settlements.cancel(actorOf(ctx), a.id)), + + closeCashDay: (_: unknown, { input }: { input: { branchId: string; day: string; countedPaise: number; denominations?: { valuePaise: number; count: number }[] | null; notes?: string | null } }, ctx: Ctx) => + ctx.services.cashBank.closeCashDay(authorize(ctx, "accounts:post", input.branchId), input), + importBankStatement: async (_: unknown, { input }: { input: { branchId: string; ledgerId: string; csv: string; fileName?: string | null } }, ctx: Ctx) => { + const actor = authorize(ctx, "accounts:post", input.branchId); + await ctx.services.books.ledger(actor, input.ledgerId); + return ctx.services.cashBank.importBankStatement(actor, input); + }, + reconcileBank: (_: unknown, a: { ledgerId: string; statementId?: string | null }, ctx: Ctx) => ctx.services.cashBank.reconcile(authorize(ctx, "accounts:post"), a), + matchBankLine: (_: unknown, a: { lineId: string; voucherIds: string[] }, ctx: Ctx) => ctx.services.cashBank.matchLine(authorize(ctx, "accounts:post"), a), + setBankLineStatus: (_: unknown, a: { lineId: string; status: "ignored" | "unmatched" }, ctx: Ctx) => ctx.services.cashBank.setLineStatus(authorize(ctx, "accounts:post"), a), + + setLockDate: (_: unknown, { input }: { input: { branchId?: string | null; upTo: string | null; reason?: string | null } }, ctx: Ctx) => + ctx.services.periods.setLockDate(authorize(ctx, "accounts:manage", input.branchId), input), + closeFinancialYear: (_: unknown, a: { fy: string }, ctx: Ctx) => ctx.services.periods.closeFinancialYear(authorize(ctx, "accounts:manage"), a.fy), + }, +}); diff --git a/src/controllers/lab.controller.ts b/src/controllers/lab.controller.ts new file mode 100644 index 0000000..27840d6 --- /dev/null +++ b/src/controllers/lab.controller.ts @@ -0,0 +1,107 @@ +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import type { Booking, BookingStatus, BookingTest, Patient, Report, ResultValue, Sample } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards"; +import type { CreateBookingInput } from "../services/booking.service"; +import type { DoctorInput, RegisterPatientInput } from "../services/patient.service"; +import { scopedFind } from "../services/util"; + +/** A result flag "" (normal) is NONE in GraphQL. */ +export const flagOut = (f: ResultValue["flag"]) => (f === "" ? "NONE" : f ?? null); +export const flagIn = (f?: string | null): ResultValue["flag"] => (f === "NONE" ? "" : ((f ?? undefined) as ResultValue["flag"])); + +async function sampleFor(ctx: Ctx, id: string, permission: string) { + const s = await ctx.services.bookings.sample(actorOf(ctx), id); + authorize(ctx, permission, s.branchId); + return s; +} + +async function reportFor(ctx: Ctx, id: string, permission: string) { + const r = await ctx.services.reports.get(actorOf(ctx), id); + authorize(ctx, permission, r.branchId); + return r; +} + +/** Patients, doctors, bookings, samples, reports. */ +export const labController = withErrorCodes({ + Query: { + patient: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.patients.get(authorize(ctx, "patient:read"), a.id), + patients: (_: unknown, a: { search: string; limit?: number }, ctx: Ctx) => ctx.services.patients.search(authorize(ctx, "patient:read"), a.search, cap(a.limit, 20)), + doctors: (_: unknown, a: { activeOnly?: boolean }, ctx: Ctx) => ctx.services.patients.doctors(authorize(ctx, "doctor:read"), a.activeOnly ?? true), + + booking: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.id); + authorize(ctx, "booking:read", b.branchId); + return b; + }, + bookings: (_: unknown, a: { branchId?: string | null; status?: BookingStatus | null; patientId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.bookings.list(actorOf(ctx), { scope: branchScope(ctx, "booking:read", a.branchId), status: a.status, patientId: a.patientId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + sample: (_: unknown, a: { id: string }, ctx: Ctx) => sampleFor(ctx, a.id, "sample:read"), + sampleByBarcode: async (_: unknown, a: { barcode: string }, ctx: Ctx) => { + const s = await ctx.services.bookings.sampleByBarcode(actorOf(ctx), a.barcode); + if (s) authorize(ctx, "sample:read", s.branchId); + return s; + }, + report: (_: unknown, a: { id: string }, ctx: Ctx) => reportFor(ctx, a.id, "report:read"), + }, + + Patient: { + branch: (p: Patient, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(p.branchId), + bookings: (p: Patient, a: { limit?: number }, ctx: Ctx) => + canSee(ctx, "booking:read") ? scopedFind(ctx.repos.bookings, { patientId: p.id }, ctx.branchesFor("booking:read"), cap(a.limit, 20), 0, "createdAt") : [], + invoices: (p: Patient, a: { limit?: number }, ctx: Ctx) => + canSee(ctx, "invoice:read") ? scopedFind(ctx.repos.invoices, { patientId: p.id }, ctx.branchesFor("invoice:read"), cap(a.limit, 20), 0, "createdAt") : [], + }, + Booking: { + patient: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(b.patientId), + doctor: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.doctor.load(b.doctorId), + branch: (b: Booking, _: unknown, ctx: Ctx) => ctx.loaders.branch.load(b.branchId), + samples: (b: Booking, _: unknown, ctx: Ctx) => ctx.services.bookings.samplesOf(b.id), + reports: (b: Booking, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", b.branchId) ? ctx.services.reports.ofBooking(b.id) : []), + invoice: (b: Booking, _: unknown, ctx: Ctx) => (canSee(ctx, "invoice:read", b.branchId) ? ctx.loaders.invoice.load(b.invoiceId) : null), + }, + BookingTest: { + catalog: (t: BookingTest) => getTestByCode(t.testCode) ?? null, + }, + Sample: { + booking: (s: Sample, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(s.bookingId), + }, + Report: { + results: (r: Report) => r.results.map((x) => ({ ...x, flag: flagOut(x.flag) })), + patient: (r: Report, _: unknown, ctx: Ctx) => ctx.loaders.patient.load(r.patientId), + booking: (r: Report, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(r.bookingId), + }, + + Mutation: { + registerPatient: (_: unknown, { input }: { input: RegisterPatientInput }, ctx: Ctx) => ctx.services.patients.register(authorize(ctx, "patient:create", input.branchId), input), + updatePatient: async (_: unknown, a: { id: string; input: Partial<RegisterPatientInput> }, ctx: Ctx) => { + const actor = authorize(ctx, "patient:update"); + return ctx.services.patients.update(actor, a.id, a.input); + }, + createDoctor: (_: unknown, { input }: { input: DoctorInput }, ctx: Ctx) => ctx.services.patients.createDoctor(authorize(ctx, "doctor:manage"), input), + updateDoctor: (_: unknown, a: { id: string; input: Partial<DoctorInput> }, ctx: Ctx) => ctx.services.patients.updateDoctor(authorize(ctx, "doctor:manage"), a.id, a.input), + + createBooking: (_: unknown, { input }: { input: CreateBookingInput }, ctx: Ctx) => ctx.services.bookings.create(authorize(ctx, "booking:create", input.branchId), input), + cancelBooking: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.id); + return ctx.services.bookings.cancel(authorize(ctx, "booking:cancel", b.branchId), a.id); + }, + collectSample: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.bookings.collectSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:collect")), + receiveSample: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.bookings.receiveSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:receive")), + rejectSample: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => ctx.services.bookings.rejectSample(actorOf(ctx), await sampleFor(ctx, a.id, "sample:reject"), a.reason), + + createReport: async (_: unknown, a: { bookingId: string; testCode: string; testName?: string | null }, ctx: Ctx) => { + const b = await ctx.services.bookings.get(actorOf(ctx), a.bookingId); + return ctx.services.reports.create(authorize(ctx, "report:enter", b.branchId), b, a.testCode, a.testName); + }, + saveResults: async (_: unknown, a: { reportId: string; results: (Omit<ResultValue, "flag"> & { flag?: string | null })[] }, ctx: Ctx) => { + const r = await reportFor(ctx, a.reportId, "report:enter"); + const results = a.results.map((x) => ({ parameter: x.parameter, value: x.value, ...(x.unit != null ? { unit: x.unit } : {}), ...(x.range != null ? { range: x.range } : {}), ...(x.flag != null ? { flag: flagIn(x.flag) } : {}) })); + return ctx.services.reports.saveResults(actorOf(ctx), r, results); + }, + verifyReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.verify(actorOf(ctx), await reportFor(ctx, a.id, "report:verify")), + releaseReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.release(actorOf(ctx), await reportFor(ctx, a.id, "report:release")), + amendReport: async (_: unknown, a: { id: string }, ctx: Ctx) => ctx.services.reports.amend(actorOf(ctx), await reportFor(ctx, a.id, "report:amend")), + }, +}); diff --git a/src/controllers/organization.controller.ts b/src/controllers/organization.controller.ts new file mode 100644 index 0000000..cf704d9 --- /dev/null +++ b/src/controllers/organization.controller.ts @@ -0,0 +1,29 @@ +import type { AuditEntry, Organization } from "@mgpl-diagnostics/db-kit"; +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize, cap } from "../graphql/guards"; +import type { BranchInput, OrganizationPatch } from "../services/organization.service"; + +export const organizationController = withErrorCodes({ + Query: { + organization: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.organization(actorOf(ctx)), + branches: (_: unknown, __: unknown, ctx: Ctx) => ctx.services.organization.branches(actorOf(ctx)), + branch: async (_: unknown, a: { id: string }, ctx: Ctx) => { + const b = await ctx.loaders.branch.load(a.id); + return b && b.orgId === actorOf(ctx).orgId ? b : null; + }, + auditLog: (_: unknown, a: { entity?: string | null; entityId?: string | null; limit?: number; offset?: number }, ctx: Ctx) => + ctx.services.organization.audit(authorize(ctx, "audit:read"), { entity: a.entity, entityId: a.entityId, limit: cap(a.limit, 50), offset: a.offset ?? 0 }), + }, + Organization: { + branches: (o: Organization, _: unknown, ctx: Ctx) => ctx.services.organization.branches({ orgId: o.id, userId: ctx.viewer!.userId }), + }, + AuditEntry: { + user: (e: AuditEntry, _: unknown, ctx: Ctx) => ctx.loaders.user.load(e.userId).then((u) => (u ? (({ passwordHash: _p, ...rest }) => rest)(u) : null)), + }, + Mutation: { + updateOrganization: (_: unknown, { input }: { input: OrganizationPatch }, ctx: Ctx) => ctx.services.organization.updateOrganization(authorize(ctx, "org:update"), input), + createBranch: (_: unknown, { input }: { input: BranchInput }, ctx: Ctx) => ctx.services.organization.createBranch(authorize(ctx, "branch:create"), input), + updateBranch: (_: unknown, a: { id: string; input: Partial<BranchInput> }, ctx: Ctx) => ctx.services.organization.updateBranch(authorize(ctx, "branch:update", a.id), a.id, a.input), + }, +}); diff --git a/src/graphql/context.ts b/src/graphql/context.ts new file mode 100644 index 0000000..0169579 --- /dev/null +++ b/src/graphql/context.ts @@ -0,0 +1,49 @@ +import type { AuthContext, Viewer } from "@mgpl-diagnostics/auth-kit"; +import type { Repositories } from "@mgpl-diagnostics/db-kit"; +import type { Container } from "../container"; +import type { Services } from "../services"; +import { createLoaders, type Loaders } from "./loaders"; + +/** Who is acting: always taken from the session, never from client input. */ +export interface Actor { + userId: string; + orgId: string; +} + +/** Everything a controller (resolver) gets for one request. */ +export interface RequestContext extends AuthContext { + services: Services; + /** Cached repositories (db-kit `db.repos`); workflows open their own transactions. */ + repos: Repositories; + viewer: Viewer | null; + /** The bearer token of this request (needed by logout and password change). */ + token: string | null; + ip?: string; + userAgent?: string; + /** Per-request batch loaders, created on first use. */ + readonly loaders: Loaders; +} + +export function bearerToken(header: string | null | undefined): string | null { + const m = /^Bearer\s+(.+)$/i.exec(header?.trim() ?? ""); + return m ? m[1]!.trim() : null; +} + +/** Builds the request context from the Authorization header, through auth-kit. */ +export async function createContext(container: Container, req: { headers: Headers; ip?: string }): Promise<RequestContext> { + const token = bearerToken(req.headers.get("authorization")); + const auth = await container.auth.context(token); + let loaders: Loaders | undefined; + const repos = container.db.repos; + return { + ...auth, + services: container.services, + repos, + token, + ip: req.ip, + userAgent: req.headers.get("user-agent") ?? undefined, + get loaders() { + return (loaders ??= createLoaders(repos)); + }, + }; +} diff --git a/src/graphql/errors.ts b/src/graphql/errors.ts new file mode 100644 index 0000000..ff7d3a9 --- /dev/null +++ b/src/graphql/errors.ts @@ -0,0 +1,60 @@ +import { AuthError } from "@mgpl-diagnostics/auth-kit"; +import { ConflictError, NotFoundError, PeriodLockedError, ValidationError } from "@mgpl-diagnostics/db-kit"; +import { CreditLimitError } from "@mgpl-diagnostics/finance-kit"; +import { HttpError } from "@mgpl-diagnostics/report-kit"; +import { GraphQLError } from "graphql"; + +/** + * Every error code a client can see in `errors[].extensions.code`. A public contract: add codes, + * never rename them (docs/05-errors.md). Anything else is masked as "Unexpected error". + */ +export const ERROR_CODES = [ + "UNAUTHENTICATED", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "BAD_USER_INPUT", + "PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED", + // auth-kit's own codes, passed through + "INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN", +] as const; +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export function apiError(code: ErrorCode, message: string, status: number, extra: Record<string, unknown> = {}): GraphQLError { + // never pass `originalError`: Yoga masks any GraphQLError that wraps a non-GraphQL error as "Unexpected error" + return new GraphQLError(message, { extensions: { code, http: { status }, ...extra } }); +} + +export const unauthenticated = () => apiError("UNAUTHENTICATED", "Sign in required", 401); +export const forbidden = (permission: string) => apiError("FORBIDDEN", `Missing permission ${permission}`, 403, { permission }); +export const notFound = (what: string) => apiError("NOT_FOUND", `${what} not found`, 404); +export const badInput = (message: string) => apiError("BAD_USER_INPUT", message, 400); + +/** Domain errors from the kits → GraphQL errors with codes. Anything unknown is re-thrown (and masked by Yoga). */ +export function toGraphQLError(e: unknown): unknown { + if (e instanceof GraphQLError) return e; + if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {}); + if (e instanceof PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409); + if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409); + if (e instanceof NotFoundError) return apiError("NOT_FOUND", e.message, 404); + if (e instanceof ConflictError) return apiError("CONFLICT", e.message, 409); + if (e instanceof ValidationError) return apiError("BAD_USER_INPUT", e.message, 400); + // report-kit / invoice-kit input validation (HttpError 400) + if (e instanceof HttpError && e.status === 400) return apiError("BAD_USER_INPUT", e.message, 400); + return e; +} + +type ResolverMap = Record<string, Record<string, unknown>>; + +/** Wraps every resolver function of a map so domain errors surface with proper codes. */ +export function withErrorCodes<R extends ResolverMap>(resolvers: R): R { + for (const type of Object.values(resolvers)) { + for (const [field, fn] of Object.entries(type)) { + if (typeof fn !== "function") continue; + type[field] = async (...args: unknown[]) => { + try { + return await (fn as (...a: unknown[]) => unknown)(...args); + } catch (e) { + throw toGraphQLError(e); + } + }; + } + } + return resolvers; +} diff --git a/src/graphql/guards.ts b/src/graphql/guards.ts new file mode 100644 index 0000000..52f851d --- /dev/null +++ b/src/graphql/guards.ts @@ -0,0 +1,34 @@ +import type { Id } from "@mgpl-diagnostics/db-kit"; +import type { Actor, RequestContext } from "./context"; +import { forbidden, unauthenticated } from "./errors"; + +/** The signed-in user as an Actor; UNAUTHENTICATED otherwise. */ +export function actorOf(ctx: RequestContext): Actor { + if (!ctx.viewer) throw unauthenticated(); + return { userId: ctx.viewer.userId, orgId: ctx.viewer.orgId }; +} + +/** FORBIDDEN unless the viewer has `permission` in `branchId` (anywhere, when omitted). */ +export function authorize(ctx: RequestContext, permission: string, branchId?: Id | null): Actor { + const actor = actorOf(ctx); + if (!ctx.can(permission, branchId)) throw forbidden(permission); + return actor; +} + +/** True when the viewer may see a nested field (no error: the field resolves to empty). */ +export const canSee = (ctx: RequestContext, permission: string, branchId?: Id | null) => !!ctx.viewer && ctx.can(permission, branchId); + +/** The branch filter for a list: the requested branch (checked) or every branch the viewer may read. */ +export function branchScope(ctx: RequestContext, permission: string, branchId?: Id | null): "all" | Id[] { + if (branchId) { + authorize(ctx, permission, branchId); + return [branchId]; + } + actorOf(ctx); + const allowed = ctx.branchesFor(permission); + if (allowed !== "all" && !allowed.length) throw forbidden(permission); + return allowed; +} + +/** List limits are capped at 200. */ +export const cap = (n: number | null | undefined, d: number) => Math.min(Math.max(1, n ?? d), 200); diff --git a/src/graphql/limits.ts b/src/graphql/limits.ts new file mode 100644 index 0000000..281de2e --- /dev/null +++ b/src/graphql/limits.ts @@ -0,0 +1,28 @@ +import { GraphQLError, Kind, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql"; + +/** Rejects queries nested deeper than `max` levels (a cheap guard against abusive queries). */ +export function depthLimit(max: number) { + return (context: ValidationContext): ASTVisitor => { + const fragments = new Map<string, FragmentDefinitionNode>(); + for (const d of context.getDocument().definitions) if (d.kind === Kind.FRAGMENT_DEFINITION) fragments.set(d.name.value, d); + const depthOf = (set: SelectionSetNode | undefined, depth: number, seen: Set<string>): number => { + if (!set) return depth; + let deepest = depth; + for (const s of set.selections) { + if (s.kind === Kind.FIELD) deepest = Math.max(deepest, depthOf(s.selectionSet, s.selectionSet ? depth + 1 : depth, seen)); + else if (s.kind === Kind.INLINE_FRAGMENT) deepest = Math.max(deepest, depthOf(s.selectionSet, depth, seen)); + else if (!seen.has(s.name.value)) { + const f = fragments.get(s.name.value); + if (f) deepest = Math.max(deepest, depthOf(f.selectionSet, depth, new Set(seen).add(s.name.value))); + } + } + return deepest; + }; + return { + OperationDefinition(op) { + const depth = depthOf(op.selectionSet, 0, new Set()); + if (depth > max) context.reportError(new GraphQLError(`Query is ${depth} levels deep; the limit is ${max}`, { extensions: { code: "BAD_USER_INPUT" } })); + }, + }; + }; +} diff --git a/src/graphql/loaders.ts b/src/graphql/loaders.ts new file mode 100644 index 0000000..c18ccd3 --- /dev/null +++ b/src/graphql/loaders.ts @@ -0,0 +1,67 @@ +import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, Repositories, User } from "@mgpl-diagnostics/db-kit"; + +/** + * Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results are + * memoised for the request. Prevents N+1 queries when a list resolves nested fields + * (50 invoices → 1 query for their patients). + */ +export class BatchLoader<T extends { id: string }> { + private cache = new Map<string, Promise<T | null>>(); + private queue: { id: string; resolve: (v: T | null) => void; reject: (e: unknown) => void }[] = []; + + constructor(private readonly fetchMany: (ids: readonly string[]) => Promise<T[]>) {} + + load(id: string | null | undefined): Promise<T | null> { + if (!id) return Promise.resolve(null); + const hit = this.cache.get(id); + if (hit) return hit; + const p = new Promise<T | null>((resolve, reject) => { + this.queue.push({ id, resolve, reject }); + if (this.queue.length === 1) queueMicrotask(() => void this.flush()); + }); + this.cache.set(id, p); + return p; + } + + /** Seeds the cache with an entity you already have (e.g. just created). */ + prime(entity: T): void { + this.cache.set(entity.id, Promise.resolve(entity)); + } + + clear(id: string): void { + this.cache.delete(id); + } + + private async flush(): Promise<void> { + const batch = this.queue; + this.queue = []; + try { + const rows = await this.fetchMany([...new Set(batch.map((b) => b.id))]); + const byId = new Map(rows.map((r) => [r.id, r])); + for (const b of batch) b.resolve(byId.get(b.id) ?? null); + } catch (e) { + for (const b of batch) { + this.cache.delete(b.id); + b.reject(e); + } + } + } +} + +/** One set per request (created lazily by the context). */ +export function createLoaders(r: Repositories) { + return { + patient: new BatchLoader<Patient>((ids) => r.patients.getMany(ids)), + branch: new BatchLoader<Branch>((ids) => r.branches.getMany(ids)), + doctor: new BatchLoader<Doctor>((ids) => r.doctors.getMany(ids)), + booking: new BatchLoader<Booking>((ids) => r.bookings.getMany(ids)), + invoice: new BatchLoader<Invoice>((ids) => r.invoices.getMany(ids)), + ledger: new BatchLoader<Ledger>((ids) => r.ledgers.getMany(ids)), + group: new BatchLoader<AccountGroup>((ids) => r.accountGroups.getMany(ids)), + customer: new BatchLoader<Customer>((ids) => r.customers.getMany(ids)), + partner: new BatchLoader<Partner>((ids) => r.partners.getMany(ids)), + user: new BatchLoader<User>((ids) => r.users.getMany(ids)), + }; +} + +export type Loaders = ReturnType<typeof createLoaders>; diff --git a/src/graphql/scalars.ts b/src/graphql/scalars.ts new file mode 100644 index 0000000..70bbbe7 --- /dev/null +++ b/src/graphql/scalars.ts @@ -0,0 +1,57 @@ +import { istDay } from "@mgpl-diagnostics/finance-kit"; +import { GraphQLScalarType, Kind } from "graphql"; + +const DAY = /^\d{4}-\d{2}-\d{2}$/; + +const toDate = (v: unknown): Date => { + const d = v instanceof Date ? v : new Date(v as string); + if (Number.isNaN(d.getTime())) throw new TypeError("Invalid DateTime"); + return d; +}; + +/** ISO-8601 instant, e.g. "2026-10-14T09:30:00.000Z". */ +export const DateTime = new GraphQLScalarType({ + name: "DateTime", + description: "ISO-8601 date-time (UTC), e.g. 2026-10-14T09:30:00.000Z", + serialize: (v) => toDate(v).toISOString(), + parseValue: (v) => toDate(v), + parseLiteral: (ast) => (ast.kind === Kind.STRING ? toDate(ast.value) : null), +}); + +const toDay = (v: unknown): string => { + if (typeof v !== "string" || !DAY.test(v) || Number.isNaN(Date.parse(`${v}T00:00:00Z`))) throw new TypeError(`Invalid Day "${String(v)}", expected YYYY-MM-DD`); + return v; +}; + +/** A calendar day in India (IST), e.g. "2026-10-14". */ +export const Day = new GraphQLScalarType({ + name: "Day", + description: "Calendar day in IST, YYYY-MM-DD", + serialize: (v) => (v instanceof Date ? istDay(v) : toDay(v)), + parseValue: toDay, + parseLiteral: (ast) => (ast.kind === Kind.STRING ? toDay(ast.value) : null), +}); + +/** Free-form JSON (report-kit report requests and other open shapes). */ +export const JSONScalar = new GraphQLScalarType({ + name: "JSON", + description: "Any JSON value", + serialize: (v) => v, + parseValue: (v) => v, + parseLiteral: function literal(ast): unknown { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: + return ast.value; + case Kind.INT: + case Kind.FLOAT: + return Number(ast.value); + case Kind.OBJECT: + return Object.fromEntries(ast.fields.map((f) => [f.name.value, literal(f.value)])); + case Kind.LIST: + return ast.values.map((v) => literal(v)); + default: + return null; + } + }, +}); diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts new file mode 100644 index 0000000..cac6fb7 --- /dev/null +++ b/src/graphql/schema.ts @@ -0,0 +1,44 @@ +import { makeExecutableSchema } from "@graphql-tools/schema"; +import type { GraphQLSchema } from "graphql"; +import { authController } from "../controllers/auth.controller"; +import { baseController } from "../controllers/base.controller"; +import { billingController } from "../controllers/billing.controller"; +import { booksController } from "../controllers/books.controller"; +import { catalogController } from "../controllers/catalog.controller"; +import { documentsController } from "../controllers/documents.controller"; +import { financeController } from "../controllers/finance.controller"; +import { labController } from "../controllers/lab.controller"; +import { organizationController } from "../controllers/organization.controller"; +import { authTypeDefs } from "../schema/auth.graphql"; +import { baseTypeDefs } from "../schema/base.graphql"; +import { billingTypeDefs } from "../schema/billing.graphql"; +import { booksTypeDefs } from "../schema/books.graphql"; +import { catalogTypeDefs } from "../schema/catalog.graphql"; +import { documentsTypeDefs } from "../schema/documents.graphql"; +import { financeTypeDefs } from "../schema/finance.graphql"; +import { labTypeDefs } from "../schema/lab.graphql"; +import { organizationTypeDefs } from "../schema/organization.graphql"; + +/** One domain = one SDL file in src/schema + one controller in src/controllers. */ +export interface GraphQLModule { + name: string; + typeDefs: string; + resolvers: Record<string, unknown>; +} + +export const MODULES: GraphQLModule[] = [ + { name: "base", typeDefs: baseTypeDefs, resolvers: baseController }, + { name: "auth", typeDefs: authTypeDefs, resolvers: authController }, + { name: "organization", typeDefs: organizationTypeDefs, resolvers: organizationController }, + { name: "catalog", typeDefs: catalogTypeDefs, resolvers: catalogController }, + { name: "lab", typeDefs: labTypeDefs, resolvers: labController }, + { name: "billing", typeDefs: billingTypeDefs, resolvers: billingController }, + { name: "books", typeDefs: booksTypeDefs, resolvers: booksController }, + { name: "finance", typeDefs: financeTypeDefs, resolvers: financeController }, + { name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController }, +]; + +/** The whole API as one executable schema. */ +export function buildSchema(modules: GraphQLModule[] = MODULES): GraphQLSchema { + return makeExecutableSchema({ typeDefs: modules.map((m) => m.typeDefs), resolvers: modules.map((m) => m.resolvers) as never }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..23763ca --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +// The api as a library (tests, scripts, other entry points): build a container and a server. +export { loadConfig, type Config } from "./config/env"; +export { createContainer, type Container, type ContainerOptions } from "./container"; +export { buildServer } from "./server"; +export { buildSchema, MODULES, type GraphQLModule } from "./graphql/schema"; +export { ERROR_CODES, type ErrorCode } from "./graphql/errors"; +export type { RequestContext, Actor } from "./graphql/context"; +export type { Mailer } from "./services/auth.service"; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c5da584 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,20 @@ +// Starts the API: `npm run dev` (watch) or `npm run build && npm start`. +import { loadConfig } from "./config/env"; +import { createContainer } from "./container"; +import { buildServer } from "./server"; + +const config = loadConfig(); +const container = await createContainer({ config }); +if (!container.db.redis && config.env === "production") console.warn("[api] REDIS_URL is not set: sessions live in this process only (one server, lost on restart)"); +// No mailer is wired in this entry: requestPasswordReset still answers true but sends nothing. +// Embed the API with createContainer({ config, mailer }) to send reset links (docs/12-deployment.md). +if (config.env === "production") console.warn("[api] no Mailer configured: password-reset links are not sent"); +const app = await buildServer(container); +await app.listen({ host: config.host, port: config.port }); +console.log(`[api] GraphQL on http://${config.host}:${config.port}${config.graphqlPath} (${config.db.backend})`); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + app.close().then(() => process.exit(0), () => process.exit(1)); + }); +} diff --git a/src/schema/auth.graphql.ts b/src/schema/auth.graphql.ts new file mode 100644 index 0000000..df7949f --- /dev/null +++ b/src/schema/auth.graphql.ts @@ -0,0 +1,75 @@ +/** Sign-in, sessions, passwords (auth-kit) and users and roles. */ +export const authTypeDefs = /* GraphQL */ ` + enum UserStatus { active disabled invited } + + type RoleAssignment { role: String!, branchId: ID, branch: Branch } + input RoleAssignmentInput { role: String!, branchId: ID } + + type User { + id: ID! + name: String! + email: String! + phone: String + status: UserStatus! + roles: [RoleAssignment!]! + lastLoginAt: DateTime + createdAt: DateTime! + } + + type Role { id: ID!, key: String!, name: String!, permissions: [String!]!, system: Boolean! } + + "The signed-in user and what they may do." + type Viewer { + user: User! + organization: Organization! + "Permissions held in this branch (or organisation-wide when branchId is omitted)." + permissions(branchId: ID): [String!]! + "Branches where the user has this permission; null means every branch." + branchesFor(permission: String!): [ID!] + can(permission: String!, branchId: ID): Boolean! + } + + type Session { id: ID!, current: Boolean!, createdAt: DateTime!, lastSeenAt: DateTime!, expiresAt: DateTime!, ip: String, userAgent: String } + + type LoginResult { + "Send as 'Authorization: Bearer <token>'. Shown once; the server stores only its SHA-256." + token: String! + expiresAt: DateTime! + user: User! + } + + extend type Query { + "null when not signed in." + viewer: Viewer + mySessions: [Session!]! + users: [User!]! + user(id: ID!): User + roles: [Role!]! + "Every permission name the API checks." + permissions: [String!]! + } + + input LoginInput { email: String!, password: String!, "Only when the same email exists in several organisations." orgCode: String } + input CreateUserInput { name: String!, email: String!, phone: String, password: String!, roles: [RoleAssignmentInput!]! } + input RoleInput { key: String!, name: String!, permissions: [String!]! } + + extend type Mutation { + login(input: LoginInput!): LoginResult! + "Ends this session." + logout: Boolean! + "Ends every other session of the signed-in user; returns how many." + logoutOtherSessions: Int! + revokeSession(id: ID!): Boolean! + changePassword(currentPassword: String!, newPassword: String!): Boolean! + "Always true (never reveals whether the email exists). The link is sent by the server's mailer." + requestPasswordReset(email: String!, orgCode: String): Boolean! + resetPassword(token: String!, newPassword: String!): Boolean! + + createUser(input: CreateUserInput!): User! + setUserRoles(userId: ID!, roles: [RoleAssignmentInput!]!): User! + disableUser(userId: ID!): User! + enableUser(userId: ID!): User! + setUserPassword(userId: ID!, newPassword: String!): Boolean! + upsertRole(input: RoleInput!): Role! + } +`; diff --git a/src/schema/base.graphql.ts b/src/schema/base.graphql.ts new file mode 100644 index 0000000..63e8c8a --- /dev/null +++ b/src/schema/base.graphql.ts @@ -0,0 +1,31 @@ +/** + * Shared scalars, enums and the root types. Money is Int paise (Rs 1 = 100). Instants are DateTime + * (ISO-8601, UTC); calendar days are Day ("2026-10-14", IST). Every query is scoped to the signed-in + * user's organisation: nothing takes an orgId. + */ +export const baseTypeDefs = /* GraphQL */ ` + scalar DateTime + scalar Day + scalar JSON + + enum Sex { male female other } + enum PaymentMode { cash upi card netbanking wallet cheque insurance } + enum ItemCategory { test package service consumable medicine other } + enum Taxability { taxable exempt nil_rated non_gst } + enum VoucherType { sales receipt payment journal contra credit_note debit_note purchase closing } + + type Address { line1: String, line2: String, city: String, district: String, state: String, pincode: String } + input AddressInput { line1: String, line2: String, city: String, district: String, state: String, pincode: String } + + "Service health: database reachable, versions." + type Health { ok: Boolean!, service: String!, version: String!, database: String!, time: DateTime! } + + type Query { + health: Health! + } + + type Mutation { + "Does nothing; lets clients check that mutations reach the server." + ping: String! + } +`; diff --git a/src/schema/billing.graphql.ts b/src/schema/billing.graphql.ts new file mode 100644 index 0000000..22c6d40 --- /dev/null +++ b/src/schema/billing.graphql.ts @@ -0,0 +1,75 @@ +/** Patient invoices and payments. Issuing and paying post to the books automatically. */ +export const billingTypeDefs = /* GraphQL */ ` + enum InvoiceStatus { draft issued partially_paid paid cancelled refunded credited } + enum PaymentKind { payment refund } + + type InvoiceItem { + code: String + name: String! + category: ItemCategory! + qty: Float! + ratePaise: Int! + discountPaise: Int! + taxPercent: Float! + hsnSac: String + taxability: Taxability + amountPaise: Int! + } + + type Invoice { + id: ID! + invoiceNo: String! + status: InvoiceStatus! + patient: Patient! + branch: Branch! + booking: Booking + customer: Customer + placeOfSupply: String + buyerGstin: String + items: [InvoiceItem!]! + subtotalPaise: Int! + discountPaise: Int! + taxPaise: Int! + roundOffPaise: Int! + totalPaise: Int! + paidPaise: Int! + creditedPaise: Int! + duePaise: Int! + issuedAt: DateTime + cancelReason: String + createdAt: DateTime! + payments: [Payment!]! + vouchers: [Voucher!]! + creditNotes: [CreditNote!]! + } + + type Payment { id: ID!, kind: PaymentKind!, amountPaise: Int!, mode: PaymentMode!, reference: String, receivedAt: DateTime! } + + extend type Query { + invoice(id: ID!): Invoice + invoices(branchId: ID, status: InvoiceStatus, patientId: ID, customerId: ID, limit: Int = 50, offset: Int = 0): [Invoice!]! + } + + "A catalog-kit test code alone is enough: name and MRP come from the catalog." + input InvoiceItemInput { code: String, name: String, category: ItemCategory = test, qty: Float = 1, ratePaise: Int, discountPaise: Int = 0, taxPercent: Float = 0, hsnSac: String, taxability: Taxability } + input PaymentInput { amountPaise: Int!, mode: PaymentMode!, reference: String } + + input IssueInvoiceInput { + branchId: ID! + patientId: ID! + bookingId: ID + items: [InvoiceItemInput!]! + billDiscountPaise: Int = 0 + "Taken at the counter with the invoice." + payments: [PaymentInput!] + "Fill HSN/SAC, taxability and GST rate from the tax rules." + applyTaxRules: Boolean = false + } + + extend type Mutation { + issueInvoice(input: IssueInvoiceInput!): Invoice! + recordPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice! + cancelInvoice(id: ID!, reason: String!): Invoice! + } +`; diff --git a/src/schema/books.graphql.ts b/src/schema/books.graphql.ts new file mode 100644 index 0000000..a9b5434 --- /dev/null +++ b/src/schema/books.graphql.ts @@ -0,0 +1,74 @@ +/** The books (tally-kit, our accounting engine): chart of accounts, vouchers, reports. */ +export const booksTypeDefs = /* GraphQL */ ` + enum AccountNature { assets liabilities income expenses } + enum VoucherStatus { posted cancelled } + enum AgeBucket { D0_30 D31_60 D61_90 D90_PLUS } + + type AccountGroup { id: ID!, name: String!, nature: AccountNature!, parent: AccountGroup, affectsGrossProfit: Boolean! } + + type Ledger { + id: ID! + name: String! + group: AccountGroup! + openingPaise: Int! + systemKey: String + gstin: String + active: Boolean! + "Closing balance, positive = Debit." + balancePaise(asOf: DateTime, branchId: ID): Int! + } + + type VoucherEntry { ledger: Ledger!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } + + type Voucher { + id: ID! + type: VoucherType! + voucherNo: String! + date: DateTime! + narration: String + status: VoucherStatus! + entries: [VoucherEntry!]! + branch: Branch! + invoiceId: ID + } + + type StatementRow { date: DateTime!, voucherNo: String!, voucherType: VoucherType!, debitPaise: Int!, creditPaise: Int!, balancePaise: Int!, invoiceId: ID } + type LedgerStatement { ledger: Ledger!, openingPaise: Int!, closingPaise: Int!, totalDebitPaise: Int!, totalCreditPaise: Int!, rows: [StatementRow!]! } + type TrialBalanceRow { ledger: Ledger!, group: AccountGroup!, primaryGroup: AccountGroup!, debitPaise: Int!, creditPaise: Int! } + type TrialBalance { rows: [TrialBalanceRow!]!, totalDebitPaise: Int!, totalCreditPaise: Int!, differencePaise: Int! } + type LedgerAmount { ledger: Ledger!, amountPaise: Int! } + type GroupLine { group: AccountGroup!, amountPaise: Int!, ledgers: [LedgerAmount!]! } + type ProfitAndLoss { income: [GroupLine!]!, expenses: [GroupLine!]!, grossProfitPaise: Int!, netProfitPaise: Int! } + type BalanceSheet { assets: [GroupLine!]!, liabilities: [GroupLine!]!, profitPaise: Int!, totalAssetsPaise: Int!, totalLiabilitiesPaise: Int!, differencePaise: Int! } + type OutstandingRow { invoice: Invoice!, ageDays: Int!, bucket: AgeBucket! } + type BucketTotals { d0to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } + type Outstanding { rows: [OutstandingRow!]!, buckets: BucketTotals!, totalDuePaise: Int! } + type GstSummary { taxableValuePaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int!, totalTaxPaise: Int! } + + extend type Query { + accountGroups: [AccountGroup!]! + ledgers: [Ledger!]! + ledger(id: ID!): Ledger + voucher(id: ID!): Voucher + ledgerStatement(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! + cashBook(from: DateTime!, to: DateTime!, branchId: ID): LedgerStatement! + trialBalance(asOf: DateTime, branchId: ID): TrialBalance! + profitAndLoss(from: DateTime!, to: DateTime!, branchId: ID): ProfitAndLoss! + balanceSheet(asOf: DateTime!, branchId: ID): BalanceSheet! + dayBook(from: DateTime!, to: DateTime!, branchId: ID, type: VoucherType): [Voucher!]! + outstandingReceivables(asOf: DateTime, branchId: ID): Outstanding! + gstSummary(from: DateTime!, to: DateTime!, branchId: ID): GstSummary! + } + + input VoucherEntryInput { ledgerId: ID!, debitPaise: Int = 0, creditPaise: Int = 0, invoiceId: ID } + input PostVoucherInput { branchId: ID!, type: VoucherType!, date: DateTime, narration: String, entries: [VoucherEntryInput!]! } + input LedgerInput { name: String!, groupId: ID!, openingPaise: Int = 0, gstin: String } + + extend type Mutation { + "Seeds the standard chart (28 groups, default ledgers) and finance-kit's ledgers. Idempotent." + setupChartOfAccounts: [Ledger!]! + createLedger(input: LedgerInput!): Ledger! + postVoucher(input: PostVoucherInput!): Voucher! + cancelVoucher(id: ID!): Voucher! + } +`; diff --git a/src/schema/catalog.graphql.ts b/src/schema/catalog.graphql.ts new file mode 100644 index 0000000..b8f0544 --- /dev/null +++ b/src/schema/catalog.graphql.ts @@ -0,0 +1,53 @@ +/** The test catalog (catalog-kit): tests, parameters, ranges, sample types, prices, search. */ +export const catalogTypeDefs = /* GraphQL */ ` + enum ParameterType { numeric text options image title } + + type CatalogInterval { low: String, high: String } + + type CatalogParameter { + name: String + unit: String + method: String + type: ParameterType! + male: CatalogInterval + female: CatalogInterval + options: [String!] + calculated: Boolean! + "Readable formula of a calculated parameter." + formulaText: String + referenceText: String + "The printed range for a sex (or both)." + range(sex: Sex): String! + } + + type CatalogTest { + id: Int! + name: String! + code: String + shortCode: String + department: String! + sampleType: String + secondarySampleTypes: [String!] + "MRP in rupees." + mrp: Int + "MRP in paise (what bookings and invoices use)." + pricePaise: Int + isProfile: Boolean! + parameterCount: Int! + parameters: [CatalogParameter!]! + } + + type CatalogHit { test: CatalogTest!, score: Float! } + type PriceQuote { "Rupees." total: Int!, totalPaise: Int!, tests: [CatalogTest!]!, unknown: [String!]! } + + extend type Query { + "Typo-tolerant search with lab shorthand (cbc, lft, vit d…)." + catalogSearch(query: String!, limit: Int = 20, department: String, sampleType: String, fuzzy: Boolean = true): [CatalogHit!]! + catalogTest(id: Int, code: String): CatalogTest + catalogTests(department: String, sampleType: String, limit: Int = 50, offset: Int = 0): [CatalogTest!]! + catalogDepartments: [String!]! + catalogSampleTypes: [String!]! + "Total MRP for test codes (or numeric ids as strings)." + priceQuote(tests: [String!]!): PriceQuote! + } +`; diff --git a/src/schema/documents.graphql.ts b/src/schema/documents.graphql.ts new file mode 100644 index 0000000..212c790 --- /dev/null +++ b/src/schema/documents.graphql.ts @@ -0,0 +1,49 @@ +/** + * Documents rendered by report-kit (lab reports, letterhead) and invoice-kit (invoices), used as + * internal libraries. PDFs come back base64-encoded in \`base64\`; HTML is self-contained in \`html\`. + */ +export const documentsTypeDefs = /* GraphQL */ ` + enum DocumentFormat { pdf html } + enum ReportVariant { header non_header } + enum ReportDesign { sidebar classic } + + type Document { + format: DocumentFormat! + fileName: String! + mimeType: String! + byteLength: Int! + "PDF bytes, base64 (null for HTML)." + base64: String + "Self-contained HTML (null for PDF)." + html: String + } + + type TemplateField { key: String!, label: String!, unit: String, range: [String!] } + type ReportTemplate { id: String!, name: String!, category: String, specimen: String, method: String, fields: [TemplateField!]! } + type ReportProfile { id: String!, name: String!, tests: [String!]! } + type TemplateCatalog { templates: [ReportTemplate!]!, profiles: [ReportProfile!]! } + type PriceListEntry { code: String!, name: String!, category: String!, "rupees" rate: Float!, taxPercent: Float, hsn: String } + type DocumentSample { id: String!, label: String!, group: String } + + extend type Query { + reportTemplates: TemplateCatalog! + "invoice-kit's sample price list (codes = report-kit template and profile ids, services, consumables)." + priceList: [PriceListEntry!]! + "Demo samples (empty unless the server enables them)." + documentSamples: [DocumentSample!]! + invoiceSamples: [DocumentSample!]! + + "Any report: request = report-kit's ReportRequest JSON (patient + tests | profile | report | sample)." + renderReport(request: JSON!, format: DocumentFormat = pdf, variant: ReportVariant = header, design: ReportDesign = sidebar, fileName: String): Document! + "Any invoice: request = invoice-kit's InvoiceRequest JSON ({ invoice } or { sample })." + renderInvoice(request: JSON!, format: DocumentFormat = pdf, variant: ReportVariant = header, fileName: String): Document! + "invoice-kit's calculation of an InvoiceRequest, without rendering (rupees)." + invoiceTotals(request: JSON!): JSON! + letterhead(format: DocumentFormat = pdf): Document! + + "A stored invoice, printed." + invoiceDocument(invoiceId: ID!, format: DocumentFormat = pdf, variant: ReportVariant = header): Document! + "A booking's verified or released reports, printed together (or only reportIds)." + bookingReportDocument(bookingId: ID!, reportIds: [ID!], format: DocumentFormat = pdf, variant: ReportVariant = header, design: ReportDesign = sidebar): Document! + } +`; diff --git a/src/schema/finance.graphql.ts b/src/schema/finance.graphql.ts new file mode 100644 index 0000000..1d33629 --- /dev/null +++ b/src/schema/finance.graphql.ts @@ -0,0 +1,326 @@ +/** + * finance-kit: GST, B2B receivables and credit notes, collection-centre settlements with TDS, day-end + * cash, bank reconciliation, lock dates, year-end close, CSV exports. Compliance values are + * configuration to be verified with the lab's CA (see verifyWithCA); nothing here is legal advice. + */ +export const financeTypeDefs = /* GraphQL */ ` + # ---------------------------------------------------------------- GST + type TaxRule { id: ID!, code: String, category: ItemCategory, hsnSac: String!, taxability: Taxability!, ratePercent: Float!, effectiveFrom: DateTime!, effectiveTo: DateTime, note: String } + + type TdsConfig { section: String!, rateBp: Int!, noPanRateBp: Int!, thresholdPaise: Int } + type FinanceConfig { b2clThresholdPaise: Int!, tds: TdsConfig!, bankToleranceDays: Int!, bankMaxFeeBp: Int!, clearingLedgerKeys: [String!]! } + + type RateTotals { ratePercent: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type TaxTotals { taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type Gstr1Invoice { invoiceId: ID!, invoiceNo: String!, day: Day!, valuePaise: Int!, placeOfSupply: String!, reverseCharge: String!, ctin: String, items: [RateTotals!]! } + type Gstr1Note { noteId: ID!, noteNo: String!, day: Day!, noteType: String!, invoiceNo: String!, invoiceDay: Day!, valuePaise: Int!, placeOfSupply: String!, ctin: String, type: String, items: [RateTotals!]! } + type B2csRow { supplyType: String!, placeOfSupply: String!, ratePercent: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int! } + type NilRow { description: String!, nilRatedPaise: Int!, exemptedPaise: Int!, nonGstPaise: Int! } + type HsnRow { hsnSac: String!, ratePercent: Float!, taxability: Taxability!, qty: Float!, taxablePaise: Int!, igstPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, cessPaise: Int!, totalValuePaise: Int! } + type HsnSummary { b2b: [HsnRow!]!, b2c: [HsnRow!]! } + type DocSeries { nature: String!, series: String!, from: String!, to: String!, total: Int!, cancelled: Int!, net: Int! } + type Period { from: DateTime!, to: DateTime! } + + type Gstr1 { + gstin: String! + period: Period! + b2b: [Gstr1Invoice!]! + b2cl: [Gstr1Invoice!]! + b2cs: [B2csRow!]! + cdnr: [Gstr1Note!]! + cdnur: [Gstr1Note!]! + nil: [NilRow!]! + hsn: HsnSummary! + docs: [DocSeries!]! + totals: TaxTotals! + verifyWithCA: [String!]! + } + + type InterStateRow { placeOfSupply: String!, taxablePaise: Int!, igstPaise: Int! } + type Gstr3b { + gstin: String! + period: Period! + "3.1(a)" + outwardTaxable: TaxTotals! + "3.1(c)" + outwardNilExemptPaise: Int! + "3.1(e)" + outwardNonGstPaise: Int! + "3.2" + interStateUnregistered: [InterStateRow!]! + verifyWithCA: [String!]! + } + + # ---------------------------------------------------------------- receivables + enum CustomerKind { hospital insurer tpa corporate other } + enum OpenItemBucket { not_due D1_30 D31_60 D61_90 D90_PLUS } + + type Customer { + id: ID! + code: String! + name: String! + kind: CustomerKind! + legalName: String + gstin: String + stateCode: String + pan: String + phone: String + email: String + address: Address + "null = no limit." + creditLimitPaise: Int + creditDays: Int! + ledger: Ledger! + active: Boolean! + } + + type OpenItem { invoiceId: ID!, invoiceNo: String!, day: Day!, dueDay: Day!, totalPaise: Int!, paidPaise: Int!, creditedPaise: Int!, duePaise: Int!, overdueDays: Int!, bucket: OpenItemBucket! } + type Ageing { notDue: Int!, d1to30: Int!, d31to60: Int!, d61to90: Int!, d90plus: Int! } + type CustomerStatement { + customer: Customer! + openingPaise: Int! + rows: [StatementRow!]! + totalDebitPaise: Int! + totalCreditPaise: Int! + closingPaise: Int! + open: [OpenItem!]! + ageing: Ageing! + openTotalPaise: Int! + availableCreditPaise: Int + } + type ReceivablesRow { customer: Customer!, balancePaise: Int!, overduePaise: Int!, ageing: Ageing! } + type ReceivablesSummary { rows: [ReceivablesRow!]!, totalPaise: Int! } + + type CreditNoteLine { itemIndex: Int!, name: String!, code: String, hsnSac: String, taxability: Taxability, taxPercent: Float!, taxablePaise: Int!, taxPaise: Int!, cgstPaise: Int!, sgstPaise: Int!, igstPaise: Int! } + type CreditNote { + id: ID! + noteNo: String! + date: DateTime! + reason: String! + invoice: Invoice! + customer: Customer + lines: [CreditNoteLine!]! + taxablePaise: Int! + taxPaise: Int! + cgstPaise: Int! + sgstPaise: Int! + igstPaise: Int! + roundOffPaise: Int! + totalPaise: Int! + placeOfSupply: String + buyerGstin: String + status: String! + } + type CreditNoteResult { creditNote: CreditNote!, invoice: Invoice!, "Refund with refundPayment." refundablePaise: Int! } + + # ---------------------------------------------------------------- settlements + enum PartnerKind { collection_centre franchise } + enum AgreementKind { revenue_share fixed_rate } + enum SettlementStatus { draft accrued paid cancelled } + + type PartnerTds { section: String!, rateBp: Int! } + type Partner { + id: ID! + code: String! + name: String! + kind: PartnerKind! + branch: Branch! + legalName: String + pan: String + gstin: String + stateCode: String + phone: String + email: String + address: Address + ledger: Ledger! + "Overrides the default TDS (verify with CA)." + tds: PartnerTds + active: Boolean! + agreements: [PartnerAgreement!]! + } + + type ShareRate { code: String!, sharePaise: Int! } + type PartnerAgreement { id: ID!, kind: AgreementKind!, shareBp: Int, rates: [ShareRate!], defaultSharePaise: Int, categories: [ItemCategory!], effectiveFrom: DateTime!, effectiveTo: DateTime, notes: String } + + type SettlementLine { invoiceId: ID!, invoiceNo: String!, day: Day!, code: String, name: String!, netPaise: Int!, sharePaise: Int!, agreementId: ID!, creditNoteId: ID } + type SettlementDeduction { label: String!, amountPaise: Int!, ledgerId: ID } + type Tds { section: String, rateBp: Int!, basePaise: Int!, tdsPaise: Int! } + + "A settlement computed but not saved." + type SettlementPreview { + partner: Partner! + period: String! + from: DateTime! + to: DateTime! + lines: [SettlementLine!]! + grossPaise: Int! + sharePaise: Int! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + tds: Tds! + netPaise: Int! + "Invoice numbers with no agreement in force on their date." + uncoveredInvoices: [String!]! + } + + type Settlement { + id: ID! + partner: Partner! + branch: Branch! + period: String! + from: DateTime! + to: DateTime! + lines: [SettlementLine!]! + grossPaise: Int! + sharePaise: Int! + deductions: [SettlementDeduction!]! + deductionsPaise: Int! + tdsSection: String + tdsRateBp: Int! + tdsBasePaise: Int! + tdsPaise: Int! + netPaise: Int! + status: SettlementStatus! + accrualVoucher: Voucher + payoutVoucher: Voucher + paidAt: DateTime + createdAt: DateTime! + } + + # ---------------------------------------------------------------- cash and bank + enum BankLineStatus { unmatched matched ignored } + + type CashDenomination { valuePaise: Int!, count: Int! } + type CashClosing { id: ID!, branch: Branch!, day: Day!, bookPaise: Int!, countedPaise: Int!, "counted − book; negative = short." differencePaise: Int!, denominations: [CashDenomination!], voucher: Voucher, notes: String, createdAt: DateTime! } + + type BankStatement { id: ID!, branch: Branch!, ledger: Ledger!, fileName: String, from: DateTime, to: DateTime, openingPaise: Int, closingPaise: Int, lineCount: Int!, createdAt: DateTime! } + type BankSettlementMatch { ledgerId: ID!, day: Day!, grossPaise: Int!, feePaise: Int! } + type BankLine { id: ID!, statementId: ID!, date: DateTime!, description: String!, reference: String, debitPaise: Int!, creditPaise: Int!, balancePaise: Int, status: BankLineStatus!, matchKind: String, voucherIds: [ID!]!, settlement: BankSettlementMatch, matchedAt: DateTime } + type BankImportResult { statement: BankStatement!, added: Int!, skipped: Int! } + type ReconcileResult { matched: Int!, settlements: Int!, unmatched: Int! } + type LedgerEntryRow { id: ID!, voucherId: ID!, voucherType: VoucherType!, voucherNo: String!, date: DateTime!, debitPaise: Int!, creditPaise: Int!, invoiceId: ID } + type ReconciledTotals { lines: Int!, amountPaise: Int! } + type BankReconciliation { + bookPaise: Int! + bankPaise: Int + paymentsNotPresentedPaise: Int! + depositsNotCreditedPaise: Int! + bankCreditsNotInBooksPaise: Int! + bankDebitsNotInBooksPaise: Int! + expectedBankPaise: Int! + differencePaise: Int + reconciled: ReconciledTotals! + unmatchedLines: [BankLine!]! + unreconciledEntries: [LedgerEntryRow!]! + } + + # ---------------------------------------------------------------- periods + type PeriodLock { id: ID!, scope: String!, branch: Branch, lockedUpTo: DateTime, reason: String, updatedAt: DateTime! } + type YearEndResult { vouchers: [Voucher!]!, netProfitPaise: Int! } + + # ---------------------------------------------------------------- exports + type CsvFile { fileName: String!, mimeType: String!, content: String! } + + extend type Query { + taxRules: [TaxRule!]! + financeConfig: FinanceConfig! + "Items the lab's CA must confirm before these values are relied on." + verifyWithCA: [String!]! + gstr1(from: DateTime!, to: DateTime!, gstin: String): Gstr1! + gstr3b(from: DateTime!, to: DateTime!, gstin: String): Gstr3b! + + customers(activeOnly: Boolean = true): [Customer!]! + customer(id: ID!): Customer + customerStatement(customerId: ID!, from: DateTime!, to: DateTime!): CustomerStatement! + receivablesSummary(asOf: DateTime!): ReceivablesSummary! + creditNote(id: ID!): CreditNote + creditNotes(invoiceId: ID, customerId: ID, limit: Int = 50, offset: Int = 0): [CreditNote!]! + + partners(branchId: ID): [Partner!]! + partner(id: ID!): Partner + "Computes a month's settlement without saving it. period: YYYY-MM." + settlementPreview(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): SettlementPreview! + settlements(partnerId: ID, status: SettlementStatus, branchId: ID, limit: Int = 50, offset: Int = 0): [Settlement!]! + settlement(id: ID!): Settlement + + cashClosings(branchId: ID!, limit: Int = 31, offset: Int = 0): [CashClosing!]! + bankStatements(ledgerId: ID, limit: Int = 50, offset: Int = 0): [BankStatement!]! + bankLines(ledgerId: ID, statementId: ID, status: BankLineStatus, limit: Int = 100, offset: Int = 0): [BankLine!]! + bankReconciliation(ledgerId: ID!, asOf: DateTime!): BankReconciliation! + + lockDates: [PeriodLock!]! + + exportDayBook(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportLedger(ledgerId: ID!, from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportTrialBalance(asOf: DateTime, branchId: ID, bom: Boolean = false): CsvFile! + exportProfitAndLoss(from: DateTime!, to: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportBalanceSheet(asOf: DateTime!, branchId: ID, bom: Boolean = false): CsvFile! + exportSettlement(settlementId: ID!, bom: Boolean = false): CsvFile! + "One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs." + exportGstr1(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean = false): [CsvFile!]! + exportGstr3b(from: DateTime!, to: DateTime!, gstin: String, bom: Boolean = false): CsvFile! + } + + input TaxRuleInput { code: String, category: ItemCategory, hsnSac: String!, taxability: Taxability!, ratePercent: Float!, effectiveFrom: Day!, note: String } + + input CustomerInput { code: String!, name: String!, kind: CustomerKind!, legalName: String, gstin: String, stateCode: String, pan: String, phone: String, email: String, address: AddressInput, creditLimitPaise: Int, creditDays: Int } + input CustomerPatch { name: String, kind: CustomerKind, legalName: String, gstin: String, stateCode: String, pan: String, phone: String, email: String, address: AddressInput, "null removes the limit." creditLimitPaise: Int, creditDays: Int, active: Boolean } + input CustomerInvoiceInput { + branchId: ID! + patientId: ID! + customerId: ID! + bookingId: ID + items: [InvoiceItemInput!]! + billDiscountPaise: Int = 0 + payments: [PaymentInput!] + "State code override." + placeOfSupply: String + applyTaxRules: Boolean = true + } + input CreditNoteLineInput { itemIndex: Int!, taxablePaise: Int } + input CreditNoteInput { invoiceId: ID!, reason: String!, "Default: everything not yet credited." lines: [CreditNoteLineInput!], date: DateTime } + + input PartnerTdsInput { section: String!, rateBp: Int! } + input PartnerInput { code: String!, name: String!, kind: PartnerKind!, branchId: ID!, legalName: String, pan: String, gstin: String, stateCode: String, phone: String, email: String, address: AddressInput, tds: PartnerTdsInput } + input PartnerPatch { name: String, kind: PartnerKind, legalName: String, pan: String, gstin: String, stateCode: String, phone: String, email: String, address: AddressInput, "null = default TDS." tds: PartnerTdsInput, active: Boolean } + input ShareRateInput { code: String!, sharePaise: Int! } + input AgreementInput { partnerId: ID!, kind: AgreementKind!, shareBp: Int, rates: [ShareRateInput!], defaultSharePaise: Int, categories: [ItemCategory!], effectiveFrom: Day!, effectiveTo: Day, notes: String } + input SettlementDeductionInput { label: String!, amountPaise: Int!, ledgerId: ID } + input PaySettlementInput { settlementId: ID!, mode: PaymentMode!, date: DateTime, reference: String } + + input CashDenominationInput { valuePaise: Int!, count: Int! } + input CloseCashDayInput { branchId: ID!, day: Day!, countedPaise: Int!, denominations: [CashDenominationInput!], notes: String } + input BankImportInput { branchId: ID!, ledgerId: ID!, "The bank's CSV export, as text." csv: String!, fileName: String } + enum BankLineManualStatus { ignored unmatched } + + input LockDateInput { "Omit for the whole organisation." branchId: ID, "Last locked IST day; null removes the lock." upTo: Day, reason: String } + + extend type Mutation { + addTaxRule(input: TaxRuleInput!): TaxRule! + "finance-kit's starting rules. Every rate must be verified with the lab's CA." + seedTaxRules: [TaxRule!]! + + createCustomer(input: CustomerInput!): Customer! + updateCustomer(id: ID!, input: CustomerPatch!): Customer! + issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice! + issueCreditNote(input: CreditNoteInput!): CreditNoteResult! + + createPartner(input: PartnerInput!): Partner! + updatePartner(id: ID!, input: PartnerPatch!): Partner! + addAgreement(input: AgreementInput!): PartnerAgreement! + createSettlement(partnerId: ID!, period: String!, deductions: [SettlementDeductionInput!]): Settlement! + accrueSettlement(id: ID!): Settlement! + paySettlement(input: PaySettlementInput!): Settlement! + cancelSettlement(id: ID!): Settlement! + + closeCashDay(input: CloseCashDayInput!): CashClosing! + importBankStatement(input: BankImportInput!): BankImportResult! + reconcileBank(ledgerId: ID!, statementId: ID): ReconcileResult! + matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine! + setBankLineStatus(lineId: ID!, status: BankLineManualStatus!): BankLine! + + setLockDate(input: LockDateInput!): PeriodLock! + "Closing vouchers on 31 March and a lock. fy: 2026-27." + closeFinancialYear(fy: String!): YearEndResult! + } +`; diff --git a/src/schema/lab.graphql.ts b/src/schema/lab.graphql.ts new file mode 100644 index 0000000..398f776 --- /dev/null +++ b/src/schema/lab.graphql.ts @@ -0,0 +1,127 @@ +/** Patients, doctors, bookings, samples and reports (db-kit workflows). */ +export const labTypeDefs = /* GraphQL */ ` + enum BookingStatus { booked sample_collected in_process completed cancelled } + enum CollectionType { walk_in home } + enum SampleStatus { pending collected received rejected } + enum ReportStatus { draft entered verified released amended } + enum ResultFlag { L H A NONE } + + type Patient { + id: ID! + uhid: String! + title: String + name: String! + sex: Sex! + dob: DateTime + ageYears: Int + phone: String + email: String + address: Address + branch: Branch + createdAt: DateTime! + bookings(limit: Int = 20): [Booking!]! + invoices(limit: Int = 20): [Invoice!]! + } + + type Doctor { id: ID!, name: String!, qualification: String, phone: String, clinic: String, active: Boolean! } + + type BookingTest { testCode: String!, name: String!, pricePaise: Int!, sampleType: String, "The catalog entry, when the code is in catalog-kit." catalog: CatalogTest } + type SampleCollection { type: CollectionType!, scheduledAt: DateTime, address: Address } + + type Booking { + id: ID! + bookingNo: String! + status: BookingStatus! + patient: Patient! + doctor: Doctor + branch: Branch! + tests: [BookingTest!]! + sampleCollection: SampleCollection! + samples: [Sample!]! + reports: [Report!]! + invoice: Invoice + notes: String + createdAt: DateTime! + } + + type Sample { + id: ID! + barcode: String! + sampleType: String! + status: SampleStatus! + collectedAt: DateTime + receivedAt: DateTime + rejectReason: String + booking: Booking! + } + + type ResultValue { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } + + type Report { + id: ID! + testCode: String! + testName: String! + status: ReportStatus! + results: [ResultValue!]! + version: Int! + verifiedAt: DateTime + releasedAt: DateTime + patient: Patient! + booking: Booking! + updatedAt: DateTime! + } + + type CreateBookingResult { booking: Booking!, samples: [Sample!]! } + + extend type Query { + patient(id: ID!): Patient + "Name, phone or UHID." + patients(search: String!, limit: Int = 20): [Patient!]! + doctors(activeOnly: Boolean = true): [Doctor!]! + booking(id: ID!): Booking + bookings(branchId: ID, status: BookingStatus, patientId: ID, limit: Int = 50, offset: Int = 0): [Booking!]! + sample(id: ID!): Sample + sampleByBarcode(barcode: String!): Sample + report(id: ID!): Report + } + + input RegisterPatientInput { branchId: ID!, title: String, name: String!, sex: Sex!, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } + input UpdatePatientInput { title: String, name: String, sex: Sex, dob: DateTime, ageYears: Int, phone: String, email: String, address: AddressInput } + input DoctorInput { name: String!, qualification: String, phone: String, clinic: String, active: Boolean = true } + input DoctorPatch { name: String, qualification: String, phone: String, clinic: String, active: Boolean } + + "A test by catalog code: name, price and sample type come from catalog-kit unless given." + input BookingTestInput { testCode: String!, name: String, pricePaise: Int, sampleType: String } + + input CreateBookingInput { + branchId: ID! + patientId: ID! + doctorId: ID + tests: [BookingTestInput!]! + collectionType: CollectionType = walk_in + scheduledAt: DateTime + notes: String + } + + "Unit, range and flag are filled from catalog-kit when omitted and the test is in the catalog." + input ResultInput { parameter: String!, value: String!, unit: String, flag: ResultFlag, range: String } + + extend type Mutation { + registerPatient(input: RegisterPatientInput!): Patient! + updatePatient(id: ID!, input: UpdatePatientInput!): Patient! + createDoctor(input: DoctorInput!): Doctor! + updateDoctor(id: ID!, input: DoctorPatch!): Doctor! + + createBooking(input: CreateBookingInput!): CreateBookingResult! + cancelBooking(id: ID!): Booking! + collectSample(id: ID!): Sample! + receiveSample(id: ID!): Sample! + rejectSample(id: ID!, reason: String!): Sample! + + createReport(bookingId: ID!, testCode: String!, testName: String): Report! + saveResults(reportId: ID!, results: [ResultInput!]!): Report! + verifyReport(id: ID!): Report! + releaseReport(id: ID!): Report! + amendReport(id: ID!): Report! + } +`; diff --git a/src/schema/organization.graphql.ts b/src/schema/organization.graphql.ts new file mode 100644 index 0000000..dc5cd23 --- /dev/null +++ b/src/schema/organization.graphql.ts @@ -0,0 +1,37 @@ +/** The organisation, its branches and the audit trail. */ +export const organizationTypeDefs = /* GraphQL */ ` + enum BranchKind { lab collection_center } + + type Organization { + id: ID! + name: String! + code: String! + legalName: String + gstin: String + phone: String + email: String + address: Address + branches: [Branch!]! + } + + type Branch { id: ID!, code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, stateCode: String, address: Address, active: Boolean! } + + type AuditEntry { id: ID!, action: String!, entity: String, entityId: ID, branchId: ID, user: User, ip: String, data: JSON, at: DateTime! } + + extend type Query { + organization: Organization! + branches: [Branch!]! + branch(id: ID!): Branch + auditLog(entity: String, entityId: ID, limit: Int = 50, offset: Int = 0): [AuditEntry!]! + } + + input OrganizationInput { name: String, legalName: String, gstin: String, phone: String, email: String, address: AddressInput } + input BranchInput { code: String!, name: String!, kind: BranchKind!, phone: String, gstin: String, stateCode: String, address: AddressInput, active: Boolean } + input BranchPatch { name: String, kind: BranchKind, phone: String, gstin: String, stateCode: String, address: AddressInput, active: Boolean } + + extend type Mutation { + updateOrganization(input: OrganizationInput!): Organization! + createBranch(input: BranchInput!): Branch! + updateBranch(id: ID!, input: BranchPatch!): Branch! + } +`; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b581255 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,65 @@ +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; +import { NoSchemaIntrospectionCustomRule } from "graphql"; +import { createYoga, type Plugin } from "graphql-yoga"; +import type { Container } from "./container"; +import { createContext, type RequestContext } from "./graphql/context"; +import { depthLimit } from "./graphql/limits"; +import { buildSchema } from "./graphql/schema"; + +type ServerContext = { req: FastifyRequest; reply: FastifyReply }; + +/** Validation rules on every operation: depth limit, and no introspection unless enabled. */ +function rules(introspection: boolean): Plugin { + return { + onValidate({ addValidationRule }) { + addValidationRule(depthLimit(12)); + if (!introspection) addValidationRule(NoSchemaIntrospectionCustomRule); + }, + }; +} + +/** + * The HTTP server: ONE GraphQL endpoint (Yoga on Fastify) for everything, plus GET /health for load + * balancers. No other routes: PDFs and CSVs travel inside GraphQL responses. + */ +export async function buildServer(container: Container): Promise<FastifyInstance> { + const { config } = container; + const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: config.bodyLimit, trustProxy: config.trustProxy }); + + const yoga = createYoga<ServerContext, RequestContext>({ + schema: buildSchema(), + graphqlEndpoint: config.graphqlPath, + graphiql: config.graphiql ? { title: "MGPL Diagnostics API" } : false, + landingPage: false, + // domain errors carry their own codes (src/graphql/errors.ts); everything else becomes "Unexpected error" + maskedErrors: true, + cors: { origin: config.corsOrigins.includes("*") ? "*" : config.corsOrigins, methods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["content-type", "authorization"] }, + logging: config.env === "test" ? false : "warn", + plugins: [rules(config.introspection)], + context: ({ request, req }) => createContext(container, { headers: request.headers, ip: req.ip }), + }); + + app.route({ + url: config.graphqlPath, + method: ["GET", "POST", "OPTIONS"], + handler: async (req, reply) => { + const response = await yoga.handleNodeRequestAndResponse(req, reply, { req, reply }); + response.headers.forEach((value, key) => void reply.header(key, value)); + reply.status(response.status); + reply.send(response.body); + return reply; + }, + }); + + app.get("/health", async (_req, reply) => { + try { + await container.db.rawRepos.orgs.count(); + return { ok: true }; + } catch { + return reply.status(503).send({ ok: false }); + } + }); + + app.addHook("onClose", async () => container.close()); + return app; +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..408714d --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,152 @@ +import { PERMISSIONS, SYSTEM_ROLES, type Auth, type PublicUser } from "@mgpl-diagnostics/auth-kit"; +import { ConflictError, createUser, NotFoundError, ValidationError, type Id, type Repositories, type Role, type RoleAssignment, type User } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +/** Sends password-reset links. Plug in email/SMS in the container; the API never returns reset tokens. */ +export interface Mailer { + passwordReset(to: { email: string; name: string }, link: string, expiresAt: Date): Promise<void>; +} + +const publicUser = ({ passwordHash: _p, ...u }: User): Omit<User, "passwordHash"> => u; + +/** Sign-in, sessions and passwords (auth-kit), plus users and roles (db-kit storage). */ +export class AuthService { + constructor( + private readonly repos: Repositories, + private readonly auth: Auth, + private readonly opts: { mailer?: Mailer; resetUrl: string | null }, + ) {} + + // ---------------------------------------------------------------- sessions + + login(input: { email: string; password: string; orgCode?: string | null; ip?: string; userAgent?: string }) { + return this.auth.login({ ...input, orgCode: input.orgCode ?? undefined }); + } + + logout(token: string) { + return this.auth.logout(token); + } + + /** Ends every other session of the user (keeps the current one). */ + logoutOthers(actor: Actor, currentToken: string) { + return this.auth.logoutAll(actor.userId, currentToken); + } + + sessions(actor: Actor) { + return this.auth.sessionsOf(actor.userId); + } + + revokeSession(actor: Actor, sessionId: string) { + return this.auth.revokeSession(actor.userId, sessionId); + } + + changePassword(actor: Actor, currentPassword: string, newPassword: string, currentToken?: string) { + return this.auth.changePassword({ userId: actor.userId, currentPassword, newPassword, currentToken }); + } + + /** Always resolves (never reveals whether the email exists). The link goes out through the mailer. */ + async requestPasswordReset(input: { email: string; orgCode?: string | null; ip?: string }): Promise<void> { + const r = await this.auth.requestPasswordReset({ email: input.email, orgCode: input.orgCode ?? undefined, ip: input.ip }); + if (!r || !this.opts.mailer) return; + const base = this.opts.resetUrl ?? "/reset-password"; + await this.opts.mailer.passwordReset({ email: r.user.email, name: r.user.name }, `${base}?token=${encodeURIComponent(r.token)}`, r.expiresAt); + } + + resetPassword(token: string, newPassword: string): Promise<PublicUser> { + return this.auth.resetPassword({ token, newPassword }); + } + + // ---------------------------------------------------------------- users + + async me(actor: Actor) { + const u = await this.repos.users.get(actor.userId); + if (!u) throw new NotFoundError("User", actor.userId); + return publicUser(u); + } + + async users(actor: Actor) { + return (await this.repos.users.find({ orgId: actor.orgId }, { sort: { field: "name" } })).map(publicUser); + } + + async user(actor: Actor, id: Id) { + const u = await this.repos.users.get(id); + return u && u.orgId === actor.orgId ? publicUser(u) : null; + } + + async createUser(actor: Actor, input: { name: string; email: string; phone?: string | null; password: string; roles: RoleAssignment[] }) { + await this.assertRoles(actor, input.roles); + const passwordHash = await this.auth.hashNewPassword(input.password, { email: input.email, name: input.name }); + const u = await createUser(this.repos, { orgId: actor.orgId, name: input.name, email: input.email, phone: input.phone ?? undefined, roles: input.roles, passwordHash }, actor.userId); + return publicUser(u); + } + + async setUserRoles(actor: Actor, userId: Id, roles: RoleAssignment[]) { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + await this.assertRoles(actor, roles); + const u = await this.repos.users.update(userId, { roles }); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "user.roles", entity: "user", entityId: userId }); + return publicUser(u!); + } + + /** Disabling ends every session at once (auth-kit re-reads the user on each request). */ + async setUserStatus(actor: Actor, userId: Id, status: "active" | "disabled") { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + if (userId === actor.userId && status === "disabled") throw new ConflictError("You cannot disable your own account"); + const u = await this.repos.users.update(userId, { status }); + if (status === "disabled") await this.auth.logoutAll(userId); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: `user.${status === "disabled" ? "disable" : "enable"}`, entity: "user", entityId: userId }); + return publicUser(u!); + } + + async setUserPassword(actor: Actor, userId: Id, newPassword: string) { + mustOwn(await this.repos.users.get(userId), actor.orgId, "User", userId); + await this.auth.setPassword(userId, newPassword, actor.userId); + } + + // ---------------------------------------------------------------- roles + + roles(actor: Actor): Promise<Role[]> { + return this.repos.roles.find({ orgId: actor.orgId }, { sort: { field: "key" } }); + } + + permissions(): readonly string[] { + return PERMISSIONS; + } + + /** Creates or replaces a custom role. System roles (seeded from auth-kit) cannot be changed. */ + async upsertRole(actor: Actor, input: { key: string; name: string; permissions: string[] }) { + const key = input.key.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{1,40}$/.test(key)) throw new ValidationError("Role key: lower-case letters, digits and _"); + for (const p of input.permissions) { + if (!/^([a-z_]+|\*):([a-z_]+|\*)$/.test(p)) throw new ValidationError(`Invalid permission "${p}"`); + } + const existing = await this.repos.roles.findOne({ orgId: actor.orgId, key }); + if (existing?.system) throw new ConflictError(`Role ${key} is a system role`); + const role = existing + ? await this.repos.roles.update(existing.id, { name: input.name, permissions: input.permissions }) + : await this.repos.roles.create({ orgId: actor.orgId, key, name: input.name, permissions: input.permissions, system: false }); + this.auth.invalidateRoles(actor.orgId); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "role.upsert", entity: "role", entityId: role!.id }); + return role!; + } + + /** Seeds auth-kit's SYSTEM_ROLES for an organisation that lacks some (idempotent). */ + async ensureSystemRoles(orgId: Id) { + for (const r of SYSTEM_ROLES) { + const existing = await this.repos.roles.findOne({ orgId, key: r.key }); + if (existing) await this.repos.roles.update(existing.id, { name: r.name, permissions: r.permissions, system: true }); + else await this.repos.roles.create({ orgId, key: r.key, name: r.name, permissions: r.permissions, system: true }); + } + this.auth.invalidateRoles(orgId); + } + + private async assertRoles(actor: Actor, roles: RoleAssignment[]) { + if (!roles.length) throw new ValidationError("Give the user at least one role"); + const keys = new Set((await this.roles(actor)).map((r) => r.key)); + for (const r of roles) { + if (!keys.has(r.role)) throw new ValidationError(`Unknown role "${r.role}"`); + if (r.branchId) mustOwn(await this.repos.branches.get(r.branchId), actor.orgId, "Branch", r.branchId); + } + } +} diff --git a/src/services/billing.service.ts b/src/services/billing.service.ts new file mode 100644 index 0000000..be9e9be --- /dev/null +++ b/src/services/billing.service.ts @@ -0,0 +1,103 @@ +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { + cancelInvoice, issueInvoice, recordPayment, ValidationError, + type Id, type Invoice, type InvoiceStatus, type ItemCategory, type ItemInput, type PaymentMode, type Repositories, type Taxability, +} from "@mgpl-diagnostics/db-kit"; +import { applyTaxRules } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, scopedFind } from "./util"; + +export interface InvoiceItemInput { + code?: string | null; + name?: string | null; + category?: ItemCategory | null; + qty?: number | null; + ratePaise?: number | null; + discountPaise?: number | null; + taxPercent?: number | null; + hsnSac?: string | null; + taxability?: Taxability | null; +} + +export interface PaymentInput { + amountPaise: number; + mode: PaymentMode; + reference?: string | null; +} + +export interface IssueInvoiceInput { + branchId: Id; + patientId: Id; + bookingId?: Id | null; + items: InvoiceItemInput[]; + billDiscountPaise?: number | null; + payments?: PaymentInput[] | null; + /** Fill GST (HSN/SAC, taxability, rate) from finance-kit's tax rules. */ + applyTaxRules?: boolean | null; +} + +/** An invoice line; a catalog-kit test code alone is enough (name and MRP from the catalog). */ +export function invoiceItem(i: InvoiceItemInput): ItemInput { + const cat = i.code && (i.name == null || i.ratePaise == null) ? getTestByCode(i.code) : undefined; + const name = i.name ?? cat?.name; + const ratePaise = i.ratePaise ?? (cat?.mrp != null ? cat.mrp * 100 : undefined); + if (!name || ratePaise == null) throw new ValidationError(`Item ${i.code ?? "(no code)"} is not in the catalog; send its name and ratePaise`); + return clean({ + code: i.code, name, category: i.category ?? "test", qty: i.qty ?? 1, ratePaise, discountPaise: i.discountPaise ?? 0, + taxPercent: i.taxPercent ?? 0, hsnSac: i.hsnSac, taxability: i.taxability, + }) as ItemInput; +} + +const paymentOf = (p: PaymentInput) => clean({ amountPaise: p.amountPaise, mode: p.mode, reference: p.reference }) as { amountPaise: number; mode: PaymentMode; reference?: string }; + +/** Patient invoices and payments (db-kit workflows; posting to the books happens inside them). */ +export class BillingService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.invoices.get(id), actor.orgId, "Invoice", id); + } + + list(actor: Actor, q: { scope: "all" | Id[]; status?: InvoiceStatus | null; patientId?: Id | null; customerId?: Id | null; limit: number; offset: number }) { + return scopedFind(this.repos.invoices, clean({ orgId: actor.orgId, status: q.status, patientId: q.patientId, customerId: q.customerId }), q.scope, q.limit, q.offset, "createdAt"); + } + + payments(invoiceId: Id) { + return this.repos.payments.find({ invoiceId }, { sort: { field: "receivedAt" } }); + } + + vouchers(invoiceId: Id) { + return this.repos.vouchers.find({ invoiceId } as never, { sort: { field: "date" } }); + } + + creditNotes(invoiceId: Id) { + return this.repos.creditNotes.find({ invoiceId } as never, { sort: { field: "date" } }); + } + + async issue(actor: Actor, input: IssueInvoiceInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + mustOwn(await this.repos.patients.get(input.patientId), actor.orgId, "Patient", input.patientId); + if (input.bookingId) mustOwn(await this.repos.bookings.get(input.bookingId), actor.orgId, "Booking", input.bookingId); + let items = input.items.map(invoiceItem); + if (input.applyTaxRules) items = await applyTaxRules(this.repos, actor.orgId, items); + const { invoice } = await issueInvoice(this.repos, { + orgId: actor.orgId, branchId: input.branchId, patientId: input.patientId, bookingId: input.bookingId ?? null, + items, billDiscountPaise: input.billDiscountPaise ?? 0, payments: input.payments?.map(paymentOf), + }, actor.userId); + return invoice; + } + + async pay(actor: Actor, invoice: Invoice, payment: PaymentInput, kind: "payment" | "refund") { + return (await recordPayment(this.repos, { invoiceId: invoice.id, ...paymentOf(payment), kind }, actor.userId)).invoice; + } + + cancel(actor: Actor, invoice: Invoice, reason: string) { + if (!reason.trim()) throw new ValidationError("Give a reason"); + return cancelInvoice(this.repos, invoice.id, reason.trim(), actor.userId); + } + + /** Is any discount on this request? (Discounts need the invoice:discount permission.) */ + static discounted(input: Pick<IssueInvoiceInput, "billDiscountPaise" | "items">) { + return (input.billDiscountPaise ?? 0) > 0 || input.items.some((i) => (i.discountPaise ?? 0) > 0); + } +} diff --git a/src/services/booking.service.ts b/src/services/booking.service.ts new file mode 100644 index 0000000..0838dd0 --- /dev/null +++ b/src/services/booking.service.ts @@ -0,0 +1,113 @@ +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { ConflictError, createBooking, ValidationError, type BookingStatus, type BookingTest, type CollectionType, type Id, type Repositories, type Sample } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, owned, scopedFind } from "./util"; + +export interface BookingTestInput { + testCode: string; + name?: string | null; + pricePaise?: number | null; + sampleType?: string | null; +} + +export interface CreateBookingInput { + branchId: Id; + patientId: Id; + doctorId?: Id | null; + tests: BookingTestInput[]; + collectionType?: CollectionType | null; + scheduledAt?: Date | null; + notes?: string | null; +} + +/** + * A test line from catalog-kit when only the code is given: name, price (MRP, rupees → paise) and + * sample type come from the catalog; anything sent explicitly wins. + */ +export function bookingTest(t: BookingTestInput): BookingTest { + const cat = getTestByCode(t.testCode); + const name = t.name ?? cat?.name; + const pricePaise = t.pricePaise ?? (cat?.mrp != null ? cat.mrp * 100 : undefined); + if (!name || pricePaise == null) throw new ValidationError(`Test ${t.testCode} is not in the catalog; send its name and pricePaise`); + if (!Number.isInteger(pricePaise) || pricePaise < 0) throw new ValidationError(`Test ${t.testCode}: pricePaise must be a whole number >= 0`); + return clean({ testCode: t.testCode, name, pricePaise, sampleType: t.sampleType ?? cat?.sampleType ?? undefined }) as BookingTest; +} + +/** Bookings and their samples (barcodes). */ +export class BookingService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.bookings.get(id), actor.orgId, "Booking", id); + } + + list(actor: Actor, q: { scope: "all" | Id[]; status?: BookingStatus | null; patientId?: Id | null; limit: number; offset: number }) { + return scopedFind(this.repos.bookings, clean({ orgId: actor.orgId, status: q.status, patientId: q.patientId }), q.scope, q.limit, q.offset, "createdAt"); + } + + async create(actor: Actor, input: CreateBookingInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + mustOwn(await this.repos.patients.get(input.patientId), actor.orgId, "Patient", input.patientId); + if (input.doctorId) mustOwn(await this.repos.doctors.get(input.doctorId), actor.orgId, "Doctor", input.doctorId); + return createBooking(this.repos, { + orgId: actor.orgId, + branchId: input.branchId, + patientId: input.patientId, + doctorId: input.doctorId ?? null, + tests: input.tests.map(bookingTest), + sampleCollection: { type: input.collectionType ?? "walk_in", scheduledAt: input.scheduledAt ?? null }, + notes: input.notes ?? undefined, + }, actor.userId); + } + + async cancel(actor: Actor, id: Id) { + const b = await this.get(actor, id); + if (b.status === "cancelled") return b; + if (b.invoiceId) { + const inv = await this.repos.invoices.get(b.invoiceId); + if (inv && inv.status !== "cancelled") throw new ConflictError("Cancel the booking's invoice first"); + } + const out = await this.repos.bookings.update(id, { status: "cancelled" }); + await this.repos.audit.log({ orgId: b.orgId, branchId: b.branchId, userId: actor.userId, action: "booking.cancel", entity: "booking", entityId: id }); + return out!; + } + + // ---------------------------------------------------------------- samples + + samplesOf(bookingId: Id) { + return this.repos.samples.find({ bookingId }, { sort: { field: "barcode" } }); + } + + async sample(actor: Actor, id: Id) { + return mustOwn(await this.repos.samples.get(id), actor.orgId, "Sample", id); + } + + async sampleByBarcode(actor: Actor, barcode: string) { + return owned(await this.repos.samples.findOne({ orgId: actor.orgId, barcode: barcode.trim() }), actor.orgId); + } + + async collectSample(actor: Actor, s: Sample) { + if (s.status !== "pending") throw new ConflictError(`Sample is ${s.status}`); + const out = await this.repos.samples.update(s.id, { status: "collected", collectedAt: new Date(), collectedBy: actor.userId }); + const b = await this.repos.bookings.get(s.bookingId); + if (b?.status === "booked") await this.repos.bookings.update(b.id, { status: "sample_collected" }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.collect", entity: "sample", entityId: s.id }); + return out!; + } + + async receiveSample(actor: Actor, s: Sample) { + if (s.status !== "collected") throw new ConflictError(`Sample is ${s.status}`); + const out = await this.repos.samples.update(s.id, { status: "received", receivedAt: new Date() }); + await this.repos.bookings.update(s.bookingId, { status: "in_process" }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.receive", entity: "sample", entityId: s.id }); + return out!; + } + + async rejectSample(actor: Actor, s: Sample, reason: string) { + if (s.status === "rejected") throw new ConflictError("Sample is already rejected"); + if (!reason.trim()) throw new ValidationError("Give a reason"); + const out = await this.repos.samples.update(s.id, { status: "rejected", rejectReason: reason.trim() }); + await this.repos.audit.log({ orgId: s.orgId, branchId: s.branchId, userId: actor.userId, action: "sample.reject", entity: "sample", entityId: s.id }); + return out!; + } +} diff --git a/src/services/books.service.ts b/src/services/books.service.ts new file mode 100644 index 0000000..db952e3 --- /dev/null +++ b/src/services/books.service.ts @@ -0,0 +1,110 @@ +import { NotFoundError, type Id, type Repositories, type VoucherEntry, type VoucherType } from "@mgpl-diagnostics/db-kit"; +import { seedFinanceLedgers } from "@mgpl-diagnostics/finance-kit"; +import { + balanceSheet, cancelVoucher, cashBook, dayBook, gstSummary, ledgerBalance, ledgerStatement, outstandingReceivables, postVoucher, profitAndLoss, trialBalance, +} from "@mgpl-diagnostics/tally-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export interface PostVoucherInput { + branchId: Id; + type: VoucherType; + date?: Date | null; + narration?: string | null; + entries: { ledgerId: Id; debitPaise?: number | null; creditPaise?: number | null; invoiceId?: Id | null }[]; +} + +/** + * The books, through tally-kit (our accounting engine): chart of accounts, ledgers, manual vouchers + * and the books reports. Automatic posting of invoices and payments happens inside db-kit's workflows + * through finance-kit's posting hook (set in the container). + */ +export class BooksService { + constructor(private readonly repos: Repositories) {} + + groups(actor: Actor) { + return this.repos.accountGroups.find({ orgId: actor.orgId }, { sort: { field: "name" } }); + } + + ledgers(actor: Actor) { + return this.repos.ledgers.find({ orgId: actor.orgId }, { sort: { field: "name" } }); + } + + async ledger(actor: Actor, id: Id) { + return mustOwn(await this.repos.ledgers.get(id), actor.orgId, "Ledger", id); + } + + /** The standard chart (28 groups, default ledgers) plus finance-kit's ledgers. Idempotent. */ + setupChartOfAccounts(actor: Actor) { + return seedFinanceLedgers(this.repos, actor.orgId); + } + + async createLedger(actor: Actor, input: { name: string; groupId: Id; openingPaise?: number | null; gstin?: string | null }) { + mustOwn(await this.repos.accountGroups.get(input.groupId), actor.orgId, "Account group", input.groupId); + const l = await this.repos.ledgers.create({ orgId: actor.orgId, name: input.name.trim(), groupId: input.groupId, openingPaise: input.openingPaise ?? 0, gstin: input.gstin ?? undefined, systemKey: null, active: true } as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "ledger.create", entity: "ledger", entityId: l.id }); + return l; + } + + async postVoucher(actor: Actor, input: PostVoucherInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + await this.assertOwnLedgers(actor, input.entries.map((e) => e.ledgerId)); // no posting into another organisation's books + for (const e of input.entries) if (e.invoiceId) mustOwn(await this.repos.invoices.get(e.invoiceId), actor.orgId, "Invoice", e.invoiceId); + const entries: VoucherEntry[] = input.entries.map((e) => ({ ledgerId: e.ledgerId, debitPaise: e.debitPaise ?? 0, creditPaise: e.creditPaise ?? 0, invoiceId: e.invoiceId ?? null })); + return postVoucher(this.repos, { orgId: actor.orgId, branchId: input.branchId, type: input.type, date: input.date ?? undefined, narration: input.narration ?? undefined, entries }, actor.userId); + } + + async voucher(actor: Actor, id: Id) { + return mustOwn(await this.repos.vouchers.get(id), actor.orgId, "Voucher", id); + } + + cancelVoucher(actor: Actor, id: Id) { + return cancelVoucher(this.repos, id, actor.userId); + } + + // ---------------------------------------------------------------- reports (tally-kit) + + balance(orgId: Id, ledgerId: Id, q: { asOf?: Date | null; branchId?: Id | null }) { + return ledgerBalance(this.repos, { orgId, ledgerId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + async ledgerStatement(actor: Actor, q: { ledgerId: Id; from: Date; to: Date; branchId?: Id | null }) { + await this.assertOwnLedgers(actor, [q.ledgerId]); + return ledgerStatement(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + cashBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return cashBook(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + trialBalance(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null }) { + return trialBalance(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + profitAndLoss(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return profitAndLoss(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + balanceSheet(actor: Actor, q: { asOf: Date; branchId?: Id | null }) { + return balanceSheet(this.repos, { orgId: actor.orgId, asOf: q.asOf, branchId: q.branchId ?? undefined }); + } + + dayBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; type?: VoucherType | null }) { + return dayBook(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, type: q.type ?? undefined }); + } + + outstanding(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null }) { + return outstandingReceivables(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined }); + } + + gstSummary(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null }) { + return gstSummary(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined }); + } + + private async assertOwnLedgers(actor: Actor, ids: Id[]) { + const unique = [...new Set(ids)]; + const found = await this.repos.ledgers.getMany(unique); + const bad = unique.find((id) => !found.some((l) => l.id === id && l.orgId === actor.orgId)); + if (bad) throw new NotFoundError("Ledger", bad); + } +} diff --git a/src/services/cash-bank.service.ts b/src/services/cash-bank.service.ts new file mode 100644 index 0000000..c56e0a8 --- /dev/null +++ b/src/services/cash-bank.service.ts @@ -0,0 +1,53 @@ +import type { BankLineStatus, CashDenomination, Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { bankReconciliation, closeCashDay, importBankStatement, matchBankLine, reconcileBank, setBankLineStatus } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +/** Day-end cash closing and bank reconciliation, through finance-kit. */ +export class CashBankService { + constructor(private readonly repos: Repositories) {} + + closeCashDay(actor: Actor, input: { branchId: Id; day: string; countedPaise: number; denominations?: CashDenomination[] | null; notes?: string | null }) { + return closeCashDay(this.repos, { ...(clean(input) as typeof input), orgId: actor.orgId } as never, actor.userId); + } + + cashClosings(actor: Actor, q: { branchId: Id; limit: number; offset: number }) { + return this.repos.cashClosings.find({ orgId: actor.orgId, branchId: q.branchId }, { limit: q.limit, offset: q.offset, sort: { field: "day", dir: "desc" } }); + } + + importBankStatement(actor: Actor, input: { branchId: Id; ledgerId: Id; csv: string; fileName?: string | null }) { + return importBankStatement(this.repos, { orgId: actor.orgId, branchId: input.branchId, ledgerId: input.ledgerId, csv: input.csv, fileName: input.fileName ?? undefined }, actor.userId); + } + + async bankStatement(actor: Actor, id: Id) { + return mustOwn(await this.repos.bankStatements.get(id), actor.orgId, "Bank statement", id); + } + + bankStatements(actor: Actor, q: { ledgerId?: Id | null; limit: number; offset: number }) { + return this.repos.bankStatements.find(clean({ orgId: actor.orgId, ledgerId: q.ledgerId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "createdAt", dir: "desc" } }); + } + + bankLines(actor: Actor, q: { ledgerId?: Id | null; statementId?: Id | null; status?: BankLineStatus | null; limit: number; offset: number }) { + return this.repos.bankLines.find(clean({ orgId: actor.orgId, ledgerId: q.ledgerId, statementId: q.statementId, status: q.status }) as never, { limit: q.limit, offset: q.offset, sort: { field: "date", dir: "desc" } }); + } + + async bankLine(actor: Actor, id: Id) { + return mustOwn(await this.repos.bankLines.get(id), actor.orgId, "Bank line", id); + } + + reconcile(actor: Actor, q: { ledgerId: Id; statementId?: Id | null }) { + return reconcileBank(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, statementId: q.statementId ?? undefined }, actor.userId); + } + + matchLine(actor: Actor, q: { lineId: Id; voucherIds: Id[] }) { + return matchBankLine(this.repos, { orgId: actor.orgId, ...q }, actor.userId); + } + + setLineStatus(actor: Actor, q: { lineId: Id; status: "ignored" | "unmatched" }) { + return setBankLineStatus(this.repos, { orgId: actor.orgId, ...q }, actor.userId); + } + + reconciliation(actor: Actor, q: { ledgerId: Id; asOf: Date }) { + return bankReconciliation(this.repos, { orgId: actor.orgId, ...q }); + } +} diff --git a/src/services/catalog.service.ts b/src/services/catalog.service.ts new file mode 100644 index 0000000..fb280bf --- /dev/null +++ b/src/services/catalog.service.ts @@ -0,0 +1,53 @@ +import * as catalog from "@mgpl-diagnostics/catalog-kit"; +import type { LabTest, Parameter } from "@mgpl-diagnostics/catalog-kit"; + +/** catalog-kit: the lab's tests, parameters, ranges, sample types and prices. Pure data, no tenant. */ +export class CatalogService { + search(query: string, opts: { limit: number; department?: string | null; sampleType?: string | null; fuzzy?: boolean | null }) { + return catalog.search(query, { limit: opts.limit, department: opts.department ?? undefined, sampleType: opts.sampleType ?? undefined, fuzzy: opts.fuzzy ?? undefined }); + } + + /** By numeric id or by code (case-insensitive). */ + test(key: { id?: number | null; code?: string | null }): LabTest | null { + if (key.id != null) return catalog.getTest(key.id) ?? null; + if (key.code) return catalog.getTestByCode(key.code) ?? null; + return null; + } + + tests(q: { department?: string | null; sampleType?: string | null; limit: number; offset: number }): LabTest[] { + let list: readonly LabTest[] = catalog.TESTS; + if (q.department) list = catalog.testsByDepartment(q.department); + if (q.sampleType) list = list.filter((t) => t.sampleType === q.sampleType || t.secondarySampleTypes?.includes(q.sampleType!)); + return list.slice(q.offset, q.offset + q.limit); + } + + count(): number { + return catalog.TESTS.length; + } + + departments(): readonly string[] { + return catalog.DEPARTMENTS; + } + + sampleTypes(): readonly string[] { + return catalog.SAMPLE_TYPES; + } + + isProfile(t: LabTest): boolean { + return catalog.isProfile(t); + } + + formatRange(p: Parameter, sex?: catalog.Sex | null): string { + return catalog.formatRange(p, sex ?? undefined); + } + + /** "L", "H" or "" for a numeric result against the parameter's range. */ + flag(p: Parameter, value: number, sex?: catalog.Sex | null): string { + return catalog.flagResult(p, value, sex ?? undefined); + } + + /** Price quote for test ids or codes: total in rupees, the tests found and the keys not found. */ + quote(items: (string | number)[]) { + return catalog.totalPrice(items); + } +} diff --git a/src/services/document.service.ts b/src/services/document.service.ts new file mode 100644 index 0000000..602e91e --- /dev/null +++ b/src/services/document.service.ts @@ -0,0 +1,220 @@ +import { getTestByCode } from "@mgpl-diagnostics/catalog-kit"; +import { + ConflictError, NotFoundError, ValidationError, + type Id, type Invoice as DbInvoice, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, +} from "@mgpl-diagnostics/db-kit"; +import { + computeInvoice, INVOICE_SAMPLES, invoiceFromRequest, invoiceTotalsJson, PRICE_LIST, renderInvoiceHtml, renderInvoicePdf, + type Invoice as PrintInvoice, type InvoiceItem as PrintItem, type InvoiceRequest, +} from "@mgpl-diagnostics/invoice-kit"; +import { + buildReport, COLS_STD, defaultLab, renderLetterheadHtml, renderLetterheadPdf, renderReportHtml, renderReportPdf, reportFromRequest, SAMPLES, templateCatalog, TEMPLATES, + type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportVariant, type Section, type TestRow, +} from "@mgpl-diagnostics/report-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export type DocumentFormat = "pdf" | "html"; + +/** A rendered document. PDFs travel as base64 in `base64`; HTML (self-contained, fonts inlined) in `html`. */ +export interface RenderedDocument { + format: DocumentFormat; + fileName: string; + mimeType: string; + byteLength: number; + base64: string | null; + html: string | null; +} + +export interface RenderOptions { + format: DocumentFormat; + variant: ReportVariant; + design?: ReportDesign; + fileName?: string | null; +} + +const MODE_LABEL: Record<PaymentMode, string> = { cash: "Cash", upi: "UPI", card: "Card", netbanking: "Online", wallet: "Wallet", cheque: "Cheque", insurance: "Insurance" }; +const rupees = (paise: number) => paise / 100; +const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +function ageOf(p: DbPatient, at: Date): number | string { + if (p.ageYears != null) return p.ageYears; + if (!p.dob) return ""; + const dob = new Date(p.dob); + let years = at.getUTCFullYear() - dob.getUTCFullYear(); + if (at.getUTCMonth() < dob.getUTCMonth() || (at.getUTCMonth() === dob.getUTCMonth() && at.getUTCDate() < dob.getUTCDate())) years--; + return Math.max(0, years); +} + +/** db-kit patient → the printed patient block. */ +export function printPatient(p: DbPatient, at: Date, extra: Partial<PrintPatient> = {}): PrintPatient { + return { + title: p.title, name: p.name, age: ageOf(p, at), sex: titleCase(p.sex), patientId: p.uhid, phone: p.phone, + address: [p.address?.line1, p.address?.city].filter(Boolean).join(", ") || undefined, ...extra, + }; +} + +/** + * report-kit (lab reports, letterhead) and invoice-kit (invoices) used as internal libraries: render + * from a request (any data) or from what is stored in db-kit (an invoice, a booking's reports). + */ +export class DocumentService { + private readonly lab: LabProfile; + private readonly samples: boolean; + + constructor(private readonly repos: Repositories, opts: { lab?: LabProfile; samples: boolean }) { + this.lab = opts.lab ?? defaultLab(); + this.samples = opts.samples; + } + + // ---------------------------------------------------------------- catalogs + + templates() { + return templateCatalog(); + } + + priceList() { + return Object.entries(PRICE_LIST).map(([code, e]) => ({ code, ...e })); + } + + reportSamples() { + return this.samples ? SAMPLES.map((s) => ({ id: s.id, label: s.label, group: s.group })) : []; + } + + invoiceSamples() { + return this.samples ? INVOICE_SAMPLES.map((s) => ({ id: s.id, label: s.label, invoiceNo: s.invoice.invoiceNo })) : []; + } + + // ---------------------------------------------------------------- from a request + + renderReportRequest(request: ReportRequest, o: RenderOptions) { + return this.renderReport(reportFromRequest(request, { samples: this.samples }), o); + } + + renderInvoiceRequest(request: InvoiceRequest, o: RenderOptions) { + return this.renderInvoice(invoiceFromRequest(request, { samples: this.samples }), o); + } + + /** invoice-kit's calculation (integer paise inside, rupees out) without rendering. */ + invoiceTotals(request: InvoiceRequest) { + return invoiceTotalsJson(invoiceFromRequest(request, { samples: this.samples })); + } + + async letterhead(format: DocumentFormat): Promise<RenderedDocument> { + if (format === "html") return htmlDoc("letterhead.html", await renderLetterheadHtml({ lab: this.lab, inline: true })); + return pdfDoc("letterhead.pdf", await renderLetterheadPdf({ lab: this.lab })); + } + + // ---------------------------------------------------------------- from stored data + + /** The printed form of a stored invoice (with its payments). */ + async storedInvoice(actor: Actor, invoice: DbInvoice): Promise<PrintInvoice> { + const patient = mustOwn(await this.repos.patients.get(invoice.patientId), actor.orgId, "Patient", invoice.patientId); + const payments = await this.repos.payments.find({ invoiceId: invoice.id }, { sort: { field: "receivedAt" } }); + const at = invoice.issuedAt ?? invoice.createdAt; + const lineDiscounts = invoice.items.reduce((s, i) => s + i.discountPaise, 0); + const billDiscount = invoice.discountPaise - lineDiscounts; + const refunded = payments.filter((p) => p.kind === "refund").reduce((s, p) => s + p.amountPaise, 0); + const items: PrintItem[] = invoice.items.map((i) => ({ + code: i.code, name: i.name, category: i.category === "package" ? "profile" : i.category, qty: i.qty, rate: rupees(i.ratePaise), + discountAmount: i.discountPaise ? rupees(i.discountPaise) : undefined, taxPercent: i.taxPercent || undefined, hsn: i.hsnSac, + })); + const notes = [invoice.status === "cancelled" ? `CANCELLED: ${invoice.cancelReason ?? ""}`.trim() : "", refunded ? `Refunded Rs. ${rupees(refunded).toFixed(2)}` : ""].filter(Boolean); + return { + invoiceNo: invoice.invoiceNo, + date: at, + patient: printPatient(patient, at), + items, + discount: billDiscount > 0 ? { amount: rupees(billDiscount) } : undefined, + payments: payments.filter((p) => p.kind === "payment").map((p) => ({ mode: MODE_LABEL[p.mode], amount: rupees(p.amountPaise), date: p.receivedAt, reference: p.reference })), + notes: notes.length ? notes : undefined, + roundOff: true, + }; + } + + async renderStoredInvoice(actor: Actor, invoice: DbInvoice, o: RenderOptions) { + const printed = await this.storedInvoice(actor, invoice); + // the printed total must equal the stored one (both are integer-paise maths with the same rules) + const t = computeInvoice(printed); + if (Math.round(t.total * 100) !== invoice.totalPaise) throw new ConflictError(`Invoice ${invoice.invoiceNo}: printed total ${t.total} differs from the stored total`); + return this.renderInvoice(printed, { ...o, fileName: o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}` }); + } + + /** + * A booking's reports as one printed report. Only verified or released reports print. A report + * whose test code is a report-kit template uses that template (results keyed by its field keys); + * any other test prints as a standard Test | Result | Unit | Range table. + */ + async renderBookingReports(actor: Actor, bookingId: Id, reportIds: Id[] | null, o: RenderOptions) { + const booking = mustOwn(await this.repos.bookings.get(bookingId), actor.orgId, "Booking", bookingId); + let reports = await this.repos.reports.find({ bookingId }); + if (reportIds?.length) reports = reports.filter((r) => reportIds.includes(r.id)); + if (!reports.length) throw new NotFoundError("Report", reportIds?.join(",") ?? bookingId); + const unready = reports.find((r) => r.status !== "verified" && r.status !== "released"); + if (unready) throw new ConflictError(`Report ${unready.testName} is ${unready.status}; verify it before printing`); + const patient = mustOwn(await this.repos.patients.get(booking.patientId), actor.orgId, "Patient", booking.patientId); + const doctor = booking.doctorId ? await this.repos.doctors.get(booking.doctorId) : null; + const samples = await this.repos.samples.find({ bookingId }); + const collected = samples.map((s) => s.collectedAt).filter(Boolean).sort()[0] ?? booking.createdAt; + const reportedAt = reports.map((r) => r.verifiedAt ?? r.updatedAt).filter(Boolean).sort().at(-1) ?? new Date(); + const printed = printPatient(patient, reportedAt, { + refDoctor: doctor?.name ?? "Self", sampleId: booking.bookingNo, sampleCollectionDate: collected, collectedAt: collected, registeredAt: booking.createdAt, reportedAt, + }); + const report: PrintReport = { patient: printed, sections: reports.flatMap((r) => this.sections(r, printed)) }; + return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` }); + } + + private sections(r: DbReport, patient: PrintPatient): Section[] { + const template = (TEMPLATES as Record<string, (typeof TEMPLATES)[keyof typeof TEMPLATES]>)[r.testCode.toLowerCase()]; + const flag = (f: string | undefined): TestRow["flag"] => (f === "H" ? "high" : f === "L" ? "low" : f === "A" ? "abnormal" : f === "" ? "normal" : "auto"); + if (template) { + // results match a template row by its key ("hb") or its label ("Haemoglobin (HB)"), ignoring case and punctuation + const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, ""); + const keyOf = new Map<string, string>(); + for (const f of template.fields) { + keyOf.set(norm(f.key), f.key); + keyOf.set(norm(f.label), f.key); + } + const matched = r.results.flatMap((x) => { + const key = keyOf.get(norm(x.parameter)); + return key ? [[key, { value: x.value, flag: flag(x.flag), unit: x.unit, range: x.range }] as const] : []; + }); + if (matched.length === r.results.length && matched.length) return buildReport(patient, [{ template: template.id as never, results: Object.fromEntries(matched) }]).sections; + } + const cat = getTestByCode(r.testCode); + return [{ + title: (cat?.department ?? "LABORATORY").toUpperCase(), + subtitle: r.testName, + specimen: cat?.sampleType ?? undefined, + columns: COLS_STD, + rows: r.results.map((x): TestRow => ({ kind: "test", name: x.parameter, result: x.value, unit: x.unit, range: x.range, flag: flag(x.flag) })), + }]; + } + + // ---------------------------------------------------------------- rendering + + private async renderReport(report: PrintReport, o: RenderOptions): Promise<RenderedDocument> { + const name = o.fileName ?? `${report.patient.patientId ?? "report"}-${o.variant}.${o.format}`; + if (o.format === "html") return htmlDoc(name, await renderReportHtml({ report, variant: o.variant, design: o.design, lab: this.lab, inline: true })); + return pdfDoc(name, await renderReportPdf({ report, variant: o.variant, design: o.design, lab: this.lab })); + } + + private async renderInvoice(invoice: PrintInvoice, o: RenderOptions): Promise<RenderedDocument> { + const name = o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}`; + if (o.format === "html") return htmlDoc(name, await renderInvoiceHtml({ invoice, variant: o.variant, lab: this.lab, inline: true })); + return pdfDoc(name, await renderInvoicePdf({ invoice, variant: o.variant, lab: this.lab })); + } +} + +function pdfDoc(fileName: string, pdf: Buffer): RenderedDocument { + return { format: "pdf", fileName, mimeType: "application/pdf", byteLength: pdf.byteLength, base64: pdf.toString("base64"), html: null }; +} + +function htmlDoc(fileName: string, html: string): RenderedDocument { + return { format: "html", fileName, mimeType: "text/html; charset=utf-8", byteLength: Buffer.byteLength(html), base64: null, html }; +} + +export const assertFormat = (f: string): DocumentFormat => { + if (f !== "pdf" && f !== "html") throw new ValidationError(`format must be pdf or html`); + return f; +}; diff --git a/src/services/export.service.ts b/src/services/export.service.ts new file mode 100644 index 0000000..f5966f0 --- /dev/null +++ b/src/services/export.service.ts @@ -0,0 +1,54 @@ +import type { Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { + balanceSheetCsv, dayBookCsv, gstr1, gstr1Csv, gstr3b, gstr3bCsv, ledgerCsv, profitAndLossCsv, settlementCsv, trialBalanceCsv, +} from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +export interface CsvFile { + fileName: string; + mimeType: string; + content: string; +} + +const csv = (fileName: string, content: string): CsvFile => ({ fileName, mimeType: "text/csv; charset=utf-8", content }); +const day = (d: Date) => d.toISOString().slice(0, 10); + +/** CSV exports (finance-kit): every file ends with a TOTAL row equal to its report. */ +export class ExportService { + constructor(private readonly repos: Repositories) {} + + async dayBook(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`day-book-${day(q.from)}-${day(q.to)}.csv`, await dayBookCsv(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async ledger(actor: Actor, q: { ledgerId: Id; from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`ledger-${day(q.from)}-${day(q.to)}.csv`, await ledgerCsv(this.repos, { orgId: actor.orgId, ledgerId: q.ledgerId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async trialBalance(actor: Actor, q: { asOf?: Date | null; branchId?: Id | null; bom: boolean }) { + return csv(`trial-balance${q.asOf ? `-${day(q.asOf)}` : ""}.csv`, await trialBalanceCsv(this.repos, { orgId: actor.orgId, asOf: q.asOf ?? undefined, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async profitAndLoss(actor: Actor, q: { from: Date; to: Date; branchId?: Id | null; bom: boolean }) { + return csv(`profit-and-loss-${day(q.from)}-${day(q.to)}.csv`, await profitAndLossCsv(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async balanceSheet(actor: Actor, q: { asOf: Date; branchId?: Id | null; bom: boolean }) { + return csv(`balance-sheet-${day(q.asOf)}.csv`, await balanceSheetCsv(this.repos, { orgId: actor.orgId, asOf: q.asOf, branchId: q.branchId ?? undefined, bom: q.bom })); + } + + async settlement(actor: Actor, q: { settlementId: Id; bom: boolean }) { + return csv(`settlement-${q.settlementId}.csv`, await settlementCsv(this.repos, { orgId: actor.orgId, settlementId: q.settlementId, bom: q.bom })); + } + + /** One file per GSTR-1 section (b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs). */ + async gstr1(actor: Actor, q: { from: Date; to: Date; gstin?: string | null; bom: boolean }) { + const r = await gstr1(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + return Object.entries(gstr1Csv(r, { bom: q.bom })).map(([section, content]) => csv(`gstr1-${section}-${day(q.from)}-${day(q.to)}.csv`, content)); + } + + async gstr3b(actor: Actor, q: { from: Date; to: Date; gstin?: string | null; bom: boolean }) { + const r = await gstr3b(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + return csv(`gstr3b-${day(q.from)}-${day(q.to)}.csv`, gstr3bCsv(r, { bom: q.bom })); + } +} diff --git a/src/services/gst.service.ts b/src/services/gst.service.ts new file mode 100644 index 0000000..e111549 --- /dev/null +++ b/src/services/gst.service.ts @@ -0,0 +1,51 @@ +import type { Id, ItemCategory, Repositories, Taxability } from "@mgpl-diagnostics/db-kit"; +import { addTaxRule, DEFAULT_TAX_RULES, financeConfig, gstr1, gstr3b, seedTaxRules, VERIFY_WITH_CA } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +export interface TaxRuleInput { + code?: string | null; + category?: ItemCategory | null; + hsnSac: string; + taxability: Taxability; + ratePercent: number; + /** IST day, "2026-04-01". */ + effectiveFrom: string; + note?: string | null; +} + +/** + * GST through finance-kit: tax rules (configuration, to be verified with the lab's CA), GSTR-1 and + * GSTR-3B data. Nothing here is legal advice; `verifyWithCA` travels with every return. + */ +export class GstService { + constructor(private readonly repos: Repositories) {} + + taxRules(actor: Actor) { + return this.repos.taxRules.find({ orgId: actor.orgId }, { sort: { field: "effectiveFrom", dir: "desc" } }); + } + + addTaxRule(actor: Actor, input: TaxRuleInput) { + return addTaxRule(this.repos, actor.orgId, { ...input, code: input.code ?? null, category: input.category ?? null, note: input.note ?? undefined }, actor.userId); + } + + /** finance-kit's starting rules (DEFAULT_TAX_RULES). Every rate is marked "verify with CA". */ + seedTaxRules(actor: Actor) { + return seedTaxRules(this.repos, actor.orgId, DEFAULT_TAX_RULES, actor.userId); + } + + config(actor: Actor) { + return financeConfig(this.repos, actor.orgId); + } + + verifyWithCA(): readonly string[] { + return VERIFY_WITH_CA; + } + + gstr1(actor: Actor, q: { from: Date; to: Date; gstin?: string | null }) { + return gstr1(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + } + + gstr3b(actor: Actor, q: { from: Date; to: Date; gstin?: string | null }) { + return gstr3b(this.repos, { orgId: actor.orgId, from: q.from, to: q.to, gstin: q.gstin ?? undefined }); + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..fdd42cc --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,51 @@ +import type { Auth } from "@mgpl-diagnostics/auth-kit"; +import type { Repositories } from "@mgpl-diagnostics/db-kit"; +import type { LabProfile } from "@mgpl-diagnostics/report-kit"; +import { AuthService, type Mailer } from "./auth.service"; +import { BillingService } from "./billing.service"; +import { BookingService } from "./booking.service"; +import { BooksService } from "./books.service"; +import { CashBankService } from "./cash-bank.service"; +import { CatalogService } from "./catalog.service"; +import { DocumentService } from "./document.service"; +import { ExportService } from "./export.service"; +import { GstService } from "./gst.service"; +import { OrganizationService } from "./organization.service"; +import { PatientService } from "./patient.service"; +import { PeriodService } from "./period.service"; +import { ReceivablesService } from "./receivables.service"; +import { ReportService } from "./report.service"; +import { SettlementService } from "./settlement.service"; + +export interface ServiceDeps { + repos: Repositories; + auth: Auth; + mailer?: Mailer; + resetUrl: string | null; + lab?: LabProfile; + samples: boolean; +} + +/** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */ +export function createServices(d: ServiceDeps) { + return { + auth: new AuthService(d.repos, d.auth, { mailer: d.mailer, resetUrl: d.resetUrl }), + organization: new OrganizationService(d.repos), + catalog: new CatalogService(), + patients: new PatientService(d.repos), + bookings: new BookingService(d.repos), + reports: new ReportService(d.repos), + billing: new BillingService(d.repos), + books: new BooksService(d.repos), + gst: new GstService(d.repos), + receivables: new ReceivablesService(d.repos), + settlements: new SettlementService(d.repos), + cashBank: new CashBankService(d.repos), + periods: new PeriodService(d.repos), + exports: new ExportService(d.repos), + documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }), + }; +} + +export type Services = ReturnType<typeof createServices>; +export type { Mailer }; diff --git a/src/services/organization.service.ts b/src/services/organization.service.ts new file mode 100644 index 0000000..40f6773 --- /dev/null +++ b/src/services/organization.service.ts @@ -0,0 +1,77 @@ +import { ConflictError, NotFoundError, ValidationError, type Address, type Branch, type BranchKind, type Id, type Repositories } from "@mgpl-diagnostics/db-kit"; +import { isValidGstin } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +export interface BranchInput { + code: string; + name: string; + kind: BranchKind; + phone?: string | null; + gstin?: string | null; + stateCode?: string | null; + address?: Address | null; + active?: boolean | null; +} + +export interface OrganizationPatch { + name?: string | null; + legalName?: string | null; + gstin?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; +} + +const checkGstin = (g?: string | null) => { + if (g && !isValidGstin(g)) throw new ValidationError(`Invalid GSTIN ${g}`); +}; + +/** The signed-in user's organisation and its branches. */ +export class OrganizationService { + constructor(private readonly repos: Repositories) {} + + async organization(actor: Actor) { + const org = await this.repos.orgs.get(actor.orgId); + if (!org) throw new NotFoundError("Organization", actor.orgId); + return org; + } + + async updateOrganization(actor: Actor, patch: OrganizationPatch) { + checkGstin(patch.gstin); + const org = await this.repos.orgs.update(actor.orgId, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "org.update", entity: "organization", entityId: actor.orgId }); + return org!; + } + + branches(actor: Actor): Promise<Branch[]> { + return this.repos.branches.find({ orgId: actor.orgId }, { sort: { field: "code" } }); + } + + async branch(actor: Actor, id: Id) { + return mustOwn(await this.repos.branches.get(id), actor.orgId, "Branch", id); + } + + async createBranch(actor: Actor, input: BranchInput) { + checkGstin(input.gstin); + const code = input.code.trim().toUpperCase(); + if (!/^[A-Z0-9]{2,12}$/.test(code)) throw new ValidationError("Branch code: 2-12 letters or digits"); + if (await this.repos.branches.findOne({ orgId: actor.orgId, code })) throw new ConflictError(`Branch ${code} already exists`); + const b = await this.repos.branches.create({ ...(clean(input) as BranchInput), orgId: actor.orgId, code, active: input.active ?? true } as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: b.id, userId: actor.userId, action: "branch.create", entity: "branch", entityId: b.id }); + return b as Branch; + } + + async updateBranch(actor: Actor, id: Id, patch: Partial<Omit<BranchInput, "code">>) { + mustOwn(await this.repos.branches.get(id), actor.orgId, "Branch", id); + checkGstin(patch.gstin); + const b = await this.repos.branches.update(id, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: id, userId: actor.userId, action: "branch.update", entity: "branch", entityId: id }); + return b!; + } + + /** Audit trail, newest first. */ + audit(actor: Actor, q: { entity?: string | null; entityId?: Id | null; limit: number; offset: number }) { + return this.repos.audit.find(clean({ orgId: actor.orgId, entity: q.entity, entityId: q.entityId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "at", dir: "desc" } }); + } +} diff --git a/src/services/patient.service.ts b/src/services/patient.service.ts new file mode 100644 index 0000000..507f308 --- /dev/null +++ b/src/services/patient.service.ts @@ -0,0 +1,61 @@ +import { registerPatient, type Address, type Doctor, type Id, type Patient, type Repositories, type Sex } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn, owned } from "./util"; + +export interface RegisterPatientInput { + branchId: Id; + title?: string | null; + name: string; + sex: Sex; + dob?: Date | null; + ageYears?: number | null; + phone?: string | null; + email?: string | null; + address?: Address | null; +} + +export interface DoctorInput { + name: string; + qualification?: string | null; + phone?: string | null; + clinic?: string | null; + active?: boolean | null; +} + +/** Patients (UHID per organisation) and referring doctors. */ +export class PatientService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id): Promise<Patient | null> { + return owned(await this.repos.patients.get(id), actor.orgId); + } + + search(actor: Actor, text: string, limit: number) { + return this.repos.patients.search(actor.orgId, text, limit); + } + + async register(actor: Actor, input: RegisterPatientInput) { + mustOwn(await this.repos.branches.get(input.branchId), actor.orgId, "Branch", input.branchId); + return registerPatient(this.repos, { ...(clean(input) as RegisterPatientInput), orgId: actor.orgId } as never, actor.userId); + } + + async update(actor: Actor, id: Id, patch: Partial<Omit<RegisterPatientInput, "branchId">>) { + mustOwn(await this.repos.patients.get(id), actor.orgId, "Patient", id); + const p = await this.repos.patients.update(id, clean(patch) as never); + await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: "patient.update", entity: "patient", entityId: id }); + return p!; + } + + doctors(actor: Actor, activeOnly: boolean): Promise<Doctor[]> { + return this.repos.doctors.find(activeOnly ? { orgId: actor.orgId, active: true } : { orgId: actor.orgId }, { sort: { field: "name" } }); + } + + async createDoctor(actor: Actor, input: DoctorInput) { + return this.repos.doctors.create({ ...(clean(input) as DoctorInput), orgId: actor.orgId, active: input.active ?? true } as never); + } + + async updateDoctor(actor: Actor, id: Id, patch: Partial<DoctorInput>) { + mustOwn(await this.repos.doctors.get(id), actor.orgId, "Doctor", id); + return (await this.repos.doctors.update(id, clean(patch) as never))!; + } +} diff --git a/src/services/period.service.ts b/src/services/period.service.ts new file mode 100644 index 0000000..01915ce --- /dev/null +++ b/src/services/period.service.ts @@ -0,0 +1,20 @@ +import type { Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { closeFinancialYear, listLocks, setLockDate } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; + +/** Lock dates and the year-end close, through finance-kit. */ +export class PeriodService { + constructor(private readonly repos: Repositories) {} + + locks(actor: Actor) { + return listLocks(this.repos, actor.orgId); + } + + setLockDate(actor: Actor, input: { branchId?: Id | null; upTo: string | null; reason?: string | null }) { + return setLockDate(this.repos, { orgId: actor.orgId, branchId: input.branchId ?? null, upTo: input.upTo, reason: input.reason ?? undefined }, actor.userId); + } + + closeFinancialYear(actor: Actor, fy: string) { + return closeFinancialYear(this.repos, { orgId: actor.orgId, fy }, actor.userId); + } +} diff --git a/src/services/receivables.service.ts b/src/services/receivables.service.ts new file mode 100644 index 0000000..8716911 --- /dev/null +++ b/src/services/receivables.service.ts @@ -0,0 +1,83 @@ +import type { Address, CustomerKind, Id, Repositories } from "@mgpl-diagnostics/db-kit"; +import { createCustomer, customerStatement, issueCreditNote, issueCustomerInvoice, receivablesSummary, updateCustomer } from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { invoiceItem, type IssueInvoiceInput } from "./billing.service"; +import { clean, mustOwn } from "./util"; + +export interface CustomerInput { + code: string; + name: string; + kind: CustomerKind; + legalName?: string | null; + gstin?: string | null; + stateCode?: string | null; + pan?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; + creditLimitPaise?: number | null; + creditDays?: number | null; +} + +export interface CustomerInvoiceInput extends Omit<IssueInvoiceInput, "applyTaxRules"> { + customerId: Id; + placeOfSupply?: string | null; + /** Default true: GST from the tax rules. */ + applyTaxRules?: boolean | null; +} + +/** B2B receivables (hospitals, insurers, TPAs, corporates) and credit notes, through finance-kit. */ +export class ReceivablesService { + constructor(private readonly repos: Repositories) {} + + customers(actor: Actor, activeOnly: boolean) { + return this.repos.customers.find(activeOnly ? { orgId: actor.orgId, active: true } : { orgId: actor.orgId }, { sort: { field: "code" } }); + } + + async customer(actor: Actor, id: Id) { + return mustOwn(await this.repos.customers.get(id), actor.orgId, "Customer", id); + } + + createCustomer(actor: Actor, input: CustomerInput) { + return createCustomer(this.repos, { ...(clean(input) as CustomerInput), orgId: actor.orgId } as never, actor.userId); + } + + updateCustomer(actor: Actor, id: Id, patch: Partial<Omit<CustomerInput, "code">> & { active?: boolean | null }) { + // creditLimitPaise: null means "no limit", so it is passed through when present + const p = clean(patch) as Record<string, unknown>; + if (patch.creditLimitPaise === null) p.creditLimitPaise = null; + return updateCustomer(this.repos, { ...p, orgId: actor.orgId, customerId: id } as never, actor.userId); + } + + async issueInvoice(actor: Actor, input: CustomerInvoiceInput) { + const { invoice } = await issueCustomerInvoice(this.repos, { + orgId: actor.orgId, branchId: input.branchId, patientId: input.patientId, bookingId: input.bookingId ?? null, customerId: input.customerId, + items: input.items.map(invoiceItem), billDiscountPaise: input.billDiscountPaise ?? 0, + payments: input.payments?.map((p) => clean(p)) as never, placeOfSupply: input.placeOfSupply ?? undefined, applyTaxRules: input.applyTaxRules ?? true, + }, actor.userId); + return invoice; + } + + statement(actor: Actor, q: { customerId: Id; from: Date; to: Date }) { + return customerStatement(this.repos, { orgId: actor.orgId, ...q }); + } + + summary(actor: Actor, asOf: Date) { + return receivablesSummary(this.repos, { orgId: actor.orgId, asOf }); + } + + async creditNote(actor: Actor, id: Id) { + return mustOwn(await this.repos.creditNotes.get(id), actor.orgId, "Credit note", id); + } + + creditNotes(actor: Actor, q: { invoiceId?: Id | null; customerId?: Id | null; limit: number; offset: number }) { + return this.repos.creditNotes.find(clean({ orgId: actor.orgId, invoiceId: q.invoiceId, customerId: q.customerId }) as never, { limit: q.limit, offset: q.offset, sort: { field: "date", dir: "desc" } }); + } + + issueCreditNote(actor: Actor, input: { invoiceId: Id; reason: string; lines?: { itemIndex: number; taxablePaise?: number | null }[] | null; date?: Date | null }) { + return issueCreditNote(this.repos, { + orgId: actor.orgId, invoiceId: input.invoiceId, reason: input.reason, date: input.date ?? undefined, + lines: input.lines?.map((l) => clean(l) as { itemIndex: number; taxablePaise?: number }), + }, actor.userId); + } +} diff --git a/src/services/report.service.ts b/src/services/report.service.ts new file mode 100644 index 0000000..e0d30e6 --- /dev/null +++ b/src/services/report.service.ts @@ -0,0 +1,76 @@ +import { flagResult, formatRange, getTestByCode, type Sex as CatalogSex } from "@mgpl-diagnostics/catalog-kit"; +import { ConflictError, saveResults, transitionReport, ValidationError, type Booking, type Id, type Report, type Repositories, type ResultValue, type Sex } from "@mgpl-diagnostics/db-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +const catalogSex = (s: Sex): CatalogSex | undefined => (s === "male" || s === "female" ? s : undefined); + +/** + * Fills unit, range and flag from catalog-kit when the report's test is in the catalog and the + * result does not carry them. Values sent explicitly are kept (the pathologist has the last word). + */ +export function withCatalogDefaults(testCode: string, results: ResultValue[], sex: Sex): ResultValue[] { + const test = getTestByCode(testCode); + if (!test) return results; + const byName = new Map(test.parameters.filter((p) => p.name).map((p) => [p.name!.trim().toLowerCase(), p])); + return results.map((r) => { + const p = byName.get(r.parameter.trim().toLowerCase()); + if (!p) return r; + const out: ResultValue = { ...r }; + if (out.unit == null && p.unit) out.unit = p.unit; + if (out.range == null) { + const range = formatRange(p, catalogSex(sex)); + if (range) out.range = range; + } + const n = Number(r.value); + if (out.flag == null && p.type === "numeric" && r.value.trim() !== "" && Number.isFinite(n)) out.flag = flagResult(p, n, catalogSex(sex)); + return out; + }); +} + +/** Results entry and the report workflow: draft → entered → verified → released (→ amended → verified). */ +export class ReportService { + constructor(private readonly repos: Repositories) {} + + async get(actor: Actor, id: Id) { + return mustOwn(await this.repos.reports.get(id), actor.orgId, "Report", id); + } + + ofBooking(bookingId: Id) { + return this.repos.reports.find({ bookingId }); + } + + async create(actor: Actor, booking: Booking, testCode: string, testName?: string | null) { + const t = booking.tests.find((x) => x.testCode === testCode); + if (!t) throw new ValidationError(`Test ${testCode} is not in booking ${booking.bookingNo}`); + const existing = await this.repos.reports.findOne({ bookingId: booking.id, testCode }); + if (existing) throw new ConflictError(`Booking ${booking.bookingNo} already has a ${testCode} report`); + const r = await this.repos.reports.create({ + orgId: booking.orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId, + testCode, testName: testName ?? t.name, results: [], status: "draft", version: 1, + } as never); + await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "report.create", entity: "report", entityId: r.id }); + return r as Report; + } + + async saveResults(actor: Actor, report: Report, results: ResultValue[]) { + const patient = await this.repos.patients.get(report.patientId); + return saveResults(this.repos, report.id, withCatalogDefaults(report.testCode, results, patient?.sex ?? "other"), actor.userId); + } + + verify(actor: Actor, report: Report) { + return transitionReport(this.repos, report.id, "verified", actor.userId); + } + + /** Releasing the last report of a booking completes the booking. */ + async release(actor: Actor, report: Report) { + const out = await transitionReport(this.repos, report.id, "released", actor.userId); + const all = await this.repos.reports.find({ bookingId: report.bookingId }); + if (all.every((x) => x.status === "released")) await this.repos.bookings.update(report.bookingId, { status: "completed" }); + return out; + } + + amend(actor: Actor, report: Report) { + return transitionReport(this.repos, report.id, "amended", actor.userId); + } +} diff --git a/src/services/settlement.service.ts b/src/services/settlement.service.ts new file mode 100644 index 0000000..f2ccf4a --- /dev/null +++ b/src/services/settlement.service.ts @@ -0,0 +1,99 @@ +import type { Address, AgreementKind, Id, ItemCategory, PartnerKind, PaymentMode, Repositories, SettlementDeduction, SettlementStatus } from "@mgpl-diagnostics/db-kit"; +import { + accrueSettlement, addAgreement, cancelSettlement, computeSettlement, createPartner, createSettlement, paySettlement, updatePartner, +} from "@mgpl-diagnostics/finance-kit"; +import type { Actor } from "../graphql/context"; +import { clean, mustOwn } from "./util"; + +export interface PartnerInput { + code: string; + name: string; + kind: PartnerKind; + branchId: Id; + legalName?: string | null; + pan?: string | null; + gstin?: string | null; + stateCode?: string | null; + phone?: string | null; + email?: string | null; + address?: Address | null; + /** Overrides the default TDS (e.g. a lower-deduction certificate). Verify with CA. */ + tds?: { section: string; rateBp: number } | null; +} + +export interface AgreementInput { + partnerId: Id; + kind: AgreementKind; + shareBp?: number | null; + rates?: { code: string; sharePaise: number }[] | null; + defaultSharePaise?: number | null; + categories?: ItemCategory[] | null; + effectiveFrom: string; + effectiveTo?: string | null; + notes?: string | null; +} + +/** + * Collection-centre and franchise settlements with TDS, through finance-kit. No doctor referral + * commissions: settlements are for collection centres and franchises only. + */ +export class SettlementService { + constructor(private readonly repos: Repositories) {} + + partners(actor: Actor, q: { branchIds: "all" | Id[] }) { + return this.repos.partners.find({ orgId: actor.orgId }, { sort: { field: "code" } }).then((all) => (q.branchIds === "all" ? all : all.filter((p) => (q.branchIds as Id[]).includes(p.branchId)))); + } + + async partner(actor: Actor, id: Id) { + return mustOwn(await this.repos.partners.get(id), actor.orgId, "Partner", id); + } + + createPartner(actor: Actor, input: PartnerInput) { + return createPartner(this.repos, { ...(clean(input) as PartnerInput), tds: input.tds ?? undefined, orgId: actor.orgId } as never, actor.userId); + } + + updatePartner(actor: Actor, id: Id, patch: Partial<Omit<PartnerInput, "code" | "branchId">> & { active?: boolean | null }) { + const p = clean(patch) as Record<string, unknown>; + if (patch.tds === null) p.tds = null; // back to the default TDS + return updatePartner(this.repos, { ...p, orgId: actor.orgId, partnerId: id } as never, actor.userId); + } + + agreements(partnerId: Id) { + return this.repos.partnerAgreements.find({ partnerId }, { sort: { field: "effectiveFrom", dir: "desc" } }); + } + + addAgreement(actor: Actor, input: AgreementInput) { + return addAgreement(this.repos, { ...(clean(input) as AgreementInput), orgId: actor.orgId } as never, actor.userId); + } + + compute(actor: Actor, q: { partnerId: Id; period: string; deductions?: SettlementDeduction[] | null }) { + return computeSettlement(this.repos, { orgId: actor.orgId, partnerId: q.partnerId, period: q.period, deductions: q.deductions ?? undefined }); + } + + async settlement(actor: Actor, id: Id) { + return mustOwn(await this.repos.settlements.get(id), actor.orgId, "Settlement", id); + } + + settlements(actor: Actor, q: { partnerId?: Id | null; status?: SettlementStatus | null; branchIds: "all" | Id[]; limit: number; offset: number }) { + const filter = clean({ orgId: actor.orgId, partnerId: q.partnerId, status: q.status }) as never; + return this.repos.settlements.find(filter, { limit: q.limit + q.offset, sort: { field: "createdAt", dir: "desc" } }).then((all) => + (q.branchIds === "all" ? all : all.filter((s) => (q.branchIds as Id[]).includes(s.branchId))).slice(q.offset, q.offset + q.limit), + ); + } + + create(actor: Actor, q: { partnerId: Id; period: string; deductions?: SettlementDeduction[] | null }) { + return createSettlement(this.repos, { orgId: actor.orgId, partnerId: q.partnerId, period: q.period, deductions: q.deductions ?? undefined }, actor.userId); + } + + accrue(actor: Actor, settlementId: Id) { + return accrueSettlement(this.repos, { orgId: actor.orgId, settlementId }, actor.userId); + } + + pay(actor: Actor, q: { settlementId: Id; mode: PaymentMode; date?: Date | null; reference?: string | null }) { + return paySettlement(this.repos, { orgId: actor.orgId, settlementId: q.settlementId, mode: q.mode, date: q.date ?? undefined, reference: q.reference ?? undefined }, actor.userId); + } + + cancel(actor: Actor, settlementId: Id) { + return cancelSettlement(this.repos, { orgId: actor.orgId, settlementId }, actor.userId); + } +} diff --git a/src/services/util.ts b/src/services/util.ts new file mode 100644 index 0000000..aae1b27 --- /dev/null +++ b/src/services/util.ts @@ -0,0 +1,31 @@ +import { NotFoundError, type Id, type Repo } from "@mgpl-diagnostics/db-kit"; + +/** The entity when it belongs to `orgId`, otherwise null (tenant isolation). */ +export const owned = <T extends { orgId: Id }>(entity: T | null | undefined, orgId: Id): T | null => (entity && entity.orgId === orgId ? entity : null); + +/** The entity when it belongs to `orgId`; NotFoundError otherwise (another organisation's data looks absent). */ +export function mustOwn<T extends { orgId: Id }>(entity: T | null | undefined, orgId: Id, what: string, id: Id): T { + const e = owned(entity, orgId); + if (!e) throw new NotFoundError(what, id); + return e; +} + +/** Drops undefined and null values (partial updates). */ +export const clean = <T extends object>(o: T): Partial<T> => + Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null)) as Partial<T>; + +/** Branch-aware list: all branches, or a merge of each allowed branch, newest first. */ +export async function scopedFind<T extends { id: Id }>( + repo: Repo<T>, + filter: Record<string, unknown>, + scope: "all" | Id[], + limit: number, + offset: number, + sortField: string, +): Promise<T[]> { + const sort = { field: sortField as keyof T & string, dir: "desc" as const }; + if (scope === "all") return repo.find(filter as never, { limit, offset, sort }); + const lists = await Promise.all(scope.map((b) => repo.find({ ...filter, branchId: b } as never, { limit: limit + offset, sort }))); + const key = (x: T) => +((x as Record<string, unknown>)[sortField] as Date); + return lists.flat().sort((a, b) => key(b) - key(a)).slice(offset, offset + limit); +} diff --git a/test/run.ts b/test/run.ts new file mode 100644 index 0000000..fa0a00a --- /dev/null +++ b/test/run.ts @@ -0,0 +1,282 @@ +// End-to-end tests: the real server (Fastify + Yoga) over HTTP inject, real auth-kit sessions, real +// databases started in-process: PostgreSQL always, MongoDB (replica set) unless TEST_BACKENDS=pg. +// npm test both backends (first run downloads the database binaries) +// TEST_BACKENDS=pg npm test PostgreSQL only (when MongoDB binaries cannot be downloaded) +import { readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; +import { createDb, seedOrganization, type Db, type Repositories } from "@mgpl-diagnostics/db-kit"; +import { financePosting, istDay } from "@mgpl-diagnostics/finance-kit"; +import { parse, validate } from "graphql"; +import { loadConfig } from "../src/config/env"; +import { buildSchema } from "../src/graphql/schema"; +import { createContainer } from "../src/container"; +import { buildServer } from "../src/server"; +import type { Mailer } from "../src/services/auth.service"; + +let passed = 0; +let failed = 0; +const ok = (cond: unknown, msg: string) => { + if (cond) passed++; + else failed++; + console.log(`${cond ? "✓" : "✗"} ${msg}`); +}; + +type Gql = { data?: any; errors?: { message: string; extensions?: { code?: string } }[] }; +const code = (r: Gql) => r.errors?.[0]?.extensions?.code; + +async function suite(db: Db, tag: string) { + console.log(`\n[${tag}]`); + // count batched patient lookups (N+1 check) on the repositories the services use + let patientBatches = 0; + const repos: Repositories = { ...db.repos, patients: { ...db.repos.patients, getMany: (ids) => { patientBatches++; return db.repos.patients.getMany(ids); } } }; + const mails: { to: string; link: string }[] = []; + const mailer: Mailer = { passwordReset: async (to, link) => void mails.push({ to: to.email, link }) }; + const config = { ...loadConfig({ NODE_ENV: "test", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused" }) }; + const container = await createContainer({ config, db: { ...db, repos }, mailer }); + const app = await buildServer(container); + + const gql = async (token: string | null, query: string, variables?: Record<string, unknown>): Promise<Gql> => { + const res = await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, payload: JSON.stringify({ query, variables }) }); + const out = res.json() as Gql; + if (process.env.DEBUG && out.errors) console.log(" ", JSON.stringify(out.errors).slice(0, 400)); + return out; + }; + + // ---------------------------------------------------------------- setup: two organisations, auth-kit roles + const pw = "Lab-Owner-2026!"; + const a = await seedOrganization(db.rawRepos, { org: { name: "API Lab", code: "APL", gstin: "22AAAAA0000A1Z5" }, branch: { code: "A01", name: "Branch A", stateCode: "22" }, owner: { name: "Owner", email: "owner@apl.test", passwordHash: await hashPassword(pw) }, roles: SYSTEM_ROLES }); + const other = await seedOrganization(db.rawRepos, { org: { name: "Other Lab", code: "OTH" }, branch: { code: "Z01", name: "Z", stateCode: "22" }, owner: { name: "X", email: "x@oth.test", passwordHash: await hashPassword(pw) }, roles: SYSTEM_ROLES }); + + const health = await app.inject({ url: "/health" }); + ok(health.statusCode === 200 && (await gql(null, "{ health { ok database } }")).data?.health.ok, `[${tag}] GET /health and { health } report the database`); + ok(code(await gql(null, "{ organization { name } }")) === "UNAUTHENTICATED", `[${tag}] no token → UNAUTHENTICATED`); + ok(code(await gql(null, 'mutation { login(input: { email: "owner@apl.test", password: "wrong-password" }) { token } }')) === "INVALID_CREDENTIALS", `[${tag}] wrong password → INVALID_CREDENTIALS`); + const login = async (email: string, password = pw) => (await gql(null, "mutation($i: LoginInput!) { login(input: $i) { token user { email } } }", { i: { email, password } })).data?.login.token as string; + const owner = await login("owner@apl.test"); + const otherOwner = await login("x@oth.test"); + ok(typeof owner === "string" && owner.length > 20, `[${tag}] login → bearer token`); + const viewer = await gql(owner, '{ viewer { user { email } organization { code } can(permission: "accounts:post") branchesFor(permission: "invoice:read") } }'); + ok(viewer.data?.viewer.user.email === "owner@apl.test" && viewer.data.viewer.organization.code === "APL" && viewer.data.viewer.can && viewer.data.viewer.branchesFor === null, `[${tag}] viewer: user, organisation, permissions`); + ok((await gql(null, "{ viewer { user { id } } }")).data?.viewer === null, `[${tag}] viewer is null without a token`); + + const chart = await gql(owner, "mutation { setupChartOfAccounts { name systemKey } }"); + ok(chart.data?.setupChartOfAccounts.length > 10, `[${tag}] setupChartOfAccounts (tally-kit chart + finance-kit ledgers): ${chart.data?.setupChartOfAccounts.length} ledgers`); + await gql(otherOwner, "mutation { setupChartOfAccounts { id } }"); + const branchB = (await gql(owner, 'mutation { createBranch(input: { code: "b01", name: "Branch B", kind: collection_center, stateCode: "22" }) { id code } }')).data?.createBranch; + ok(branchB?.code === "B01", `[${tag}] createBranch (code upper-cased)`); + const deskUser = await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id roles { role branch { code } } } }", { + i: { name: "Desk", email: "desk@apl.test", password: "Counter-Shift-7391", roles: [{ role: "front_desk", branchId: a.branch.id }] }, + }); + ok(deskUser.data?.createUser.roles[0].branch.code === "A01", `[${tag}] createUser with a branch role (auth-kit hashes the password)`); + ok(code(await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id } }", { i: { name: "W", email: "w@apl.test", password: "password", roles: [{ role: "front_desk" }] } })) === "WEAK_PASSWORD", `[${tag}] weak password → WEAK_PASSWORD`); + const desk = await login("desk@apl.test", "Counter-Shift-7391"); + + // ---------------------------------------------------------------- catalog (public) + const cat = await gql(null, '{ catalogSearch(query: "cbc", limit: 3) { test { code name pricePaise sampleType parameters { name unit range(sex: male) } } } priceQuote(tests: ["CBC", "NOPE"]) { total totalPaise unknown } }'); + const cbc = cat.data?.catalogSearch[0]?.test; + ok(cbc?.code === "CBC" && cbc.pricePaise === 39900 && cat.data.priceQuote.unknown[0] === "NOPE", `[${tag}] catalog search + price quote (catalog-kit, no sign-in)`); + + // ---------------------------------------------------------------- lab workflow + const reg = await gql(owner, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id uhid name } }", { i: { branchId: a.branch.id, name: "Asha Patel", sex: "female", ageYears: 29, phone: "9111111111" } }); + const patientId = reg.data?.registerPatient.id; + ok(reg.data?.registerPatient.uhid === "APL-000001", `[${tag}] registerPatient → ${reg.data?.registerPatient.uhid}`); + const book = await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo tests { testCode name pricePaise sampleType catalog { department } } } samples { id barcode sampleType } } }", { + i: { branchId: a.branch.id, patientId, tests: [{ testCode: "CBC" }] }, + }); + const booking = book.data?.createBooking; + ok(booking?.booking.tests[0].name === "Complete Blood Count (CBC)" && booking.booking.tests[0].pricePaise === 39900 && booking.samples.length === 1, `[${tag}] createBooking by catalog code: name, price and sample type from catalog-kit`); + ok((await gql(owner, "mutation($id: ID!) { collectSample(id: $id) { status } }", { id: booking.samples[0].id })).data?.collectSample.status === "collected", `[${tag}] collectSample`); + ok((await gql(owner, "mutation($id: ID!) { receiveSample(id: $id) { status booking { status } } }", { id: booking.samples[0].id })).data?.receiveSample.booking.status === "in_process", `[${tag}] receiveSample → booking in process`); + const rep = await gql(owner, 'mutation($b: ID!) { createReport(bookingId: $b, testCode: "CBC") { id status testName } }', { b: booking.booking.id }); + const reportId = rep.data?.createReport.id; + const saved = await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { status results { parameter value unit flag range } } }", { + r: reportId, x: [{ parameter: "Hemoglobin", value: "10.2" }, { parameter: "Total Leucocytes Count", value: "7.5" }], + }); + const hb = saved.data?.saveResults.results[0]; + ok(hb?.unit === "g/dL" && hb.flag === "L" && saved.data.saveResults.results[1].flag === "NONE", `[${tag}] saveResults: unit, range and flag filled from catalog-kit (Hb 10.2 → L)`); + ok(code(await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b) { fileName } }", { b: booking.booking.id })) === "CONFLICT", `[${tag}] an unverified report does not print → CONFLICT`); + await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { status } }", { r: reportId }); + const released = await gql(owner, "mutation($r: ID!) { releaseReport(id: $r) { status booking { status } } }", { r: reportId }); + ok(released.data?.releaseReport.booking.status === "completed", `[${tag}] verify + release → booking completed`); + const doc = await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b) { format fileName mimeType byteLength base64 } }", { b: booking.booking.id }); + const pdf = Buffer.from(doc.data?.bookingReportDocument.base64 ?? "", "base64"); + ok(pdf.subarray(0, 5).toString() === "%PDF-" && pdf.byteLength === doc.data.bookingReportDocument.byteLength, `[${tag}] bookingReportDocument: report-kit PDF (${(pdf.byteLength / 1024).toFixed(0)} KB, base64)`); + const html = await gql(owner, 'query($b: ID!) { bookingReportDocument(bookingId: $b, format: html, variant: non_header) { html } }', { b: booking.booking.id }); + ok(/Asha Patel/.test(html.data?.bookingReportDocument.html ?? "") && /10\.2/.test(html.data.bookingReportDocument.html), `[${tag}] bookingReportDocument html (non-header) carries the patient and results`); + + // ---------------------------------------------------------------- billing + automatic posting (finance-kit → tally-kit) + const inv = await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id invoiceNo status totalPaise duePaise items { name ratePaise } } }", { + i: { branchId: a.branch.id, patientId, bookingId: booking.booking.id, items: [{ code: "CBC" }, { code: "HOME", name: "Home collection", category: "service", ratePaise: 10000 }], payments: [{ amountPaise: 20000, mode: "cash" }] }, + }); + const invoiceId = inv.data?.issueInvoice.id; + ok(inv.data?.issueInvoice.status === "partially_paid" && inv.data.issueInvoice.totalPaise === 49900, `[${tag}] issueInvoice ${inv.data?.issueInvoice.invoiceNo}: CBC price from the catalog, Rs 499, partially paid`); + ok(code(await gql(owner, "mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 999999, mode: upi }) { id } }", { id: invoiceId })) === "CONFLICT", `[${tag}] overpayment → CONFLICT`); + const paid = await gql(owner, 'mutation($id: ID!) { recordPayment(invoiceId: $id, payment: { amountPaise: 29900, mode: upi, reference: "UPI9" }) { status payments { mode } vouchers { type entries { ledger { name } debitPaise creditPaise } } } }', { id: invoiceId }); + ok(paid.data?.recordPayment.status === "paid" && paid.data.recordPayment.vouchers.length === 3, `[${tag}] recordPayment → paid; sales + 2 receipt vouchers posted through finance-kit`); + const tb = await gql(owner, "{ trialBalance { differencePaise totalDebitPaise } outstandingReceivables { totalDuePaise } }"); + ok(tb.data?.trialBalance.differencePaise === 0 && tb.data.trialBalance.totalDebitPaise > 0 && tb.data.outstandingReceivables.totalDuePaise === 0, `[${tag}] trial balance balances (tally-kit); nothing outstanding`); + const invDoc = await gql(owner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { base64 fileName } h: invoiceDocument(invoiceId: $id, format: html) { html } }", { id: invoiceId }); + ok(Buffer.from(invDoc.data?.invoiceDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && invDoc.data.h.html.includes(inv.data.issueInvoice.invoiceNo), `[${tag}] invoiceDocument: invoice-kit PDF + HTML of the stored invoice (totals match)`); + const anyDoc = await gql(owner, '{ renderReport(request: { sample: "cbc" }, format: html) { html } invoiceTotals(request: { invoice: { invoiceNo: "T-1", patient: { name: "Kavita Sharma", age: 32, sex: "Female" }, items: ["cbc"] } }) reportTemplates { templates { id } } }'); + ok(anyDoc.data?.renderReport.html.includes("<html") && anyDoc.data.invoiceTotals.total > 0 && anyDoc.data.reportTemplates.templates.length > 20, `[${tag}] renderReport / invoiceTotals / reportTemplates from request data`); + ok(code(await gql(owner, "{ renderReport(request: { patient: { name: \"X\" } }) { fileName } }")) === "BAD_USER_INPUT", `[${tag}] bad report request → BAD_USER_INPUT`); + + const cash = (await db.rawRepos.ledgers.findBySystemKey(a.org.id, "cash"))!; + const capital = (await db.rawRepos.ledgers.findBySystemKey(a.org.id, "capital"))!; + ok(code(await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 50 }] } })) === "BAD_USER_INPUT", `[${tag}] unbalanced voucher → BAD_USER_INPUT`); + const journal = await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id voucherNo } }", { i: { branchId: a.branch.id, type: "journal", narration: "Capital", entries: [{ ledgerId: cash.id, debitPaise: 100000 }, { ledgerId: capital.id, creditPaise: 100000 }] } }); + ok(/^JRN\//.test(journal.data?.postVoucher.voucherNo ?? ""), `[${tag}] postVoucher (tally-kit): ${journal.data?.postVoucher.voucherNo}`); + ok(code(await gql(otherOwner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: other.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] } })) === "NOT_FOUND", `[${tag}] cannot post into another organisation's ledgers`); + ok(code(await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "X", qty: 0, ratePaise: 100 }] } })) === "BAD_USER_INPUT", `[${tag}] qty 0 → BAD_USER_INPUT`); + + // ---------------------------------------------------------------- permissions (front desk, branch A) + ok(code(await gql(desk, "{ trialBalance { differencePaise } }")) === "FORBIDDEN", `[${tag}] front desk cannot read the books → FORBIDDEN`); + const deskA = await gql(desk, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: a.branch.id, name: "Walk In", sex: "male" } }); + const deskB = await gql(desk, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { uhid } }", { i: { branchId: branchB.id, name: "Walk In", sex: "male" } }); + ok(deskA.data?.registerPatient.uhid && code(deskB) === "FORBIDDEN", `[${tag}] front desk registers in branch A, not branch B`); + await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: branchB.id, patientId, items: [{ code: "CBC" }] } }); + const deskList = await gql(desk, "{ invoices { branch { code } } }"); + ok(deskList.data?.invoices.length > 0 && deskList.data.invoices.every((i: any) => i.branch.code === "A01"), `[${tag}] lists only show the viewer's branches`); + ok(code(await gql(desk, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ code: "CBC" }], billDiscountPaise: 100 } })) === "FORBIDDEN", `[${tag}] discounts need invoice:discount`); + ok((await gql(desk, "query($id: ID!) { invoice(id: $id) { vouchers { id } } }", { id: invoiceId })).data?.invoice.vouchers.length === 0, `[${tag}] nested vouchers hidden without accounts:read`); + + // ---------------------------------------------------------------- tenant isolation + ok(code(await gql(otherOwner, "query($id: ID!) { invoice(id: $id) { id } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] another organisation cannot read this invoice`); + ok((await gql(otherOwner, "query($id: ID!) { patient(id: $id) { id } }", { id: patientId })).data?.patient === null, `[${tag}] …or this patient`); + ok(code(await gql(otherOwner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { fileName } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] …or print it`); + + // ---------------------------------------------------------------- N+1 + for (let i = 0; i < 8; i++) await gql(owner, "mutation($i: IssueInvoiceInput!) { issueInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, items: [{ name: "T", ratePaise: 1000 }] } }); + patientBatches = 0; + const list = await gql(owner, "{ invoices(limit: 10) { invoiceNo patient { name } } }"); + ok(list.data?.invoices.length === 10 && patientBatches === 1, `[${tag}] 10 invoices → ${patientBatches} batched patient query (no N+1)`); + + // ---------------------------------------------------------------- finance-kit + const rules = await gql(owner, "mutation { seedTaxRules { hsnSac ratePercent } }"); + ok(rules.data?.seedTaxRules.length > 0, `[${tag}] seedTaxRules (finance-kit defaults, verify with CA)`); + const cust = await gql(owner, 'mutation { createCustomer(input: { code: "city-hosp", name: "City Hospital", kind: hospital, stateCode: "22", creditLimitPaise: 100000, creditDays: 30 }) { id code ledger { name } } }'); + const customerId = cust.data?.createCustomer.id; + ok(cust.data?.createCustomer.code === "CITY-HOSP" && cust.data.createCustomer.ledger.name, `[${tag}] createCustomer with its receivable ledger`); + const b2b = await gql(owner, "mutation($i: CustomerInvoiceInput!) { issueCustomerInvoice(input: $i) { id totalPaise customer { code } } }", { i: { branchId: a.branch.id, patientId, customerId, items: [{ code: "CBC" }] } }); + ok(b2b.data?.issueCustomerInvoice.customer.code === "CITY-HOSP", `[${tag}] issueCustomerInvoice (tax rules applied)`); + ok(code(await gql(owner, "mutation($i: CustomerInvoiceInput!) { issueCustomerInvoice(input: $i) { id } }", { i: { branchId: a.branch.id, patientId, customerId, items: [{ name: "Big", ratePaise: 200000 }] } })) === "CREDIT_LIMIT_EXCEEDED", `[${tag}] over the credit limit → CREDIT_LIMIT_EXCEEDED`); + const yearRange = { from: new Date(Date.now() - 86400_000 * 30), to: new Date(Date.now() + 86400_000) }; + const stmt = await gql(owner, "query($c: ID!, $f: DateTime!, $t: DateTime!) { customerStatement(customerId: $c, from: $f, to: $t) { closingPaise open { bucket } ageing { notDue } } }", { c: customerId, f: yearRange.from, t: yearRange.to }); + ok(stmt.data?.customerStatement.closingPaise === b2b.data.issueCustomerInvoice.totalPaise && stmt.data.customerStatement.open[0].bucket === "not_due", `[${tag}] customerStatement: closing = billed, not yet due`); + const cn = await gql(owner, 'mutation($i: CreditNoteInput!) { issueCreditNote(input: $i) { creditNote { noteNo totalPaise } invoice { status creditedPaise } } }', { i: { invoiceId: b2b.data.issueCustomerInvoice.id, reason: "Test not done" } }); + ok(cn.data?.issueCreditNote.invoice.status === "credited", `[${tag}] issueCreditNote ${cn.data?.issueCreditNote.creditNote.noteNo} → invoice credited`); + const returns = await gql(owner, "query($f: DateTime!, $t: DateTime!) { gstr3b(from: $f, to: $t) { outwardNilExemptPaise verifyWithCA } gstr1(from: $f, to: $t) { docs { nature total } } }", { f: yearRange.from, t: yearRange.to }); + ok(returns.data?.gstr3b.verifyWithCA.length > 0 && Array.isArray(returns.data.gstr1.docs), `[${tag}] gstr1 + gstr3b data, with the verify-with-CA list`); + const csv = await gql(owner, "{ exportTrialBalance { fileName content } }"); + ok(/TOTAL/.test(csv.data?.exportTrialBalance.content ?? ""), `[${tag}] exportTrialBalance CSV ends with a TOTAL row`); + const partner = await gql(owner, 'mutation($b: ID!) { createPartner(input: { code: "cc1", name: "Collection Centre One", kind: collection_centre, branchId: $b, pan: "ABCDE1234F" }) { id branch { code } } }', { b: branchB.id }); + ok(partner.data?.createPartner.branch.code === "B01", `[${tag}] createPartner on branch B`); + const agr = await gql(owner, 'mutation($p: ID!, $d: Day!) { addAgreement(input: { partnerId: $p, kind: revenue_share, shareBp: 2500, effectiveFrom: $d }) { id shareBp } }', { p: partner.data?.createPartner.id, d: "2026-04-01" }); + ok(agr.data?.addAgreement.shareBp === 2500, `[${tag}] addAgreement (25% revenue share)`); + const period = istDay(new Date()).slice(0, 7); + const prev = await gql(owner, "query($p: ID!, $m: String!) { settlementPreview(partnerId: $p, period: $m) { grossPaise sharePaise netPaise tds { tdsPaise } } }", { p: partner.data?.createPartner.id, m: period }); + ok(prev.data?.settlementPreview.sharePaise === Math.round(prev.data.settlementPreview.grossPaise / 4) && prev.data.settlementPreview.grossPaise > 0, `[${tag}] settlementPreview: share 25% of branch B's billing`); + + // ---------------------------------------------------------------- lock dates + const today = istDay(new Date()); + await gql(owner, "mutation($d: Day!) { setLockDate(input: { upTo: $d, reason: \"test\" }) { lockedUpTo } }", { d: today }); + ok(code(await gql(owner, "mutation($i: PostVoucherInput!) { postVoucher(input: $i) { id } }", { i: { branchId: a.branch.id, type: "journal", entries: [{ ledgerId: cash.id, debitPaise: 100 }, { ledgerId: capital.id, creditPaise: 100 }] } })) === "PERIOD_LOCKED", `[${tag}] posting into a locked period → PERIOD_LOCKED`); + await gql(owner, "mutation { setLockDate(input: { upTo: null }) { id } }"); + + // ---------------------------------------------------------------- sessions and passwords + ok((await gql(owner, "mutation { requestPasswordReset(email: \"desk@apl.test\") }")).data?.requestPasswordReset === true && mails.length === 1, `[${tag}] requestPasswordReset sends a link through the mailer`); + ok((await gql(null, "mutation { requestPasswordReset(email: \"nobody@apl.test\") }")).data?.requestPasswordReset === true && mails.length === 1, `[${tag}] …and does not reveal unknown emails`); + const resetToken = new URL(mails[0]!.link, "http://x").searchParams.get("token"); + ok((await gql(null, "mutation($t: String!) { resetPassword(token: $t, newPassword: \"Quiet-River-5821\") }", { t: resetToken })).data?.resetPassword === true, `[${tag}] resetPassword with the emailed token`); + ok(code(await gql(desk, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] a password reset ends the old sessions`); + const desk2 = await login("desk@apl.test", "Quiet-River-5821"); + ok(!!desk2, `[${tag}] login with the new password`); + await gql(owner, "mutation($u: ID!) { disableUser(userId: $u) { status } }", { u: deskUser.data.createUser.id }); + ok(code(await gql(desk2, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] disableUser signs the user out at once`); + const sessions = await gql(owner, "{ mySessions { current } }"); + ok(sessions.data?.mySessions.some((s: any) => s.current), `[${tag}] mySessions marks the current session`); + await gql(owner, "mutation { logout }"); + ok(code(await gql(owner, "{ organization { id } }")) === "UNAUTHENTICATED", `[${tag}] logout ends the session`); + + // ---------------------------------------------------------------- server guards + ok(code(await gql(null, "{ __schema { types { name } } }")) === "GRAPHQL_VALIDATION_FAILED", `[${tag}] introspection disabled when INTROSPECTION=0`); + const deep = `{ invoices ${"{ booking { invoice ".repeat(7)}{ id }${" } }".repeat(7)} }`; + ok(/levels deep/.test((await gql(otherOwner, deep)).errors?.[0]?.message ?? ""), `[${tag}] queries deeper than 12 levels are refused`); + + await app.close(); +} + +/** The production path: the container builds its own database and auth from the environment. */ +async function fromConfig(url: string) { + console.log("\n[config]"); + const container = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url }) }); + const app = await buildServer(container); + const post = async (payload: unknown, token?: string) => + (await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, payload: JSON.stringify(payload) })).json() as Gql; + const login = await post({ query: 'mutation { login(input: { email: "owner@apl.test", password: "Lab-Owner-2026!" }) { token } }' }); + const org = await post({ query: "{ organization { code } }" }, login.data?.login.token); + ok(org.data?.organization.code === "APL", "[config] createContainer from env: own db-kit + auth-kit (memory sessions), login works"); + const pre = await app.inject({ method: "OPTIONS", url: "/graphql", headers: { origin: "http://localhost:3000", "access-control-request-method": "POST" } }); + ok(pre.statusCode < 300 && pre.headers["access-control-allow-origin"] !== undefined, "[config] CORS preflight answered"); + await app.close(); +} + +/** Static checks: the documented example operations stay valid against the schema. */ +function staticChecks() { + console.log("\n[static]"); + const schema = buildSchema(); + const dir = new URL("../docs/examples/", import.meta.url); + for (const f of readdirSync(dir).filter((n) => n.endsWith(".graphql"))) { + const errs = validate(schema, parse(readFileSync(new URL(f, dir), "utf8"))); + ok(errs.length === 0, `docs/examples/${f} is valid${errs.length ? `: ${errs.map((e) => e.message).join("; ")}` : ""}`); + } +} + +async function main() { + const t0 = Date.now(); + staticChecks(); + const backends = (process.env.TEST_BACKENDS ?? "pg,mongo").split(",").map((s) => s.trim()); + + if (backends.includes("pg")) { + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + // in the temp folder: as root, embedded-postgres runs initdb as the "postgres" user, who may not write here + const dir = join(tmpdir(), `mgpl-api-pg-${process.pid}`); + rmSync(dir, { recursive: true, force: true }); + const pg = new EmbeddedPostgres({ databaseDir: dir, user: "postgres", password: "postgres", port: 54331, persistent: false }); + await pg.initialise(); + await pg.start(); + await pg.createDatabase("mgpl_api_test"); + const url = "postgres://postgres:postgres@localhost:54331/mgpl_api_test"; + const db = await createDb({ backend: "pg", pg: { url }, posting: financePosting }); + try { + await suite(db, "pg"); + await fromConfig(url); + } finally { + await db.close(); + await pg.stop(); + rmSync(dir, { recursive: true, force: true }); + } + } + + if (backends.includes("mongo")) { + const { MongoMemoryReplSet } = await import("mongodb-memory-server"); + const rs = await MongoMemoryReplSet.create({ replSet: { count: 1, storageEngine: "wiredTiger" } }); + const db = await createDb({ backend: "mongo", mongo: { uri: rs.getUri("mgpl_api_test") }, posting: financePosting }); + try { + await suite(db, "mongo"); + } finally { + await db.close(); + await rs.stop(); + } + } + + console.log(`\n${passed} passed, ${failed} failed in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..974bf4f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["es2023"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node"], + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["src", "scripts", "test"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..32d1c76 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +// The server bundle: our code only; the kits and drivers stay in node_modules. +export default defineConfig({ + entry: { main: "src/main.ts", index: "src/index.ts" }, + format: ["esm"], + dts: { entry: { index: "src/index.ts" } }, + target: "node20", + platform: "node", + clean: true, + packages: "external", +}); From c0fb62f58536db7302edaf4b71318a54c130eb50 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 21:01:22 +0000 Subject: [PATCH 2/2] test: validate the client's GraphQL operations against the schema Pins @mgpl-diagnostics/client at the merge of client#6 (1.1.0). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA5Vx77ihDsLiZpyg5duJV --- docs/11-testing.md | 7 ++++--- kits.json | 2 +- llms-full.txt | 17 +++++++++++++---- test/run.ts | 8 ++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/11-testing.md b/docs/11-testing.md index 30afa09..0b60fc2 100644 --- a/docs/11-testing.md +++ b/docs/11-testing.md @@ -25,6 +25,7 @@ flowchart LR | Books | chart setup, trial balance balances, manual voucher, unbalanced voucher refused | | Finance | tax rules, customer + credit invoice, credit limit, statement, credit note, GSTR-1/3B data with the verify-with-CA list, trial balance CSV with TOTAL row, partner + agreement + settlement preview, lock date → `PERIOD_LOCKED` | | Documents | booking report PDF and non-header HTML, stored invoice PDF + HTML (totals match), `renderReport` / `invoiceTotals` / `reportTemplates`, bad request → `BAD_USER_INPUT` | +| Client contract | all client operations valid; every root field covered | | Performance | 10 invoices with nested `patient` → one batched lookup (no N+1) | Not covered yet (good next tests): refunds and invoice cancellation, settlement accrue/pay/cancel, cash @@ -46,9 +47,9 @@ on both backends, the build, and checks that `schema.graphql`, `docs/04-api-refe ## The client contract -`kits.json` pins `@mgpl-diagnostics/client`. Once the client ships its GraphQL operations, the suite -validates every operation the client sends against `buildSchema()` and checks that each root field has -one, so a renamed field fails here before it reaches an app. +`kits.json` pins `@mgpl-diagnostics/client`. The `[static]` checks validate every GraphQL operation the +client sends (138) against `buildSchema()` and check that each query and mutation has one, so a renamed +or new field fails here before it reaches an app. Change the client first, then bump its ref. ## Adding a test diff --git a/kits.json b/kits.json index 80a4e04..67983dd 100644 --- a/kits.json +++ b/kits.json @@ -23,7 +23,7 @@ }, { "name": "client", - "ref": "b26a341f91aef3665a646cc6efe0a748d50806f3" + "ref": "d973de38d278e5047f9591d7171431be37d045d9" }, { "name": "invoice-kit", diff --git a/llms-full.txt b/llms-full.txt index dc825e6..45b9008 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1389,6 +1389,7 @@ flowchart LR | Books | chart setup, trial balance balances, manual voucher, unbalanced voucher refused | | Finance | tax rules, customer + credit invoice, credit limit, statement, credit note, GSTR-1/3B data with the verify-with-CA list, trial balance CSV with TOTAL row, partner + agreement + settlement preview, lock date → `PERIOD_LOCKED` | | Documents | booking report PDF and non-header HTML, stored invoice PDF + HTML (totals match), `renderReport` / `invoiceTotals` / `reportTemplates`, bad request → `BAD_USER_INPUT` | +| Client contract | all client operations valid; every root field covered | | Performance | 10 invoices with nested `patient` → one batched lookup (no N+1) | Not covered yet (good next tests): refunds and invoice cancellation, settlement accrue/pay/cancel, cash @@ -1410,9 +1411,9 @@ on both backends, the build, and checks that `schema.graphql`, `docs/04-api-refe ## The client contract -`kits.json` pins `@mgpl-diagnostics/client`. Once the client ships its GraphQL operations, the suite -validates every operation the client sends against `buildSchema()` and checks that each root field has -one, so a renamed field fails here before it reaches an app. +`kits.json` pins `@mgpl-diagnostics/client`. The `[static]` checks validate every GraphQL operation the +client sends (138) against `buildSchema()` and check that each query and mutation has one, so a renamed +or new field fails here before it reaches an app. Change the client first, then bump its ref. ## Adding a test @@ -1954,7 +1955,7 @@ flowchart TD }, { "name": "client", - "ref": "b26a341f91aef3665a646cc6efe0a748d50806f3" + "ref": "d973de38d278e5047f9591d7171431be37d045d9" }, { "name": "invoice-kit", @@ -7947,6 +7948,7 @@ import { join } from "node:path"; import { hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; import { createDb, seedOrganization, type Db, type Repositories } from "@mgpl-diagnostics/db-kit"; import { financePosting, istDay } from "@mgpl-diagnostics/finance-kit"; +import { OPERATIONS } from "@mgpl-diagnostics/client"; import { parse, validate } from "graphql"; import { loadConfig } from "../src/config/env"; import { buildSchema } from "../src/graphql/schema"; @@ -8171,6 +8173,13 @@ function staticChecks() { const errs = validate(schema, parse(readFileSync(new URL(f, dir), "utf8"))); ok(errs.length === 0, `docs/examples/${f} is valid${errs.length ? `: ${errs.map((e) => e.message).join("; ")}` : ""}`); } + // the client contract: every operation @mgpl-diagnostics/client sends is valid here, and every root field has one + const invalid = Object.entries(OPERATIONS).flatMap(([name, op]) => validate(schema, parse(op.document)).map((e) => `${name}: ${e.message}`)); + ok(invalid.length === 0, `client: all ${Object.keys(OPERATIONS).length} GraphQL operations are valid${invalid.length ? `: ${invalid.join("; ")}` : ""}`); + const covered = new Set(Object.values(OPERATIONS).map((op) => op.field)); + const roots = [...Object.keys(schema.getQueryType()!.getFields()), ...Object.keys(schema.getMutationType()!.getFields())]; + const missing = roots.filter((f) => !covered.has(f)); + ok(missing.length === 0, `client: every query and mutation has a client operation${missing.length ? ` (missing: ${missing.join(", ")})` : ""}`); } async function main() { diff --git a/test/run.ts b/test/run.ts index fa0a00a..fea2639 100644 --- a/test/run.ts +++ b/test/run.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { hashPassword, SYSTEM_ROLES } from "@mgpl-diagnostics/auth-kit"; import { createDb, seedOrganization, type Db, type Repositories } from "@mgpl-diagnostics/db-kit"; import { financePosting, istDay } from "@mgpl-diagnostics/finance-kit"; +import { OPERATIONS } from "@mgpl-diagnostics/client"; import { parse, validate } from "graphql"; import { loadConfig } from "../src/config/env"; import { buildSchema } from "../src/graphql/schema"; @@ -232,6 +233,13 @@ function staticChecks() { const errs = validate(schema, parse(readFileSync(new URL(f, dir), "utf8"))); ok(errs.length === 0, `docs/examples/${f} is valid${errs.length ? `: ${errs.map((e) => e.message).join("; ")}` : ""}`); } + // the client contract: every operation @mgpl-diagnostics/client sends is valid here, and every root field has one + const invalid = Object.entries(OPERATIONS).flatMap(([name, op]) => validate(schema, parse(op.document)).map((e) => `${name}: ${e.message}`)); + ok(invalid.length === 0, `client: all ${Object.keys(OPERATIONS).length} GraphQL operations are valid${invalid.length ? `: ${invalid.join("; ")}` : ""}`); + const covered = new Set(Object.values(OPERATIONS).map((op) => op.field)); + const roots = [...Object.keys(schema.getQueryType()!.getFields()), ...Object.keys(schema.getMutationType()!.getFields())]; + const missing = roots.filter((f) => !covered.has(f)); + ok(missing.length === 0, `client: every query and mutation has a client operation${missing.length ? ` (missing: ${missing.join(", ")})` : ""}`); } async function main() {