another step just a little remaining

This commit is contained in:
hamid
2026-06-21 00:05:07 +03:30
parent da42f15a32
commit 3fd147cf80
35 changed files with 4620 additions and 4537 deletions
+14 -1
View File
@@ -37,7 +37,20 @@
"Bash(node -e \"const n = require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/notistack'\\); console.log\\(Object.keys\\(n\\)\\)\")", "Bash(node -e \"const n = require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/notistack'\\); console.log\\(Object.keys\\(n\\)\\)\")",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\client\" -Force)", "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\client\" -Force)",
"Bash(Select-Object Name, PSIsContainer)", "Bash(Select-Object Name, PSIsContainer)",
"Bash(npm audit *)" "Bash(npm audit *)",
"Bash(*)",
"Read(*)",
"Edit(*)",
"Write(*)",
"Glob(*)",
"Grep(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"NotebookEdit(*)",
"Skill(*)",
"mcp__claude_ai_Mermaid_Chart__validate_and_render_mermaid_diagram",
"Edit(/.claude/skills/frontend-designer/**)"
], ],
"defaultMode": "bypassPermissions" "defaultMode": "bypassPermissions"
} }
+237
View File
@@ -0,0 +1,237 @@
---
name: frontend-designer
description: >-
Design and build UI for the Balinyaar client (Next.js 16 + MUI v9). Use when
creating or restyling any screen, page, component, layout, or visual in
client/ — turning a feature, mockup, or Figma design into branded, RTL-aware,
dark-mode-ready, i18n-complete React/MUI code. Covers the brand palette,
design tokens, typography, the App* component library, layout shells, icons,
and the hard rules every Balinyaar UI must follow.
---
# Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both
color schemes on the first try. This skill is the design contract; the engineering
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
before touching layout/provider/data code, **don't restate it**, and never violate it.
**Stack:** Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (`@mui/material`) ·
Emotion (RTL via `stylis-plugin-rtl`) · next-intl v4 · notistack. Everything below
lives under `client/src/`.
---
## 1. Brand identity
Balinyaar is a **trust-first home-nursing marketplace in Iran**. The visual tone is
calm, warm, clinical-but-human — not a cold medical dashboard. Default audience is
Persian (RTL); English is secondary.
**Logo mark** (`product/balinyaar.html` seed deck): deep-teal square, lowercase
display glyph in cream, a single terracotta dot. That trio — **teal ground, cream
text, terracotta accent** — is the whole identity. Use terracotta sparingly as the
single accent; teal carries everything else.
| Role | Light | Dark |
|------|-------|------|
| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) |
| Secondary — terracotta | `#d98c6a` | `#e6a98a` |
| Page surface | `#faf9f5` cream | `#0f1c19` deep teal |
| Paper / card | `#ffffff` | `#16302a` teal surface |
| Text primary | `#1b2521` ink | `#f3efe9` cream |
---
## 2. Design tokens — the two-layer system (read this before styling anything)
Colors exist in **two mirrored places** that must stay in sync. Pick the right one:
1. **MUI palette**`src/theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`).
Drives `--mui-palette-*` and all MUI component coloring. Reach it through MUI APIs:
`color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}`.
**This is the default for component styling.** It auto-switches with the color scheme.
2. **`--bal-*` CSS variables** — `src/theme/tokens.css`, defined under
`[data-mui-color-scheme='light'|'dark']`. The source of truth for **custom CSS
outside MUI's palette** and for **semantic feedback colors MUI doesn't define**:
`--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each `+ -contrast`).
Reference as `var(--bal-primary)`, `var(--bal-success-contrast)`, etc.
**Rules:**
- Styling a MUI component → use palette keys (`color="primary"`, `sx` palette refs).
- Need success/error/warning/info → use `--bal-*` tokens, **not** MUI defaults — the
MUI palette has no semantic colors, and these tokens are brand-harmonized.
- Need a custom color in raw CSS → add a `--bal-*` token (in **both** scheme blocks),
then `var(--…)`. **Never hard-code a hex** in `sx`, `styled`, or a component.
- Adding/changing a color means editing `tokens.css` **and** `colors.ts` together (the
file headers call out the sync requirement).
---
## 3. Typography & fonts
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius.
Don't override per-component unless deliberate; prefer multiples that read as related.
- Buttons: `textTransform: 'none'`, weight 600 (set globally in `typography.ts`). Never
re-uppercase button text.
- Headings (`h1``h6`) use the display font; `h6` is weight 600, the rest 700.
- **Fonts are loaded per-locale in `src/app/[locale]/layout.tsx` only** — Mikhak
(`--font-mikhak`) for `fa`, system stack for `en` (Space Grotesk `--font-space-grotesk`
is declared but not yet wired). **Never load a font in a component or page.**
- Use `<Typography variant=…>` for text — it inherits the correct direction-aware family
(`TYPOGRAPHY_RTL` = Mikhak everywhere for full Persian glyph coverage; `TYPOGRAPHY_LTR`).
Import neither directly in components; let the theme apply them.
---
## 4. The component library — reach for these before raw MUI
Shared primitives live in `src/components/` (barrel: `@/components`). Prefer the `App*`
wrapper over the bare MUI component — the wrappers carry the house defaults.
| Component | Use for | Notes |
|-----------|---------|-------|
| `AppButton` | all buttons & button-links | default `variant="contained"`; pass `to`/`href` to render as a link automatically; `startIcon`/`endIcon` accept an **icon name string** (e.g. `startIcon="search"`) or a node; non-MUI `color` strings become text color |
| `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` |
| `AppImage` | images | wrapper around next/image conventions |
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
| `UserInfo` | user avatar/identity block | feature component |
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
loading defaults). Change a default there, not per-call-site.
For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline
pixel margins for rhythm.
**New shared component?** Put it in `src/components/<Name>/<Name>.tsx` with an
`index.tsx` barrel, follow the `App*` prop-spreading + JSDoc style of `AppButton.tsx`,
and add a co-located `.test.tsx` (mandatory for anything imported in >1 place — see
CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
---
## 5. Layout & page shells
- **Private (authenticated) screens** render inside `PrivateLayout`
`TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant
`sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) +
a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with
`useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`.
- **Public screens** use `PublicLayout`.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
top-bar `56px` mobile / `64px` desktop, anchors). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
is fine for dashboards/tables.
- Use `useIsMobile()` (`@/hooks`) for responsive branching, or MUI breakpoints in `sx`.
---
## 6. Icons
Icons are a **name registry**, not free imports. `src/components/common/AppIcon/config.ts`
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
the name to `AppButton`/`AppIconButton` (`icon="search"`).
Currently registered: `default, logo, close, menu, settings, visibilityon,
visibilityoff, daynight, night, day, search, info, home, account, signup, login,
logout, notifications, error`.
**Need a new icon:** import it into `config.ts`, add a **lowercase** key to `ICONS`, then
reference by that name. Custom SVGs go in `AppIcon/icons/`. An unregistered name logs a
warning and falls back to `default` — never pass a raw MUI icon where a name is expected.
---
## 7. Non-negotiable rules for every Balinyaar UI
Every screen/component you produce must satisfy **all** of these:
1. **i18n — no hard-coded user-facing strings.** Add the key to **both**
`messages/en.json` and `messages/fa.json` (keep them in sync; top-level keys are
namespaces). Client: `useTranslations(ns)`. Server: `getTranslations(ns)`.
2. **RTL-safe.** `fa` is the default locale and is **RTL**. Never use directional
hard-coding (`marginLeft`, `left:`, `textAlign: 'left'`) for layout flow — use
logical/MUI-flipped props (`ml`→ MUI flips; prefer `marginInlineStart`, `start`/`end`,
`sx` shorthand that the RTL Emotion cache mirrors). Test the layout visually at `/fa`.
Do **not** pass `flexWrap`/`useFlexGap` as `Stack` props — use `sx={{ flexWrap: 'wrap' }}`.
3. **Dark-mode correct.** Pull every color from the palette or `--bal-*` tokens so it
switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
names → named constants (CLAUDE.md "Constants").
6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw.
---
## 8. Workflow: turning a design or feature into a screen
1. **Locate & scope.** Decide private vs public route; confirm the layout shell. Identify
which existing `App*` components and tokens already cover the design.
2. **Tokens first.** If the design needs a color/spacing not in the system, add the
`--bal-*` token (both schemes) + mirror in `colors.ts` if it's palette-level.
3. **Compose with primitives.** Build with `Box`/`Stack`/`Grid`/`Paper` + `App*`
wrappers. Keep raw MUI to layout/structural components.
4. **Wire copy through i18n.** Every label/placeholder/aria string → both message files.
5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`,
`@/lib/cookies/*`, `dispatchToast`/`useSnackbar`). Don't reinvent these.
---
## 9. Anti-patterns (design-specific — CLAUDE.md has the full engineering list)
- Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic tokens.
- `marginLeft`/`left`/`textAlign:'left'` for flow → breaks RTL; use logical props.
- Hard-coded English (or any) UI string → add to both message files.
- Loading a font in a component/page → fonts live only in `src/app/[locale]/layout.tsx`.
- `createTheme()` in a component → use `APP_THEME_LTR`/`APP_THEME_RTL`.
- Raw MUI icon where a registry name is expected → register it in `AppIcon/config.ts`.
- New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`.
---
## 10. Design ↔ Figma (optional)
A Figma MCP is connected. When the user provides a figma.com URL or asks to implement a
Figma frame, use the Figma tools (`get_design_context`, `get_screenshot`, `get_metadata`)
to pull the design, then map it onto **this** system: translate Figma colors to the
nearest `--bal-*`/palette token (don't introduce new hexes unless the design truly adds a
brand color), Figma type to `<Typography>` variants, and Figma components to the `App*`
library. Follow the Figma plugin skills (`/figma-use`, `/figma-generate-design`) when
pushing code back into Figma.
---
## Key files
| Concern | File |
|---------|------|
| Brand color tokens (CSS) | `client/src/theme/tokens.css` |
| MUI palette (mirror) | `client/src/theme/colors.ts` |
| Typography & fonts | `client/src/theme/typography.ts` |
| Theme factory (shape, schemes, dir) | `client/src/theme/theme.ts` |
| Theme provider / RTL cache | `client/src/theme/ThemeProvider.tsx` |
| Component library | `client/src/components/` (`@/components`) |
| Component defaults | `client/src/components/config.ts` |
| Icon registry | `client/src/components/common/AppIcon/config.ts` |
| Layout shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` |
| Engineering contract | `client/CLAUDE.md` |
+7 -37
View File
@@ -1,40 +1,10 @@
# AGENTS.md — Balinyaar # AGENTS.md
Guidance for AI coding agents working in this repository. Read this first, then the project-specific `AGENTS.md` for whichever side you are editing. The canonical guidance for AI coding agents in this repository lives in **[CLAUDE.md](CLAUDE.md)**
(same folder). Read that first, then the `CLAUDE.md` inside whichever project you are editing:
## What this repository is - Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/CLAUDE.md)
Balinyaar is a full-stack application split into two independent projects: `CLAUDE.md` is the single source of truth at every level of this repo; these `AGENTS.md` files are
just pointers so the convention is discoverable under either name.
| Path | Project | Stack | Detail |
| -------------------------- | -------------- | ----------------------------------------------------------- | ------------------------------- |
| [`client/`](client/) | Web frontend | Next.js (App Router), React, TypeScript, Material UI (MUI) | [client/AGENTS.md](client/AGENTS.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10), Clean Architecture, CQRS, EF Core | [server/AGENTS.md](server/AGENTS.md) |
The two communicate over HTTP/JSON (and optionally gRPC). The client reads the API base URL from `NEXT_PUBLIC_API_URL`; the server runs on `https://localhost:5002` by default.
There is **no root-level build** — each project is built and run on its own. `client/` and `server/` are not part of the same package/solution.
## Working agreements
- **Stay within one project per change** unless the task explicitly spans both. A frontend change rarely needs server files and vice-versa.
- **Match the surrounding style.** Each project has its own conventions (see its `AGENTS.md`). Mirror the existing patterns rather than introducing new ones.
- **Run the project's own checks** before declaring work done:
- client: `npm run lint` and `npm run type` (and `npm run test:ci` if tests are touched)
- server: `dotnet build` and `dotnet test`
- **Do not reintroduce template/starter scaffolding.** This repo was derived from two open-source starters; their branding, demo/showcase pages, NuGet template packaging, and `_TITLE_`/`_DESCRIPTION_` placeholders have been intentionally removed. Don't add them back.
- **Secrets**: never commit real connection strings, keys, or tokens. Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
## Quick start
```bash
# Frontend
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
```
## Naming note
The server's C# namespaces, projects, and solution file all use the `Baya*` prefix (e.g. `Baya.Web.Api`, `Baya.sln`). Keep new server code under the same `Baya.*` namespace convention.
+92
View File
@@ -0,0 +1,92 @@
# Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short.
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`.
> **Read the guide for the side you are editing — and only that one.**
> Working in `client/`? Read [client/CLAUDE.md](client/CLAUDE.md).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md) (+ [server/CONVENTIONS.md](server/CONVENTIONS.md)).
> You almost never need both. A frontend change does not touch server files, and vice-versa.
> `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder.
> `CLAUDE.md` is the single source of truth at every level.
---
## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and
nursing-company employees) list configurable services; families search, book, pay, and review.
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed
check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the
verification pipeline — is **not** in the code. It lives in [`product/`](product/):
| Doc | What it covers |
| --- | --- |
| [product/business-requirements.md](product/business-requirements.md) | Full functional/business requirements |
| [product/database-model.md](product/database-model.md) | ~50-table SQL Server schema + ER diagrams + rationale |
| [product/payments-and-installments.md](product/payments-and-installments.md) | BNPL, escrow ledger, settlement, VAT (with sources) |
| [product/Home-Nursing-Platform-Research*.md](product/) | Market research (EN + FA) |
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer
business rules from code — the code is young and the docs are the source of truth.
---
## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or
solution — each project is built, linted, and run on its own.
| Path | Project | Stack | Guide |
| --- | --- | --- | --- |
| [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) |
| [`product/`](product/) | Product docs | Markdown | — (see table above) |
The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default.
---
## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each
project documents its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:**
- client: `npm run check` (type + lint), plus `npm run test:ci` if you touched a tested component.
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`.
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were
intentionally removed. Don't add them back.
6. **Never commit secrets.** Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
Real connection strings, keys, and tokens never enter git.
7. **Keep docs honest.** If you change how something works, update the `CLAUDE.md` that describes it
in the same change. Stale instructions are worse than none.
---
## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`.
The product/brand name is **Balinyaar**; the server's `Baya*` prefix is a legacy code namespace —
do not rename it without explicit instruction.
---
## Quick start
```bash
# Frontend
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
```
-6
View File
@@ -1,6 +0,0 @@
{
"extends": "next/core-web-vitals",
"rules": {
"import/no-cycle": "error"
}
}
+8 -62
View File
@@ -1,66 +1,12 @@
# AGENTS.md — Balinyaar Web Client # AGENTS.md — Balinyaar Web Client
Agent-oriented guide to the frontend. For human setup/run instructions see [README.md](README.md). The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming,
i18n, cookies, and the rules every change must follow.
## Stack - Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill
- **Next.js** with the **App Router** (`src/app/`), statically exported (`output: 'export'` in `next.config.mjs` → builds to `out/`) `CLAUDE.md` is the single source of truth; this file is just a pointer so the convention is
- **React** + **TypeScript** (`strict` mode) discoverable under the `AGENTS.md` name too.
- **Material UI (MUI)** for components and theming (Emotion under the hood)
- **Jest** + **Testing Library** for unit tests
- ESLint (`eslint-config-next`) + Prettier
## Commands
| Task | Command |
| ------------ | ------------------ |
| Dev server | `npm run dev` |
| Build | `npm run build` |
| Lint | `npm run lint` |
| Type-check | `npm run type` |
| Format | `npm run format` |
| Test (watch) | `npm test` |
| Test (CI) | `npm run test:ci` |
Always run `npm run lint` and `npm run type` after a change. Run `npm run test:ci` when you touch a component that has a `*.test.tsx`.
## Directory map
```
src/
├── app/ Routes (App Router). Each folder = a route; page.tsx = the page.
│ ├── layout.tsx Root layout: store + theme providers, global <metadata>
│ ├── page.tsx "/" entry (delegates to home)
│ ├── home/ about/ Content pages
│ ├── auth/login|signup Auth pages (LoginForm is currently a stub)
│ └── me/ Authenticated user page
├── components/
│ ├── common/ Reusable App* primitives (see below) + ErrorBoundary
│ └── UserInfo/ Logged-in user summary
├── hooks/ useIsAuthenticated, useIsMobile, event hooks, useWindowSize
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar, config
├── store/ Global state: React context + reducer (AppStore, AppReducer)
├── theme/ ThemeProvider, light/dark palettes, colors, MUI-for-Next bridge
└── utils/ storage (local/session), navigation, environment, text, types
```
## Conventions (follow these)
- **Imports**: use the `@/*` alias for anything under `src/` (e.g. `import { AppButton } from '@/components'`). The alias is defined in `tsconfig.json`.
- **Barrel files**: most folders export through an `index.ts(x)`. Add new public exports there (e.g. a new common component is re-exported from `src/components/common/index.tsx`, which `src/components/index.tsx` re-exports).
- **Common components** live in `src/components/common/<Name>/` as a folder containing `<Name>.tsx`, `index.tsx` (re-export), and an optional `<Name>.test.tsx`. Existing ones: `AppAlert`, `AppButton`, `AppIcon`, `AppIconButton`, `AppImage`, `AppLink`, `AppLoading`. Prefer reusing these over raw MUI where one exists.
- **Icons**: reference icons by string name through `AppIcon`. The name→icon map is in `src/components/common/AppIcon/config.ts`; add new icons there (custom SVGs go in `AppIcon/icons/`).
- **Navigation**: sidebar/bottom-bar items are arrays of `LinkToPage` defined inside `PublicLayout.tsx` / `PrivateLayout.tsx`. Add a route → add an entry there to surface it in the nav.
- **Two layouts**: `PrivateLayout` (after auth) and `PublicLayout` (before auth); `CurrentLayout` picks between them based on auth state. The app name lives in the `TITLE_PRIVATE` / `TITLE_PUBLIC` constants in those files.
- **Auth is a stub.** `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` fake login by writing a placeholder token to session storage (`// TODO: AUTH:`). When implementing real auth, replace those spots and point requests at `NEXT_PUBLIC_API_URL` (the `server` project's JWT endpoints).
- **Theming**: use the theme/palette via MUI's `sx` / `useTheme`; don't hardcode colors. Light/dark values are in `src/theme/light.ts` and `dark.ts`; dark-mode toggle flows through the store.
- **Client vs server components**: files needing hooks/browser APIs start with `'use client';` (see `LoginForm.tsx`). Pages that only render markup can stay server components.
## Environment
Browser-exposed config comes from `NEXT_PUBLIC_*` variables (copy `.env.sample``.env`). The ones actually read in code: `NEXT_PUBLIC_ENV`, `NEXT_PUBLIC_DEBUG`, `NEXT_PUBLIC_PUBLIC_URL` (see `src/config.ts`) and `NEXT_PUBLIC_VERSION` (see `src/utils/environment.ts`). `NEXT_PUBLIC_API_URL` is the backend base URL.
## Gotchas
- Static export (`output: 'export'`) means **no server-side runtime** — no API routes, no SSR-only features, `images.unoptimized` is on.
- Don't reintroduce the removed demo/showcase route (`src/app/dev`) or `Demo*` components — they were template content.
+125 -31
View File
@@ -1,5 +1,92 @@
# Balinyaar Client — Claude Code Guidelines # Balinyaar Client — Claude Code Guidelines
The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. This file is the
**engineering contract** for everything under `client/`: providers, routing, data fetching, theming,
i18n, cookies, and the rules every change must follow.
- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (what to build) → [`product/`](../product/) — read the relevant doc before
designing a feature; don't infer business rules from code.
- Visual/design work (brand palette, tokens, component look-and-feel) → the **frontend-designer**
skill. It is the *design* contract and defers to this file for *engineering* rules. Don't restate
this file there.
## Stack
- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export** — the app
relies on server components, middleware, and server-side cookies. (`next.config.mjs` only wires the
next-intl plugin + `reactStrictMode`.)
- **React 19** + **TypeScript** (`strict`).
- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via
`stylis-plugin-rtl`).
- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`.
- **TanStack Query v5** for server state; a small **AppStore** (React context + reducer, `src/store/`)
for client state.
- **notistack** for toasts; **js-cookie** (wrapped) for client cookies.
- **Jest** + **Testing Library** for unit tests.
- Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**.
## Commands
| Task | Command |
| --- | --- |
| Dev server | `npm run dev` |
| Production build | `npm run build` |
| Type-check | `npm run type` |
| Lint | `npm run lint` |
| Lint + autofix | `npm run lint:fix` |
| **Type + lint (the gate)** | `npm run check` |
| Format (Prettier) | `npm run format` |
| Test (watch) | `npm test` |
| Test (CI, once) | `npm run test:ci` |
**Always run `npm run check` before declaring work done.** Run `npm run test:ci` as well when you
touch a component that has a co-located `*.test.tsx`.
## Quality gates: lint & type (how they work)
Both gates are plain CLI tools. **There is no `next lint`** — it was removed in Next 16; calling it
silently does nothing.
- `npm run type``tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*`.
- `npm run lint``eslint .` driven by **flat config** in `eslint.config.mjs`. That config spreads
`eslint-config-next` (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and
applies `eslint-config-prettier` last so ESLint never fights Prettier on formatting.
- `npm run check` runs type then lint. Keep it green.
Rules for this project:
- **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes in
`eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a
deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
scoped `// eslint-disable-next-line <rule>` with a one-line reason, never a file-wide disable.
- **Pin to ESLint 9.** ESLint 10 currently crashes with this Next 16 toolchain
(`scopeManager.addGlobals is not a function`). `import/no-cycle` is also disabled — its TS resolver
has an interface mismatch here (see the note in `eslint.config.mjs`).
## Golden rules (the short list)
A change is "done" only if it respects all of these — each has a full section below.
1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` is the root layout (it
renders `<html>`/`<body>`). A layout above it freezes `lang`/`dir`/messages on the default locale.
2. **Respect the server/client boundary.** Never import `next/headers`, `next-intl/server`, or
`@/lib/cookies/server` from a client component; never import `@/lib/cookies/client` from an RSC.
3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json`
and `messages/fa.json`.
4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain
calls live in `src/services/{domain}/apis/`.
5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`,
`js-cookie`, `localStorage`, or `sessionStorage` for app/auth state.
6. **Colors come from `tokens.css`** (`var(--…)`), never hard-coded in `sx`. Use the pre-built
`APP_THEME_LTR`/`APP_THEME_RTL`; never call `createTheme()` in a component.
7. **MUI v9 API only.** Use `sx={{ mb: 4 }}`, not `mb={4}` as a direct prop. No MUI-v5/v6-only props
(`useFlexGap`, `flexWrap` on `Stack`, `storageWindow`, `InitColorSchemeScript`, …).
8. **Shared components get a co-located `*.test.tsx`.** (A component imported from >1 place.)
9. **Magic strings become named constants** (`src/constants/` or a co-located `constants.ts`).
10. **`npm run check` is green** and translations stay in sync before you finish.
## Project Structure ## Project Structure
``` ```
@@ -11,11 +98,10 @@ client/
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js ├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
└── src/ └── src/
├── app/ ├── app/
│ ├── layout.tsx # Root RSC: reads locale + cookie → sets HTML attrs
│ ├── globals.css │ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa │ ├── fonts/ # Local font files (woff2) — Mikhak for fa
│ └── [locale]/ │ └── [locale]/
│ ├── layout.tsx # RSC: setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider │ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider
│ ├── (private-routes)/ │ ├── (private-routes)/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
│ │ └── page.tsx │ │ └── page.tsx
@@ -62,14 +148,14 @@ client/
│ └── use{Action}.ts # One hook per file — useQuery or useMutation │ └── use{Action}.ts # One hook per file — useQuery or useMutation
├── store/ # AppStore (Redux-like client state) ├── store/ # AppStore (Redux-like client state)
├── theme/ ├── theme/
│ ├── ColorSchemeScript.tsx # Inline <script> in <head> — sets attr + patches Storage │ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeCookieSync │ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
│ ├── colors.ts # LIGHT_PALETTE, DARK_PALETTE, BRAND │ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl' │ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once) │ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors │ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) │ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
│ └── index.ts # Public re-exports │ └── index.ts # Public re-exports (incl. ColorSchemeScript, ThemeProvider, getDirection)
├── constants/ # App-wide constants (routes, events, etc.) ├── constants/ # App-wide constants (routes, events, etc.)
├── hooks/ ├── hooks/
├── utils/ ├── utils/
@@ -80,21 +166,19 @@ client/
## Server / Client Component Boundaries ## Server / Client Component Boundaries
**Root layout** (`src/app/layout.tsx`) is a **lean Server Component** (RSC). It: **There is NO `src/app/layout.tsx`.** `src/app/[locale]/layout.tsx` is the application's **root layout** — it renders `<html>` and `<body>`. This is intentional and load-bearing (see below); do not re-introduce a layout above the `[locale]` segment.
- Reads locale from the **`x-app-locale` request header** (`HEADER_NAMES.LOCALE` from `@/constants`) set by the middleware (with fallback to `defaultLocale` for build-time pre-rendering where no request context exists).
- Calls `getThemeMode()` from `@/lib/cookies/server` (only `colorScheme` is used).
- Renders `<html>` with `data-mui-color-scheme`, `lang`, and `dir` from the locale/cookie.
- Does **NOT** wrap the tree with `NextIntlClientProvider`, `ThemeProvider`, or `AppStoreProvider` — those live in `[locale]/layout.tsx` where the locale is reliably sourced from URL params.
**WHY providers are NOT in root layout**: `getThemeMode()` and `headers()` are both called inside `try/catch` blocks that swallow `DYNAMIC_SERVER_USAGE`. Next.js therefore never observes a dynamic API error propagating from the root layout and treats it as a statically renderable component. At build time the locale falls back to `defaultLocale` ('fa'), the static HTML is cached, and all subsequent requests — including `/en` — are served the pre-rendered 'fa' messages. Moving providers to `[locale]/layout.tsx` sidesteps this entirely: that layout always has the correct locale from URL params. **Root / locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns the document shell, all i18n, and theme context. It:
- Sources the locale from the **URL param** (`params.locale`), validated against `routing.locales` (falls back to `defaultLocale`). No header reads.
**Locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns all i18n and theme context. It: - Renders `<html lang dir>` (`dir` from `getDirection(locale)`) plus `data-mui-color-scheme` from `getThemeMode()`.
- Loads the Mikhak font and attaches its CSS-variable class to `<html>` **only for `fa`** (see Fonts).
- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably. - Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably.
- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race. - Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race.
- Calls `getThemeMode()` for `defaultMode` needed by `ThemeProvider`.
- Wraps children with `NextIntlClientProvider`, `AppStoreProvider`, and `ThemeProvider`. - Wraps children with `NextIntlClientProvider`, `AppStoreProvider`, and `ThemeProvider`.
- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time. - Exports `generateStaticParams` so Next.js can enumerate locale routes at build time.
**WHY `<html>` MUST live in `[locale]/layout.tsx` and not a layout above it**: a layout above the `[locale]` segment is *shared* between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its `lang`/`dir`/messages therefore freeze on 'fa'/'rtl' for every route, including `/en`. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place where `<html lang dir>` reliably tracks the active locale.
**Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities. **Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities.
**Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail. **Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail.
@@ -213,6 +297,8 @@ We override all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in t
All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`. All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`.
This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each with a `*-contrast` text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does **not** define semantic colors, so prefer these tokens over MUI's defaults for brand consistency.
### Pre-built theme objects ### Pre-built theme objects
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`. `APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`.
@@ -237,7 +323,7 @@ Derived from locale via `getDirection(locale)` in `src/theme/direction.ts`:
`ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS. `ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
The root layout sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Changing locale → new request → new `dir` without any client-side state. `src/app/[locale]/layout.tsx` sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Because that layout is keyed on the `[locale]` URL param, changing locale re-renders it with a fresh `dir` — on both hard and soft navigation, no client-side state. **Do not** move the `<html dir>` render to a layout above `[locale]`; such a layout is shared across locales, gets statically cached with the default locale, and `dir` freezes on 'rtl' for `/en`.
**Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`. **Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`.
@@ -245,12 +331,12 @@ The root layout sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`
## Fonts ## Fonts
Two fonts are loaded on every request (both CSS variables are always defined on `<html>`): Fonts are loaded **per locale** — the Persian face is never shipped to English pages:
| Locale | Font | CSS variable | Source | | Locale | Font | CSS variable | Source | Loaded when |
|--------|------|--------------|--------| |--------|------|--------------|--------|-------------|
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | | `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes |
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` | | `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | (not currently wired — falls back to the system stack) | — |
**Typography exports:** **Typography exports:**
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`) - `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
@@ -258,10 +344,10 @@ Two fonts are loaded on every request (both CSS variables are always defined on
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports) - `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
**Rules:** **Rules:**
- Both font variables are loaded unconditionally so the CSS stacks can always reference them. - Mikhak is declared with `preload: false`, and its `.variable` class is attached to `<html>` **only when `locale === 'fa'`**. Both are required: a `next/font` loader called in the root layout would otherwise preload on every route (including `/en`), and `preload: false` ensures the woff2 only downloads when Persian text actually renders.
- Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file at build time. - Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file (`src/app/[locale]/layout.tsx`) at build time.
- Never load fonts inside components — all font loading lives in `src/app/layout.tsx`. - Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont` in layout.tsx, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`. - To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont`/`localFont`-equivalent in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
--- ---
@@ -293,12 +379,12 @@ Enforcement: before removing or renaming a shared component, check whether `src/
- **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`. - **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`.
- **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`. - **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`.
- **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`. - **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`.
- **Do not** put `NextIntlClientProvider`, `ThemeProvider`, or `AppStoreProvider` in the root layout — both `headers()` and `getThemeMode()` swallow `DYNAMIC_SERVER_USAGE`, so Next.js statically caches the root layout at build time with `defaultLocale` ('fa'). All `/en` requests then receive the cached 'fa' messages. These providers belong in `[locale]/layout.tsx` where locale is sourced from URL params. - **Do not** add a `src/app/layout.tsx` or any layout above the `[locale]` segment. Such a layout is shared across locales, gets statically cached at build time with `defaultLocale` ('fa'), and never re-renders on a locale switch — so `<html lang/dir>`, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for `/en`. `src/app/[locale]/layout.tsx` is the root layout (it renders `<html>`/`<body>`) precisely because it is the lowest boundary keyed on the locale param.
- **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues. - **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues.
- **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`. - **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`.
- **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior. - **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior.
- **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly. - **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly.
- **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/layout.tsx`. - **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/[locale]/layout.tsx`, with the `.variable` class attached conditionally per locale (Mikhak only for `fa`).
- **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs. - **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs.
- **Do not** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`. - **Do not** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`.
- **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`). - **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`).
@@ -308,7 +394,8 @@ Enforcement: before removing or renaming a shared component, check whether `src/
- **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`). - **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`).
- **Do not** read or write `document.cookie` directly — use the central client cookie manager. - **Do not** read or write `document.cookie` directly — use the central client cookie manager.
- **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`. - **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`.
- **Do not** pass `flexWrap` or `useFlexGap` as direct props to MUI `Stack` — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}` instead. `useFlexGap` was a MUI v5 opt-in and does not exist in v9.
- **Do not** use mui old api which cause errors
--- ---
## API Fetch Services ## API Fetch Services
@@ -340,14 +427,14 @@ Central fetch primitives live in `src/lib/api/`:
| Cookie | Constant | TTL | Set by | | Cookie | Constant | TTL | Set by |
|--------|----------|-----|--------| |--------|----------|-----|--------|
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `loginUser()` in `src/services/auth.ts` | | `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `loginUser()` in `src/services/auth.ts` | | `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
Both are regular (non-httpOnly) cookies so they are readable by both server and client. Both are regular (non-httpOnly) cookies so they are readable by both server and client.
**Lifecycle:** **Lifecycle:**
- Written after a successful login via `setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS` / `AUTH_REFRESH_COOKIE_OPTIONS` - Written after a successful login via `setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS` / `AUTH_REFRESH_COOKIE_OPTIONS`
- Deleted by `logoutUser()` and automatically by `clientFetch` on 401 - Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) / `useEventLogout()` (`src/hooks/auth.ts`), and automatically by `clientFetch` on 401
- Read by `serverFetch` via `getServerCookie(COOKIE_NAMES.ACCESS_TOKEN)` - Read by `serverFetch` via `getServerCookie(COOKIE_NAMES.ACCESS_TOKEN)`
- Read by `clientFetch` via `getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)` - Read by `clientFetch` via `getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)`
@@ -377,6 +464,13 @@ dispatchToast('Something went wrong', 'error')
`ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance. `ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance.
**Toast colors follow the theme.** `NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose `backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `*-contrast`) tokens in `tokens.css`. Because those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.
**Direction is inherited, not passed.** notistack's Portal mounts under `<body>`, so it inherits `dir` from `<html dir>` (set per-locale in the root layout). Do **not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary:
```tsx
<NotistackProvider>{children}</NotistackProvider>
```
--- ---
## Route Constants ## Route Constants
+36 -18
View File
@@ -1,8 +1,14 @@
# Balinyaar — Web Client # Balinyaar — Web Client
Frontend for the Balinyaar application, built with **Next.js (App Router)**, **React**, **TypeScript**, and **Material UI (MUI)**. Frontend for **Balinyaar**, a trust-first home-nursing marketplace in Iran. Built with
**Next.js 16 (App Router)**, **React 19**, **TypeScript**, and **Material UI (MUI) v9**.
It ships with a small library of reusable `App*` components, a theming system (light/dark), a lightweight global store, and public/private layouts wired for an authentication flow. It ships with internationalization (`fa`/`en`, RTL-first), a no-flash light/dark theme system, a small
library of reusable `App*` components, TanStack Query for server state, and public/private layout
shells wired for cookie-based authentication.
> **AI agents:** read [CLAUDE.md](CLAUDE.md) — it is the engineering contract (architecture, providers,
> data fetching, theming, i18n, cookies, and the rules every change must follow).
## Requirements ## Requirements
@@ -38,39 +44,51 @@ It ships with a small library of reusable `App*` components, a theming system (l
npm run dev npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000). Open [http://localhost:3000](http://localhost:3000). The root path redirects to `/fa` (default locale).
## Available scripts ## Available scripts
| Script | Description | | Script | Description |
| ------------------ | ------------------------------------------------------- | | ------------------ | ------------------------------------------------------- |
| `npm run dev` | Start the development server with hot reload | | `npm run dev` | Start the development server (Turbopack) with hot reload |
| `npm run build` | Production build (static export to `out/`) | | `npm run build` | Production build |
| `npm run start` | Serve a production build | | `npm run start` | Serve a production build |
| `npm run lint` | Run ESLint (`eslint-config-next`) | | `npm run lint` | Lint with ESLint (flat config, `eslint .`) |
| `npm run lint:fix` | Lint and auto-fix |
| `npm run type` | Type-check with `tsc --noEmit` |
| `npm run check` | Type-check **and** lint (the quality gate) |
| `npm run format` | Format the codebase with Prettier | | `npm run format` | Format the codebase with Prettier |
| `npm test` | Run Jest in watch mode | | `npm test` | Run Jest in watch mode |
| `npm run test:ci` | Run Jest once (CI) | | `npm run test:ci` | Run Jest once (CI) |
| `npm run type` | Type-check with `tsc` |
## Project structure ## Project structure
``` ```
src/ src/
├── app/ Next.js App Router routes (home, about, auth, me) + root layout ├── app/[locale]/ App Router routes, grouped into (public-routes) and (private-routes).
├── components/ Reusable UI: common App* components (AppButton, AppIcon, ...) + UserInfo │ [locale]/layout.tsx is the ROOT layout (renders <html>/<body>).
├── hooks/ Custom hooks (auth, layout, events, window size) ├── components/ Reusable UI: common App* components (AppButton, AppIcon, …) + UserInfo
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar ├── constants/ App-wide named constants (routes, headers, …)
├── store/ Global app store (React context + reducer) ├── hooks/ Custom hooks (auth, layout/mobile, events, window size)
├── theme/ MUI theme provider, light/dark palettes, colors ├── i18n/ next-intl routing + request config
── utils/ Helpers (storage, navigation, env, text, types) ── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar
├── lib/ api (client/server fetch), cookies, query (TanStack), toast
├── services/ Domain services: {domain}/{types,keys,apis,hooks}
├── store/ AppStore (React context + reducer) for client state
├── theme/ ThemeProvider, palettes, tokens.css, typography, direction
└── utils/ Helpers (storage, navigation, env, text, types)
``` ```
The `@/*` import alias maps to `src/*` (see `tsconfig.json`). The `@/*` import alias maps to `src/*` (see `tsconfig.json`).
Translation files live in [`messages/`](messages) (`en.json` / `fa.json`) and must stay in sync.
> For a deeper, agent-oriented map of conventions and where to make changes, see [AGENTS.md](AGENTS.md). > For the full agent-oriented map of conventions and where to make changes, see [CLAUDE.md](CLAUDE.md).
## Notes ## Notes
- `next.config.mjs` uses `output: 'export'`, producing a static site in `out/`. - This is a server-rendered Next.js app (App Router + middleware + server-side cookies) — **not** a
- Authentication in `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` is currently a stub (look for `// TODO: AUTH:`); wire it to the backend's JWT endpoints when implementing real auth. static export.
- Internationalization is locale-prefixed (`/fa`, `/en`); `fa` is the default and is RTL.
- Authentication is cookie-based (`access_token` / `refresh_token`), managed through
`src/lib/cookies/` and the `src/services/auth/` hooks. The API base URL comes from
`NEXT_PUBLIC_API_URL`.
+37
View File
@@ -0,0 +1,37 @@
// Flat ESLint config (ESLint 10 / Next.js 16).
//
// `next lint` was removed in Next 16, so linting runs through the ESLint CLI
// directly: `npm run lint`. The eslint-config-next package (v16) ships a
// ready-made flat-config array that wires up the Next core-web-vitals rules,
// the TypeScript rules, react / react-hooks / jsx-a11y / import plugins and the
// TypeScript parser — so we just spread it and layer our project rules on top.
import next from 'eslint-config-next';
import prettier from 'eslint-config-prettier/flat';
/** @type {import('eslint').Linter.Config[]} */
const config = [
// Things ESLint should never look at.
{
ignores: ['.next/**', 'out/**', 'coverage/**', 'node_modules/**', 'next-env.d.ts'],
},
// Next.js + TypeScript + React + import/a11y rule sets.
...next,
// Turn off every stylistic rule that would fight Prettier. Keep this last
// among the rule-providing entries so it wins. Formatting is owned by
// Prettier (`npm run format`), never by ESLint.
prettier,
// Project-specific rule overrides go here, e.g.:
// { rules: { 'react/jsx-key': 'error' } }
//
// NOTE: `import/no-cycle` is intentionally NOT enabled. On this toolchain the
// eslint-plugin-import TypeScript resolver bundled by eslint-config-next 16
// throws "invalid interface loaded as resolver" for that rule, and it cannot
// follow the `@/*` path alias to trace cycles anyway. Re-add it once the
// import-resolver-typescript interface is compatible.
];
export default config;
+12
View File
@@ -8,5 +8,17 @@
"light_mode": "Light mode", "light_mode": "Light mode",
"direction_ltr": "Switch to LTR", "direction_ltr": "Switch to LTR",
"direction_rtl": "Switch to RTL" "direction_rtl": "Switch to RTL"
},
"toastDemo": {
"title": "Toast Notifications Demo",
"subtitle": "Click a button to trigger each toast type",
"success_btn": "Success",
"error_btn": "Error",
"warning_btn": "Warning",
"info_btn": "Info",
"success_msg": "Profile saved successfully!",
"error_msg": "Failed to load data. Please try again.",
"warning_msg": "Your session will expire in 5 minutes.",
"info_msg": "A new version of the app is available."
} }
} }
+12
View File
@@ -8,5 +8,17 @@
"light_mode": "حالت روشن", "light_mode": "حالت روشن",
"direction_ltr": "تغییر به چپ‌به‌راست", "direction_ltr": "تغییر به چپ‌به‌راست",
"direction_rtl": "تغییر به راست‌به‌چپ" "direction_rtl": "تغییر به راست‌به‌چپ"
},
"toastDemo": {
"title": "نمایش اعلان‌های Toast",
"subtitle": "روی هر دکمه کلیک کنید تا نوع مربوطه نمایش داده شود",
"success_btn": "موفقیت",
"error_btn": "خطا",
"warning_btn": "هشدار",
"info_btn": "اطلاعات",
"success_msg": "پروفایل با موفقیت ذخیره شد!",
"error_msg": "بارگذاری اطلاعات ناموفق بود. لطفاً دوباره تلاش کنید.",
"warning_msg": "جلسه شما تا ۵ دقیقه دیگر منقضی می‌شود.",
"info_msg": "نسخه جدیدی از برنامه در دسترس است."
} }
} }
+2240 -2994
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -7,11 +7,13 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"format": "prettier ./ --write", "format": "prettier ./ --write",
"lint": "next lint", "lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "next start", "start": "next start",
"test": "jest --watch", "test": "jest --watch",
"test:ci": "jest --ci", "test:ci": "jest --ci",
"type": "tsc" "type": "tsc --noEmit",
"check": "npm run type && npm run lint"
}, },
"dependencies": { "dependencies": {
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
@@ -35,6 +37,7 @@
"stylis-plugin-rtl": "^2.1.1" "stylis-plugin-rtl": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -43,8 +46,9 @@
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "latest", "eslint": "^9.39.4",
"eslint-config-next": "latest", "eslint-config-next": "^16.2.9",
"eslint-config-prettier": "^10.1.8",
"jest": "latest", "jest": "latest",
"jest-environment-jsdom": "latest", "jest-environment-jsdom": "latest",
"next-router-mock": "latest", "next-router-mock": "latest",
@@ -2,14 +2,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PrivateLayout } from '@/layout'; import { PrivateLayout } from '@/layout';
/*
* Wraps all private (authenticated) routes with the application shell:
* TopBar, SideBar, and main content area.
*
* Authentication enforcement belongs in middleware — this layout only
* applies the visual structure. Add a middleware matcher for this group
* when real session-based auth is introduced.
*/
export default function PrivateRouteLayout({ children }: { children: ReactNode }) { export default function PrivateRouteLayout({ children }: { children: ReactNode }) {
return <PrivateLayout>{children}</PrivateLayout>; return <PrivateLayout>{children}</PrivateLayout>;
} }
@@ -1,3 +1,13 @@
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
export default function HomePage() { export default function HomePage() {
return null; const t = useTranslations('toastDemo')
return (
<Box sx={{ p: 4 }}>
<Typography>Balin yaar</Typography>
</Box>
)
} }
+70 -22
View File
@@ -1,26 +1,61 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Metadata, Viewport } from 'next';
import localFont from 'next/font/local';
import { setRequestLocale, getMessages } from 'next-intl/server'; import { setRequestLocale, getMessages } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getThemeMode } from '@/lib/cookies/server'; import { getThemeMode } from '@/lib/cookies/server';
import { AppStoreProvider } from '@/store'; import { AppStoreProvider } from '@/store';
import { ThemeProvider, getDirection } from '@/theme'; import { ThemeProvider, getDirection } from '@/theme';
import { BRAND } from '@/theme/colors';
import { NotistackProvider } from '@/lib/toast'; import { NotistackProvider } from '@/lib/toast';
import { QueryProvider } from '@/lib/query/QueryProvider'; import { QueryProvider } from '@/lib/query/QueryProvider';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import '../globals.css';
import '@/theme/tokens.css';
/* /*
* This layout is the correct place for NextIntlClientProvider because it * This is the application's ROOT layout — it renders <html> and <body>.
* receives the locale directly from URL params — no header reads, no caching
* surprises. setRequestLocale(locale) is called first so that any server
* component deeper in the tree that calls getLocale() / getTranslations()
* gets the right locale from React.cache instead of falling through to the
* header fallback.
* *
* getMessages({ locale }) passes the locale explicitly so the config * Why <html> lives here and NOT in a layout above the [locale] segment:
* callback in src/i18n/request.ts receives it via requestLocale directly * `lang` and `dir` must track the active locale, and the only layout that
* (Promise.resolve(locale)) rather than reading it from the cache — an * re-renders when the locale changes is the one keyed on the [locale] param.
* extra layer of defense against cache-ordering races. * A layout placed above [locale] is shared between /fa and /en, so it is
* statically cached with the defaultLocale and never re-renders on a locale
* switch — leaving `dir`/`lang` frozen on the default ('fa'/'rtl'). Sourcing
* the locale from URL params here means no header reads, no caching surprises.
*
* setRequestLocale(locale) is called first so that any server component
* deeper in the tree that calls getLocale() / getTranslations() gets the
* right locale from React.cache instead of falling through to the header
* fallback. getMessages({ locale }) passes the locale explicitly so the
* config callback in src/i18n/request.ts receives it via requestLocale
* directly (Promise.resolve(locale)) rather than reading it from the cache.
*/ */
// FA brand font — Mikhak, a free Persian typeface.
// preload: false + conditional className (below) ensure the woff2 files are
// only fetched on Persian routes, never on /en.
const mikhak = localFont({
src: [
{ path: '../fonts/Mikhak-Regular.woff2', weight: '400', style: 'normal' },
{ path: '../fonts/Mikhak-Medium.woff2', weight: '500', style: 'normal' },
{ path: '../fonts/Mikhak-Bold.woff2', weight: '700', style: 'normal' },
],
display: 'swap',
variable: '--font-mikhak',
preload: false,
});
export const viewport: Viewport = {
themeColor: BRAND.teal,
};
export const metadata: Metadata = {
title: 'Balinyaar | بالین‌یار',
description: 'Balinyaar web application',
manifest: '/site.webmanifest',
};
export default async function LocaleLayout({ export default async function LocaleLayout({
children, children,
params, params,
@@ -37,21 +72,34 @@ export default async function LocaleLayout({
setRequestLocale(safeLocale); setRequestLocale(safeLocale);
const messages = await getMessages({ locale: safeLocale }); const messages = await getMessages({ locale: safeLocale });
const { defaultMode } = await getThemeMode(); const { colorScheme, defaultMode } = await getThemeMode();
const dir = getDirection(safeLocale); const dir = getDirection(safeLocale);
// Only attach the Mikhak font variable on RTL (Persian) routes so the
// Persian typeface is not loaded for English pages.
const fontClassName = safeLocale === 'fa' ? mikhak.variable : undefined;
return ( return (
<NextIntlClientProvider locale={safeLocale} messages={messages}> <html
<AppStoreProvider> lang={safeLocale}
<ThemeProvider dir={dir} defaultMode={defaultMode}> dir={dir}
<QueryProvider> className={fontClassName}
<NotistackProvider> data-mui-color-scheme={colorScheme}
{children} >
</NotistackProvider> <body>
</QueryProvider> <NextIntlClientProvider locale={safeLocale} messages={messages}>
</ThemeProvider> <AppStoreProvider>
</AppStoreProvider> <ThemeProvider dir={dir} defaultMode={defaultMode}>
</NextIntlClientProvider> <QueryProvider>
<NotistackProvider>
{children}
</NotistackProvider>
</QueryProvider>
</ThemeProvider>
</AppStoreProvider>
</NextIntlClientProvider>
</body>
</html>
); );
} }
-60
View File
@@ -1,60 +0,0 @@
import type { ReactNode } from 'react';
import { Metadata, Viewport } from 'next';
import localFont from 'next/font/local';
import { headers } from 'next/headers';
import { getThemeMode } from '@/lib/cookies/server';
import { getDirection } from '@/theme';
import { BRAND } from '@/theme/colors';
import { routing } from '@/i18n/routing';
import { HEADER_NAMES } from '@/constants';
import './globals.css';
import '@/theme/tokens.css';
// FA brand font — Mikhak, a free Persian typeface
const mikhak = localFont({
src: [
{ path: './fonts/Mikhak-Regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/Mikhak-Medium.woff2', weight: '500', style: 'normal' },
{ path: './fonts/Mikhak-Bold.woff2', weight: '700', style: 'normal' },
],
display: 'swap',
variable: '--font-mikhak',
});
export const viewport: Viewport = {
themeColor: BRAND.teal,
};
export const metadata: Metadata = {
title: 'Balinyaar | بالین‌یار',
description: 'Balinyaar web application',
manifest: '/site.webmanifest',
};
export default async function RootLayout({ children }: { children: ReactNode }) {
let locale: string = routing.defaultLocale;
try {
const hdrs = await headers();
const headerLocale = hdrs.get(HEADER_NAMES.LOCALE);
if (headerLocale && routing.locales.includes(headerLocale as (typeof routing.locales)[number])) {
locale = headerLocale;
}
} catch {
// No request context (build-time prerendering) — use defaultLocale
}
const dir = getDirection(locale);
const { colorScheme } = await getThemeMode();
return (
<html
lang={locale}
dir={dir}
className={`${mikhak.variable}`}
data-mui-color-scheme={colorScheme}
>
<body>{children}</body>
</html>
);
}
@@ -1,6 +1,5 @@
'use client'; 'use client';
// See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx // See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx
/* eslint-disable jsx-a11y/anchor-has-content */
import { AnchorHTMLAttributes, forwardRef } from 'react'; import { AnchorHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
+3
View File
@@ -15,6 +15,9 @@ export function useIsAuthenticated() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => { useEffect(() => {
// SSR-safe: read the browser-only cookie once after mount so the server-rendered
// `false` reconciles on the client without a hydration mismatch. Deliberate setState.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsAuthenticated(Boolean(getClientCookie(COOKIE_NAMES.ACCESS_TOKEN))); setIsAuthenticated(Boolean(getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)));
}, []); }, []);
+4 -1
View File
@@ -39,7 +39,10 @@ function useIsMobileForNextJs() {
const [onMobileDelayed, setOnMobileDelayed] = useState(SERVER_SIDE_MOBILE_FIRST); const [onMobileDelayed, setOnMobileDelayed] = useState(SERVER_SIDE_MOBILE_FIRST);
useEffect(() => { useEffect(() => {
setOnMobileDelayed(onMobile); // Next.js don't allow to use useOnMobileXxx() directly, so we need to use this workaround // Defer the media-query result to after mount so SSR renders the mobile-first value
// and the client reconciles without a hydration mismatch. Deliberate setState.
// eslint-disable-next-line react-hooks/set-state-in-effect
setOnMobileDelayed(onMobile);
}, [onMobile]); }, [onMobile]);
return onMobileDelayed; return onMobileDelayed;
+2 -18
View File
@@ -1,19 +1,11 @@
'use client'; 'use client';
import { FunctionComponent, PropsWithChildren, useEffect } from 'react'; import { FunctionComponent, PropsWithChildren, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { LinkToPage } from '@/utils';
import { COOKIE_NAMES } from '@/lib/cookies'; import { COOKIE_NAMES } from '@/lib/cookies';
import { getClientCookie } from '@/lib/cookies/client'; import { getClientCookie } from '@/lib/cookies/client';
import { useAppStore } from '@/store'; import { useAppStore } from '@/store';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
/**
* Renders "Private Layout" composition
* @layout PrivateLayout
*/
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => { const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav'); const [,dispatch] = useAppStore();
const [, dispatch] = useAppStore();
useEffect(() => { useEffect(() => {
if (getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)) { if (getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)) {
@@ -21,16 +13,8 @@ const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
} }
}, [dispatch]); }, [dispatch]);
const sidebarItems: Array<LinkToPage> = [
{ title: t('home'), path: '/', icon: 'home' },
{ title: '404', path: '/wrong-url', icon: 'error' },
];
return ( return (
<TopBarAndSideBarLayout sidebarItems={sidebarItems} title="Balinyaar" variant="sidebarPersistentOnDesktop"> children
{children}
{/* <Stack component="footer">Copyright &copy; </Stack> */}
</TopBarAndSideBarLayout>
); );
}; };
+46 -3
View File
@@ -1,12 +1,55 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { SnackbarProvider } from 'notistack'; import { SnackbarProvider, MaterialDesignContent } from 'notistack';
import { styled } from '@mui/material/styles';
import { ToastBridge } from './ToastBridge'; import { ToastBridge } from './ToastBridge';
export function NotistackProvider({ children }: { children: ReactNode }) { /*
* Toast colors follow the theme rules: every value is a `--bal-*` token from
* src/theme/tokens.css (scheme-aware via [data-mui-color-scheme]). Although
* notistack renders in a Portal outside the MUI theme tree, the tokens are
* defined on <html>, so they cascade into the portal and switch with dark mode
* for free — no hard-coded colors here.
*
* Direction is inherited the same way: <html dir> cascades to the portal, so
* toasts are RTL on `fa` without any explicit prop.
*/
const StyledSnackbarContent = styled(MaterialDesignContent)({
'&.notistack-MuiContent-success': {
backgroundColor: 'var(--bal-success)',
color: 'var(--bal-success-contrast)',
},
'&.notistack-MuiContent-error': {
backgroundColor: 'var(--bal-error)',
color: 'var(--bal-error-contrast)',
},
'&.notistack-MuiContent-warning': {
backgroundColor: 'var(--bal-warning)',
color: 'var(--bal-warning-contrast)',
},
'&.notistack-MuiContent-info': {
backgroundColor: 'var(--bal-info)',
color: 'var(--bal-info-contrast)',
},
});
interface NotistackProviderProps {
children: ReactNode;
}
export function NotistackProvider({ children }: NotistackProviderProps) {
return ( return (
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> <SnackbarProvider
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
Components={{
success: StyledSnackbarContent,
error: StyledSnackbarContent,
warning: StyledSnackbarContent,
info: StyledSnackbarContent,
}}
>
<ToastBridge /> <ToastBridge />
{children} {children}
</SnackbarProvider> </SnackbarProvider>
-23
View File
@@ -19,31 +19,8 @@ const AppStoreProvider: FunctionComponent<PropsWithChildren> = ({ children }) =>
return <AppContext.Provider value={value}>{children}</AppContext.Provider>; return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}; };
/**
* Hook to use the AppStore in functional components
* @hook useAppStore
* import {useAppStore} from './store'
* ...
* const [state, dispatch] = useAppStore();
* OR
* const [state] = useAppStore();
*/
const useAppStore = (): AppContextReturningType => useContext(AppContext); const useAppStore = (): AppContextReturningType => useContext(AppContext);
/**
* HOC to inject the ApStore to class component, also works for functional components
* @hok withAppStore
* import {withAppStore} from './store'
* ...
* class MyComponent
*
* render () {
* const [state, dispatch] = this.props.appStore;
* ...
* }
* ...
* export default withAppStore(MyComponent)
*/
interface WithAppStoreProps { interface WithAppStoreProps {
appStore: AppContextReturningType; appStore: AppContextReturningType;
} }
+20
View File
@@ -42,6 +42,16 @@
/* Divider */ /* Divider */
--bal-divider: rgba(29, 74, 64, 0.14); --bal-divider: rgba(29, 74, 64, 0.14);
/* Feedback — toast / alert backgrounds (brand-harmonized; cream text) */
--bal-success: #1f6b50;
--bal-success-contrast: #f3efe9;
--bal-error: #a8392a;
--bal-error-contrast: #f3efe9;
--bal-warning: #8a6418;
--bal-warning-contrast: #f3efe9;
--bal-info: #1d4a40;
--bal-info-contrast: #f3efe9;
} }
/* ── Dark scheme ────────────────────────────────────────────────────────── */ /* ── Dark scheme ────────────────────────────────────────────────────────── */
@@ -68,4 +78,14 @@
/* Divider */ /* Divider */
--bal-divider: rgba(243, 239, 233, 0.14); --bal-divider: rgba(243, 239, 233, 0.14);
/* Feedback — toast / alert backgrounds (lifted for dark surfaces; cream text) */
--bal-success: #257659;
--bal-success-contrast: #f3efe9;
--bal-error: #b5402f;
--bal-error-contrast: #f3efe9;
--bal-warning: #97701f;
--bal-warning-contrast: #f3efe9;
--bal-info: #2f6b5e;
--bal-info-contrast: #f3efe9;
} }
+4 -2
View File
@@ -1,9 +1,11 @@
import { TypographyVariantsOptions } from '@mui/material'; import { TypographyVariantsOptions } from '@mui/material';
/** CSS variable injected by next/font/google (Space Grotesk) in src/app/layout.tsx */ /** Space Grotesk CSS variable. Not currently wired to a font loader; the LTR
* stack falls back to the system fonts until it is added to the locale layout. */
export const BRAND_FONT_VARIABLE_EN = '--font-space-grotesk'; export const BRAND_FONT_VARIABLE_EN = '--font-space-grotesk';
/** CSS variable injected by next/font/local (Mikhak) in src/app/layout.tsx */ /** CSS variable injected by next/font/local (Mikhak), attached to <html> only
* on `fa` routes in src/app/[locale]/layout.tsx. */
export const BRAND_FONT_VARIABLE_FA = '--font-mikhak'; export const BRAND_FONT_VARIABLE_FA = '--font-mikhak';
const SYSTEM_FONT_STACK = [ const SYSTEM_FONT_STACK = [
-2
View File
@@ -1,9 +1,7 @@
export const IS_SERVER = typeof window === 'undefined'; export const IS_SERVER = typeof window === 'undefined';
export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined'; export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined';
/* eslint-disable no-restricted-globals */
export const IS_WEBWORKER = export const IS_WEBWORKER =
typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope'; typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope';
/* eslint-enable no-restricted-globals */
/** /**
* Returns the value of the environment variable with the given name, raises an error if it is required and not set. * Returns the value of the environment variable with the given name, raises an error if it is required and not set.
+397
View File
@@ -0,0 +1,397 @@
# Balinyaar — Business Requirements
> **Purpose.** This document specifies the business requirements for **Balinyaar**, an MVP home-nursing marketplace in Iran where independent, individually-verified nurses list configurable services, families search and request a nurse, the nurse accepts, the family pays *through* the platform, the platform holds the money as an internal escrow ledger state, the nurse performs one or more visits with Electronic Visit Verification (EVV) check-in/out, and the platform pays the nurse weekly minus a commission. It is grounded in the verified payment/settlement research, the adversarial fact-checks, the database-model critiques, and the market/legal/verification research. It is written to be an MVP that is decisive but **not naive** about Iranian payment law, tax, and the realities of caring for vulnerable people at home. All monetary values are in **IRR (Rials)**; Toman is a display concern only and is converted to/from Rials solely at an external provider's API boundary.
**Date:** 2026-06-20
---
## How to read this document
Each section covers one business area and states, in order:
- **(a) Business requirements** — what the platform must do.
- **(b) Iran-specific considerations** — the local legal, fiscal, cultural, and infrastructural realities that shape the requirement.
- **(c) MVP vs DEFERRED** — an explicit callout of what ships at launch and what is intentionally postponed.
- **(d) Supporting database entities** — the entities (using the final names from the refined data model) that implement the requirement.
**Cross-cutting ground truths (true in every section):**
1. **Balinyaar cannot legally custody customer cash.** Under Iranian rules a پرداخت‌یار (payment facilitator) is forbidden from holding deposits, running wallets, or moving money between merchants. Money always flows card → licensed PSP → Shaparak settlement → **bank-registered IBANs**. "Escrow" is therefore an **internal ledger state** over funds custodied at a licensed provider/partner bank — never a Balinyaar-owned cash balance. (`ledger_entries`)
2. **VAT is 10%**, not 9% — it rose from 9% to 10% in 1403 (7% government + 3% municipal) and is treated as a configurable rate.
3. **BNPL is full-upfront.** A BNPL provider settles **one full-amount lump (net of its commission) to the merchant-of-record**, bears 100% of customer-default risk, and owns the customer's installment repayment entirely. A BNPL order behaves in Balinyaar's books exactly like a card payment landing net-of-fee. (`bnpl_transactions`)
4. **The nurse is paid by Balinyaar, weekly, on Balinyaar's own schedule** — gated on EVV completion and a closed dispute window — regardless of how the family paid.
---
## 1. Actors & Onboarding
### (a) Business requirements
- Three actor types: **customer** (the family member / payer), **nurse** (the independent caregiver / seller), and **admin** (Balinyaar back-office staff: support, finance, moderation, super-admin).
- **Phone number is the primary login credential.** Authentication is **phone-OTP** (one-time code by SMS). Email is optional/secondary (required only for admin accounts).
- The **patient** (care recipient) is a first-class entity distinct from the customer, because the payer (an adult child, a spouse) is frequently not the patient (an elderly parent, a newborn, a post-surgical adult). A customer may register multiple patients.
- **KYC timing is role- and risk-staged, not up-front-for-everyone:**
- A **customer** can register and browse with only a verified phone (OTP). National-ID KYC for customers is anti-fraud only and is **deferred** at launch.
- A **nurse** must complete the full verification pipeline (Section 2) before any of their service variants become bookable. `national_id` is populated only after the identity step passes.
- An **admin** is provisioned internally with RBAC roles.
- Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection).
### (b) Iran-specific considerations
- Phone-OTP is the dominant Iranian login norm and is also the anchor for **Shahkar** SIM↔national-ID binding (Section 2).
- Storing `national_id` only post-KYC matches the reality that identity is verified through gated vendor APIs, not collected casually at signup.
- Cultural reality: the booking flow must let a family member act on behalf of a patient who cannot self-advocate (infant, dementia, post-anesthesia). The customer/patient split is essential, not cosmetic.
### (c) MVP vs DEFERRED
- **MVP:** phone-OTP login; customer/nurse/admin roles; customer→patient (1:N); session management; admin RBAC; nurse onboarding gated on verification.
- **DEFERRED:** customer national-ID KYC (`customer_profiles.national_id_verified_at` exists but is optional/unused at launch); push notifications; social login; nursing-company (organization) self-onboarding.
### (d) Supporting database entities
`users`, `user_sessions`, `roles`, `user_roles`, `nurse_profiles`, `customer_profiles`, `patients`, `customer_addresses`.
---
## 2. Nurse Verification & Credentials
### (a) Business requirements
Verified trust is the **entire brand**. Vetting is **platform-owned, non-optional, and performed at the authoritative source** — never delegated to families, and never marketed as a check the platform does not actually perform. A nurse is bookable only after all *required* verification steps pass.
The pipeline is **data-driven**: the set of steps lives as rows in `verification_step_types` (not a code enum), so a new regulatory requirement (e.g., professional liability insurance) is one INSERT, not a migration. Each step can be **automated** (a KYC vendor API call) or **manual** (admin reviews an uploaded document). The aggregate `nurse_verifications` record rolls the step outcomes into a single status; `nurse_profiles.is_verified` flips to true **only inside the same transaction** that confirms every required step is `passed`.
The verification steps:
1. **Identity (KYC) — automated.** Match person ↔ کد ملی (national ID) ↔ phone ↔ face via one Iranian KYC vendor: national-ID validity/name match + photo/video **liveness** against the national-card / civil-registry (ثبت احوال) photo. Binds the profile to a real identity and a liveness selfie to defeat the stolen-identity / alias fraud pattern.
2. **Shahkar phone↔national-id binding — automated.** Confirm the login SIM is registered to the nurse's own کد ملی. The binding result (when, which vendor, the reference) is recorded, and **re-verification is triggered on phone change**. The shared-SIM failure mode (a SIM owned by a family member) is an explicit, handled state, not an undefined edge case.
3. **MoH پروانه صلاحیت حرفه‌ای (professional-competency license) — the single most important credential.** It is the MoH-mandated license for in-home nursing and **already bundles the criminal-record (سوء پیشینه) screen** plus scientific/ethical/health vetting. Verified against the MoH source (Rn.behdasht.gov.ir). No public B2B API exists, so the realistic method at launch is **nurse-uploaded document + manual admin verification against the official record**.
4. **نظام پرستاری (Iranian Nursing Organization / INO) membership — cross-check.** The INO membership number is captured and cross-checked (ino.ir) as a second source. Manual at launch.
5. **عدم سوء پیشینه (criminal-record certificate).** Consent-gated to the individual (obtained by the nurse via adliran.ir / their own ثنا password); **no company/employer API exists**. The nurse uploads it; it is **time-limited** — on expiry the step reverts to pending and a support alert is raised. Partly covered already by credential #3.
6. **IBAN ownership verification.** The payout IBAN (Sheba) must be proven to belong to the verified nurse — the account-holder national ID must equal the verified nurse national ID. Done via automated IBAN-ownership inquiry (استعلام شبا) where available, gating the **first payout**, not merely an admin eyeballing the number. Prevents paying a nurse's earnings into a third party's account (money-mule risk).
**Structured credential registry.** Beyond opaque uploaded files, the actual license **numbers**, issuing authority, holder-name-as-printed, and issue/expiry dates are stored as typed, queryable rows in `nurse_credentials`. This powers renewal/expiry alerts, the public "verified" trust badge, cross-checking against official portals, and audit defensibility — and survives the future arrival of an MoH/INO API.
**Continuous monitoring**, not one-and-done: license validity and the criminal-record certificate are periodically re-verified; Shahkar is re-run on phone change. Expiring credentials raise `support_alerts`.
### (b) Iran-specific considerations
- The license layer is **fragmented across regulators** (MoH vs INO) and has **no public B2B API** — manual verification against the official portal is the realistic MVP method; the structured registry makes that defensible and renewable.
- The criminal-record check is **consent-gated to the person** and cannot be pulled by a company — hence nurse-uploaded + re-requested periodically, leaning on the MoH license which already embeds it.
- Identity (Shahkar, liveness, national-ID match) is the **easy** layer because a competitive market of Iranian e-KYC vendors (Finnotech, U-ID, Jibbit, Farashensa, Verify, Kavoshak) already holds the regulator-gated upstream agreements. **Buy this, don't build it.**
- Document forgery is the documented attack (the "imposter nurse" pattern): verify at source, bind to national ID + liveness, never trust an uploaded PDF alone.
### (c) MVP vs DEFERRED
- **MVP:** all six steps; data-driven `verification_step_types`; structured `nurse_credentials` registry; manual MoH/INO verification; nurse-uploaded عدم سوء پیشینه with expiry; automated identity + Shahkar + IBAN-ownership via one KYC vendor; expiry-driven re-verification alerts; transactional `is_verified`.
- **DEFERRED:** automated MoH/INO license lookup (pending a B2B API); ML-driven fraud scoring (`fraud_flags` is modeled but inactive); professional-liability-insurance step (addable as a row when required).
### (d) Supporting database entities
`nurse_verifications`, `verification_step_types`, `verification_steps`, `verification_documents`, **`nurse_credentials`** (structured license registry), `nurse_bank_accounts` (IBAN ownership), `support_alerts` (expiry/renewal), `audit_logs`.
---
## 3. Service Catalog & Pricing
### (a) Business requirements
- **Admin defines the catalog skeleton:** top-level **service categories** (e.g., مراقبت از سالمند / Elderly Care, مراقبت پس از جراحی / Post-Surgery Recovery, مراقبت از نوزاد / Infant Care, مدیریت بیماری مزمن / Chronic Illness Management) and **configurable option dimensions** as admin-managed **option groups** (e.g., تعداد بیمار / patient count, نوع شیفت / shift type) each with concrete **option values** (e.g., ۱ نفر, ۲ نفر, شبانه‌روزی). Admin can add new dimensions without a schema change.
- **Each nurse defines their own offerings as variants.** A **variant** is the atomic bookable unit: a category + a chosen combination of option values + the nurse's **own price** and **price unit**. A nurse may have many variants per category, one per combination they choose to offer and price independently.
- **Price units** must support the real shapes of home nursing: `per_hour`, `per_session`, `per_half_day`, `per_day`, and `per_24h` (شبانه‌روزی / live-in). For hourly variants an estimated duration helps the customer estimate total cost.
- The variant `display_name` auto-generates from option labels but is nurse-editable. Nurses can deactivate (not delete) a variant; deactivated variants cannot be booked.
- Catalog and prices are **snapshotted onto the booking** at booking time (`variant_snapshot_json`) so historical records survive later edits.
### (b) Iran-specific considerations
- Iranian competitors sell exactly these shapes — hourly / daily / 24-hour (شبانه‌روزی) shifts and multi-day packages — so `per_24h` and `per_day` are first-class, not edge cases.
- Competitor pricing is opaque and "توافقی" (negotiable); **transparent, upfront, nurse-set pricing is a deliberate differentiator** families value.
- All catalog tables carry `name_fa` / `name_en` pairs (Persian primary).
### (c) MVP vs DEFERRED
- **MVP:** admin categories + option groups/values; nurse variants with own price + price unit across all five units; activate/deactivate; snapshotting.
- **DEFERRED:** holiday/surge pricing rules; a lighter "companionship / daily-living" tier (modeled as a future category); dynamic/tiered commission per category.
### (d) Supporting database entities
`service_categories`, `service_option_groups`, `service_option_values`, `nurse_service_variants` (carries `price`, `price_unit`), `nurse_service_variant_options`.
---
## 4. Search & Matching
### (a) Business requirements
- Families search by **service category**, **geography** (city, and optionally district), price, and availability, with results sortable by rating.
- **Geography** is driven by nurse-declared **service areas**: a nurse covers one or more cities, optionally specific districts; a city-level row (no district) means the whole city.
- **Search must be cheap from day one.** The naive query joins nurse profile (verified + accepting) → variants (category/price) → variant options → service areas → rating across 4+ tables. Instead a **denormalized `nurse_search_index`** holds one flat row per active, bookable variant with all search-relevant fields, maintained on write. A row exists **only** when the nurse is `is_verified` and not suspended and the variant `is_active`. This is far cheaper than introducing Elasticsearch at MVP stage.
- **Same-gender caregiver matching** is a first-class filter and a near-hard requirement: in Iranian bodily-care (bathing, toileting, intimate post-surgical care) same-gender caregiving is culturally decisive, not optional. The customer specifies a required caregiver gender on the booking request (`required_caregiver_gender`), and nurse gender is an exposed search filter so families can narrow to same-gender caregivers up front. The patient's gender (`patients.gender`) and the nurse's gender support this matching.
### (b) Iran-specific considerations
- District granularity varies: in Tehran, districts map to the 22 official municipal مناطق; in smaller cities they are major neighborhoods. Districts are optional.
- **Same-gender matching is the single most Iran-specific matching constraint** — every real elder/post-surgical bodily-care request implies it. It must be surfaced before booking, not discovered after.
- White-space opportunity: incumbents concentrate ~99% in Tehran/Karaj; the search/area model must work for under-served second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom).
### (c) MVP vs DEFERRED
- **MVP:** category + city/district geo search; `nurse_search_index` denormalization; same-gender filter via `required_caregiver_gender`; rating sort.
- **DEFERRED:** map-based discovery; availability-window filtering as a hard constraint (availability slots are soft guidance at launch); algorithmic ranking beyond rating; continuity-of-carer "preferred nurse" suggestions.
### (d) Supporting database entities
`nurse_service_areas`, `cities`, `districts`, **`nurse_search_index`**, `nurse_service_variants`, `nurse_profiles` (rating, gender via `users`), `patients.gender`; `booking_requests.required_caregiver_gender` (the requested constraint).
---
## 5. Booking & Scheduling
### (a) Business requirements
The lifecycle has two phases separated into two tables so each table's invariants stay clean: a **request phase** (no money) and a **booking phase** (always implies captured payment).
**Request → accept → pay → confirm lifecycle:**
1. Customer submits a **booking request** (nurse, patient, variant, address, date/time, requested caregiver gender, customer notes). Status `pending_nurse_response`.
2. The nurse must respond before a **response deadline** (`nurse_response_deadline_at`, computed from config and frozen on the request). The nurse **accepts**`accepted_awaiting_payment`, or **rejects**`rejected_by_nurse`, or the deadline passes → `expired_no_response`.
3. On accept, a **30-minute payment window** opens (`payment_deadline_at`). The customer pays within it → a `bookings` row is created (`confirmed`). If the window lapses → `payment_deadline_expired`.
**Single-visit AND multi-session / long-duration engagements must both be representable.** Home nursing is frequently multi-visit: post-surgery daily visits for ten days, month-long nightly or شبانه‌روزی (24h live-in) care. A booking therefore carries a `session_count` and owns **N `booking_sessions`** (one row per scheduled visit), each with its own schedule, its own EVV check-in/out, and its own payout eligibility. A single EVV per booking cannot represent a multi-day engagement, so the engagement-to-session split is the core scheduling model.
**Booking lifecycle:** `pending_payment``confirmed` (payment captured) → `in_progress` (first/relevant session check-in) → `completed` (sessions checked out) → optionally `disputed``closed`; or `cancelled` before service. Allowed transitions are guarded explicitly so the booking and EVV state machines cannot silently contradict.
**Snapshots:** `variant_snapshot_json` and `address_snapshot_json` freeze the service and address at booking time.
### (b) Iran-specific considerations
- Multi-session and شبانه‌روزی live-in care is the **dominant** elder-care shape in Iran, not a niche — modeling only single visits would fail to represent demand.
- Heavy platform control over multi-visit scheduling **strengthens a worker-misclassification argument** under labor law; this is flagged for counsel, and the platform deliberately keeps the nurse's accept/reject autonomy per request.
- Availability slots/exceptions are **soft guidance only** (informing search), not hard blocks — the nurse still individually accepts or rejects each request, which also fits the Shamsi week and holiday rhythm.
### (c) MVP vs DEFERRED
- **MVP:** request→accept→pay→confirm lifecycle with response deadline + 30-min payment window; single-visit bookings; `booking_sessions` for multi-session/long-duration engagements with per-session EVV and payout; explicit status-transition guards; snapshots; soft availability slots/exceptions.
- **DEFERRED:** open-ended recurring schedules (`recurring_booking_schedules` modeled, inactive — launch is all finite engagements); milestone/progress-payment UX beyond per-session accrual; hard availability-based booking blocks.
### (d) Supporting database entities
`booking_requests` (carries `nurse_response_deadline_at`, `payment_deadline_at`, `required_caregiver_gender`), `bookings` (carries `session_count`, `dispute_window_ends_at`, fee split), **`booking_sessions`**, `booking_care_instructions`, `nurse_availability_slots`, `nurse_availability_exceptions`, `nurse_service_variants`, `patients`, `customer_addresses`.
---
## 6. EVV / Service Delivery
### (a) Business requirements
- **Electronic Visit Verification (EVV)** is the authoritative record that a visit actually happened, for how long, and where. The nurse **clocks in and out via the app per session**, capturing GPS coordinates and timestamps.
- An **address-match tolerance** check computes whether the nurse's GPS at check-in falls within an acceptable radius of the booking address (`evv_location_tolerance_meters`). A mismatch is **advisory** — it raises a support alert / review flag but does **not** auto-cancel; it does not silently block the visit.
- If the nurse has not checked in by a configurable threshold after the scheduled start, a **no-show / late support alert** is created and the family is notified.
- **Payout is gated on EVV completion.** A session/booking becomes payout-eligible only after EVV check-out **and** the dispute window has closed (Section 10). EVV completion is the trigger that lets the booking enter the next weekly payout batch; for a multi-session engagement, payout accrues per completed session.
### (b) Iran-specific considerations
- EVV is the core operational-trust mitigation for **unobserved in-home care** of vulnerable patients who often cannot reliably report what happened (infants, dementia, post-anesthesia) — the platform compensates for unobservability with structured proof of service.
- Releasing escrow against proof of service is also a financial-correctness requirement under the Iranian "hold then pay weekly" model — the platform must not pay a nurse for a visit that has no EVV evidence.
### (c) MVP vs DEFERRED
- **MVP:** per-session GPS check-in/out, timestamps, address-match tolerance flag, no-show alerting, payout gated on EVV completion + closed dispute window.
- **DEFERRED:** continuous geofencing during a live-in shift; supervisory tele-check-ins; family-visible live care logs; consented in-home cameras.
### (d) Supporting database entities
`visit_verifications` (per session, with check-in/out GPS, timestamps, `check_in_address_match`, status), `booking_sessions`, `support_alerts` (no-show / location-mismatch), `platform_configs` (`evv_location_tolerance_meters`).
---
## 7. Cancellation & Refunds
### (a) Business requirements
- Cancellation/refund rules are **tiered and structured**, not a single blunt "default 100%". The platform defines `cancellation_policies` tiers by **lead time** and **initiating actor**:
- **Free** cancellation more than 24h before start.
- **Partial** refund (e.g., 50%) under 24h.
- **Customer no-show:** up to 100% charge.
- **Nurse no-show:** full refund to the customer **and** a penalty/forfeiture for the nurse.
- The **applicable policy is snapshotted onto the booking** at booking time (mirroring the per-booking fee-rate snapshot), so later policy edits never rewrite history. The **resolved** cancellation fee / refund percentage is recorded on the refund event.
- For multi-session engagements, **cancellation is per remaining session:** cancelling mid-engagement refunds only the un-started sessions, while completed-and-verified sessions remain payout-eligible.
- **Refunds are admin-only** — there is no customer self-service refund. A refund is initiated by an admin and **must be linked to a support ticket** (`tickets`) that holds the conversation and dispute evidence.
- A refund **decomposes across the two fee legs** — how much of the platform commission and how much of the nurse payout is being reversed — because the booking gross is `platform commission + nurse payout`.
### (b) Iran-specific considerations
- A flat percentage is too blunt for شبانه‌روزی live-in engagements and Iranian holiday-period bookings; tiered, snapshotted policy reduces dispute load.
- **The refund money path depends on whether the nurse has already been paid** (Section 8/10): pre-payout it is a clean reversal; post-payout it becomes a platform-funded refund plus a nurse clawback, because an Iranian bank transfer to a nurse's IBAN is effectively irreversible.
- For BNPL bookings, the refund **never** goes nurse→customer or Balinyaar→customer directly — it is initiated through the BNPL provider's revert/cancel API (Section 8/9).
### (c) MVP vs DEFERRED
- **MVP:** tiered `cancellation_policies`; per-booking policy snapshot; admin-only, ticket-linked refunds; per-session cancellation for engagements; nurse-no-show vs customer-no-show handling; fee-leg decomposition on refunds.
- **DEFERRED:** automated nurse no-show penalty (manual admin action at launch); self-service partial-refund UI; holiday-specific cancellation overrides.
### (d) Supporting database entities
**`cancellation_policies`**, `bookings` (policy snapshot, `dispute_window_ends_at`), `refunds` (admin-only, `ticket_id`, fee-leg decomposition, `refund_channel`), `tickets`, `nurse_clawbacks` (post-payout case), `ledger_entries`.
---
## 8. Payments & Escrow
### (a) Business requirements
- The family pays the **gross** booking price **through the platform** by card via a licensed PSP's IPG. The platform is the **merchant-of-record**; the payment lands net of provider/Shaparak fees.
- **Escrow is an internal ledger state, not platform-held cash.** The platform models money state with a minimal **double-entry `ledger_entries`** ledger: each money event posts **balanced** legs grouped by a transaction group. Account types: `escrow_held`, `platform_revenue`, `nurse_payable`, `refund_payable`, `bnpl_fee_expense`, `nurse_clawback_receivable`. The ledger is the **single source of truth** for "how much is held," "how much do we owe nurses now," and "what is our commission income" — replacing fragile inference from scattered status booleans.
- On a successful card payment: debit `escrow_held` (gross), credit `platform_revenue` (Balinyaar commission), credit `nurse_payable` (nurse payout).
- **Settlement-sharing (تسهیم).** The compliant marketplace primitive is splitting one incoming card payment across multiple **registered IBANs** (the nurse's share and the platform's commission) at settlement, performed by Shaparak/the provider — the platform never touches the actual split. The internal ledger mirrors this split; the per-booking fee snapshot freezes it.
- **Per-booking the three amounts are stored separately and never conflated:** `gross_price_irr` (what the customer is charged), `balinyaar_commission_irr` (platform's cut — drives the nurse payout), and (for BNPL) `bnpl_commission_irr` (the provider's merchant discount — a platform expense). `nurse_payout_amount = gross_price_irr balinyaar_commission_irr`.
- **Webhook idempotency is mandatory before money moves.** Every PSP/BNPL callback is stored raw and **deduplicated by a unique external event id** in `payment_webhook_events` before any money state mutates — preventing double-confirmed bookings and double-settlements from at-least-once, retried callbacks.
- **Payment uniqueness:** at most one `succeeded` payment transaction per booking, and the Shaparak reference is unique — enforced so a retried success webhook cannot double-confirm.
- **Multi-provider failover.** Provider settlement cut-offs are a real continuity risk (the Toman/Jibit Nov-2024 suspensions cut businesses off mid-cycle). The payment layer abstracts the provider behind configuration so a blocked provider can be swapped, and the reconciliation ledger survives a provider being cut off.
### (b) Iran-specific considerations
- **The load-bearing legal constraint:** a پرداخت‌یار may **not** hold customer deposits, run wallets, or move money between merchants; the Shaparak ban on inter-merchant/inter-facilitator transfers means the "delay the تسهیم and redistribute later from a platform pool" pattern is regulatory grey-to-prohibited. The compliant posture is: collect via the provider, model escrow as an **internal ledger over funds custodied at the licensed provider/partner bank**, and pay out by provider-side settlement to **verified, registered nurse IBANs**. A bank-grade escrow product (e.g., Vandar میندو / معاملات امن) is the only true hold/release/refund mechanism, and its EVV-triggered hold is unverified — so the platform never assumes it can lawfully custody the cash itself.
- **PSP received ≠ cash in bank.** Iranian PAYA settlement is cyclic (T+0/T+1, holiday-deferred), so the ledger separates a clearing/receivable state from settled cash, making bank reconciliation possible.
- Toman/PSP units differ from internal Rials; convert only at the API boundary. Amounts are BIGINT IRR internally to avoid float/rounding bugs.
### (c) MVP vs DEFERRED
- **MVP:** card payment via one licensed PSP; internal double-entry `ledger_entries` escrow; per-booking three-way amount split; تسهیم-style commission/nurse-share modeling; `payment_webhook_events` idempotency; single-succeeded-transaction-per-booking guard; provider abstraction for failover.
- **DEFERRED:** a nurse-facing wallet with on-demand withdrawal (facilitator wallet prohibition risk); multiple simultaneous live PSPs at launch (abstraction is built, second provider added later); bank-grade EVV-triggered escrow product integration.
### (d) Supporting database entities
`payment_gateways`, `payment_transactions` (unique Shaparak ref, single-succeeded-per-booking), **`payment_webhook_events`**, **`ledger_entries`**, `bookings` (`gross_price_irr`, `balinyaar_commission_irr`, `platform_fee_rate`, `nurse_payout_amount`), `refunds`, `nurse_bank_accounts` (verified registered IBANs).
---
## 9. Installments / BNPL
### (a) Business requirements
- BNPL is offered as an alternative checkout. The decisive, verified model is **full-upfront settlement**: on approval the BNPL provider pays Balinyaar the **full booking amount in one lump, net of the provider's merchant commission**, and **bears 100% of customer-default risk**. The customer's interest-free installment repayment (typically a 4-installment plan) is **owned entirely by the provider** and is **decoupled** from Balinyaar's escrow/EVV/payout cycle.
- **Therefore a BNPL order is, in Balinyaar's books, identical to a card payment that lands net-of-fee in one inbound settlement.** Balinyaar **does NOT track customer installments, per-installment webhooks, or default propagation** — that fragile subsystem is intentionally not built.
- A BNPL order is recorded once as a single inbound settlement in `bnpl_transactions` (1:1 with a payment transaction), capturing the provider, the merchant-of-record (Balinyaar), the external payment token / transaction id, `order_amount_irr`, `settled_amount_irr` (net of provider commission), `bnpl_commission_irr`, currency (converted at the boundary), an idempotent status state-machine (`eligible`/`token_issued`/`verified`/`settled`/`reverted`/`cancelled`/`failed`), `installment_count` (informational, default 4), `settled_at`, and the revert fields.
- **BNPL refunds flow only customer ↔ provider ↔ Balinyaar** — never nurse→customer or Balinyaar→customer directly. Balinyaar initiates the reversal via the provider's revert (full) / cancel/update (partial, new amount strictly lower) API using the stored token; the provider cancels the customer's unpaid installments, restores their credit, and refunds any already-paid installment to the customer's bank in ~710 business days (asynchronous, owned by the provider). The refund still decomposes across the platform-fee and nurse-payout legs in Balinyaar's ledger.
- **The nurse's payout is unchanged by BNPL:** computed from `gross_price_irr balinyaar_commission_irr`, paid weekly after EVV + dispute window — the provider's commission is a **platform cost of accepting BNPL** and is **never** passed through to the nurse.
> **This is a summary. Deep BNPL provider mechanics, the exact revert/cancel/settle API flows, commission-as-config, settlement-timing nuances, and provider-specific behavior are specified in `payments-and-installments.md` — cross-reference it for implementation detail.**
### (b) Iran-specific considerations
- Provider-financed Iranian BNPLs (SnappPay, Digipay, Tara, Torob Pay) are uniformly **full-upfront, provider-bears-risk, interest-free-to-customer**; only bank-financed POS loans (Lendo) charge the customer interest and are a poor fit for short, cancellable nursing visits.
- **Settlement timing is contract-defined and may be gated on the customer's first installment** (daily / T+1-3 / weekly / 15-day) — "full amount" does not mean "instant cash." Timing is config + a per-transaction `settled_at`; weekly nurse payout may key off settlement actually received, never an assumption.
- **Commission rate is per-contract and not public** (anecdotal 715% for SnappPay; Torob Pay's published 6.6%) — always a config field read from the actual settlement, never hardcoded.
- Onboarding requires جواز کسب **and** اینماد for the Balinyaar/partner entity, and whether a multi-vendor re-disbursing marketplace qualifies as a single BNPL merchant is publicly undocumented — an ops/contracting task, not a schema dependency.
### (c) MVP vs DEFERRED
- **MVP:** full-upfront BNPL via one provider modeled as a single inbound settlement (`bnpl_transactions`); provider-mediated revert/cancel refunds; nurse payout decoupled from BNPL; commission + settlement timing as config.
- **DEFERRED:** customer installment tracking (`installment_entries`**cut**, owned by the provider); tranched settlement (`bnpl_settlement_entries` modeled-only, added if a future provider tranches); multiple BNPL providers.
### (d) Supporting database entities
**`bnpl_transactions`** (replaces the old `installment_plans`; the old `installment_entries` is cut), `payment_transactions`, `payment_webhook_events`, `refunds` (`refund_channel = 'bnpl_revert'`, `external_revert_reference`, `expected_customer_refund_eta`), `ledger_entries`. See `payments-and-installments.md`.
---
## 10. Payouts to Nurses
### (a) Business requirements
- Nurses are paid in **weekly batches**. A batch aggregates the amounts owed for completed, payout-eligible bookings/sessions and produces one payout per nurse with earnings in that window.
- **Payout eligibility is gated on EVV completion AND a closed dispute window.** A booking/session enters a batch only when `status = 'completed'` AND `dispute_window_ends_at < now()` (the dispute window is config-driven, default 72h post-completion). This deliberately prevents paying a nurse before a dispute can surface, shrinking clawback frequency — important because an Iranian bank transfer, once sent, is effectively irreversible.
- The nurse payout amount derives from `gross_price_irr balinyaar_commission_irr` (never from a BNPL provider's net settlement).
- **Clawbacks** (`nurse_clawbacks`) handle the refund-after-payout case: if a booking is refunded/disputed **after** the nurse was already paid, a clawback receivable is recorded (negative ledger entry against the nurse) and recovered by **netting against the nurse's next weekly batch**, or written off if uncollectable. The nurse's payable balance is **derived from the ledger** (it may go negative), and a batch can net prior clawbacks (`gross_earnings`, `clawback_applied`, `net_amount`).
- **Each booking is paid at most once** (the payout↔booking link is unique), preventing double-pay across batches.
- **Bank-holiday-aware scheduling.** Payout period-end and processing dates are shifted off bank-closed days using a shared `iranian_holidays` calendar — a weekly payout landing on a multi-day Nowruz closure would otherwise fail, since PAYA/SATNA transfers do not settle on closed days.
- Payouts go to the nurse's **verified, registered primary IBAN**, with the IBAN snapshotted and a transfer reference stored for reconciliation. Each payout item carries a unique track id + (for batches) a batch id.
### (b) Iran-specific considerations
- Payouts are **real bank transfers to registered IBANs** (PAYA/SATNA cycles, next-business-day on holidays) — there is no chargeback-style reversal, which is *why* the dispute window must close before payout and why clawback is a netting/receivable mechanism rather than an automatic reversal.
- Provider settlement cut-offs (Toman/Jibit) mean payout must tolerate a provider being unavailable mid-cycle; the batch + reconciliation references survive a swap.
- Each nurse must have a Shahkar/KYC-verified, IBAN-ownership-checked account registered as a beneficiary before any payout targets it.
### (c) MVP vs DEFERRED
- **MVP:** weekly batches; EVV + dispute-window gating; per-session accrual for engagements; `nurse_clawbacks` with next-batch netting and write-off; unique booking↔payout link; `iranian_holidays`-aware scheduling; verified-IBAN payouts with reconciliation references.
- **DEFERRED:** on-demand / instant nurse withdrawal; per-nurse configurable payout frequency; automated clawback recovery beyond netting.
### (d) Supporting database entities
`nurse_payout_batches`, `nurse_payouts` (with `gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr`, `iban_snapshot`), `nurse_payout_booking_links` (unique per booking), **`nurse_clawbacks`**, `ledger_entries`, **`iranian_holidays`**, `bookings.dispute_window_ends_at`, `nurse_bank_accounts`.
---
## 11. Reviews, Trust & Safety
### (a) Business requirements
- A customer can leave **one review per completed booking** (rating 15 + free text), tied to a verified, completed, on-platform booking.
- **Moderation:** reviews enter `pending_moderation` and are not public until approved by an admin (or an AI moderator). Aggregate nurse rating/counts are recomputed on **every** review status transition — publish, **hide**, reject, unpublish — so hiding a 1-star review never leaves a stale, inflated average.
- **Low-rating alerting:** a rating at or below a configurable threshold (default ≤ 2) with negative content automatically raises a `support_alerts` row for the support team to investigate.
- **Incident handling:** rapid-response protocols with immediate suspension on credible complaints; structured family check-ins and easy in-app concern flagging (the patient is not the sole information source); high-acuity cases routed only to appropriately verified nurses.
### (b) Iran-specific considerations
- The buyers are **vulnerable people** cared for **unobserved at home**; a single incident can destroy a fragile, trust-first brand — so moderation, low-rating alerting, and immediate suspension are core, not optional.
- Verified-trust is the brand; reviews must be bound to real completed bookings to resist fake-review fraud (gig-marketplace fraud is ~2× elsewhere, mostly impersonation).
### (c) MVP vs DEFERRED
- **MVP:** one-per-completed-booking customer reviews; moderation with full recompute-on-every-transition; low-rating `support_alerts`; manual incident suspension.
- **DEFERRED:** two-way (nurse-reviews-customer) double-blind reviews with timed reveal; structured review-tag aggregation (`review_tags_master` / `review_tag_links` modeled but a phase-2 nicety); a dedicated `incidents` entity; ML fraud scoring.
### (d) Supporting database entities
`reviews` (moderation status, recompute triggers), `review_tags_master`, `review_tag_links`, `support_alerts` (low-rating, fraud-signal), `nurse_profiles` (denormalized aggregates), `audit_logs`.
---
## 12. Messaging & On-Site Emergencies
### (a) Business requirements
- **There is no live chat and no direct nurse↔customer messaging.** All post-booking communication runs through a structured **ticket** system that admin can read in full. This is a deliberate **anti-disintermediation** and **patient-safety** design: it protects vulnerable patients, creates a dispute paper trail, and prevents families and nurses pairing off-platform.
- A **booking-scoped coordination ticket** is auto-created so the nurse and customer can coordinate logistics (arrival time, room location) under admin visibility. Internal admin-only notes are supported and never shown to users.
- Tickets also carry refund conversations and any support request, and are the mandatory anchor for admin refunds (Section 7).
- **On-site emergency playbook.** The ticket system is async and has no real-time channel, so the operational playbook is explicit: **in an emergency (no answer at the door, a medical emergency), the nurse calls the emergency-contact number surfaced in the app, then opens a ticket.** The emergency contact number is surfaced prominently in the booking UI (drawn from encrypted care instructions), so a nurse never needs to find the family's number by other means (which would break the platform's communication control).
### (b) Iran-specific considerations
- Disintermediation is the predictable failure mode of recurring, relationship-based care; the ticket-only model retains value (escrow, dispute protection, backup coverage, insurance that only applies on-platform) instead of relying on punitive lock-in.
- For unobserved in-home care of patients who cannot self-report, the controlled-but-auditable communication channel plus a clear emergency escalation path is a safety requirement.
### (c) MVP vs DEFERRED
- **MVP:** ticket-only messaging (admin-readable); auto-created booking-coordination ticket; internal notes; prominent in-app emergency contact + documented playbook.
- **DEFERRED:** real-time chat; a first-class `incidents`/emergency-event entity with SLA; push/real-time alerting.
### (d) Supporting database entities
`tickets`, `ticket_participants`, `ticket_messages`, `booking_care_instructions` (encrypted emergency contact), `support_alerts`.
---
## 13. Tax, Invoicing & Legal
### (a) Business requirements
- **The nurse is the taxable seller of the nursing service; Balinyaar is the taxable seller only of its commission.** This mirrors the Snapp/Tapsi sharing-economy precedent: the nurse's fee is the nurse's income (the nurse files their own income tax — out of Balinyaar's scope), and Balinyaar's commission is the company's VAT-relevant revenue.
- **VAT is 10%** (configurable), applied to Balinyaar's commission line. The home-nursing **service's** own VAT treatment is **unconfirmed** (medical services are commonly exempt) — so the VAT field is config-driven and can be 0/exempt, keeping the model correct whichever way the ruling lands. Confirm with an Iranian tax advisor before launch.
- **سامانه مودیان (taxpayer system) readiness, minimal footprint.** The platform produces a minimal `invoices` record per booking capturing the gross, the platform commission, any BNPL commission, VAT, and a place for the مودیان reference fields (22-digit fiscal number, memory tax id, status) and PDF. The seller issues the invoice (the buyer cannot), so Balinyaar issues only its own **commission** invoice; it does not issue the nurse's service invoice.
- **e-namad (نماد اعتماد الکترونیکی)** is de-facto mandatory: a monetized Iranian site needs e-namad to obtain an online payment gateway from PSP/Shaparak. It is held by the legal launch entity.
- **Partner licensed-center (Asanism-style) as the launch legal vehicle.** Home nursing is a **licensed healthcare activity** (MoH establishment permit پروانه تأسیس + technical-director license پروانه مسئول فنی via the Article-20 commission), in the **home-nursing-services-center** track (a nurse with BSc + ≥5 yrs experience can found/direct it). The fast, legal go-to-market is to **partner with already-licensed centers** while Balinyaar's own permit is pending. A `partner_centers` entity represents the licensed center that holds the جواز کسب + اینماد + MoH license, sponsors nurses, and **may be the merchant-of-record / invoice issuer** for payments — making BNPL and online payment legally feasible without each nurse holding a license.
### (b) Iran-specific considerations
- Operating **without** a permit is the real legal risk (penalty ladder up to permanent revocation + judicial referral). The partner-center vehicle is the launch-critical mechanism that makes the whole money flow legal.
- مودیان obligation phases in by revenue thresholds; most individual nurses fall below mandatory thresholds early, but the **platform's commission line is VAT/e-invoice-relevant** — so per-nurse مودیان obligation is a configurable flag and the platform's own commission invoicing is the in-scope obligation.
- The licensed center (not Balinyaar-the-tech-company, initially) is plausibly the IPG merchant-of-record and the invoice issuer — the data model represents this explicitly.
### (c) MVP vs DEFERRED
- **MVP:** `partner_centers` as the launch legal vehicle with merchant-of-record flag and nurse sponsorship; minimal per-booking `invoices` with 10% configurable VAT on commission and مودیان reference fields; e-namad held by the launch entity; nurse-as-taxable-seller / platform-as-commission-seller split.
- **DEFERRED:** full مودیان e-invoice automation / digital-signature pipeline; nurse-side service-invoice issuance on the nurse's behalf; insurer/B2B-payor invoicing; the future employer-style `organizations` model.
### (d) Supporting database entities
**`invoices`** (minimal, commission-focused, مودیان fields, VAT), **`partner_centers`** (MoH license, اینماد, merchant-of-record), `nurse_profiles.partner_center_id`, `payment_transactions` (Shaparak reference for reconciliation), `platform_configs` (VAT rate, merchant-of-record).
---
## 14. Notifications & Admin / Backoffice
### (a) Business requirements
- **In-app notifications** to all user types for booking, payment, payout, review, verification, and alert events. Carried as typed in-app records the front-end fetches on load and uses to deep-link to the relevant entity. **No push notifications at launch.**
- A retention job hard-deletes read notifications older than 90 days to keep the table bounded.
- **Admin / backoffice tooling** must cover the operational spine:
- **Verification queue** — review uploaded MoH/INO/criminal-record documents, record structured credential numbers/expiries, pass/fail steps, and flip `is_verified` transactionally.
- **Refund tooling** — initiate admin-only, ticket-linked refunds with tiered policy application and fee-leg decomposition; for BNPL, trigger the provider revert/cancel.
- **Payout tooling** — initiate/inspect weekly batches, see eligibility (EVV + closed dispute window), apply clawback netting, schedule around bank holidays, and reconcile transfer references.
- **Support-alert console** — triage low-rating, no-show, location-mismatch, expiry, and fraud-signal alerts.
- **RBAC** — admin roles (super_admin / admin / support / finance / moderator) scope who can verify, refund, pay out, and moderate.
- An **append-only audit trail** records every state-changing operation on sensitive entities (bookings, payments, refunds, verifications, reviews, users), and config changes (e.g., the platform fee rate) are auditable.
### (b) Iran-specific considerations
- No push at launch reflects a pragmatic MVP and the in-app polling norm; SMS-OTP already covers the critical auth path.
- Back-office must reason over the Shamsi calendar and `iranian_holidays` for payout scheduling and deadline computation, and over the verification realities (manual MoH/INO checks, expiry-driven re-verification).
- High-volume logs (`audit_logs`, `system_events`, `notifications`) need partitioning/retention planned before launch to avoid unbounded growth.
### (c) MVP vs DEFERRED
- **MVP:** in-app notifications with 90-day retention; admin verification/refund/payout/alert tooling; RBAC; append-only `audit_logs`; config-change auditing.
- **DEFERRED:** push notifications; SMS/email notification channels beyond OTP; a full analytics warehouse (`system_events` piped out rather than queried in the transactional DB); ML fraud console.
### (d) Supporting database entities
`notifications`, `support_alerts`, `roles`, `user_roles`, `audit_logs`, `system_events`, `platform_configs`, plus the operational entities each tool acts on (`nurse_verifications` / `verification_steps` / `nurse_credentials`, `refunds`, `nurse_payout_batches` / `nurse_payouts` / `nurse_clawbacks`, `bookings`).
---
## Appendix — MVP vs Deferred at a glance
| Area | MVP | Deferred |
|---|---|---|
| Onboarding | phone-OTP; customer/nurse/admin; patient split | customer KYC; org self-onboarding; push |
| Verification | 6-step data-driven pipeline; `nurse_credentials`; IBAN ownership | MoH/INO API; liability-insurance step; ML fraud |
| Catalog | admin categories/options; nurse variants & price units | holiday/surge pricing; companionship tier |
| Search | geo + category; `nurse_search_index`; same-gender filter | map discovery; availability hard-filter |
| Booking | request→accept→pay→confirm; `booking_sessions` multi-visit | recurring schedules; milestone-payment UX |
| EVV | per-session GPS check-in/out; payout gating | geofencing; tele-check-ins; cameras |
| Cancellation | tiered policy + snapshot; admin/ticket refunds; per-session | auto no-show penalty; self-service refunds |
| Payments/Escrow | ledger escrow; `payment_webhook_events`; provider abstraction | nurse wallet; multi-PSP live; bank escrow product |
| BNPL | full-upfront `bnpl_transactions`; provider-revert refunds | installment tracking (cut); tranched settlement; multi-provider |
| Payouts | weekly batches; clawbacks; holiday-aware; verified IBAN | instant withdrawal; per-nurse frequency |
| Reviews | one-per-booking; moderation; low-rating alerts | two-way reviews; tag aggregation; `incidents` |
| Messaging | ticket-only; coordination ticket; emergency playbook | live chat; emergency-event entity |
| Tax/Legal | `partner_centers`; minimal `invoices`; 10% VAT on commission; e-namad | full مودیان automation; nurse-side invoicing; B2B |
| Notifications/Admin | in-app + retention; verify/refund/payout tooling; RBAC | push; analytics warehouse; ML console |
+657 -1078
View File
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
# Balinyaar — Payments, Escrow, Settlement & Installments (BNPL)
> **Purpose.** This is the fintech deep-dive for Balinyaar, an MVP home-nursing marketplace in Iran. It pins down how the platform collects money from families, holds an *internal escrow ledger state* (not custodied cash), pays nurses weekly minus commission, and integrates Iranian Buy-Now-Pay-Later (خرید اقساطی / BNPL). It answers the two questions the team cares most about — how a BNPL booking is cancelled/refunded mid-plan, and who pays the nurse (and when) under BNPL — and it grounds every decision in verified research, separating **VERIFIED** facts from **CONFIGURABLE / UNCERTAIN** items that must be confirmed at contracting. All money is **IRR (Rials), stored as `BIGINT`**; Toman is display-only and converted only at the provider API boundary.
**Date:** 2026-06-20
---
## 1. Executive summary
- **Balinyaar legally CANNOT custody buyer funds.** In Iran money always flows card → licensed PSP → Shaparak settlement → bank-registered IBANs. A پرداخت‌یار (payment facilitator — the license class an MVP rides on) is explicitly **barred from holding deposits, running wallets, paying interest, or moving money between merchants**. "Platform holds escrow" must therefore be implemented as an **internal double-entry ledger state** over funds custodied at a licensed provider — never as cash in a Balinyaar bank account. *(VERIFIED — multiple independent sources + CBI/Shaparak directives.)*
- **The compliant marketplace primitive is تسهیم (settlement-sharing):** one incoming card payment is split by the provider/Shaparak across multiple registered IBANs (the nurse's share, the platform's commission) and deposited directly — the platform never touches the split. Shaparak has separately **banned inter-merchant / inter-facilitator transfers and wallet-style holding**, which makes a "delay-then-redistribute" pool legally grey-to-prohibited. *(VERIFIED.)*
- **The decisive BNPL finding: full-upfront settlement.** Provider-financed Iranian BNPLs (SnappPay, Digipay, Tara, Torob Pay, ZarinPlus) pay the **merchant the FULL amount in ONE lump, minus a merchant commission, and bear 100% of customer-default risk.** The customer's installments are owned entirely by the provider and are **decoupled** from Balinyaar's escrow/EVV/payout cycle. *(VERIFIED for SnappPay and Torob Pay from credible sources; consistent for Digipay and Tara.)*
- **Consequence — a BNPL order = a card payment landing net-of-fee.** Therefore Balinyaar **does NOT track customer installments** (no `installment_entries`, no per-installment webhooks, no default propagation). This deletes an entire fragile subsystem.
- **But settlement TIMING is not instant.** The "full amount" is true in *amount*, not *timing*: cadence is contract-defined (daily / T+13 / weekly / 15-day), and at least one authoritative SnappPay source gates settlement on the customer's **first installment** (پس از واریز اولین قسط). Model a per-transaction `settled_at`; never assume instant. *(VERIFIED correction to the original research.)*
- **Under BNPL the nurse is paid by Balinyaar, on Balinyaar's own weekly schedule, exactly as for a card booking** — after EVV check-out and the dispute window. The nurse's payout is computed from `gross_price_irr balinyaar_commission_irr`, **NOT** from the BNPL-net amount. The BNPL commission is a **platform expense** that must never touch the nurse's payout.
- **Avoid Lendo for the MVP.** It is bank-financed (Bank Ayandeh): the **customer** pays ~1823% interest plus a ~5% (often non-refundable) service fee over 612 months — a POS loan, a poor fit for short, cancellable nursing visits.
- **Two corrections the research forced:** VAT in Iran is **10%** (rose from 9% in 1403, = govt 7% + municipal 3%), not 9% — and make it configurable since it has moved two years running. And the Digipay "24h-pay / collect-in-525-days-from-business" sentence describes the **early-settlement/factoring** product, **not** BNPL — right conclusion, wrong evidence; the correct first-party BNPL source is `mydigipay.com/bpg/`.
- **Provider continuity is a real risk.** In **Nov 2024 the CBI abruptly cut Toman and Jibit's settlement/withdrawal services** with no stated cause, stranding businesses (including millions of Snapp drivers). Design for multi-provider failover and a reconciliation ledger that survives a provider being cut mid-cycle.
- **Recommendation:** integrate **SnappPay first**, **Digipay second**, **avoid Lendo**. Onboarding the Balinyaar entity needs **both جواز کسب AND eNamad (اینماد)**. Whether a multi-vendor marketplace re-disbursing to many independent nurses qualifies as a single merchant is **publicly UNCONFIRMED** — confirm with provider sales before relying on it.
---
## 2. The Iranian payment reality
### 2.1 The rails: card → PSP → Shaparak → registered IBANs
Every card payment in Iran is acquired by a licensed **PSP** and cleared through **Shaparak** (the national switch), which settles to **bank-registered IBANs (شِبا)** of the merchant/beneficiaries. There is **no native marketplace-escrow construct** the way a US/EU platform would hold buyer cash in trust. The platform does not — and legally may not — sit in the money path as a custodian.
### 2.2 The license class: پرداخت‌یار (payment facilitator) and the custody prohibition
An MVP marketplace like Balinyaar rides on a **پرداخت‌یار (payment facilitator / aggregator)** arrangement under a contracted PSP and the CBI/Shaparak agreement. A facilitator is **explicitly forbidden** from:
- holding customer deposits,
- operating wallets,
- paying interest,
- granting credit / guarantees,
- temporarily using merchant balances.
Settlement must go **only** to merchant-registered bank accounts, and **only Shaparak** can withdraw from the special facilitator settlement account (حساب ویژه پرداخت‌یاری). Unauthorized fund-holding draws penalties, license suspension, and AML exposure. **This is the single load-bearing constraint of the whole design: Balinyaar cannot be the custodian of buyer funds.** *(VERIFIED — way2pay, Zibal legal blog, finolaw, peivast.)*
### 2.3 تسهیم (settlement-sharing) — the compliant marketplace primitive
The legitimate way to pay many providers is **تسهیم / تسویه اشتراکی (settlement-sharing)**: a single incoming card payment is split across multiple registered IBANs (the nurse's net share + the platform's commission) and **credited directly by Shaparak/the provider** to each party. The platform never touches the actual split. ZarinPal markets this for marketplaces (بازارگاه) with split-by-ratio to each partner's registered Sheba; Zibal, Sadad, SizPay, Vandar, Jibit, Zibal, PayPing, IDPay offer variants. *(VERIFIED.)*
> **Caveat (CONFIGURABLE):** ZarinPal's تسهیم appears gated to a "golden" (طلایی) membership tier; all timing/minimum/limit numbers (e.g. ~100,000 IRR minimum, processing windows, "no beneficiary limit") came from single doc reads and must be reconfirmed at contracting.
### 2.4 The banned move: inter-merchant / inter-facilitator transfers and held pools
A tempting design — "collect into a platform pool, hold until EVV check-out, then redistribute" — is **regulatory grey-to-prohibited.** Shaparak **explicitly banned inter-facilitator and inter-merchant fund transfers and wallet-style holding.** A *delayed but pre-fixed* split to the **same registered IBANs** may be tolerable; **moving/holding funds in a platform-controlled pool to release conditionally is the banned behavior.** The only clean hold/release/refund mechanism is a **bank-grade escrow product** (e.g. Vandar میندو / معاملات امن — buyer pays into trust, released to seller on confirmation, refundable on seller failure), but even that is flagged as regulatorily fragile and its API-level EVV trigger is unverified. **Practical conclusion: model escrow as an internal ledger over whichever provider primitive you contract; do not assume you can lawfully custody cash yourself.** *(VERIFIED ban; the "delay-then-hold" pattern is the OVERSTATED part of the original research.)*
### 2.5 Provider cut-off continuity risk (Toman / Jibit, Nov 2024)
In **November 2024 the CBI abruptly cut Toman's and Jibit's settlement/withdrawal services** with no stated cause, disrupting businesses including millions of Snapp drivers who could not settle. Wallet/balance facilitator models have been blocked and re-permitted before (Vandar's gateway was blocked then unblocked by Shaparak). **Design for multi-provider failover and a reconciliation ledger that survives a provider being cut off mid-cycle.** *(VERIFIED — zoomit, way2pay, ecoiran.)*
### 2.6 Tax: سامانه مودیان and VAT = 10%
- Iran's **سامانه مودیان (taxpayer / e-invoicing system)** requires electronic invoices (صورتحساب الکترونیکی) with a **22-digit number**, a **memory tax-id (شناسه یکتای حافظه مالیاتی)**, and a digital signature. The **SELLER issues the invoice** and remits VAT; the buyer cannot. For a marketplace this maps cleanly: **each nurse is the taxable seller of the nursing service; Balinyaar's taxable supply is ONLY its commission** (the Snapp/Tapsi precedent). *(VERIFIED.)*
- **VAT is 10%**, not 9%: the standard rate **rose from 9% to 10% in 1403** (govt 7% + municipal 3%) and remains 10% in 1404. Any VAT field hardcoded at 9% is stale — **use 10% and make it a configurable parameter**, since it has changed two years running. *(VERIFIED correction.)*
- مودیان enrollment is phased in by revenue threshold (individuals with sales above ~144bn IRR through end of 1404 must issue e-invoices from Tir 1405). Whether the home-nursing **service** itself is VAT-exempt (medical exemption) is **UNCERTAIN** — do not assume; model a config-driven VAT rate that can be 0.
---
## 3. Escrow as an internal ledger, not held cash
Because Balinyaar cannot custody buyer funds (§2.2), **"escrow" must be a software construct: a double-entry ledger STATE over money that legally sits at a licensed provider/bank.** The original ~45-table model had **no ledger** — escrow was only inferable by joining `bookings.status`, `bookings.payout_released`, `payment_transactions.status`, and `refunds`, with no single answer to "how much do we owe nurses right now?" Three independent critiques rated this a **critical** gap. The fix is one append-only table.
### 3.1 `ledger_entries` — the financial source of truth
Append-only, never updated or deleted. Every money event posts **balanced** rows sharing a `transaction_group_id` (Σ debit = Σ credit). Per-nurse balances are *derived by filter*, never cached in a drifting wallet-balance column.
**Account types:**
| account_type | Meaning |
|---|---|
| `escrow_held` | Funds received and held (over provider custody) not yet released or refunded |
| `platform_revenue` | Balinyaar's own commission income |
| `nurse_payable` | What the platform owes the nurse (accrued, awaiting weekly payout) |
| `refund_payable` | Amount owed back to the customer / in-flight reversal |
| `bnpl_fee_expense` | The BNPL provider's merchant commission — a platform expense |
| `nurse_clawback_receivable` | Money a nurse owes back after a refund-after-payout |
### 3.2 The postings
Amounts are positive; `direction` carries the sign. The three-amount split (`gross_price_irr`, `balinyaar_commission_irr`, `bnpl_commission_irr`) is defined in §7.
**(a) Card payment capture (inbound):**
```
DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount (= gross balinyaar_commission)
```
**(b) BNPL settle (inbound) — identical to a card capture, plus the provider-fee leg:**
```
DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount
DEBIT bnpl_fee_expense bnpl_commission_irr
CREDIT escrow_held bnpl_commission_irr (escrow reflects NET cash actually received)
```
Posted **once**, idempotently, keyed on the settling transaction. **No installment-level postings** — the customer's repayment schedule is SnappPay's ledger, not ours.
**(c) Refund — BEFORE the nurse is paid out (clean reversal):**
```
DEBIT platform_revenue platform_fee_refunded_irr
DEBIT nurse_payable nurse_payout_refunded_irr
CREDIT refund_payable (sum)
```
Clear `refund_payable` when the PSP / SnappPay confirms the customer cash-back. Nothing leaves Balinyaar toward the nurse — the `nurse_payable` accrual is simply reversed.
**(d) Clawback — refund AFTER the nurse was already paid:**
The nurse's `nurse_payable` was already drained by a processed payout batch, so there is nothing left to reverse. Instead the platform books a receivable:
```
DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes the platform)
CREDIT refund_payable amount_irr
```
Recovered by **netting against the nurse's next `nurse_payable`** at batch time, or marked `written_off` if uncollectable. A `nurse_clawbacks` row carries the lifecycle (`pending` / `recovered` / `written_off`). This is unavoidable because **Iranian payouts are real bank transfers — hard/impossible to reverse** — so the right defense is *gating payout on the dispute window*, with clawback as the fallback.
### 3.3 Why the ledger, not more columns
A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is **one table + posting discipline**. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the *pricing* record; the ledger is the *financial-truth / reconciliation* layer posted alongside.
---
## 4. BNPL landscape — comparison
All six are Iranian. The structurally important fact (full-upfront-to-merchant, provider bears default risk) holds for every **provider-financed** option; **Lendo is the outlier (bank-financed, customer pays interest).**
| Provider | Settlement model | Who bears financing cost | Customer plan (installments / tenor / interest) | Credit ceiling | Merchant fee | Service / marketplace eligibility | Integration | Confidence |
|---|---|---|---|---|---|---|---|---|
| **SnappPay (اسنپ‌پی)** | **Full-upfront**, single lump minus commission; provider bears default risk | **Merchant** (commission) | 4 installments / 4 months (1 at purchase + 3 monthly); **interest-free** | ~20M Toman (→~50M good payers); separate bank-credit product up to 100M Toman, 1224 mo, **carries bank interest** | **Undocumented** (anecdotal ~715% + ~10% VAT; one merchant cited 15%); per-contract config | Services incl. "بعضی خدمات پزشکی" eligible; in-person/appointment OK. **Multi-vendor re-disbursement UNCONFIRMED** | API + IPG redirect (OAuth → eligible → token → verify → settle → revert/cancel/update/status) | High (model) / Medium (fee, eligibility) |
| **Digipay (دیجی‌پی)** | **Full-upfront** to contracted merchant; provider bears default risk | **Customer** bears markup; merchant pays acquiring commission | 1-month (interest-free) + 4-installment (~48% on installments 24) + 3/6/9/12-mo loan | Varies widely by product/campaign (monthly ~130M Toman; 4-inst up to ~50200M; loan up to 2bn IRR) | **توافقی (negotiable)**, settlement-speed dependent; sells *early settlement* as a paid add-on | Explicit **services** (travel, hotels, insurance, **dental**); single contracted merchant, **no sub-merchant split** | UPG: ticket → redirect → callback → verify; deliver-confirm then refund. Type codes IPG=0/Wallet=11/Credit=5/BNPL=13/Credit-Card=24 | High (model, API) / Uncertain (fee, mid-plan mechanics) |
| **Tara (تارا)** | Provider-financed, full amount to seller | Merchant (interest-free to customer) | 2 interest-free installments, starting 1 month after purchase | **Up to 20M Toman** (research's "10M / 2-month" is OUTDATED); other tiers 5M / 10M / 100M / 150M | Per-contract | Interest-free (بدون سود) standard tier | API/gateway | Medium |
| **Torob Pay** | **Full-upfront**, cash to seller | Merchant | 4 equal installments, **25% down**, interest-free | New users ~12M Toman → 35M | **Concrete: 6% + VAT = 6.6%** per order | Third-party explainers (not first-party docs) | Gateway | Medium (firm fee) |
| **ZarinPlus (ZarinPal)** | Provider-financed BNPL inside the ZarinPal gateway; standard ZarinPal merchant settlement ~T+1 | Merchant | 4 installments | ~2M Toman (ID only) → 515M with history → up to 20M with cheque/promissory note | ZarinPal gateway ~1% (cap ~4000 Toman) + per-txn; BNPL-specific terms not confirmed | Inside ZarinPal IPG | Gateway / تسهیم | Medium |
| **Lendo** | Bank-financed (Bank Ayandeh); merchant effectively gets full value | **CUSTOMER** (~1823% interest **+ ~5% upfront service fee**, often non-refundable) | 6 / 9 / 12 months — a **POS loan**, not interest-free BNPL | Bank-set | — | POS loan | — | **AVOID for MVP** |
> **Key contrast:** Torob Pay's **6.6% (6% + VAT)** is the only *published* rate; SnappPay's true rate **cannot be inferred** from it and must be treated as negotiated per-contract config. Lendo's customer-borne interest + non-refundable fee make mid-engagement cancellations leave the customer out of pocket — a poor fit for short, cancellable nursing visits.
---
## 5. The decisive finding: full-upfront settlement
**The provider pays the merchant the full amount minus commission in ONE lump and bears default risk. The customer's installments are owned by the provider and are decoupled from Balinyaar's escrow/payout.** *(VERIFIED — SnappPay CEO: "ارایه‌دهنده سرویس تمام پول پذیرنده را پرداخت کرده و هیچ ریسکی سمت پذیرنده نیست"; Digipay first-party bpg page: "مبلغ حاصل از فروش را یک‌جا دریافت کنید … نگران ریسک عدم بازپرداخت اقساط نباشید". No source described any tranched-to-merchant model among provider-financed BNPLs.)*
**Therefore, in Balinyaar's books, a BNPL order is identical to a card payment that lands net-of-fee in one inbound settlement.** We **DO NOT model customer-installment tracking** (`installment_entries`, per-installment webhooks, default propagation). This removes the fragile subsystem the original model flagged as unresolved.
### Verified caveats that must remain CONFIGURABLE / UNCERTAIN
- **Settlement TIMING is NOT instant.** Cadence is contract-defined (daily / T+13 / weekly / 15-day / occasionally longer — one merchant alleged ~4 months), and the most concrete SnappPay statement gates settlement on the customer's **first installment** ("پس از واریز اولین قسط"). Digipay even sells *early settlement* as a paid feature, implying its baseline is delayed. **Model a per-transaction `settled_at` and a per-provider settlement-lag; gate weekly nurse payouts on settlement actually received, so you never pay a nurse before you hold the cash.**
- **Commission rate is per-contract config, never hardcoded.** SnappPay publishes no public rate; read the actual deducted amount from each settlement record.
- **Marketplace re-disbursement eligibility is publicly UNCONFIRMED.** SnappPay's and Digipay's documented model is **single-merchant-receiver**. Design so the provider pays **Balinyaar (the merchant-of-record) one lump**, and Balinyaar does the internal escrow→nurse allocation itself. Confirm any per-nurse routing directly with provider sales. Even Snapp Doctor's own home-nursing page does not advertise SnappPay installments — service eligibility is *plausible, not confirmed* for an in-home individual-nurse marketplace.
- **Onboarding requires BOTH جواز کسب AND eNamad (اینماد)** for the Balinyaar entity (the original research omitted eNamad). For Digipay, an activity license is mandatory only for "sensitive trades" (صنوف حساس); home-healthcare may be treated as one — confirm.
---
## 6. Q1 — Cancellation / refund of a BNPL booking mid-plan
**Decisive rule: money ALWAYS flows `customer ↔ SnappPay ↔ Balinyaar`. Never refund the customer directly, and never route a nurse→customer refund.** Balinyaar initiates the reversal through SnappPay's API using the stored payment token/transaction id:
- **Full cancel/refund → `revert`** (full amount).
- **Partial / shortened-visit → `update`** (new amount must be strictly lower than the original settled amount) — or `cancel` per the provider's partial semantics.
SnappPay then, on its own ledger and asynchronously:
1. **cancels the customer's remaining UNPAID installments** and credits their equivalent back to the customer's **credit wallet** (reusable BNPL credit — not merely "wiped"),
2. **refunds any already-PAID installment** to the customer's **bank account in ~710 business days.**
The merchant's only role is to authorize/cancel; **SnappPay owns the unwind.** *(VERIFIED verbatim: "اقساط پرداخت‌نشده لغو و معادل آن به موجودی حساب اعتباری شما برگشت داده می‌شود"; "مبلغ قسط پرداخت‌شده به حساب بانکی شما برگشت داده خواهد شد (۷ تا ۱۰ روز کاری)".)*
### Balinyaar's internal bookkeeping
1. Record a **`refund` row** with `refund_channel = 'bnpl_revert'`, `external_revert_reference`, `expected_customer_refund_eta`, and a `refund_status` that stays `processing` until SnappPay confirms (a reconciliation job clears it). **Surface the asynchronous 710-day window in the UI and reconciliation** — never assume instant.
2. **Decompose the refund** across the two fee legs: `platform_fee_refunded_irr` and `nurse_payout_refunded_irr` (the booking gross = platform fee + nurse payout; the refund must say how much of each is reversed).
3. **Post balanced ledger entries** (§3.2c/d): debit the decomposed `platform_revenue` / `nurse_payable`, credit `refund_payable`; record the revert reference on the `bnpl_transactions` row (`reverted_amount_irr`, `reverted_at`, `refund_channel`).
4. **If the nurse has NOT been paid** (booking still inside the dispute window / not in a processed batch): reverse the `nurse_payable` accrual — clean, nothing leaves Balinyaar. *(This is the common case if you gate payout on the dispute window.)*
5. **If the nurse HAS been paid** (refund-after-payout): take the **clawback** path — a `nurse_clawbacks` row + a `nurse_clawback_receivable` ledger leg (§3.2d), recovered from the next payout batch or written off.
**Partial / shortened-visit** maps to the `update` endpoint with a reduced amount: record `refund_delta_irr`, reduce `settled_amount_irr` on the `bnpl_transactions` row, and apply the same fee-leg decomposition.
> **UNCERTAIN (confirm at contracting):** whether the provider returns *its* merchant commission on a full vs partial refund (full / pro-rata / not at all) is **undocumented** and directly affects platform P&L on cancellations. Model `provider_commission_reversed_amount` as nullable and reconcile from the provider's refund response — do not hardcode. Digipay's exact mid-installment proration mechanics are likewise undocumented and contract-dependent.
---
## 7. Q2 — Under BNPL, who pays the nurse, and when?
**Balinyaar pays the nurse, on Balinyaar's own normal weekly payout schedule, after EVV check-out and after the dispute window closes — exactly the same path as a card-funded booking.** SnappPay **never** pays the nurse and is indifferent to Balinyaar's internal split. The customer's BNPL repayment timeline is completely decoupled from the nurse payout cycle.
### The crucial accounting rule — the three-amount split
The nurse's payout is computed from the booking's **own price and Balinyaar's own commission**, **NOT** from the BNPL-net amount. SnappPay's commission is a **cost of accepting BNPL, borne by Balinyaar**, and must **never** be passed through to the nurse. Store three separate amounts so the two fee deductions are never conflated:
| Amount | Meaning | Drives |
|---|---|---|
| `gross_price_irr` | What the customer is charged (booking price) | The invoice; the inbound `escrow_held` debit |
| `balinyaar_commission_irr` | Balinyaar's own cut (was `platform_fee_amount`) | `platform_revenue`; **the nurse payout** |
| `bnpl_commission_irr` | The BNPL provider's merchant discount | `bnpl_fee_expense` (platform expense) — **never the nurse** |
```
nurse_payout_amount = gross_price_irr balinyaar_commission_irr
```
**The nurse receives the identical amount whether the family paid by card or by SnappPay, and on the identical weekly timing** (batch, gated on `dispute_window_ends_at < now()`). The only difference a BNPL order makes to the books is the extra `bnpl_fee_expense` leg that reduces *Balinyaar's* margin — not the nurse's pay.
**Worked example** (illustrative; rates are config): gross `5,000,000` IRR, Balinyaar commission 15% = `750,000`, nurse payout = `4,250,000`. If paid via SnappPay at a 10% merchant commission, `bnpl_commission_irr = 500,000` is a Balinyaar expense; SnappPay settles `4,500,000` net to Balinyaar; the **nurse still receives `4,250,000`**, and Balinyaar's net margin is `750,000 500,000 = 250,000` (before PSP/VAT). The nurse payout is invariant to the payment method.
> **Timing guard (CONFIGURABLE):** because BNPL settlement can lag, optionally key weekly-payout eligibility off `bnpl_transactions.settled_at` (settlement actually received) in addition to EVV + dispute window, so the platform never advances a nurse before it holds the cash.
---
## 8. Integration notes
### 8.1 SnappPay (اسنپ‌پی) — primary
API-based with an IPG redirect. Endpoint paths are **VERIFIED** against the open-source Laravel package and match exactly:
```
POST api/online/v1/oauth/token → OAuth bearer token
GET api/online/offer/v1/eligible → eligibility / credit check on the customer
POST api/online/payment/v1/token → payment token → redirect customer to SnappPay
POST api/online/payment/v1/verify → verify after callback
POST api/online/payment/v1/settle → settle (capture the merchant lump)
POST api/online/payment/v1/revert → full reversal
POST api/online/payment/v1/cancel → cancel
POST api/online/payment/v1/update → partial (new amount strictly lower)
GET api/online/payment/v1/status → status
```
Credentials issued only after a signed contract + business-license review: `user_name`, `password`, `client_id`, `client_secret`, merchant/customer number, security code, `base_url`. Sandbox availability is plausible (issued by sales) but **TO BE CONFIRMED** — the public package does not evidence it.
> **WARNING:** the `SnapPayInc/open-api-java-sdk` GitHub repo is the **unrelated CANADIAN SnapPay** (snappay.ca, CAD) — **do NOT use it**. Likewise, English searches for "digipay split payment" return **DigiPay.Guru**, an unrelated white-label vendor — not the Iranian Digipay.
### 8.2 Digipay (دیجی‌پی) — secondary / fallback
Unified **UPG** gateway, server-side + hosted redirect:
```
POST /digipay/api/tickets/business?type=… → ticket + redirectUrl (type MUST match product)
(callback to merchant)
POST /digipay/api/purchases/verify → verify (re-check amount + providerId before trusting)
POST /digipay/api/purchases/deliver?type=… → delivery confirmation (Credit=5 / BNPL=13) — GATE ON EVV CHECK-OUT
POST /digipay/api/refunds?type=… → refund (providerId, amount, saleTrackingCode)
GET /digipay/api/refunds/{InquiryId} → poll refund status
POST /digipay/api/reverse → manual reverse (~25 min, IPG/DPG only)
```
**Type codes (VERIFIED, first-party):** IPG=0, Wallet=11, Credit=5, BNPL=13, Credit-Card=24 — **persist the gateway type per transaction**; deliver/refund calls must carry the matching code. Each purchase supports **EITHER refund OR manual reverse, not both** — store a mutually-exclusive reversal-mode flag. For a *service*, the "delivery" is the completed visit, so **gate `deliver` on the nurse's EVV check-out.** A BNPL refund returns to the customer's Digipay credit/wallet (or bank/SHEBA), **not** the original card.
### 8.3 Cross-cutting integration rules
- **Webhook idempotency:** every PSP/BNPL callback is at-least-once and retried. Upsert into **`payment_webhook_events`** keyed `UNIQUE(external_event_id)` **first**, inside the same transaction that mutates money state, and **no-op on duplicate** — prevents double-confirm / double-settle / double-refund.
- **Never trust the callback alone** — always `verify` server-side and re-check `amount` + `providerId`/reference before treating funds as captured.
- **Amounts in IRR Rials as `BIGINT`** everywhere; SnappPay/Digipay quote in **Toman** at the API boundary — store a `currency` field on the BNPL row and **convert only at the boundary, never internally.**
- **State-machine guard** on BNPL status transitions (`eligible → token_issued → verified → settled → reverted`) so callbacks/retries cannot double-settle or double-refund.
---
## 9. Schema touchpoints
Final, aligned table/field names (these supersede `installment_plans` / `installment_entries`):
- **`bnpl_transactions`** (new, **replaces `installment_plans`**; `installment_entries` **CUT**) — 1:1 with a `payment_transaction`. Fields: `payment_transaction_id` FK UNIQUE, `provider_code`, `merchant_of_record`, `external_payment_token`, `external_transaction_id`, `eligibility_status`, `order_amount_irr`, `settled_amount_irr` (net of provider commission), `bnpl_commission_irr`, `currency` (`IRR`/`TOMAN`), `status` (`eligible`/`token_issued`/`verified`/`settled`/`reverted`/`cancelled`/`failed`), `installment_count` (default 4, informational only), `settled_at`, `revert_transaction_id`, `reverted_amount_irr`, `reverted_at`, `refund_channel`, `callback_payload_json`.
- **`payment_transactions`** — keep full gateway response + Shaparak reference; **ADD** a filtered `UNIQUE(gateway_reference_code) WHERE NOT NULL` and a filtered `UNIQUE(booking_id) WHERE status='succeeded'` (single capture per booking; idempotent retries).
- **`payment_webhook_events`** (new) — `provider_code`, `event_type`, `external_event_id UNIQUE`, `payload_json`, `signature_valid`, `processing_status` (`received`/`processed`/`failed`/`ignored`), `related_payment_transaction_id` NULL, `received_at`, `processed_at`.
- **`refunds`** — **1:N** per `payment_transaction` (the original "1:1" claim is wrong); **ADD** `platform_fee_refunded_irr`, `nurse_payout_refunded_irr` (fee-leg decomposition), `refund_channel` (`psp_card`/`bnpl_revert`/`manual_bank`), `external_revert_reference`, `expected_customer_refund_eta`; app invariant `Σ refunded ≤ captured`.
- **`ledger_entries`** (new) — `transaction_group_id`, `account_type` (`escrow_held`/`platform_revenue`/`nurse_payable`/`refund_payable`/`bnpl_fee_expense`/`nurse_clawback_receivable`), `nurse_id` NULL, `direction`, `amount_irr`, `booking_id` NULL, `source_ref_type`, `source_ref_id`, `memo`, `created_at`. Append-only; balanced per group.
- **`nurse_clawbacks`** (new) — `nurse_id`, `booking_id`, `refund_id`, `amount_irr`, `status` (`pending`/`recovered`/`written_off`), `recovered_in_payout_id` NULL, `created_at`, `resolved_at`.
- **`payment_gateways`** — encrypted provider config in `config_json` / secrets: SnappPay `client_id`, `client_secret`/`username`+`password`, merchant number, security code, `base_url`, `sandbox` flag. **Never** store credentials per-transaction.
**Supporting changes:** `bookings` gets the three-way split (`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`) and `dispute_window_ends_at`; `payout_released` BIT is **CUT** (derive from `nurse_payout_booking_links` + ledger). `nurse_payouts` gets `gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr`. An **`invoices`** table (minimal) captures the commission VAT line.
---
## 10. Recommendations & open questions to confirm at contracting
### Recommendations
1. **Integrate SnappPay first, Digipay second, avoid Lendo.** SnappPay has the largest reach, explicit service-merchant support, true full-upfront settlement, full default-risk transfer, and a coded API. Digipay is the redundancy/fallback with the broadest healthcare/service coverage. Lendo's customer-borne interest + non-refundable fee is wrong for short, cancellable visits.
2. **Treat a BNPL order as one net inbound settlement** identical to a card payment net-of-fee. **Do not** build customer-installment tracking.
3. **Make escrow an internal double-entry ledger** over funds custodied at a single licensed provider; **abstract the provider** behind config so it can be swapped if blocked (Toman/Jibit precedent).
4. **Pay the nurse from `gross balinyaar_commission`, weekly, after EVV + dispute window** — identical for card and BNPL; the BNPL commission is a platform expense only.
5. **Gate payout on the dispute window** (default 72h) rather than relying on clawback — Iranian bank transfers are effectively irreversible; keep clawback as the modeled fallback.
6. **Build webhook idempotency before touching real money**, and store all amounts in IRR `BIGINT`, converting from Toman only at the API boundary.
7. **Use 10% VAT, configurable.** Treat each nurse as the taxable seller; invoice only Balinyaar's commission.
### Open questions to confirm with provider sales / at contracting
- **Marketplace eligibility:** does the provider's merchant contract permit a multi-vendor home-services marketplace that re-disburses to many independent nurses as a **single** merchant-of-record? (Publicly undocumented; their known model is single-receiver.)
- **Commission rate (%):** the actual rate for the health/home-services category (SnappPay publishes none; ~715% is anecdotal; Torob Pay's 6.6% is not a proxy).
- **Settlement SLA / timing:** daily vs T+13 vs weekly vs 15-day, and whether it is gated on the customer's first installment. Get it in writing; do not assume same-day.
- **Commission-clawback-on-refund behavior:** on a full vs partial refund, does the provider return its merchant commission fully, pro-rata, or not at all?
- **Onboarding documents:** confirm جواز کسب **and** eNamad suffice for the Balinyaar entity, and whether home-healthcare is a "sensitive trade" needing a sectoral license.
- **Sandbox credentials:** request early; confirm availability (not evidenced publicly).
- **Settlement-provider (تسهیم/payout) choice for the card leg:** which licensed provider (ZarinPal تسهیم / Vandar / Jibit), its fee schedule, batch caps, minimums, and whether delayed settlement / a bank-grade escrow product (Vandar میندو) is permissible for the EVV-gated hold.
---
## Sources
**Iranian payment-facilitator / escrow / settlement legality**
- finolaw.net — مقررات پرداخت‌یاری (facilitator rules): `https://finolaw.net/مقررات-پرداخت-یاری/`
- way2pay.ir — CBI facilitator framework: `https://way2pay.ir/480525/`, `https://way2pay.ir/484056/`
- Zibal legal blog — internet-payment rules: `https://zibal.ir/blog/قوانین-پرداخت-اینترنتی-درگاه-پرداخت-ک/`
- peivast.com — Shaparak inter-merchant/wallet ban: `https://peivast.com/p/148655`
- ZarinPal تسهیم (split-payment): `https://zarinpal.com/split-payment.html`, `https://www.zarinpal.com/blog/درگاه-پرداخت-اشتراکی-چیست؟/`, `https://next.zarinpal.com/paymentGateway/setshare.html`
- Vandar — facilitator / میندو escrow / Bank Ayandeh custody: `https://vandar.io/blog/پرداختیاری-چیست-و-پرداختیار-کیست؟/`, `https://vandar.io/miando/`, `https://docs.vandar.io/payout_service/settlement`
- Jibit transferor / payout: `https://www.jibit.io/transferor/`
- Toman/Jibit Nov-2024 cut-off: `https://www.zoomit.ir/tech-iran/429145-banning-payment-services-on-toman-and-jibit/`, `https://way2pay.ir/389544/`
**Tax / مودیان / VAT (10%)**
- systemgroup.net — مودیان registration: `https://www.systemgroup.net/knowledge-network/registration-in-the-tax-system/`
- hesabandish.com — taxpayer rules: `https://hesabandish.com/rules-taxpayer-system/`
- sepidarsystem.com — VAT rate: `https://www.sepidarsystem.com/blog/vat-rate/`
- Tapsi/Snapp commission-tax precedent: `https://ip30.ir/tapsi-taxation-challenge/`, `https://drhesaab.ir/how-is-digital-platform-tax-calculated/`
**SnappPay**
- Merchant settlement (full-upfront, risk): `https://limoo.host/blog/snap-pay-merchant-settlement/`, `https://www.portal.ir/snappay-payment-method`, `https://way2pay.ir/278219/`
- Product / CEO revenue model: `https://see5.net/blog/what-is-snappay`, `https://ideaagency.net/snapppay-the-correct-revenue-model-landtechs/`, `https://snapppay.ir/`, `https://pay.snapp.ir/`
- Refund/cancel FAQ (710 business days): `https://allsport.ir/faq/5/8.html`, `https://sourmeh.ir/common-question-about-snapppay/`
- API (Laravel package) + eligibility: `https://github.com/backendprogramer/laravel-snapp-pay`, `https://payzito.net/docs/gateways/snapppay`, `https://snapppay.ir/merchant-acquisition/`
**Digipay**
- BNPL full-upfront (credit gateway): `https://www.mydigipay.com/bpg/`, `https://matson.online/digipay-seller/`, `https://digiato.com/tech/digipay-business-solutions-pr`
- Services / merchants: `https://www.mydigipay.com/credit/merchants/`, `https://www.mydigipay.com/credit/c-credit/`, `https://www.mydigipay.com/bnpl/c-bnpl/`
- UPG dev docs (type codes, deliver/refund/reverse): `https://www.mydigipay.com/developers/docs/upg/`
- Onboarding: `https://limoo.host/blog/signup-on-digipay/`
**Tara / Torob Pay / ZarinPlus / Lendo**
- Tara: `https://tara360.ir/bnpl/`, `https://tara360.ir/`, `https://itresan.com/384039/`
- Torob Pay (6% + VAT): `https://blupoz.com/`, `https://ranginstore.com/`
- ZarinPlus: `https://www.zarinpal.com/blog/bnpl-زرین-پلاس/`, `https://www.zarinpal.com/payment-gateway`
- Lendo (bank-financed): `https://lendo.ir/blog/`, `https://lendo.ir/`
**Internal**
- Existing research: `c:\Users\Lenovo\Desktop\balinyaar\product\Home-Nursing-Platform-Research.md`
- Database model to refine: `c:\Users\Lenovo\Desktop\balinyaar\product\database-model.md`
> **Confidence legend.** VERIFIED = survived adversarial verification against multiple/first-party sources. CONFIGURABLE = real but contract-/campaign-dependent (store as config, read actuals from provider). UNCERTAIN = plausible but unconfirmed publicly — confirm at contracting before depending on it.
+24 -5
View File
@@ -1,14 +1,33 @@
add no unused var rule to client lint rules and agent rules,
for both projects,
read agent specific files, and if it's not specified, specify a place which define project arcitecture and also add rules for subsequent agents to update that file, if their task changes the project in a way that the description should be updated
# cleanup layout =======================================================================
# add rules and conventions and structure again for both projects
the auth flow should be revised,
we send the cookie to the server which contains the auth cookies, so we can pass that down as props also and use it as initial data for the AppStore, also the AppStore name should change to AuthContext, thats better
and the flow of authentication and authorization should be reviewed to ensure it is configured with regaurd to best practices,
=======================================================================
rules should be added to the projects for agents to not to add verbose comment, if some where there is a really decision made that the code does not tells us ( me and agent) why that piece of code is like that,
comment should be added
===================================================================
in client project if there is still javascript code, rewrite it with ts, with reguard to type rules, no type error and mismatch
=====================================================================================
in product folder, read all the docs, and extract the full information without summarizing and skipping data ( obviousely you can skip duplicate data), and create a single file,
# add proper lint and type rules in client project and add rules for agents to do not violate those rules which is a coprehensive step by step explaination to the bussiness and data model with clear descriptions.
write it in as html file with styles matched to the client project theme,
just a single file, not more. so, long story short:
# do not skip or ignore data
# clear and comprehensive step by step explanation
# map each step of the bussines and its description to the data model.
# at the end of the file consider a whole section for all data models together with their realtions and the diagram( you can use canvas or anyhting that you can attach using cdn in the file)
+2
View File
@@ -1,5 +1,7 @@
ticket page, ticket page,
backoffice, backoffice,
terms of services page ( needs research )
privacy and policy page ( needs research )
verify the registration code verify the registration code
rate limit rate limit
workbox for cache ( maybe ) workbox for cache ( maybe )
+8 -156
View File
@@ -1,161 +1,13 @@
# AGENTS.md — Balinyaar Server # AGENTS.md — Balinyaar Server
> **Coding rules** are in [CONVENTIONS.md](CONVENTIONS.md) — read it before writing any server code. The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): role, stack,
commands, architecture, project map, and a conventions quick-reference.
--- The **full coding rule set** is in **[CONVENTIONS.md](CONVENTIONS.md)** — read it before writing any
server code.
## Role - Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Human setup/run instructions → [README.md](README.md)
You are a **senior .NET software engineer** working on this codebase. That means: `CLAUDE.md` is the single source of truth; this file is just a pointer so the convention is
discoverable under the `AGENTS.md` name too.
- You write production-quality code, not demo code. Every file you touch should look like it was written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it requires touching a contract that other layers depend on, think about the downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should be able to read your code without a guide.
- You never leave the codebase in a worse state than you found it. If you touch a file, leave it at least as clean as it was.
---
## Stack
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, not MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions centrally pinned in `Directory.Packages.props`
---
## Commands (run from `server/`)
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL Server is required to start.
---
## Quality gates — run these before declaring work done
1. `dotnet build Baya.sln` — zero new warnings introduced.
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR. Ask: would a senior engineer approve this without comment?
---
## Project map
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Order, Role…), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application Features/ (Commands & Queries), Contracts/, Models/, pipeline behaviors (Common/)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup)
└── Baya.Test.Infrastructure.Identity xUnit identity tests
```
**Dependency direction points inward.** Domain has no dependencies. Application depends only on Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API — this is a hard rule.
---
## Startup wiring
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
```
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + validators + pipeline behaviors
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies
AddPersistenceServices(...) // DbContext, UnitOfWork, repositories
AddWebFrameworkServices() // API versioning + snake_case routing
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
```
Pipeline order: exception handler → Swagger → routing → **authentication → authorization** → controllers → metrics → health checks → gRPC.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
```
Features/Order/
├── Commands/CreateOrderCommand/
│ ├── CreateOrderCommand.cs record : ICommand<OperationResult<T>>
│ ├── CreateOrderCommand.Handler.cs internal sealed class : ICommandHandler<...>
│ └── CreateOrderCommand.Validator.cs
└── Queries/GetUserOrdersQuery/
├── GetUserOrdersQuery.cs
├── GetUserOrdersQuery.Handler.cs
└── GetUserOrdersQuery.Result.cs
```
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected failures — use `OperationResult` factory methods.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller action to `sender.Send(...)`.
---
## Persistence
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
---
## Identity & auth
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. The key format is set at runtime — always use `[controller]`/`[action]` tokens (see CONVENTIONS.md Routing rule) so the keys are consistent.
- Settings bound from `appsettings.json``IdentitySettings`.
---
## Conventions (see [CONVENTIONS.md](CONVENTIONS.md) for the full rule set)
Quick reference:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
- Never call `Ok()` / `BadRequest()` directly in controllers.
- Handlers are `internal sealed`; never throw for expected failures.
- Mapster for mapping; FluentValidation for validation.
- Package versions only in `Directory.Packages.props`.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
+191
View File
@@ -0,0 +1,191 @@
# Balinyaar Server — Claude Code Guidelines
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
- **Coding rules** (the full rule set you must follow) → [CONVENTIONS.md](CONVENTIONS.md). Read it
before writing any server code.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (business logic, schema, payments, escrow, verification) → [`product/`](../product/).
Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules
from code.
---
## Role
You are a **senior .NET software engineer** working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was
written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries
are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a
contract other layers depend on, think about downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your
code without a guide.
- You never leave the codebase in a worse state than you found it.
---
## Stack
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props`
> Note: some prose elsewhere may say "MediatR" — the actual dispatcher is `martinothamar/Mediator`.
> Use `ISender`/`ICommand`/`IQuery` from that package, not MediatR types.
---
## Commands (run from `server/`)
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL
Server is required to start.
---
## Quality gates — run before declaring work done
1. `dotnet build Baya.sln` — zero new warnings introduced.
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
---
## Project map
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Order, Role…), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application Features/ (Commands & Queries), Contracts/, Models/, pipeline behaviors (Common/)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup)
└── Baya.Test.Infrastructure.Identity xUnit identity tests
```
**Dependency direction points inward.** Domain has no dependencies. Application depends only on
Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or
Application reference Infrastructure or the API — this is a hard rule.
---
## Startup wiring
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
```
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + validators + pipeline behaviors
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies
AddPersistenceServices(...) // DbContext, UnitOfWork, repositories
AddWebFrameworkServices() // API versioning + snake_case routing
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
```
Pipeline order: exception handler → Swagger → routing → **authentication → authorization**
controllers → metrics → health checks → gRPC.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs`
never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
```
Features/Order/
├── Commands/CreateOrderCommand/
│ ├── CreateOrderCommand.cs record : ICommand<OperationResult<T>>
│ ├── CreateOrderCommand.Handler.cs internal sealed class : ICommandHandler<...>
│ └── CreateOrderCommand.Validator.cs
└── Queries/GetUserOrdersQuery/
├── GetUserOrdersQuery.cs
├── GetUserOrdersQuery.Handler.cs
└── GetUserOrdersQuery.Result.cs
```
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are
picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected
failures — use `OperationResult` factory methods.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any
new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller
action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIONS.md) §5.
---
## Persistence
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
---
## Identity & auth
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json``IdentitySettings`.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11).
---
## Conventions — quick reference
Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
Never call `Ok()` / `BadRequest()` / `NotFound()` directly.
- Handlers are `internal sealed`; never throw for expected failures — return `OperationResult`.
- `record` for requests/DTOs, `class` for entities (no public setters), `sealed class` for handlers/services.
- `async`/`await` all the way; pass `CancellationToken` through every async call; never `.Result`/`.Wait()`/`async void`.
- Mapster for mapping; FluentValidation for validation (validate at the boundary).
- Package versions live **only** in `Directory.Packages.props` — never `Version=` in a `.csproj`.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
+1 -1
View File
@@ -1,6 +1,6 @@
# Server Coding Conventions # Server Coding Conventions
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [AGENTS.md](AGENTS.md). Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [CLAUDE.md](CLAUDE.md).
When in doubt, ask: _would a senior engineer approve this diff without comment?_ When in doubt, ask: _would a senior engineer approve this diff without comment?_
+4 -3
View File
@@ -2,14 +2,15 @@
Backend API for the Balinyaar application. It is an **ASP.NET Core (.NET 10)** solution organized around **Clean Architecture**, with: Backend API for the Balinyaar application. It is an **ASP.NET Core (.NET 10)** solution organized around **Clean Architecture**, with:
- **CQRS** via MediatR (source-generated) - **CQRS** via `martinothamar/Mediator` (source-generated; not MediatR)
- **ASP.NET Core Identity** with **JWE** (signed + encrypted JWT) and **OTP** authentication - **ASP.NET Core Identity** with **JWE** (signed + encrypted JWT) and **OTP** authentication
- **Dynamic, permission-based authorization** - **Dynamic, permission-based authorization**
- **EF Core** persistence (SQL Server) with the Repository + Unit of Work patterns - **EF Core** persistence (SQL Server) with the Repository + Unit of Work patterns
- A modular **gRPC plugin** mounted via Application Parts - A modular **gRPC plugin** mounted via Application Parts
- Observability out of the box: Serilog, OpenTelemetry, Prometheus metrics, health checks - Observability out of the box: Serilog, OpenTelemetry, Prometheus metrics, health checks
> Looking for an architecture/file map to navigate the code? See [AGENTS.md](AGENTS.md). > Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (agent guide)
> and [CONVENTIONS.md](CONVENTIONS.md) (coding rules).
## Requirements ## Requirements
@@ -85,7 +86,7 @@ The core of the project. Each entity may carry its own behavior. Entities derive
### Application ### Application
Routes requests and defines the **contracts** (interfaces) the system depends on, without knowing their implementations. This is where **CQRS** lives: Commands and Queries are kept separate and dispatched to their handlers by **MediatR**. Cross-cutting concerns (validation, metrics) are applied as MediatR pipeline behaviors. Routes requests and defines the **contracts** (interfaces) the system depends on, without knowing their implementations. This is where **CQRS** lives: Commands and Queries are kept separate and dispatched to their handlers by **Mediator** (`martinothamar/Mediator`, source-generated). Cross-cutting concerns (validation, metrics) are applied as Mediator pipeline behaviors.
### Infrastructure ### Infrastructure