diff --git a/CLAUDE.md b/CLAUDE.md index 7187b50..01ca6f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,22 @@ The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the A 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. +7. **Keep docs honest, and keep the architecture map current.** If you change how something works, + update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture + in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project + Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that + structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or + changes a cross-project / cross-layer boundary — update the matching architecture section in the + same change. Stale instructions are worse than none. +8. **Write clean, self-documenting code.** + - **No dead code.** Remove unused variables, imports/usings, parameters, and private members — + don't leave them behind and don't suppress the warning. The client enforces this with ESLint + (`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and + the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` / + `CONVENTIONS.md`. + - **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code + already says. Add a comment only where a non-obvious decision, constraint, business rule, or + trade-off isn't evident from the code itself. Prefer a clearer name over a comment. --- diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 6f7e98d..52542c0 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -20,8 +20,8 @@ i18n, cookies, and the rules every change must follow. - **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. +- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer, + `src/context/auth/`, seeded with server-read auth state) for auth/session state. - **notistack** for toasts; **js-cookie** (wrapped) for client cookies. - **Jest** + **Testing Library** for unit tests. - Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**. @@ -58,6 +58,10 @@ 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. +- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from + eslint-config-next's default `warn` to **`error`** (in `eslint.config.mjs`), so dead code fails + `npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused + binding with `_` (e.g. `_event`, `catch (_err)`) to opt out. - **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 ` with a one-line reason, never a file-wide disable. @@ -86,9 +90,16 @@ A change is "done" only if it respects all of these — each has a full section 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. +11. **No dead code; comment the *why*, not the *what*.** Unused vars/imports are lint errors — remove + them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or + trade-off. See **Comments & dead code** below. ## Project Structure +**This section is the canonical description of the client's architecture.** When a change adds, removes, +or renames a route group, provider, or top-level `src/` folder, update this tree in the same change +(root `CLAUDE.md` working agreement #7). + ``` client/ ├── messages/ # Translation files (add keys to BOTH files) @@ -101,7 +112,7 @@ client/ │ ├── globals.css │ ├── fonts/ # Local font files (woff2) — Mikhak for fa │ └── [locale]/ - │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider + │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) │ ├── (private-routes)/ │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout │ │ └── page.tsx @@ -129,6 +140,9 @@ client/ │ │ ├── client.ts # clientFetch — throws ApiError on error; use in hooks/client components │ │ ├── server.ts # serverFetch — throws ApiError on error; use in RSCs/Server Actions │ │ └── errors.ts # ApiError class (status, message, code) + │ ├── auth/ + │ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers) + │ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider │ ├── query/ │ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton │ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools @@ -146,7 +160,8 @@ client/ │ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed) │ └── hooks/ │ └── use{Action}.ts # One hook per file — useQuery or useMutation - ├── store/ # AppStore (Redux-like client state) + ├── context/ # React context providers + │ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth ├── theme/ │ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync │ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE @@ -174,7 +189,7 @@ client/ - Loads the Mikhak font and attaches its CSS-variable class to `` **only for `fa`** (see Fonts). - 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. -- Wraps children with `NextIntlClientProvider`, `AppStoreProvider`, and `ThemeProvider`. +- Wraps children with `NextIntlClientProvider`, `AuthProvider` (seeded with server-read auth state), and `ThemeProvider`. - Exports `generateStaticParams` so Next.js can enumerate locale routes at build time. **WHY `` 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 `` reliably tracks the active locale. @@ -370,6 +385,21 @@ Enforcement: before removing or renaming a shared component, check whether `src/ --- +## Comments & dead code + +- **No dead code.** Unused variables, imports, parameters, and private members are lint errors + (`@typescript-eslint/no-unused-vars`, raised to `error` — see *Quality gates*). Delete them; don't + comment them out and don't silence the rule. Prefix a deliberately-unused binding with `_` to opt out. +- **Comment the *why*, never the *what*.** Code should read for itself — a comment that restates what + the code already says is noise. Don't write `// set the access token` above `setClientCookie(...)`, or + JSDoc that just echoes a function's name. +- **Do** add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a + framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain + *why it is this way*. The comments in `src/app/[locale]/layout.tsx` (why `` lives in the + `[locale]` layout) and `src/lib/auth/token.ts` (why the JWT `exp` check is UX-only, never a security + boundary) are the model to follow. +- Prefer a clearer name or a small helper over a comment whenever that removes the need for it. + ## Anti-patterns (do not do these) - **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`. @@ -423,24 +453,46 @@ Central fetch primitives live in `src/lib/api/`: --- -## Auth Cookies +## Auth Cookies & session state | Cookie | Constant | TTL | Set by | |--------|----------|-----|--------| | `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `useLogin()` in `src/services/auth/hooks/useLogin.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. +**Session state lives in `AuthContext`** (`src/context/auth/`). The root layout resolves the session on +the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie +and checks the JWT `exp` via the shared `isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to +``. So the **first render already knows whether the user is +authenticated**: no logged-out flash, no post-mount cookie read. **Lifecycle:** -- Written after a successful login via `setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS` / `AUTH_REFRESH_COOKIE_OPTIONS` -- 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 `clientFetch` via `getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)` +- Written after a successful login via `setClientCookie` (`useLogin`), which also dispatches `LOG_IN` to + keep `AuthContext` in sync on the client without a reload. +- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: it calls + the API, clears both cookies, dispatches `LOG_OUT`, and redirects — and automatically by `clientFetch` + on 401. +- Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`. +- Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`). -**Middleware:** validates the `exp` claim of the access token locally (base64-decode the JWT payload, no signature check) before rendering any private page. An absent or expired token redirects to `/{locale}/login`. +**`useIsAuthenticated()`** (`src/hooks/auth.ts`) reads the server-seeded `AuthContext`, so it is correct +on the first paint (it no longer reads the cookie after mount). -**`useIsAuthenticated()` hook:** SSR-safe — initialises `false`, sets the real value in `useEffect` by reading the cookie. This prevents hydration mismatches. +**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render. + +**Security posture — current limits and best-practice follow-ups.** The flow above is the intended +client design, but the auth model has known gaps that need *server* coordination to close. Don't +silently "fix" them client-only: +- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this + trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a + same-origin proxy) spans the server. +- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but + does **not** verify the signature. The API is the only authority; never gate real authorization on the + middleware or `isTokenAlive`. +- **No refresh-token rotation** is implemented — the `refresh_token` cookie is set/cleared but never + exchanged; access expiry just forces re-login on the next 401. +- **No role/permission model** yet (`User` is `{ id, username }`); authorization is binary. Add roles to + `User`/`AuthState` and the server before gating UI by permission. --- diff --git a/client/README.md b/client/README.md index 3a9e8aa..91e8bfd 100644 --- a/client/README.md +++ b/client/README.md @@ -72,9 +72,9 @@ src/ ├── hooks/ Custom hooks (auth, layout/mobile, events, window size) ├── i18n/ next-intl routing + request config ├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar -├── lib/ api (client/server fetch), cookies, query (TanStack), toast +├── lib/ api (client/server fetch), auth (JWT/session helpers), cookies, query, toast ├── services/ Domain services: {domain}/{types,keys,apis,hooks} -├── store/ AppStore (React context + reducer) for client state +├── context/ React context providers — auth/ is AuthContext (server-seeded session state) ├── theme/ ThemeProvider, palettes, tokens.css, typography, direction └── utils/ Helpers (storage, navigation, env, text, types) ``` @@ -89,6 +89,7 @@ Translation files live in [`messages/`](messages) (`en.json` / `fa.json`) and mu - This is a server-rendered Next.js app (App Router + middleware + server-side cookies) — **not** a 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`. +- Authentication is cookie-based (`access_token` / `refresh_token`). Session state lives in + `AuthContext` (`src/context/auth/`), seeded on the server from the request cookie so the first + render reflects the real session; cookies are managed through `src/lib/cookies/` and the + `src/services/auth/` hooks. The API base URL comes from `NEXT_PUBLIC_API_URL`. diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index e073cfd..1c22674 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -9,6 +9,31 @@ import next from 'eslint-config-next'; import prettier from 'eslint-config-prettier/flat'; +// Unused variables/imports are dead code. eslint-config-next already enables +// `@typescript-eslint/no-unused-vars` at 'warn'; we raise it to 'error' so +// `npm run check` fails on dead code instead of merely warning. We patch the +// severity *in place* on next's own config objects rather than adding a separate +// override object, because in flat config a rule can only be referenced from a +// config object that registers its plugin — and the `@typescript-eslint` plugin +// is registered inside next's objects, not ours. Prefix a name with `_` to opt +// out (intentionally-unused args, catch bindings, rest-spread siblings). +const NO_UNUSED_VARS = [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, +]; + +const nextWithStrictUnusedVars = [...next].map((entry) => + entry?.rules?.['@typescript-eslint/no-unused-vars'] + ? { ...entry, rules: { ...entry.rules, '@typescript-eslint/no-unused-vars': NO_UNUSED_VARS } } + : entry +); + /** @type {import('eslint').Linter.Config[]} */ const config = [ // Things ESLint should never look at. @@ -16,17 +41,15 @@ const config = [ ignores: ['.next/**', 'out/**', 'coverage/**', 'node_modules/**', 'next-env.d.ts'], }, - // Next.js + TypeScript + React + import/a11y rule sets. - ...next, + // Next.js + TypeScript + React + import/a11y rule sets, with no-unused-vars + // raised to error (see NO_UNUSED_VARS above). + ...nextWithStrictUnusedVars, // 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 diff --git a/client/middleware.ts b/client/middleware.ts index d1f67a7..c5a617e 100644 --- a/client/middleware.ts +++ b/client/middleware.ts @@ -2,20 +2,11 @@ import createMiddleware from 'next-intl/middleware'; import { type NextRequest, NextResponse } from 'next/server'; import { routing } from './src/i18n/routing'; import { COOKIE_NAMES } from './src/lib/cookies'; +import { isTokenAlive } from './src/lib/auth/token'; import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants'; const intlMiddleware = createMiddleware(routing); -function isTokenAlive(token?: string): boolean { - if (!token) return false; - try { - const payload = JSON.parse(atob(token.split('.')[1])); - return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now(); - } catch { - return false; - } -} - export default function middleware(request: NextRequest) { const i18nResponse = intlMiddleware(request); diff --git a/client/src/app/[locale]/layout.tsx b/client/src/app/[locale]/layout.tsx index 77a8193..8ab32fc 100644 --- a/client/src/app/[locale]/layout.tsx +++ b/client/src/app/[locale]/layout.tsx @@ -4,7 +4,8 @@ import localFont from 'next/font/local'; import { setRequestLocale, getMessages } from 'next-intl/server'; import { NextIntlClientProvider } from 'next-intl'; import { getThemeMode } from '@/lib/cookies/server'; -import { AppStoreProvider } from '@/store'; +import { getServerAuthState } from '@/lib/auth/server'; +import { AuthProvider } from '@/context/auth'; import { ThemeProvider, getDirection } from '@/theme'; import { BRAND } from '@/theme/colors'; import { NotistackProvider } from '@/lib/toast'; @@ -73,6 +74,7 @@ export default async function LocaleLayout({ const messages = await getMessages({ locale: safeLocale }); const { colorScheme, defaultMode } = await getThemeMode(); + const authState = await getServerAuthState(); const dir = getDirection(safeLocale); // Only attach the Mikhak font variable on RTL (Persian) routes so the @@ -88,7 +90,7 @@ export default async function LocaleLayout({ > - + @@ -96,7 +98,7 @@ export default async function LocaleLayout({ - + diff --git a/client/src/context/auth/AuthContext.tsx b/client/src/context/auth/AuthContext.tsx new file mode 100644 index 0000000..3e1cda9 --- /dev/null +++ b/client/src/context/auth/AuthContext.tsx @@ -0,0 +1,24 @@ +'use client'; +import { createContext, useContext, useReducer } from 'react'; +import type { Dispatch, FunctionComponent, PropsWithChildren } from 'react'; +import { authReducer } from './authReducer'; +import { INITIAL_AUTH_STATE } from './types'; +import type { AuthAction, AuthState } from './types'; + +export type AuthContextValue = [AuthState, Dispatch]; + +const AuthContext = createContext([INITIAL_AUTH_STATE, () => null]); + +interface AuthProviderProps extends PropsWithChildren { + // Auth state resolved on the server from the request's access-token cookie. + // Seeding the reducer with it means the first client render already reflects + // the real session — no logged-out flash, no post-mount cookie read. + initialState?: AuthState; +} + +export const AuthProvider: FunctionComponent = ({ initialState, children }) => { + const value = useReducer(authReducer, initialState ?? INITIAL_AUTH_STATE); + return {children}; +}; + +export const useAuth = (): AuthContextValue => useContext(AuthContext); diff --git a/client/src/context/auth/authReducer.ts b/client/src/context/auth/authReducer.ts new file mode 100644 index 0000000..a1fca03 --- /dev/null +++ b/client/src/context/auth/authReducer.ts @@ -0,0 +1,13 @@ +import type { Reducer } from 'react'; +import type { AuthAction, AuthState } from './types'; + +export const authReducer: Reducer = (state, action) => { + switch (action.type) { + case 'LOG_IN': + return { isAuthenticated: true, currentUser: action.user ?? state.currentUser }; + case 'LOG_OUT': + return { isAuthenticated: false }; + default: + return state; + } +}; diff --git a/client/src/context/auth/index.ts b/client/src/context/auth/index.ts new file mode 100644 index 0000000..69b68fb --- /dev/null +++ b/client/src/context/auth/index.ts @@ -0,0 +1,4 @@ +export { AuthProvider, useAuth } from './AuthContext'; +export type { AuthContextValue } from './AuthContext'; +export { INITIAL_AUTH_STATE } from './types'; +export type { AuthAction, AuthState } from './types'; diff --git a/client/src/context/auth/types.ts b/client/src/context/auth/types.ts new file mode 100644 index 0000000..96acfcf --- /dev/null +++ b/client/src/context/auth/types.ts @@ -0,0 +1,12 @@ +import type { User } from '@/services/auth/types'; + +export interface AuthState { + isAuthenticated: boolean; + currentUser?: User; +} + +export type AuthAction = { type: 'LOG_IN'; user?: User } | { type: 'LOG_OUT' }; + +export const INITIAL_AUTH_STATE: AuthState = { + isAuthenticated: false, +}; diff --git a/client/src/hooks/auth.ts b/client/src/hooks/auth.ts index 14aa67a..f391151 100644 --- a/client/src/hooks/auth.ts +++ b/client/src/hooks/auth.ts @@ -1,42 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useLocale } from 'next-intl'; - -import { COOKIE_NAMES } from '@/lib/cookies'; -import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client'; -import { ROUTES } from '@/constants'; -import { useAppStore } from '../store'; +import { useAuth } from '@/context/auth'; /** - * Returns true when an access_token cookie is present on the client. - * Initialises as false (SSR-safe) and updates after mount to avoid hydration mismatches. + * True when the current session is authenticated. + * + * Reads AuthContext, which the root layout seeds from the request's access-token + * cookie on the server. The value is therefore correct on the first render — no + * post-mount cookie read, no hydration flash. */ -export function useIsAuthenticated() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - - 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))); - }, []); - - return isAuthenticated; -} - -/** - * Returns a handler that logs the current user out: - * clears auth cookies, resets AppStore, and redirects to the login page. - */ -export function useEventLogout() { - const [, dispatch] = useAppStore(); - const router = useRouter(); - const locale = useLocale(); - - return useCallback(() => { - deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN); - deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN); - dispatch({ type: 'LOG_OUT' }); - router.replace(`/${locale}${ROUTES.LOGIN}`); - }, [dispatch, router, locale]); +export function useIsAuthenticated(): boolean { + const [state] = useAuth(); + return state.isAuthenticated; } diff --git a/client/src/layout/PrivateLayout.tsx b/client/src/layout/PrivateLayout.tsx index 8c06b48..9d665ac 100644 --- a/client/src/layout/PrivateLayout.tsx +++ b/client/src/layout/PrivateLayout.tsx @@ -1,21 +1,11 @@ -'use client'; -import { FunctionComponent, PropsWithChildren, useEffect } from 'react'; -import { COOKIE_NAMES } from '@/lib/cookies'; -import { getClientCookie } from '@/lib/cookies/client'; -import { useAppStore } from '@/store'; +import type { FunctionComponent, PropsWithChildren } from 'react'; -const PrivateLayout: FunctionComponent = ({ children }) => { - const [,dispatch] = useAppStore(); - - useEffect(() => { - if (getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)) { - dispatch({ type: 'LOG_IN' }); - } - }, [dispatch]); - - return ( - children - ); -}; +/** + * Authenticated layout shell. Auth state is seeded on the server by AuthProvider + * (see getServerAuthState) and kept current by the login/logout hooks, so this + * component no longer reads the cookie after mount — it is the place to build the + * authenticated layout chrome. + */ +const PrivateLayout: FunctionComponent = ({ children }) => <>{children}; export default PrivateLayout; diff --git a/client/src/layout/components/SideBar.tsx b/client/src/layout/components/SideBar.tsx index ceca569..e36adce 100644 --- a/client/src/layout/components/SideBar.tsx +++ b/client/src/layout/components/SideBar.tsx @@ -1,7 +1,8 @@ import { FunctionComponent, useCallback, MouseEvent } from 'react'; import { Stack, Divider, Drawer, DrawerProps } from '@mui/material'; import { LinkToPage } from '@/utils'; -import { useEventLogout, useIsAuthenticated, useIsMobile } from '@/hooks'; +import { useIsAuthenticated, useIsMobile } from '@/hooks'; +import { useLogout } from '@/services/auth'; import { AppIconButton, UserInfo } from '@/components'; import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config'; import SideBarNavList from './SideBarNavList'; @@ -18,7 +19,7 @@ export interface SideBarProps extends Pick = ({ anchor, open, variant, items, onClose, ...restOfProps }) => { const isAuthenticated = useIsAuthenticated(); const onMobile = useIsMobile(); - const onLogout = useEventLogout(); + const { mutate: logout } = useLogout(); const handleAfterLinkClick = useCallback( (event: MouseEvent) => { @@ -72,7 +73,7 @@ const SideBar: FunctionComponent = ({ anchor, open, variant, items {/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */} - {isAuthenticated && } + {isAuthenticated && logout()} />} diff --git a/client/src/lib/auth/server.ts b/client/src/lib/auth/server.ts new file mode 100644 index 0000000..42ff1bc --- /dev/null +++ b/client/src/lib/auth/server.ts @@ -0,0 +1,22 @@ +/** + * Server-only auth utilities. + * Import as: import { getServerAuthState } from '@/lib/auth/server' + * + * Uses `next/headers` (via the server cookie manager) and must only be called + * from Server Components, Server Actions, or Route Handlers — never from a + * client component. + */ +import { COOKIE_NAMES } from '@/lib/cookies'; +import { getServerCookie } from '@/lib/cookies/server'; +import type { AuthState } from '@/context/auth/types'; +import { isTokenAlive } from './token'; + +/** + * Resolve the current request's auth state from the access-token cookie so the + * root layout can seed AuthProvider with a server-correct value before the first + * paint. + */ +export async function getServerAuthState(): Promise { + const token = await getServerCookie(COOKIE_NAMES.ACCESS_TOKEN); + return { isAuthenticated: isTokenAlive(token) }; +} diff --git a/client/src/lib/auth/token.ts b/client/src/lib/auth/token.ts new file mode 100644 index 0000000..c86b422 --- /dev/null +++ b/client/src/lib/auth/token.ts @@ -0,0 +1,38 @@ +/** + * JWT helpers shared by the edge middleware and server components. + * + * Pure functions with no `next/headers` dependency, so they are safe to import + * from `middleware.ts` (edge runtime) and from RSCs alike. They only inspect the + * token's `exp` claim — a cheap liveness check for routing/UX, NOT a security + * boundary. The signature is never verified here; the API server is the only + * authority that actually trusts the token. + */ +export interface JwtPayload { + exp?: number; + sub?: string; + [claim: string]: unknown; +} + +// JWTs use base64url (no padding, `-`/`_` instead of `+`/`/`); atob expects +// standard base64, so normalise before decoding. +function base64UrlDecode(segment: string): string { + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return atob(padded); +} + +export function decodeJwtPayload(token: string | undefined): JwtPayload | null { + if (!token) return null; + const segment = token.split('.')[1]; + if (!segment) return null; + try { + return JSON.parse(base64UrlDecode(segment)) as JwtPayload; + } catch { + return null; + } +} + +export function isTokenAlive(token: string | undefined): boolean { + const payload = decodeJwtPayload(token); + return typeof payload?.exp === 'number' && payload.exp * 1000 > Date.now(); +} diff --git a/client/src/services/auth/hooks/useLogin.ts b/client/src/services/auth/hooks/useLogin.ts index 07b68ef..1234e82 100644 --- a/client/src/services/auth/hooks/useLogin.ts +++ b/client/src/services/auth/hooks/useLogin.ts @@ -4,16 +4,22 @@ import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES } import { setClientCookie } from '@/lib/cookies/client'; import { dispatchToast } from '@/lib/toast/dispatchToast'; import type { ApiError } from '@/lib/api/errors'; +import { useAuth } from '@/context/auth'; import { AuthClientApi } from '../apis/clientApi'; import type { LoginDto } from '../types'; export function useLogin() { + const [, dispatch] = useAuth(); + return useMutation({ mutationFn: (dto: LoginDto) => AuthClientApi.login(dto), onSuccess: (data) => { setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, data.accessToken, AUTH_ACCESS_COOKIE_OPTIONS); setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, data.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS); + // Sync AuthContext after a client-side login so the UI reflects the new + // session immediately, without waiting for the next full server render. + dispatch({ type: 'LOG_IN' }); }, onError: (error: ApiError) => { dispatchToast(error.message, 'error'); diff --git a/client/src/services/auth/hooks/useLogout.ts b/client/src/services/auth/hooks/useLogout.ts index 0939c43..0cf68a0 100644 --- a/client/src/services/auth/hooks/useLogout.ts +++ b/client/src/services/auth/hooks/useLogout.ts @@ -5,12 +5,12 @@ import { useLocale } from 'next-intl'; import { COOKIE_NAMES } from '@/lib/cookies'; import { deleteClientCookie } from '@/lib/cookies/client'; import { ROUTES } from '@/constants'; -import { useAppStore } from '@/store'; +import { useAuth } from '@/context/auth'; import { AuthClientApi } from '../apis/clientApi'; export function useLogout() { - const [, dispatch] = useAppStore(); + const [, dispatch] = useAuth(); const router = useRouter(); const locale = useLocale(); diff --git a/client/src/store/AppReducer.ts b/client/src/store/AppReducer.ts deleted file mode 100644 index 2763e61..0000000 --- a/client/src/store/AppReducer.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Reducer } from 'react'; -import { AppStoreState } from './config'; - -const AppReducer: Reducer = (state, action) => { - switch (action.type || action.action) { - case 'CURRENT_USER': - return { ...state, currentUser: action?.currentUser || action?.payload }; - case 'SIGN_UP': - case 'LOG_IN': - return { ...state, isAuthenticated: true }; - case 'LOG_OUT': - return { ...state, isAuthenticated: false, currentUser: undefined }; - default: - return state; - } -}; - -export default AppReducer; diff --git a/client/src/store/AppStore.tsx b/client/src/store/AppStore.tsx deleted file mode 100644 index abb9ea6..0000000 --- a/client/src/store/AppStore.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; -import { - createContext, - useReducer, - useContext, - FunctionComponent, - PropsWithChildren, - Dispatch, - ComponentType, -} from 'react'; -import AppReducer from './AppReducer'; -import { APP_STORE_INITIAL_STATE, AppStoreState } from './config'; - -export type AppContextReturningType = [AppStoreState, Dispatch]; -const AppContext = createContext([APP_STORE_INITIAL_STATE, () => null]); - -const AppStoreProvider: FunctionComponent = ({ children }) => { - const value: AppContextReturningType = useReducer(AppReducer, APP_STORE_INITIAL_STATE); - return {children}; -}; - -const useAppStore = (): AppContextReturningType => useContext(AppContext); - -interface WithAppStoreProps { - appStore: AppContextReturningType; -} -const withAppStore = (Component: ComponentType): FunctionComponent => - function ComponentWithAppStore(props) { - return ; - }; - -export { AppStoreProvider, useAppStore, withAppStore }; diff --git a/client/src/store/config.ts b/client/src/store/config.ts deleted file mode 100644 index 87e1203..0000000 --- a/client/src/store/config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface AppStoreState { - isAuthenticated: boolean; - currentUser?: object | undefined; -} - -export const APP_STORE_INITIAL_STATE: AppStoreState = { - isAuthenticated: false, -}; diff --git a/client/src/store/index.tsx b/client/src/store/index.tsx deleted file mode 100644 index d420cc2..0000000 --- a/client/src/store/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { AppStoreProvider, useAppStore, withAppStore } from './AppStore'; - -export { AppStoreProvider, useAppStore, withAppStore }; diff --git a/product/Balinyaar Wireframes.html b/product/Balinyaar Wireframes.html new file mode 100644 index 0000000..5223e96 --- /dev/null +++ b/product/Balinyaar Wireframes.html @@ -0,0 +1,669 @@ + + + + + + + + + + + + + + + + +
+ + +
+
+
+
ب
+
+
بالین‌یار
+
وایرفریم جریان‌ها · اپلیکیشن موبایل · نسخه میان‌فی
+
+
+
+
+ راهنما: + خودکار / تاییدشده + در انتظار + دستی / بعداً + اقساط / مالی +
+
+ + +
+
+
A
+

ورود و آنبوردینگ کاربر

+ User login & onboarding +
+ +
خانواده با شماره موبایل وارد می‌شود، یک بیمار ثبت می‌کند و مستقیم به خانه می‌رسد. احراز هویت سنگین برای کاربر لازم نیست.
+
+
+ +
+ + +
+
A۱ · ورود / ثبت‌نام
+
+
۹:۴۱▦ ▮▮▮
+
+
+
ب
+
بالین‌یار
+
مراقبت خانگی قابل‌اعتماد، نزدیک شما
+
+
+
شماره موبایل
+
۰۹۱۲ ۰۰۰ ۰۰۰۰
+
+
دریافت کد تایید
+
پرستار هستید؟ ورود پرستاران ←
+
+
+
+ +
+ + +
+
A۲ · کد تایید
+
+
۹:۴۱▦ ▮▮▮
+
+
کد تایید را وارد کنید
+
کد ۴ رقمی به شماره ۰۹۱۲ ۰۰۰ ۰۰۰۰ پیامک شد.
+
+
۴
+
۸
+
_
+
_
+
+
ارسال مجدد کد تا ۰۰:۴۵
+
تایید و ادامه
+
+
+
+ +
+ + +
+
A۳ · برای چه کسی؟
+
+
۹:۴۱▦ ▮▮▮
+
+
+
برای چه کسی مراقبت می‌خواهید؟
+
پدر یا مادر
+
همسر
+
فرزند
+
خودم
+
ادامه
+
+
+
+ +
+ + +
+
A۴ · ثبت بیمار
+
+
۹:۴۱▦ ▮▮▮
+
+
ثبت بیمار
+
نام و نام خانوادگی
مثلاً پدر — حسن رضایی
+
سن
۷۲
جنسیت
مرد
زن
+
شرایط / وضعیت
سالمندپس از جراحیدیابت+ بیشتر
+
ذخیره و ادامه
+
+
+
+ +
+ + +
+
A۵ · خانه
+
+
۹:۴۱▦ ▮▮▮
+
+
سلام،
مریم رضایی
+
جستجوی خدمت یا پرستار…
+
دسته‌بندی خدمات
+
+
مراقبت سالمند
+
پرستار کودک
+
تزریقات و سرم
+
فیزیوتراپی
+
+
پدر
پرونده بیمار را کامل کنید ←
+
+
+
خانه
+
رزروها
+
بیماران
+
کیف‌پول
+
پروفایل
+
+
+
+ +
+ + +
+
+
B
+

ورود و احراز هویت پرستار

+ Nurse login & verification + نمای پرستار +
+ +
احراز هویت پلتفرم‌محور و الزامی است: زنجیره مرحله‌ای (هویت ثبت‌احوال، پروانه نظام پرستاری، سوءپیشینه، مصاحبه). تا کامل‌شدن، پرستار قابل رزرو نیست.
+
+
+ +
+ + +
+
B۱ · ورود پرستار
+
+
۹:۴۱▦ ▮▮▮
+
+
+
ب
+
ورود پرستاران
+
ویژه پرستاران دارای پروانه نظام پرستاری
+
+
+
شماره موبایل
+
۰۹۱۲ ۰۰۰ ۰۰۰۰
+
+
دریافت کد تایید
+
خانواده هستید؟ ورود کاربران ←
+
+
+
+ +
+ + +
+
B۲ · کد تایید
+
+
۹:۴۱▦ ▮▮▮
+
+
کد تایید را وارد کنید
+
کد ۴ رقمی به ۰۹۱۲ ۰۰۰ ۰۰۰۰ پیامک شد.
+
+
۲
+
_
+
_
+
_
+
+
ارسال مجدد کد تا ۰۰:۵۸
+
تایید و ورود
+
+
+
+ +
+ + +
+
B۳ · وضعیت احراز هویت
+
+
۹:۴۱▦ ▮▮▮
+
+
احراز هویت و صلاحیت
+
۲ از ۵
+
شماره موبایل
تاییدشده
+
۲
هویت — کارت ملی و سلفی
در انتظار
+
۳
پروانه نظام پرستاری
بعدی
+
۴
گواهی سوءپیشینه
بعدی
+
۵
مصاحبه ویدیویی
بعدی
+
ادامه مرحله ۲
+
+
+
+ +
+ + +
+
B۴ · تایید هویت
+
+
۹:۴۱▦ ▮▮▮
+
+
تایید هویت
+
کد ملی
۰۰۱۲۳۴۵۶۷۸
+
بارگذاری تصویر کارت ملی
از دوربین یا گالری
+
احراز زنده‌بودن (سلفی)
تطبیق چهره با کارت ملی
+
استعلام خودکار از ثبت احوال
+
ارسال و استعلام
+
+
+
+ +
+ + +
+
B۵ · مدارک حرفه‌ای
+
+
۹:۴۱▦ ▮▮▮
+
+
مدارک پرستاری
+
شماره نظام پرستاری
ن-۱۲۳۴۵۶
+
پروانه / کارت نظام پرستاری
بارگذاری تصویر
+
مدرک تحصیلی
بارگذاری شد
+
تخصص‌ها
سالمندانICU+ افزودن
+
ثبت مدارک
+
+
+
+ +
+ + +
+
B۶ · در حال بررسی
+
+
۹:۴۱▦ ▮▮▮
+
+
+
مدارک شما در حال بررسی است
+
کارشناسان بالین‌یار صلاحیت شما را بررسی می‌کنند. معمولاً ۲۴ تا ۴۸ ساعت زمان می‌برد.
+
+
هویت تایید شد
+
بررسی مدارک حرفه‌ای
+
مصاحبه ویدیویی
+
+
مشاهده وضعیت
+
+
+
+ +
+ + +
+
B۷ · تکمیل پروفایل و خدمات
+
+
۹:۴۱▦ ▮▮▮
+
+
تکمیل پروفایل
+
+
عکس پروفایل و معرفی کوتاه خود را اضافه کنید
+
خدمات و قیمت‌ها
+
مراقبت سالمند — ساعتی
۲۸۰٬۰۰۰ تومان / ساعت
ویرایش
+
+ افزودن خدمت
+
روزهای در دسترس
+
شیدسچپج
+
انتشار پروفایل
+
+
+
+ +
+ + +
+
+
C
+

درخواست خدمت پرستاری

+ Search → match → request → book +
+ +
جستجو بر اساس دسته، موقعیت، قیمت و جنسیت. درخواست → تایید پرستار → پرداخت → تایید نهایی. هم‌جنس‌بودن مراقب نزدیک به الزامی است.
+
+
+ +
+ + +
+
C۱ · جستجو و فیلتر
+
+
۹:۴۱▦ ▮▮▮
+
+
جستجوی پرستار
+
مراقبت سالمند
+
تهران ▾تاریخ ▾جنسیت ▾
+
دسته‌بندی
+
+
مراقبت سالمند
+
پرستار کودک
+
تزریقات و سرم
+
مراقبت زخم
+
+
مشاهده ۲۴ پرستار
+
+
+
+ +
+ + +
+
C۲ · نتایج جستجو
+
+
۹:۴۱▦ ▮▮▮
+
+
۲۴ پرستارمرتب‌سازی: امتیاز ▾
+
زهرا کریمی✓ تاییدشده
★ ۴٫۹ (۱۲۰ نظر) · ۲٫۱ کیلومتر
از ۲۵۰٬۰۰۰ تومان / ساعت
+
مریم احمدی✓ تاییدشده
★ ۴٫۸ (۹۸ نظر) · ۳٫۴ کیلومتر
از ۲۹۰٬۰۰۰ تومان / ساعت
+
سارا موسوی✓ تاییدشده
★ ۴٫۷ (۷۶ نظر) · ۴٫۰ کیلومتر
از ۲۴۰٬۰۰۰ تومان / ساعت
+
+
+
خانه
+
رزروها
+
بیماران
+
کیف‌پول
+
پروفایل
+
+
+
+ +
+ + +
+
C۳ · پروفایل پرستار
+
+
۹:۴۱▦ ▮▮▮
+
+
زهرا کریمی
★ ۴٫۹ · ۱۲۰ نظر
✓ تاییدشدهنظام پرستاری ✓
+
سالمندانتزریقات۸ سال سابقه
+
خدمات و قیمت
+
مراقبت سالمند (ساعتی)۲۵۰٬۰۰۰
+
شیفت شبانه (۱۲ ساعت)۲٬۴۰۰٬۰۰۰
+
★★★★★ آخرین نظر
«بسیار دلسوز و وقت‌شناس بود، از مراقبت پدرم راضی بودیم.»
+
+
درخواست رزرو
+
+
+ +
+ + +
+
C۴ · فرم درخواست
+
+
۹:۴۱▦ ▮▮▮
+
+
درخواست خدمت
+
بیمار
پدر — حسن رضایی
+
نوع خدمت
مراقبت سالمند (ساعتی)
+
آدرس
منزل — نارمک، تهران
+
تاریخ
۱۴۰۴/۰۴/۱۵
ساعت
۰۹:۰۰
+
جنسیت پرستار
خانم
آقا
فرقی ندارد
+
ارسال درخواست
+
+
+
+ +
+ + +
+
C۵ · در انتظار تایید پرستار
+
+
۹:۴۱▦ ▮▮▮
+
+
+
درخواست برای پرستار ارسال شد
+
به‌محض تایید زهرا کریمی، برای پرداخت به شما اطلاع می‌دهیم.
+
پرستارزهرا کریمی
زمان۱۴۰۴/۰۴/۱۵ — ۰۹:۰۰
+
+
درخواست ثبت شد
+
در انتظار تایید پرستار
+
پرداخت و تایید نهایی
+
+
+
+
+ +
+ + +
+
C۶ · خلاصه و پرداخت
+
+
۹:۴۱▦ ▮▮▮
+
+
✓ پرستار تایید کرد
+
تایید و پرداخت
+
+
هزینه خدمت (۸ ساعت)۲٬۰۰۰٬۰۰۰
+
کارمزد پلتفرم۲۴۰٬۰۰۰
+
مالیات۹۰٬۰۰۰
+
+
مبلغ کل۲٬۳۳۰٬۰۰۰ تومان
+
+
🔒
مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت برای پرستار آزاد می‌شود.
+
ادامه پرداخت ←
+
+
+
+ +
+ + +
+
+
D
+

پرداخت اقساطی (BNPL)

+ دیجی‌پی · اسنپ‌پی · اقساط بالین‌یار +
+ +
یک روش پرداخت جایگزین. ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط بین مشتری و ارائه‌دهنده است.
+
+
+ +
+ + +
+
D۱ · روش پرداخت
+
+
۹:۴۱▦ ▮▮▮
+
+
روش پرداخت
+
مبلغ قابل پرداخت
۲٬۳۳۰٬۰۰۰ تومان
+
پرداخت کامل (کارت بانکی)
+
پرداخت اقساطی
+
DG
دیجی‌پی
۳ تا ۱۲ قسط
+
SP
اسنپ‌پی
۴ قسط بدون سود
+
ب
اقساط بالین‌یار
طرح داخلی
+
ادامه با دیجی‌پی
+
+
+
+ +
+ + +
+
D۲ · انتخاب طرح اقساط
+
+
۹:۴۱▦ ▮▮▮
+
+
DG
طرح اقساط دیجی‌پی
+
مبلغ کل۲٬۳۳۰٬۰۰۰
+
۳ ماهه
بدون سود
۷۷۷٬۰۰۰
ماهانه
+
۶ ماهه
کارمزد ۴٪
۴۰۴٬۰۰۰
ماهانه
+
۱۲ ماهه
کارمزد ۹٪
۲۱۲٬۰۰۰
ماهانه
+
پیش‌پرداخت۲۰٪
+
ادامه
+
+
+
+ +
+ + +
+
D۳ · اعتبارسنجی
+
+
۹:۴۱▦ ▮▮▮
+
+
اعتبارسنجی
+
کد ملی
۰۰۱۲۳۴۵۶۷۸
+
شماره موبایل
۰۹۱۲ ۰۰۰ ۰۰۰۰
+
با استعلام اعتبارسنجی و سابقه اعتباری من توسط دیجی‌پی موافقم.
+
اعتبار شما تایید شد
سقف اعتبار قابل استفاده
۱۵٬۰۰۰٬۰۰۰ تومان
+
تایید و ادامه
+
+
+
+ +
+ + +
+
D۴ · تایید طرح و قرارداد
+
+
۹:۴۱▦ ▮▮▮
+
+
جدول بازپرداخت
+
قسطسررسیدمبلغ
+
پیش‌پرداختامروز۴۶۶٬۰۰۰
+
قسط ۱۱۴۰۴/۰۵/۱۵۴۰۴٬۰۰۰
+
قسط ۲۱۴۰۴/۰۶/۱۵۴۰۴٬۰۰۰
+
تا قسط ۶۴۰۴٬۰۰۰
+
شرایط و قرارداد اقساط را خوانده‌ام و می‌پذیرم.
+
تایید نهایی و پرداخت پیش‌پرداخت
+
+
+
+ +
+ + +
+
D۵ · پیگیری اقساط (کیف‌پول)
+
+
۹:۴۱▦ ▮▮▮
+
+
کیف‌پول و اقساط
+
مانده بدهی اقساط
۱٬۶۱۶٬۰۰۰ تومان
قسط بعدی · ۱۴۰۴/۰۶/۱۵
۴۰۴٬۰۰۰
پرداخت زودهنگام
+
سررسیدها
+
قسط ۱ · ۱۴۰۴/۰۵/۱۵
پرداخت‌شده
+
قسط ۲ · ۱۴۰۴/۰۶/۱۵
سررسید نزدیک
+
قسط ۳ · ۱۴۰۴/۰۷/۱۵
آینده
+
+
+
خانه
+
رزروها
+
بیماران
+
کیف‌پول
+
پروفایل
+
+
+
+ +
+ + +
+
+
E
+

پرونده بیمار

+ Living patient profile +
+ +
پرونده متعلق به خانواده است و با تعویض پرستار حفظ می‌شود. پرستار فقط می‌تواند یادداشت ویزیت و مشاهدات اضافه کند (نه ویرایش کامل).
+
+
+ +
+ + +
+
E۱ · لیست بیماران
+
+
۹:۴۱▦ ▮▮▮
+
+
بیماران من
+
پدر — حسن رضایی
۷۲ ساله · مرد
سالمنددیابت
+
مادر — فاطمه رضایی
۶۸ ساله · زن
پس از جراحی
+
+ افزودن بیمار
+
+
+
خانه
+
رزروها
+
بیماران
+
کیف‌پول
+
پروفایل
+
+
+
+ +
+ + +
+
E۲ · پرونده بیمار
+
+
۹:۴۱▦ ▮▮▮
+
+
پدر — حسن رضایی
۷۲ ساله · مرد · دیابت، فشار خون
ویرایش
+
داروها
روتین
سوابق
وظایف
+
متفورمین ۵۰۰
۲ بار در روز
صبح ۰۸:۰۰ · شب ۲۰:۰۰ — همراه غذا
+
لوزارتان ۲۵
۱ بار در روز
صبح ۰۸:۰۰ — کنترل فشار خون
+
انسولین
حسب قند خون
پیش از وعده‌ها — طبق دستور پزشک
+
این پرونده متعلق به خانواده است و با تعویض پرستار حفظ می‌شود.
+
+
+
+ +
+ + +
+
E۳ · ثبت یادداشت ویزیت (نمای پرستار)
+
+
۹:۴۱▦ ▮▮▮
+
+
ویزیت امروز — پدرِ مریم
+
ورود ثبت شد ۰۹:۰۲ · موقعیت تایید شد (EVV)
+
وظایف امروز
+
داروی متفورمین ساعت ۱۰
+
اندازه‌گیری فشار خون
+
پیاده‌روی کوتاه
+
یادداشت ویزیت
+
وضعیت بیمار، مشاهدات و توصیه‌ها…
+
ثبت خروج (EVV)
ثبت یادداشت
+
+
+
+ +
+ +
+
+ + + diff --git a/product/additional-info.txt b/product/additional-info.txt new file mode 100644 index 0000000..6a5e28b --- /dev/null +++ b/product/additional-info.txt @@ -0,0 +1,10 @@ +1.(برای آینده) فلوی قیمت باید سمت ما باشه: +- ما وقتی فرد میخواد دستمزد ساعتی دریافتی و روزانه اش رو مشخص کنه باید یه رنجی از بازار بهش نشون بدیم بنظرم و چندتا نکته بنویسیم اگر زیاد گذاشت یا کم که آقا رنج مناسبی نذاشتی و بگیم که مشتریات کم میشه + از بابت اینکه نسبت به سابقه و مهارت ها و ... میتونیم یه عدد پیشنهادی تو بازه بهش نشون بدیم که کار رو راحت تر کنه +- در آینده باید داستان بوست رو برای هر دو طرف مشخص کنیم یعنی اگر فرد بخواد زودتر کسی رو پیدا کنه، چه پرستار بخواد زودتر کار بگیره (مرحله خیلی بعد) +- کلا پروسه بید زدن پرستار هارو بزاریم برای بعدا چون مشتری رو ناراضی میکنه الان وقتش نیست + + +2. سیستم اقساطی باید بصرفه یعنی نباید برای یک روز ما خدمات قساطی بدیم، برای درخواستی بالای سه روز باید باشه که عددش معنی دار بشه شاید هم حتی بالاتر - کسی نمیاد 4-5 تومن رو قطی بده دیگه (این گمان هست باید دیتا بیشتر ببینم) +پس نیاز داریم که بتونیم برای بازه های قیمتی متفاوت گزینه های پرداخت رو فعال یا غیر فعال کنیم. + +3. برای آینده، یک بلاگ در نظر داشته باشیم \ No newline at end of file diff --git a/product/balinyaar-business-and-data-model.html b/product/balinyaar-business-and-data-model.html new file mode 100644 index 0000000..a2a003c --- /dev/null +++ b/product/balinyaar-business-and-data-model.html @@ -0,0 +1,2696 @@ + + + + + +Balinyaar — Business & Data Model Handbook + + + + + + + +
+ + +
+
+ + + Balinyaar +  · Business & Data Model Handbook + +
+
+ +
+
+ + + + + +
+ + +
+ Internal Handbook +

Balinyaar — Business & Data Model

+

A single, comprehensive walkthrough of what Balinyaar does and how its data is shaped. It walks the platform end to end — from a family searching for a nurse to the weekly bank transfer that pays that nurse — and, at every step, names the database entities that make the step real. A final reference assembles the entire ~53-table model across 13 domains, with diagrams.

+ +
+
iHow to read this document
+

Sections 1–14 are the business, in the order the platform actually runs. Each ends with a Data model block (teal header) listing the supporting tables and explaining how they connect to that step. The colored callouts recur throughout and always mean the same thing:

+
+ Iran-specific — local legal/fiscal/cultural reality + MVP — ships at launch + Deferred — modeled now, inactive at launch + Design decision — a deliberate choice & its reasoning + Open question — confirm before building +
+

All monetary values are in IRR (Rials), stored as BIGINT. Toman is display-only and is converted to/from Rials only at an external provider's API boundary. Persian credential and product names are kept in their original script for fidelity.

+
+ +

Sources: business-requirements.md (14 business domains), database-model.md (Revision 2 — 53 tables / 13 domains / 4 diagrams), payments-and-installments.md (escrow, settlement & BNPL deep-dive), and the market/legal/verification research report. Document date basis: 2026-06-20.

+
+ + +
+

What Balinyaar is

+

Balinyaar is a trust-first home-nursing marketplace in Iran. Independent, individually-verified nurses (and, later, nursing-company employees) register, list configurable services at their own prices, and pass a multi-step verification pipeline anchored on the Ministry of Health پروانه صلاحیت حرفه‌ای (professional-competency license). Families search — filtered by city/district and same-gender caregiver preference — pick a nurse and a service variant, submit a booking request, and pay through the platform after the nurse accepts.

+

The platform records the money as an internal escrow ledger state (not platform-held cash), the nurse performs one or more EVV-verified visits, and the platform pays the nurse weekly, after a dispute window closes, minus a platform commission. All post-booking communication runs through an admin-readable ticket system — there is no direct nurse↔customer chat.

+ +
+
+
The three actors
+

Customer — the family member who pays.
Nurse — the independent caregiver who sells.
Admin — Balinyaar back-office (support, finance, moderation, super-admin).

+
+
+
The fourth entity: the patient
+

The patient (care recipient) is first-class and distinct from the payer, because the payer is usually not the patient (an adult child, a spouse vs. an elderly parent, a newborn, a post-surgical adult).

+
+
+
The launch legal vehicle
+

At launch the platform operates under a partner licensed home-nursing center (مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل) — the Asanism-style model — the legal vehicle and likely merchant-of-record while Balinyaar's own MoH permit is in process.

+
+
+ +
+
!Why "Iran-specific" is a recurring theme
+

This is not a generic marketplace. Iranian payment law forbids the platform from holding customer cash; same-gender bodily-care is culturally decisive; home nursing is a licensed healthcare activity; VAT is 10%; bank transfers are effectively irreversible; and credential verification is fragmented across regulators with no public B2B API. Each of these reshapes a requirement — so each business section carries its own Iran-specific callout.

+
+
+ + +
+

Cross-cutting ground truths

+

Four facts hold in every section below. They are the load-bearing constraints the whole design is built around.

+ +
    +
  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. +
  3. +
    VAT is 10%, configurable
    +

    It rose from 9% to 10% in 1403 (7% government + 3% municipal) and is treated as a configurable rate — it has moved two years running, so it is never hardcoded.

    +
  4. +
  5. +
    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

    +
  6. +
  7. +
    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. The nurse's payout is always gross_price_irr − balinyaar_commission_irr, never a BNPL provider's net settlement.

    +
  8. +
+
+ + +
+

Data-model design principles

+

The schema (Revision 2) follows thirteen principles. They explain why the entity tables look the way they do, and recur in the data-model mappings.

+
+ + + + + + + + + + + + + + + + + +
#Principle
1Money is BIGINT in IRR. Toman is display-only; conversion happens only at a provider's API boundary. No floats anywhere on the money path.
2The platform never legally holds buyer cash. Funds settle through a licensed PSP to registered IBANs (commission IBAN + nurse IBAN via تسهیم settlement-sharing, or one merchant-of-record account). "Escrow"/"nurse balance" are derived ledger states, never a Balinyaar cash balance.
3ledger_entries is the financial source of truth. Every capture, commission, payout, refund, and clawback posts balanced double-entry rows. Per-table money fields stay the operational/pricing record; the ledger answers "how much do we owe nurses right now" and "how much is held but unreleased."
4Fee split is captured per booking, never derived from live config, so historical reporting survives commission-schedule changes. The booking stores gross_price_irr, balinyaar_commission_irr, nurse_payout_amount.
5PII is marked (encrypted) — national ID, IBAN, phone, addresses, clinical data — column- or app-level. Clinical data has stricter access than financial data.
6Two-stage clinical disclosure is a hard rule. At the request stage the nurse sees only booking_requests.customer_notes; the full encrypted booking_care_instructions are exposed only after the booking is confirmed. Enforced at the authorization layer.
7Soft deletes on users/nurse_profiles via deleted_at. Audit, payment, ledger, and payout records are never deleted.
8Audit trail is append-only. All state transitions on bookings, payments, refunds, payouts, verifications, reviews, and platform_configs produce an audit_logs row.
9Catalog/config tables are rows, not enums (service categories, verification step types, cancellation policies, Iranian holidays) so the business evolves without migrations. They carry name_fa/name_en.
10Idempotency is mandatory on the money path. Every PSP/BNPL callback is stored raw in payment_webhook_events and deduplicated on external_event_id before any money-state mutation.
11All timestamps are DATETIME2(7) UTC. Persian-calendar display is a UI concern — except that bank-closure scheduling uses the iranian_holidays table, because PAYA/SATNA transfers fail on holidays.
12Derived flags must not drift. nurse_profiles.is_verified, rating aggregates, and the search index are written only by the code path that owns their source of truth, inside the same transaction.
13Invariants are enforced, not documented: CHECK constraints (gross = commission + payout, rating BETWEEN 1 AND 5, amounts ≥ 0, end_time > start_time), filtered-UNIQUE for "one primary"/"one active", and tenancy checks (a booking's patient/address belongs to the same customer; its variant to the same nurse).
+
+
+ + +
+

The platform at a glance

+

Before the detail, here is the end-to-end path a single engagement travels. Sections 1–14 expand each stage.

+
    +
  1. Onboard — customer registers with phone-OTP and adds a patient + address; a nurse registers and enters verification. (§1)
  2. +
  3. Verify the nurse — six-step pipeline (identity, Shahkar, MoH license, INO, criminal record, IBAN ownership); only then do the nurse's variants become bookable. (§2)
  4. +
  5. List & price services — admin defines the catalog skeleton; each nurse creates priced variants. (§3)
  6. +
  7. Search & match — family filters by category, city/district, price, rating, and same-gender preference against a denormalized search index. (§4)
  8. +
  9. Request → accept → pay → confirm — request (no money) becomes a booking (payment captured), which owns N sessions. (§5)
  10. +
  11. Deliver with EVV — the nurse clocks in/out per session with GPS; payout is gated on EVV completion. (§6)
  12. +
  13. Handle cancellation/refund — tiered, snapshotted policy; admin-only refunds decomposed across fee legs. (§7)
  14. +
  15. Move the money — card or BNPL capture posts a balanced ledger entry; escrow is a ledger state. (§8–9)
  16. +
  17. Pay the nurse — weekly batch to the verified IBAN after the dispute window, with clawback as fallback. (§10)
  18. +
  19. Review, communicate, invoice — one moderated review per booking; ticket-only messaging; a minimal commission invoice with 10% VAT. (§11–14)
  20. +
+

The four reference diagrams (domain map, booking spine, payments & payouts, financial lifecycle) plus an ER overview render this visually.

+
+ +
+ + +
+ Business · Step 1 +

1Actors & Onboarding

+

Who can use the platform, how they prove who they are, and when. KYC is staged by role and risk — not collected up-front for everyone.

+ +

How onboarding works

+
    +
  • Three actor types: customer (the family member / payer), nurse (the independent caregiver / seller), and admin (Balinyaar back-office: support, finance, moderation, super-admin).
  • +
  • Phone number is the primary login credential. Authentication is phone-OTP (one-time SMS code). 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 is frequently not the patient. A customer may register multiple patients.
  • +
  • Each successful login creates a refresh-token session that can be revoked (logout, stolen-token detection).
  • +
+ +

KYC timing is role- and risk-staged

+
    +
  • 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 (§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.
  • +
+ +
+
!Iran-specific considerations
+
    +
  • Phone-OTP is the dominant Iranian login norm and is also the anchor for Shahkar SIM↔national-ID binding (§2).
  • +
  • Storing national_id only post-KYC matches the reality that identity is verified through gated vendor APIs, not collected casually at signup.
  • +
  • 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.
  • +
+
+ +
+
+
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 unused at launch); push notifications; social login; nursing-company (organization) self-onboarding.

+
+
+ +
+
Data model — Actors & Onboarding
+
+
+ usersuser_sessionsrolesuser_rolesnurse_profilescustomer_profilespatientscustomer_addresses +
+
    +
  • users is the single identity record for every human actor; role (nurse/customer/admin) decides which profile sub-table is populated. Phone is unique & encrypted; national_id stays NULL until KYC; gender lives here because nurse gender is matched for same-gender care.
  • +
  • users 1:1 → nurse_profiles / customer_profiles by role; 1:N → user_sessions (revocable refresh tokens) and user_roles.
  • +
  • roles / user_roles implement RBAC for admin staff only (nurses/customers use users.role); user_roles keeps granted_by/revoked_at for an audit trail.
  • +
  • customer_profiles 1:N → patients (the care recipients) and customer_addresses (saved, encrypted, with coordinates for EVV distance checks; exactly one is_primary via filtered UNIQUE).
  • +
  • Tenancy invariant: a booking's patient_id and customer_address_id must belong to the same customer.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 2 +

2Nurse Verification & Credentials

+

Verified trust is the entire brand. Vetting is platform-owned, non-optional, and performed at the authoritative source — never delegated to families, never marketed as a check the platform does not actually perform. A nurse is bookable only after all required steps pass.

+ +

A data-driven pipeline

+

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 is 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 six 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 stolen-identity / alias fraud.

    +
  2. +
  3. +
    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.

    +
  4. +
  5. +
    MoH پروانه صلاحیت حرفه‌ای — the single most important credential
    +

    The MoH-mandated professional-competency license for in-home nursing. It 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 launch method is nurse-uploaded document + manual admin verification against the official record.

    +
  6. +
  7. +
    نظام پرستاری (INO) membership — cross-check
    +

    The Iranian Nursing Organization membership number is captured and cross-checked (ino.ir) as a second source. Manual at launch.

    +
  8. +
  9. +
    عدم سوء پیشینه (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.

    +
  10. +
  11. +
    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 money-mule payout diversion.

    +
  12. +
+ +
+
Design decision — a 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.

+
+ +
+
!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. (See the verification pipeline deep-dive.)
  • +
+
+ +
+
+
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 modeled but inactive); a professional-liability-insurance step (addable as a row when required).

+
+
+ +
+
Data model — Verification & Credentials
+
+
+ nurse_verificationsverification_step_typesverification_stepsverification_documentsnurse_credentialsnurse_bank_accountssupport_alertsaudit_logs +
+
    +
  • nurse_verifications is the master per-nurse header (1:1 with nurse_profiles); its status is the single source of truth for verification state and drives the is_verified flip.
  • +
  • verification_step_types is the admin catalog of pipeline steps with stable machine codes and an is_automated flag; 1:N → verification_steps (one row per step per nurse, with raw external_response_json from the KYC vendor and expires_at for time-limited steps).
  • +
  • verification_steps 1:N → verification_documents (object-storage key + integrity hash; files behind signed URLs, never public).
  • +
  • nurse_credentials is the structured registry: credential_type, encrypted credential_number, holder_name_snapshot, issued_at/expires_at — drives renewal alerts & the trust badge, cross-referenced by the relevant step.
  • +
  • nurse_bank_accounts carries the IBAN-ownership result (matched_national_id, account_holder_from_bank, ownership_vendor_ref) that gates the first payout; expiring steps/credentials raise support_alerts; every state change writes audit_logs.
  • +
+
+
+ ↑ Back to top +
+
+ + +
+ Business · Step 3 +

3Service Catalog & Pricing

+

An admin-defined skeleton that each nurse fills with their own priced offerings. The model is deliberately configurable (EAV-style) so admins add new pricing dimensions without a migration.

+ +

Three admin layers, two nurse layers

+
    +
  • Admin defines the catalog skeleton: top-level service categories (e.g. مراقبت از سالمند / Elderly Care, مراقبت پس از جراحی / Post-Surgery Recovery, مراقبت از نوزاد / Infant Care, مدیریت بیماری مزمن / Chronic Illness Management) and configurable option groups (e.g. تعداد بیمار / patient count, نوع شیفت / shift type) each with concrete option values (e.g. ۱ نفر, ۲ نفر, شبانه‌روزی). New dimensions need no 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 price independently.
  • +
+ +

Price units that match real home nursing

+
+ per_hour·per_session·per_half_day·per_day·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.

+ +
+
!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).
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Service Catalog & Pricing
+
+
+ service_categoriesservice_option_groupsservice_option_valuesnurse_service_variantsnurse_service_variant_options +
+
    +
  • service_categories (admin-managed top-level care types) 1:N → service_option_groups (the configurable dimensions; a NULL service_category_id = cross-category) 1:N → service_option_values (concrete choices).
  • +
  • nurse_service_variants is the atomic bookable unit — N:1 to nurse_profiles and service_categories; carries price and price_unit. Search and booking operate on the exact thing the customer pays for, not on "the nurse."
  • +
  • nurse_service_variant_options records one option value per dimension that defines a variant's configuration (UNIQUE(variant_id, option_group_id)); N:1 to service_option_groups / service_option_values.
  • +
  • The variant feeds the denormalized nurse_search_index (§4) and is frozen into bookings.variant_snapshot_json at booking time.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 4 +

4Search & Matching

+

Families search by category, geography, price, and availability, sortable by rating — and the search must be cheap from day one. Same-gender matching is a near-hard requirement, surfaced before booking.

+ +

How search works

+
    +
  • Families search by service category, geography (city, 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.
  • +
+ +
+
Design decision — a denormalized search index instead of Elasticsearch
+

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.

+
+ +
+
!Iran-specific considerations — same-gender matching
+

Same-gender caregiver matching is the single most Iran-specific matching constraint. In Iranian bodily-care (bathing, toileting, intimate post-surgical care) same-gender caregiving is culturally decisive, not optional — every real elder/post-surgical bodily-care request implies it. The customer specifies a required caregiver gender on the request (required_caregiver_gender), and nurse gender is an exposed search filter so families narrow to same-gender caregivers up front, not after.

+
    +
  • District granularity varies: in Tehran, districts map to the 22 official municipal مناطق; in smaller cities they are major neighborhoods. Districts are optional.
  • +
  • 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).
  • +
+
+ +
+
+
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 (slots are soft guidance at launch); algorithmic ranking beyond rating; continuity-of-carer "preferred nurse" suggestions.

+
+
+ +
+
Data model — Search & Matching
+
+
+ nurse_service_areascitiesdistrictsnurse_search_indexnurse_service_variantsnurse_profilespatientsbooking_requests +
+
    +
  • nurse_search_index is a read-only projection: one row per covered area per bookable variant, holding nurse (verified+accepting), variant (category, price, unit), city/district, nurse_gender, rating, and partner center. Maintained on writes to nurse_profiles, nurse_service_variants, nurse_service_areas, and reviews; is_searchable=1 only when its source nurse/variant are bookable.
  • +
  • nurse_service_areas declares where a nurse travels (a NULL district_id = whole city); N:1 to cities / districts with UNIQUE(nurse_id, city_id, district_id).
  • +
  • Same-gender matching pairs users.gender (the nurse, exposed via nurse_profiles) and patients.gender against booking_requests.required_caregiver_gender — the requested constraint.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 5 +

5Booking & Scheduling

+

The lifecycle is split into two tables so each table's invariants stay clean: a request phase (no money) and a booking phase (always implies captured payment). Single-visit and long multi-session engagements must both be representable.

+ +

Request → accept → pay → confirm

+
    +
  1. Customer submits a booking request (nurse, patient, variant, address, date/time, requested caregiver gender, customer notes). Status pending_nurse_response.
  2. +
  3. The nurse must respond before a response deadline (nurse_response_deadline_at, computed from config and frozen on the request). The nurse acceptsaccepted_awaiting_payment, rejectsrejected_by_nurse, or the deadline passes → expired_no_response.
  4. +
  5. 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.
  6. +
+ +
+
Design decision — engagements own N sessions
+

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 — the engagement-to-session split is the core scheduling model. For a single visit, exactly one session is created so the EVV/payout path stays uniform.

+
+ +

Booking lifecycle (guarded transitions)

+
+ pending_paymentconfirmedin_progresscompleteddisputedclosed|cancelled +
+

Allowed transitions are guarded explicitly (an allowed-transition table/CHECK) 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.

+ +
+
!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.
  • +
+
+ +
+
+
MVP
+

Request→accept→pay→confirm lifecycle with response deadline + 30-min payment window; single-visit bookings; booking_sessions for multi-session 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.

+
+
+ +
+
Data model — Booking & Scheduling
+
+
+ booking_requestsbookingsbooking_sessionsbooking_care_instructionsnurse_availability_slotsnurse_availability_exceptionsnurse_service_variantspatientscustomer_addresses +
+
    +
  • booking_requests is pre-payment intent — carries required_caregiver_gender, nurse_response_deadline_at, payment_deadline_at, and the request-stage-only customer_notes (the only clinical context the nurse sees before accepting). N:1 to customer/nurse/patient/variant/address; 1:1 → bookings on conversion.
  • +
  • bookings exists only when accepted + paid; holds the three-way money split (gross_price_irr, balinyaar_commission_irr, nurse_payout_amount with CHECK gross = commission + payout), session_count, dispute_window_ends_at, both snapshots, and partner_center_id. 1:N → booking_sessions.
  • +
  • booking_sessions is one row per visit (per-visit schedule, visit_payout_amount, payout_eligible_at, status); 1:1 → visit_verifications (EVV per session, §6).
  • +
  • booking_care_instructions (1:1, encrypted) holds clinical/logistical context visible only post-confirmation; nurse_availability_slots / nurse_availability_exceptions are soft guidance for search.
  • +
  • Tenancy invariant: the request's patient & address belong to customer_id; its variant belongs to nurse_id.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 6 +

6EVV / Service Delivery

+

Electronic Visit Verification is the authoritative record that a visit actually happened, for how long, and where — and it is the trigger that releases escrow.

+ +

How EVV works

+
    +
  • 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 and 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 (§10). For a multi-session engagement, payout accrues per completed session.
  • +
+ +
+
!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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — EVV / Service Delivery
+
+
+ visit_verificationsbooking_sessionssupport_alertsplatform_configs +
+
    +
  • visit_verifications is 1:1 with booking_sessions (the FK moved to booking_session_id so each visit in a multi-session engagement is verified independently). It stores check-in/out GPS + timestamps, check_in_address_match (advisory), and a status whose mapping to bookings.status is documented so the two state machines cannot diverge.
  • +
  • support_alerts receives no-show and location-mismatch flags for staff triage.
  • +
  • platform_configs supplies evv_location_tolerance_meters (the tolerance radius) and the no-show threshold — tunable without a deploy.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 7 +

7Cancellation & Refunds

+

Cancellation/refund rules are tiered and structured, not a single blunt "default 100%". The applicable policy is snapshotted onto the booking, and every refund decomposes across the two fee legs.

+ +

Tiered, snapshotted policy

+

The platform defines cancellation_policies tiers by lead time and initiating actor:

+
+ + + + + + + + +
ScenarioOutcome
Free cancellation > 24h before startFull refund, no fee.
Partial refund under 24he.g. 50% charge.
Customer no-showUp to 100% charge.
Nurse no-showFull 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 — no customer self-service. 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.
  • +
+ +
+
!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 (§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 (§8/§9).
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Cancellation & Refunds
+
+
+ cancellation_policiesbookingsrefundsticketsnurse_clawbacksledger_entries +
+
    +
  • cancellation_policies is config-driven tiers (applies_to customer/nurse/admin, hours_before_start_min/max, refund_percentage, fee_amount_or_rate); the resolved tier is snapshotted onto the booking and the refund.
  • +
  • refunds are admin-only, N:1 to a payment_transactions row (1:N — partials allowed) and always linked to a tickets row. They carry the fee-leg decomposition (platform_fee_refunded_irr, nurse_payout_refunded_irr), refund_channel, and the policy snapshot.
  • +
  • bookings holds the policy snapshot + dispute_window_ends_at; when the nurse was already paid, the refund spawns a nurse_clawbacks receivable; every leg posts balanced ledger_entries.
  • +
+
+
+ ↑ Back to top +
+
+ + +
+ Business · Step 8 +

8Payments & Escrow

+

The family pays the gross booking price through the platform; the platform models the money as an internal double-entry ledger state, never as cash it holds. This is the most-changed domain — the ledger is the financial source of truth.

+ +

The money flow

+
    +
  • The family pays the gross booking price 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. A minimal double-entry ledger_entries ledger models money state: each money event posts balanced legs grouped by a transaction_group_id (Σ debit = Σ credit). 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.
  • +
+ +

Account types in the ledger

+
+ + + + + + + + + + + + +
account_typeMeaning
escrow_heldFunds received and held (over provider custody), not yet released or refunded.
platform_revenueBalinyaar's own commission income.
nurse_payableWhat the platform owes the nurse (accrued, awaiting weekly payout).
refund_payableAmount owed back to the customer / in-flight reversal.
bnpl_fee_expenseThe BNPL provider's merchant commission — a platform expense.
psp_fee_expenseGateway/PSP cost on the card leg.
nurse_clawback_receivableMoney a nurse owes back after a refund-after-payout.
bad_debtWritten-off uncollectable clawback.
+
+ +

The canonical postings

+

Amounts are always positive; direction (debit/credit) carries the sign. Posted once, idempotently, keyed on the settling transaction.

+
+
(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)
+
+
+
(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)
+
+
+
(d) Clawback — refund AFTER the nurse was already paid
+
DEBIT  nurse_clawback_receivable   amount_irr   (nurse_id set; nurse now owes the platform)
+  CREDIT refund_payable               amount_irr
+
+

Clawback recovered: DEBIT nurse_payable (next batch) / CREDIT nurse_clawback_receivable. There are no installment-level postings — the customer's repayment schedule is the BNPL provider's ledger, not Balinyaar's.

+ +

The three amounts, never conflated

+
+ + + + + + + +
AmountMeaningDrives
gross_price_irrWhat the customer is charged (the booking price)The invoice; the inbound escrow_held debit; the refund base
balinyaar_commission_irrPlatform's own cut (was platform_fee_amount)platform_revenue; the nurse payout
bnpl_commission_irrThe BNPL provider's merchant discount (on bnpl_transactions)bnpl_fee_expense — a platform expense, never the nurse's
+
+

nurse_payout_amount = gross_price_irr − balinyaar_commission_irr, enforced by CHECK.

+ +
+
Design decisions baked into payments
+
    +
  • Settlement-sharing (تسهیم). The compliant marketplace primitive splits one incoming card payment across multiple registered IBANs (nurse share + platform 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.
  • +
  • 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 — so a retried success webhook cannot double-confirm.
  • +
  • Multi-provider failover. 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 (the Toman/Jibit Nov-2024 suspensions cut businesses off mid-cycle).
  • +
+
+ +
+
!Iran-specific considerations — the load-bearing 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: 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.
  • +
  • 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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Payments & Escrow
+
+
+ payment_gatewayspayment_transactionspayment_webhook_eventsledger_entriesbookingsrefundsnurse_bank_accounts +
+
    +
  • ledger_entries is the append-only double-entry source of truth: balanced legs per transaction_group_id, account_type, direction, amount_irr, optional nurse_id/booking_id, and source_ref_type/source_ref_id. Per-nurse payable balance derives by filter, never a drifting cached column.
  • +
  • payment_transactions records every attempt against a booking; the succeeded row triggers confirmation. Hardened with filtered UNIQUE(gateway_reference_code) and filtered UNIQUE(booking_id) WHERE status='succeeded'. N:1 to payment_gateways; 1:N → refunds / ledger_entries; 1:1 → bnpl_transactions if BNPL.
  • +
  • payment_webhook_events stores every callback raw, deduplicated on UNIQUE(provider_code, external_event_id), upserted first inside the money-mutating transaction.
  • +
  • payment_gateways abstracts each PSP/BNPL provider (type standard/bnpl, encrypted config_json) for failover; bookings carries the three amounts + platform_fee_rate + psp_fee_amount; payouts target verified nurse_bank_accounts IBANs.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 9 +

9Installments / BNPL

+

BNPL is an alternative checkout. The decisive, verified finding is full-upfront settlement: the provider pays Balinyaar the full booking amount in one lump (net of its commission) and bears 100% of customer-default risk. So a BNPL order is, in Balinyaar's books, identical to a card payment landing net-of-fee.

+ +

How BNPL is modeled

+
    +
  • 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 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).
  • +
  • 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.
  • +
+ +

What bnpl_transactions captures

+

Provider, merchant-of-record (Balinyaar/partner center), 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.

+ +
+
iBNPL 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 ~7–10 business days (asynchronous, owned by the provider). The refund still decomposes across the platform-fee and nurse-payout legs in Balinyaar's ledger. See the BNPL deep-dive for the exact Q1 cancellation flow and the Q2 three-amount split.

+
+ +
+
!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 7–15% for SnappPay; Torob Pay's published 6.6%) — always a config field read from the actual settlement, never hardcoded.
  • +
  • Onboarding requires جواز کسب and اینماد (eNamad) for the Balinyaar/partner entity; whether a multi-vendor re-disbursing marketplace qualifies as a single BNPL merchant is publicly undocumented — an ops/contracting task, not a schema dependency.
  • +
+
+ +
+
+
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_entriescut, owned by the provider); tranched settlement (bnpl_settlement_entries modeled-only, added if a future provider tranches); multiple BNPL providers.

+
+
+ +
+
Data model — Installments / BNPL
+
+
+ bnpl_transactionspayment_transactionspayment_webhook_eventsrefundsledger_entries +
+
    +
  • bnpl_transactions (replaces the old installment_plans; installment_entries is cut) is 1:1 with a payment_transactions row — the single inbound settlement to reconcile, plus the revert path. State-machine guard on status for idempotency.
  • +
  • refunds on a BNPL order carry refund_channel = 'bnpl_revert', external_revert_reference, and expected_customer_refund_eta (the ~7–10 business-day window surfaced in UI/reconciliation).
  • +
  • The settlement posts the same balanced ledger_entries as a card capture, plus the bnpl_fee_expense leg; callbacks are deduplicated via payment_webhook_events.
  • +
+
+
+ ↑ Back to top +
+
+ + +
+ Business · Step 10 +

10Payouts to Nurses

+

Nurses are paid in weekly batches, gated on EVV completion and a closed dispute window — because an Iranian bank transfer, once sent, is effectively irreversible. Clawbacks handle the refund-after-payout case.

+ +

How payouts work

+
    +
  • 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() (config-driven, default 72h post-completion). This prevents paying a nurse before a dispute can surface, shrinking clawback frequency.
  • +
  • The nurse payout amount derives from gross_price_irr − balinyaar_commission_irr — never from a BNPL provider's net settlement.
  • +
  • Each booking is paid at most once (the payout↔booking link is unique), preventing double-pay across batches.
  • +
  • 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.
  • +
+ +
+
Design decision — clawbacks & holiday-aware scheduling
+
    +
  • Clawbacks (nurse_clawbacks) handle the refund-after-payout case: 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); a batch nets prior clawbacks (gross_earnings, clawback_applied, net_amount).
  • +
  • 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.
  • +
+
+ +
+
!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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Payouts to Nurses
+
+
+ nurse_payout_batchesnurse_payoutsnurse_payout_booking_linksnurse_clawbacksledger_entriesiranian_holidaysbookingsnurse_bank_accounts +
+
    +
  • nurse_payout_batches is the weekly aggregation (holiday-aware period_end, CHECK total_amount = Σ payouts) 1:N → nurse_payouts (one row per nurse per batch, with gross_earnings_irr, clawback_applied_irr, net_amount_irr, iban_snapshot, transfer_reference).
  • +
  • nurse_payouts 1:N → nurse_payout_booking_links with booking_id UNIQUE — the structural anti-double-pay guard (exactly one payout per booking).
  • +
  • nurse_clawbacks (1:1 to a refunds row; N:1 to nurse/booking; links to the original & recovering payout) carries status pending/recovered/written_off.
  • +
  • iranian_holidays (with is_bank_closed) drives date-shifting; every transfer posts balanced ledger_entries; eligibility reads bookings.dispute_window_ends_at; the target is a verified nurse_bank_accounts IBAN.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 11 +

11Reviews, Trust & Safety

+

One review per completed booking, moderated before it goes public, with aggregates recomputed on every transition — because the buyers are vulnerable people cared for unobserved, and a single incident can destroy a fragile, trust-first brand.

+ +

How reviews & safety work

+
    +
  • A customer can leave one review per completed booking (rating 1–5 + 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.
  • +
+ +
+
!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).
  • +
+
+ +
+
+
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 phase-2); a dedicated incidents entity; ML fraud scoring.

+
+
+ +
+
Data model — Reviews, Trust & Safety
+
+
+ reviewsreview_tags_masterreview_tag_linkssupport_alertsnurse_profilesaudit_logs +
+
    +
  • reviews is 1:1 with a bookings row (creation allowed only for completed/closed bookings); rating CHECK 1–5; every status transition recomputes the denormalized aggregates on nurse_profiles (average_rating, total_reviews), fixing the inflated-rating-after-hide drift.
  • +
  • reviews N:N review_tags_master via review_tag_links for quantitative tag aggregation (phase-2 nicety).
  • +
  • A low rating raises a support_alerts row; sensitive transitions write audit_logs.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 12 +

12Messaging & On-Site Emergencies

+

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 — a deliberate anti-disintermediation and patient-safety design.

+ +

How communication works

+
    +
  • All post-booking communication runs through tickets admin can read in full — 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 (§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). No schema change — an operational rule.

+
+ +
+
!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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Messaging & Emergencies
+
+
+ ticketsticket_participantsticket_messagesbooking_care_instructionssupport_alerts +
+
    +
  • tickets (human-facing reference_code, optionally linked to a bookings or refunds row) 1:N → ticket_participants (UNIQUE(ticket_id, user_id)) and ticket_messages (is_internal keeps admin-only notes out of user view).
  • +
  • The emergency contact number is read from the encrypted booking_care_instructions and surfaced in the booking UI.
  • +
  • Escalations and anomalies become support_alerts for staff.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 13 +

13Tax, Invoicing & Legal

+

The nurse is the taxable seller of the nursing service; Balinyaar is the taxable seller only of its commission. A partner licensed center is the launch legal vehicle that makes the whole money flow legal.

+ +

The tax/invoice split

+
    +
  • 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. Held by the legal launch entity.
  • +
+ +
+
Design decision — partner licensed-center 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 (the Asanism model) 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. (See the legal landscape deep-dive.)

+
+ +
+
!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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Tax, Invoicing & Legal
+
+
+ invoicespartner_centersnurse_profilespayment_transactionsplatform_configs +
+
    +
  • invoices is 1:1 with a bookings row: invoice_number, issuing_entity_type (platform/partner_center), gross_irr, platform_commission_irr (the VAT-relevant line), bnpl_commission_irr, config-driven vat_rate/vat_irr, and مودیان fields (moadian_reference_number, moadian_status) + pdf_storage_key.
  • +
  • partner_centers holds the MoH license (moh_establishment_permit_no), enamad_code, settlement_iban, and is_merchant_of_record; 1:N → nurse_profiles (sponsors, via nurse_profiles.partner_center_id), bookings (legally covered by), and invoices (issuer).
  • +
  • payment_transactions supplies the Shaparak reference for reconciliation; platform_configs holds the VAT rate and merchant-of-record settings.
  • +
+
+
+ ↑ Back to top +
+ +
+ + +
+ Business · Step 14 +

14Notifications & Admin / Backoffice

+

In-app notifications keep users informed; a back-office tooling spine lets admins verify nurses, refund, pay out, triage alerts, and audit everything — all scoped by RBAC.

+ +

Notifications

+
    +
  • 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 — 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.

+
Append-only audit trail

Every state-changing operation on sensitive entities (bookings, payments, refunds, verifications, reviews, users) and config changes (e.g. the platform fee rate) are auditable.

+
+ +
+
!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.
  • +
+
+ +
+
+
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.

+
+
+ +
+
Data model — Notifications & Admin
+
+
+ notificationssupport_alertsrolesuser_rolesaudit_logssystem_eventsplatform_configs +
+
    +
  • notifications (N:1 to users) carries a typed data_json payload for front-end deep-linking; a retention job deletes read rows > 90 days.
  • +
  • support_alerts are staff worklist items (never shown to users); roles/user_roles scope admin permissions.
  • +
  • audit_logs is append-only over every sensitive transition including platform_configs changes; system_events is high-volume analytics (piped to a warehouse at scale); platform_configs holds runtime business parameters (fee rate, deadlines, dispute window, VAT, tolerance).
  • +
  • The tooling acts on the operational entities of every prior section: nurse_verifications/verification_steps/nurse_credentials, refunds, nurse_payout_batches/nurse_payouts/nurse_clawbacks, bookings.
  • +
+
+
+ ↑ Back to top +
+
+ + + + +
+ + +
+ Deep dive +

Verification pipeline (the operative detail)

+

"Is this nurse really who they say, and really licensed?" splits into two checks that are separate pipeline stages: a license check (are they a registered nurse?) and an identity + background check (are they who they claim, with no disqualifying record?). The cautionary tale is the "imposter nurse" who defeated agencies with stolen identities and forged documents — so verify at source, bind to national ID + liveness, and never trust an uploaded PDF alone.

+ +
+ + + + + + + + + +
StageGoalIran tool / howProgrammatic?
0. ConsentLawful basis to verify + store dataExplicit in-app consent at onboardingn/a
1. IdentityMatch person ↔ کد ملی ↔ phone ↔ faceShahkar + national-ID validity + video/photo liveness vs national card, via one KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak)Yes — off-the-shelf API
2. LicenseVerify nursing credential at sourceMoH پروانه صلاحیت حرفه‌ای (Rn.behdasht.gov.ir) as primary + INO نظام پرستاری number (ino.ir) as cross-checkManual — require upload + verify (no public API)
3. Criminal recordNo disqualifying recordعدم سوء پیشینه — nurse self-requests via adliran.ir / ثنا and uploads; partly covered by the MoH licenseNo company API — consent-gated, nurse-uploaded
4. Ongoing monitoringCatch revocations/expiryPeriodic re-verification of license validity + re-request of عدم سوء پیشینه; re-run Shahkar on phone changeSemi-manual; emulate Nursys e-Notify
+
+ +
+
Practical rules
+
    +
  1. Buy identity verification through one KYC provider — it shifts the regulator-gated Shahkar / ثبت احوال access burden onto a vendor that already holds the agreements.
  2. +
  3. Anchor the license check on the MoH پروانه صلاحیت حرفه‌ای — it is State-mandated for in-home nursing and bundles a criminal screen.
  4. +
  5. Treat the criminal certificate as nurse-supplied + consent-gated.
  6. +
  7. Build continuous monitoring, not one-and-done.
  8. +
  9. Route through a licensed KYC intermediary to keep data-protection exposure compliant.
  10. +
+
+

These five stages map onto the data-driven verification_step_types rows of §2, with the structured numbers captured in nurse_credentials, the raw vendor responses in verification_steps.external_response_json, and the IBAN-ownership inquiry result on nurse_bank_accounts.

+ ↑ Back to top +
+ +
+ + +
+ Deep dive +

Escrow as a ledger, not held cash

+

Because Balinyaar cannot custody buyer funds, "escrow" must be a software construct: a double-entry ledger state over money that legally sits at a licensed provider/bank. The original model had no ledger — escrow was only inferable by joining status flags, with no single answer to "how much do we owe nurses right now?" Three critiques rated this a critical gap. The fix is one append-only table.

+ +

The rails & the custody prohibition

+
    +
  • Every card payment is acquired by a licensed PSP and cleared through Shaparak, which settles to bank-registered IBANs (شِبا). There is no native marketplace-escrow construct that holds buyer cash in trust.
  • +
  • A پرداخت‌یار (payment facilitator) is explicitly forbidden from holding customer deposits, operating wallets, paying interest, granting credit/guarantees, or temporarily using merchant balances. Settlement goes only to merchant-registered accounts, and only Shaparak can withdraw from the special facilitator settlement account (حساب ویژه پرداخت‌یاری). This is the single load-bearing constraint of the whole design.
  • +
  • تسهیم (settlement-sharing) is the compliant primitive: one incoming card payment is split across multiple registered IBANs and credited directly by Shaparak/the provider — the platform never touches the split.
  • +
  • The banned move: "collect into a platform pool, hold until EVV, then redistribute" — Shaparak banned inter-facilitator/inter-merchant transfers and wallet-style holding. A bank-grade escrow product (Vandar میندو / معاملات امن) is the only true hold/release/refund mechanism, and even its EVV-trigger is unverified.
  • +
+ +
+
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 + status booleans) 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; ledger_entries is the financial-truth / reconciliation layer posted alongside. The canonical postings (card capture, BNPL settle, refund pre-payout, clawback post-payout) are in §8.

+
+ +
+
!Provider continuity risk
+

In Nov 2024 the CBI abruptly cut Toman's and Jibit's settlement/withdrawal services with no stated cause, stranding businesses (including millions of Snapp drivers). Wallet/balance facilitator models have been blocked and re-permitted before (Vandar's gateway). Design for multi-provider failover and a reconciliation ledger that survives a provider being cut off mid-cycle — which is exactly what the provider-abstracted payment_gateways + the append-only ledger_entries deliver.

+
+ ↑ Back to top +
+ +
+ + +
+ Deep dive +

BNPL mechanics — the two hard questions

+

All mainstream Iranian provider-financed BNPLs (SnappPay, Digipay, Tara, Torob Pay, ZarinPlus) use full-upfront settlement: the provider pays the merchant the whole amount minus commission in one lump and bears default risk; the customer's installments are owned by the provider and decoupled from Balinyaar's escrow/payout. Lendo is the outlier (bank-financed, customer pays interest) — avoid for the MVP.

+ +

Provider comparison (the structurally important facts)

+
+ + + + + + + + + +
ProviderSettlementWho bears costCustomer planMerchant fee
SnappPayFull-upfront, single lump minus commission; provider bears default riskMerchant (commission)4 installments / 4 months, interest-freeUndocumented (anecdotal ~7–15%); per-contract config
DigipayFull-upfront to contracted merchant; provider bears default riskCustomer markup + merchant acquiring commission1-month + 4-installment + 3/6/9/12-mo loanتوافقی (negotiable); sells early-settlement as an add-on
TaraProvider-financed, full amount to sellerMerchant (interest-free to customer)2 interest-free installments, starting 1 month afterPer-contract
Torob PayFull-upfront, cash to sellerMerchant4 equal installments, 25% down, interest-freeConcrete: 6% + VAT = 6.6%
Lendo avoidBank-financed (Bank Ayandeh)Customer (~18–23% interest + ~5% upfront fee)6 / 9 / 12 months — a POS loan
+
+ +

Q1 — Cancellation / refund of a BNPL booking mid-plan

+

Money always flows customer ↔ provider ↔ Balinyaar. Never refund the customer directly; never route a nurse→customer refund. Balinyaar initiates the reversal through the provider's API using the stored token:

+
    +
  • Full cancel/refund → revert (full amount).
  • +
  • Partial / shortened-visit → update (new amount strictly lower) or cancel per the provider's partial semantics.
  • +
+

The provider then, on its own ledger and asynchronously: (1) cancels the customer's remaining unpaid installments and credits the equivalent back to their credit wallet (reusable BNPL credit), and (2) refunds any already-paid installment to the customer's bank in ~7–10 business days. The merchant's only role is to authorize/cancel; the provider owns the unwind.

+
Balinyaar's internal bookkeeping
+
    +
  1. Record a refunds row with refund_channel = 'bnpl_revert', external_revert_reference, expected_customer_refund_eta; refund_status stays processing until a reconciliation job confirms. Surface the 7–10-day window in UI/reconciliation.
  2. +
  3. Decompose across the two fee legs: platform_fee_refunded_irr and nurse_payout_refunded_irr.
  4. +
  5. Post balanced ledger entries and record the revert reference on the bnpl_transactions row (reverted_amount_irr, reverted_at).
  6. +
  7. If the nurse has NOT been paid: reverse the nurse_payable accrual — clean, nothing leaves Balinyaar (the common case if payout is gated on the dispute window).
  8. +
  9. If the nurse HAS been paid: the clawback path — a nurse_clawbacks row + a nurse_clawback_receivable leg, recovered from the next batch or written off.
  10. +
+ +
+
?Open — 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 — never hardcode.

+
+ +

Q2 — Under BNPL, who pays the nurse, and when?

+

Balinyaar pays the nurse, on its own normal weekly schedule, after EVV check-out and after the dispute window closes — exactly the same path as a card-funded booking. The provider never pays the nurse and is indifferent to the internal split. The nurse's payout is computed from gross_price_irr − balinyaar_commission_irr, NOT from the BNPL-net amount.

+
+
iWorked 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. The only difference a BNPL order makes is the extra bnpl_fee_expense leg that reduces Balinyaar's margin.

+
+ +

Integration notes

+
    +
  • SnappPay (primary) — API + IPG redirect; verified endpoint flow: OAuth token → eligible → payment token (redirect) → verify → settle → revert/cancel/update/status.
  • +
  • Digipay (secondary) — unified UPG gateway; persist the gateway type per transaction (IPG=0, Wallet=11, Credit=5, BNPL=13, Credit-Card=24); deliver/refund calls must carry the matching code; gate deliver on the nurse's EVV check-out; each purchase supports either refund OR manual reverse, not both.
  • +
  • Cross-cutting: webhook idempotency via payment_webhook_events keyed on external_event_id, written first; never trust the callback alone — always verify server-side and re-check amount + reference; amounts in IRR BIGINT, converting from Toman only at the boundary; a state-machine guard on BNPL status transitions.
  • +
  • Recommendation: integrate SnappPay first, Digipay second, avoid Lendo.
  • +
+ ↑ Back to top +
+ +
+ + +
+ Deep dive +

Market & competitors

+

The market is real and already competitive — but incumbents are heavily concentrated in Tehran/Karaj and run mostly as direct-dispatch staffing, not trust-first marketplaces. That is the gap. The hardest problem is trust and safety, not technology.

+ +

Iranian players

+
+ + + + + + + + +
PlayerModelNotable facts
Asanism (آسانیسم)Matching/marketplace supplying caregivers through licensed partner centers (intermediary)Markets identity-vetting, a reported ~40M-toman security note, 24–48hr trial periods. ~99% concentrated in Tehran/Karaj. The model the launch vehicle imitates.
Snapp DoctorHealth vertical of Snapp; managed dispatchOperates in several cities. Holds a general online-medical-intermediary license — not a specific home-nursing MoH authorization.
Salamat AvalDirect dispatch of its own nurses (not an open marketplace)3,000+ active personnel (self-reported), 24/7 call center, holds official MoH license no. 388180-3 (displays it prominently). Pricing توافقی.
HiradApp-based managed staffing/dispatchShows both sides; advertises no placement fee; states it operates under MoH authorization. Modest adoption.
+
+

What this tells you: the dominant model is direct/managed dispatch, not a true trust-first two-sided marketplace; geographic concentration is extreme (Tehran/Karaj dominate; second-tier cities thinly served — the clearest white space); pricing is opaque/negotiable (transparency is a differentiator); and "licensed" is a real, displayed trust signal. The closest international fit is the Homage model (curated marketplace + human matching), and India shows that where licensing infrastructure is weak, vetting and quality control are the product.

+ +

The risks that shape the build

+
+
+
iTrust & safety
+

Connecting strangers to vulnerable people without rigorous platform-owned vetting enables theft, abuse, and fraud — and the public blames the platform (Care.com's records-laden listings; the "imposter nurse" with 20+ aliases / 7 SSNs / forged docs). Own the vetting; verify at source; bind to national ID + liveness; re-verify periodically.

+
+
+
iLiability & classification
+

Worker misclassification ($10M TLC judgment), vicarious liability / negligent hiring, and insurance gaps stack. The dangerous middle — heavy control for "quality" but contractor classification for cost — is what triggers misclassification judgments. Keeping the nurse's per-request accept/reject autonomy is a deliberate hedge.

+
+
+
iOperational & disintermediation
+

Extreme caregiver churn, no-shows that strand a patient, and disintermediation (families + nurses pairing off-platform). Beat leakage with retained value, not lock-in: EVV, a backup-coverage guarantee, in-platform escrow/dispute protection, and insurance that only applies on-platform — precisely the EVV, ticket-only messaging, escrow-ledger, and review mechanisms modeled above.

+
+
+
iPayment & fraud
+

Gig-marketplace fraud runs ~2× elsewhere (>90% impersonation); financial elder abuse is real (1 in 9 known-perpetrator cases is a non-family caregiver). Tie reviews to verified, completed, on-platform bookings; strong identity verification at onboarding; in-platform escrow with dispute resolution.

+
+
+ ↑ Back to top +
+
+ + +
+ Reference +

The whole data model

+

The complete schema (Revision 2): ~53 tables across 13 domains. Each domain below lists its tables with key fields/columns and relationships (foreign keys). Net change vs the original 45: −2 cut (installment_plans replaced, installment_entries removed), +10 added, 1 replaced — the financial core is now a single ledger, BNPL is one settlement row, and the clawback / dispute-window / idempotency / license / multi-session gaps are closed.

+ +

Scope & change legend

+
+ CORE launch-critical + MVP in first release + DEFERRED modeled now, inactive at launch + NEW added in Rev 2 + CHG changed/renamed + CUT removed/replaced +
+

Conventions: PII columns are (encrypted). Money is BIGINT IRR. Timestamps are DATETIME2(7) UTC. Most tables also carry created_at/updated_at (and deleted_at where soft-deleted); these are omitted from the key-field lists for brevity.

+ +
+
The 13 domains at a glance
+
+ D1 Identity & Access (9) + D2 Geographic (4) + D3 Services & Pricing (8) + D4 Verification (5) + D5 Booking & Scheduling (6) + D6 Payments / Ledger (9) + D7 Payouts (3) + D8 BNPL (2) + D9 Messaging (3) + D10 Reviews & Records (4) + D11 Notifications (2) + D12 Audit & Config (4) + D13 Partner & Future (5) +
+
+
+ + +
+

Domain 1 — Identity & Access

+

One identity table for every human actor avoids three near-duplicate user tables; role decides which profile sub-table is populated. Phone-as-primary matches Iranian OTP norms and is what Shahkar matches against.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TableKey fieldsRelationships (FK)
users COREid PK · email (enc, nullable) · phone (enc, UNIQUE) · national_id (enc, nullable) · national_id_verified_at · first_name/last_name · gender NEW · role (nurse/customer/admin) · shahkar_verified_at NEW · is_active · last_login_at/_ip · deleted_at1:1 → nurse_profiles / customer_profiles (by role); 1:N → user_sessions, user_roles, notifications, ticket_participants. Referenced as *_by_admin_id across the schema.
user_sessions COREid · user_id · refresh_token_hash · device_info · ip_address · is_revoked · revoked_at · expires_atN:1 → users. Enables logout-everywhere & stolen-token revocation.
roles COREid · name (super_admin/admin/support/finance/moderator) · descriptionN:N users via user_roles. Admin staff only.
user_roles COREuser_id · role_id · granted_by · granted_at · revoked_atJoin table; keeps a grant/revoke audit trail.
nurse_profiles COREid · user_id (UNIQUE) · partner_center_id NEW · bio · years_of_experience · education_level/_field · specializations_json · is_verified (guarded) · is_accepting_bookings · average_rating/total_reviews/total_completed_bookings (denorm). CUT verification_status, response_rate, profile_completion_score1:1 → users, nurse_verifications; 1:N → nurse_service_variants, nurse_service_areas, nurse_bank_accounts, nurse_credentials, bookings, nurse_payouts, nurse_clawbacks; N:1 → partner_centers.
customer_profiles COREid · user_id (UNIQUE) · default_emergency_contact_name/_phone (enc). CUT national_id_verified_at (deferred customer KYC)1:1 → users; 1:N → patients, customer_addresses, booking_requests, bookings.
patients COREid · customer_id · display_name · first_name/last_name · birth_date · gender · blood_type · initial_medical_notes (enc) · is_activeN:1 → customer_profiles; 1:N → booking_requests, patient_care_records. Tenancy: a request's patient must belong to the same customer.
customer_addresses COREid · customer_id · city_id · district_id · address lines (enc) · latitude/longitude (EVV) · is_primary — filtered UNIQUE(customer_id) WHERE is_primary=1N:1 → customer_profiles, cities, districts; referenced by booking_requests/bookings.
nurse_bank_accounts COREid · nurse_id · bank_name · account_holder_name (enc) · iban (enc) · iban_hash NEW (UNIQUE) · matched_national_id NEW · account_holder_from_bank NEW · ownership_vendor_ref NEW · is_primary · is_verified — filtered UNIQUE(nurse_id) WHERE is_primary=1N:1 → nurse_profiles; 1:N → nurse_payouts. The single place real money leaves the platform.
+
+
+ + +
+

Domain 2 — Geographic Data

+

A table (not a static list) so new cities/districts launch without a deploy; sort_order/is_active drive ordered, toggleable dropdowns. Districts are optional (a nurse can cover a whole city).

+
+ + + + + + + + +
TableKey fieldsRelationships (FK)
provinces COREid · name_fa/name_en · sort_order · is_active1:N → cities.
cities COREid · province_id · name_fa/name_en · sort_order · is_activeN:1 → provinces; 1:N → districts; referenced by customer_addresses, nurse_service_areas.
districts MVPid · city_id · name_fa/name_en — Tehran's 22 مناطق or major neighborhoodsN:1 → cities; referenced by customer_addresses, nurse_service_areas.
nurse_service_areas COREid · nurse_id · city_id · district_id (NULL = whole city) — UNIQUE(nurse_id, city_id, district_id)N:1 → nurse_profiles, cities, districts. Drives the geo filter in search cheaply.
+
+
+ + +
+

Domain 3 — Services & Pricing

+

Three admin layers (category → option group → option value) + two nurse layers (variant → variant option). This EAV-style configurability lets admins add a new pricing dimension without a migration; the only addition is a denormalized read model for search.

+
+ + + + + + + + + + + + +
TableKey fieldsRelationships (FK)
service_categories COREid · name_fa/name_en · icon · sort_order · is_active1:N → service_option_groups, nurse_service_variants. The primary search dimension.
service_option_groups COREid · service_category_id (NULL = cross-category) · name_fa/name_en · is_requiredN:1 → service_categories; 1:N → service_option_values.
service_option_values COREid · option_group_id · name_fa/name_en · sort_orderN:1 → service_option_groups.
nurse_service_variants COREid · nurse_id · service_category_id · display_name · price · price_unit (per_hour/per_session/per_half_day/per_day/per_24h) · estimated_duration · is_activeN:1 → nurse_profiles, service_categories; 1:N → nurse_service_variant_options, booking_requests. The atomic bookable unit.
nurse_service_variant_options COREid · variant_id · option_group_id · option_value_idUNIQUE(variant_id, option_group_id)N:1 → nurse_service_variants, service_option_groups, service_option_values.
nurse_search_index CORE NEWid · variant_id · nurse_id · service_category_id · price/price_unit · city_id/district_id (fan-out) · nurse_gender · average_rating/total_reviews · is_searchableRead-only projection maintained on writes to nurse_profiles, nurse_service_variants, nurse_service_areas, reviews. is_searchable=1 only when source is bookable.
nurse_availability_slots MVPid · nurse_id · day_of_week (0=Sat…6=Fri) · start_time/end_time — CHECK end_time > start_timeN:1 → nurse_profiles. Soft guidance only.
nurse_availability_exceptions MVPid · nurse_id · exception_date · is_available · reasonN:1 → nurse_profiles. Date overrides; informs search, never blocks.
+
+
+ + +
+

Domain 4 — Verification & Credentials

+

Data-driven (step types are rows) plus a structured credential registry, because the brand is "verified trust" and renewal tracking needs queryable license numbers, not opaque PDFs.

+
+ + + + + + + + + +
TableKey fieldsRelationships (FK)
nurse_verifications COREid · nurse_id (UNIQUE) · status (not_started/pending/in_review/approved/rejected/suspended) · submitted_at/approved_at/rejected_at/suspended_at · rejection_reason · reviewed_by_admin_id · internal_notes1:1 → nurse_profiles; 1:N → verification_steps. The single source of truth for verification state.
verification_step_types COREid · code (identity_kyc/shahkar_match/moh_competency_license/ino_membership/criminal_record/bank_account_verification) · name_fa/name_en · is_required · is_automated · automation_provider · sort_order1:N → verification_steps. Admin catalog — a new requirement is one INSERT.
verification_steps COREid · nurse_verification_id · step_type_id · status · is_automated (snapshot) · external_response_json (KYC vendor audit) · expires_at · reviewed_by_admin_idUNIQUE(nurse_verification_id, step_type_id)N:1 → nurse_verifications, verification_step_types; 1:N → verification_documents. On expiry of a time-limited step it reverts to pending + raises a support_alerts.
verification_documents COREid · verification_step_id · storage_key · integrity_hash · file_type · uploaded_atN:1 → verification_steps. Metadata only; bytes live in S3-compatible storage behind signed URLs.
nurse_credentials MVP NEWid · nurse_id · credential_type (moh_competency_license / ino_membership / criminal_record) · credential_number (enc) · holder_name_snapshot · issuing_authority · issued_at/expires_at · verification_source · verification_method (manual/portal/api) · verified_by_admin_idN:1 → nurse_profiles. Cross-referenced by the relevant verification_steps. Powers renewal alerts & the trust badge.
+
+
+ +
+

Domain 5 — Booking & Scheduling

+

Two distinct phases — the request phase (pre-payment intent) and the booking phase (post-payment commitment). The previous model's biggest gap — single-visit-only bookings — is fixed with booking_sessions.

+
+ + + + + + + + + + +
TableKey fieldsRelationships (FK)
booking_requests COREid · customer_id/nurse_id/patient_id/variant_id/customer_address_id · required_caregiver_gender NEW · requested_date/_time_start/_time_end · customer_notes (unenc, request-stage) · status · nurse_response_deadline_at · payment_deadline_at · nurse_rejection_reasonN:1 → customer_profiles, nurse_profiles, patients, nurse_service_variants, customer_addresses; 1:1 → bookings on conversion. Tenancy: patient/address belong to customer; variant to nurse.
bookings COREid · booking_request_id (UNIQUE) · customer_id/nurse_id/patient_id/variant_id/customer_address_id · variant_snapshot_json · address_snapshot_json (enc) · partner_center_id NEW · gross_price_irr CHG · balinyaar_commission_irr CHG · platform_fee_rate · nurse_payout_amount · psp_fee_amount NEW · session_count NEW · status (guarded) · dispute_window_ends_at NEW · completed_at. CUT payout_released — CHECK gross = commission + payout1:1 ← booking_requests; 1:N → booking_sessions, payment_transactions, ledger_entries; 1:1 → booking_care_instructions, reviews, invoices; referenced by nurse_payout_booking_links, refunds, nurse_clawbacks.
booking_sessions MVP NEWid · booking_id · session_index (1-based) · scheduled_date/_time_start/_time_end · visit_payout_amount · status (scheduled/in_progress/completed/missed/cancelled) · payout_eligible_at · cancellation_event_idN:1 → bookings; 1:1 → visit_verifications. One row per visit; a single-visit booking still gets exactly one session.
booking_care_instructions COREid · booking_id · current conditions · medications · allergies · special instructions · emergency contact — all (enc)1:1 → bookings. Visible only post-confirmation (Principle 6).
visit_verifications COREid · booking_session_id CHG · check_in_at/check_out_at · check_in_lat/_lng · check_out_lat/_lng · check_in_address_match (advisory) · status1:1 → booking_sessions. EVV per session; status maps to bookings.status (documented).
cancellation_policies MVP NEWid · code (UNIQUE, e.g. standard_24h) · applies_to (customer/nurse/admin) · hours_before_start_min/_max · refund_percentage · fee_amount_or_rate · is_activeReferenced (snapshot) by refunds and the cancellation event on a session/booking.
+
+
+ + +
+

Domain 6 — Payments, Ledger & Refunds

+

The most-changed domain. A double-entry ledger is the source of truth (replacing inference from scattered status flags), with the idempotency and clawback primitives any real-money platform needs before launch.

+
+ + + + + + + + + + + +
TableKey fieldsRelationships (FK)
payment_gateways COREid · name · type (standard/bnpl) · config_json (enc secrets: client_id/secret, merchant no, base_url, sandbox flag) · is_active1:N → payment_transactions, payment_webhook_events. Abstracted for failover (Toman/Jibit cut-off precedent).
payment_transactions COREid · booking_id · customer_id · gateway_id · amount · currency · status · gateway_transaction_id · gateway_reference_code (Shaparak) · gateway_response_json · is_installment — filtered UNIQUE(gateway_reference_code) + filtered UNIQUE(booking_id) WHERE status='succeeded'N:1 → bookings, payment_gateways; 1:1 → bnpl_transactions (if BNPL); 1:N → refunds, ledger_entries.
payment_webhook_events CORE NEWid · provider_code · external_event_idUNIQUE(provider_code, external_event_id) · event_type · signature_valid · payload_json · processing_status (received/processed/failed/ignored) · related_payment_transaction_id · received_at/processed_atN:1 → payment_gateways; optional → payment_transactions. Upserted first; no-ops on duplicate.
refunds COREid · payment_transaction_id · booking_id · requested_by_customer_id · ticket_id · amount · refund_percentage · status · gateway_refund_reference · platform_fee_refunded_irr NEW · nurse_payout_refunded_irr NEW · refund_channel NEW (psp_card/bnpl_revert/manual_bank) · external_revert_reference NEW · expected_customer_refund_eta NEW · cancellation_policy_code NEWN:1 (1:N per txn CHG) → payment_transactions, bookings, customer_profiles, tickets; 1:1 → nurse_clawbacks (only when nurse already paid). Admin-only.
ledger_entries CORE NEWid · transaction_group_id (UUID) · account_type (escrow_held/platform_revenue/nurse_payable/refund_payable/bnpl_fee_expense/psp_fee_expense/nurse_clawback_receivable/bad_debt) · nurse_id · direction (debit/credit) · amount_irr · booking_id · source_ref_type/source_ref_id · memo — append-only, balanced per groupN:1 → bookings; logical links to payment_transactions/refunds/nurse_payouts/bnpl_transactions via source_ref_*. The financial source of truth.
nurse_clawbacks CORE NEWid · nurse_id · booking_id · refund_id · original_payout_id · amount_irr · status (pending/recovered/written_off) · recovered_in_payout_id · resolved_atN:1 → nurse_profiles, bookings; 1:1 → refunds; → nurse_payouts (original & recovering).
invoices MVP NEWid · booking_id · invoice_number (UNIQUE) · issuing_entity_type (platform/partner_center) · gross_irr · platform_commission_irr (VAT-relevant) · bnpl_commission_irr · vat_rate (0.10) · vat_irr · moadian_reference_number · moadian_status · pdf_storage_key1:1 → bookings; N:1 → partner_centers (when issuer).
+
+
+ 9 tables — the financial core + Canonical ledger postings are in §8; the lifecycle diagram is Diagram 4. +
+
+ + +
+

Domain 7 — Payouts to Nurses

+

Weekly aggregation matching the PAYA settlement cycle, holiday-aware, with a structural anti-double-pay guard.

+
+ + + + + + + +
TableKey fieldsRelationships (FK)
nurse_payout_batches COREid · period_start/period_end (holiday-shifted) · total_amount · payout_count · status · initiated_by_admin_id · processed_at · failure_notes — CHECK total_amount = Σ payouts1:N → nurse_payouts.
nurse_payouts COREid · batch_id · nurse_id · bank_account_id · iban_snapshot (enc) · amount · booking_count · status · transfer_reference · gross_earnings_irr NEW · clawback_applied_irr NEW · net_amount_irr NEWN:1 → nurse_payout_batches, nurse_profiles, nurse_bank_accounts; 1:N → nurse_payout_booking_links; referenced by nurse_clawbacks.
nurse_payout_booking_links COREid · payout_id · booking_id (UNIQUE) · amount_irrN:1 → nurse_payouts; 1:1 → bookings. Guarantees a booking is paid in exactly one batch.
+
+
+ + +
+

Domain 8 — BNPL / Installments

+

Because verified research shows Iranian provider-financed BNPL settles the full amount to the merchant in one lump, a BNPL order is — in these books — a card payment that lands net-of-fee. The old installment_plans + installment_entries subsystem (which tried to track the customer's repayment and default) is deleted.

+
+ + + + + + +
TableKey fieldsRelationships (FK)
bnpl_transactions MVP NEWid · payment_transaction_id (UNIQUE) · provider_code (snapppay/digipay/tara/torobpay) · merchant_of_record · external_payment_token · external_transaction_id · eligibility_status · order_amount_irr · settled_amount_irr · bnpl_commission_irr · currency · installment_count (info, default 4) · status (eligible/token_issued/verified/settled/reverted/cancelled/failed) · settled_at · revert_transaction_id/reverted_amount_irr/reverted_at · refund_channel · callback_payload_json1:1 → payment_transactions. State-machine guard on status for idempotency. Replaces installment_plans; installment_entries removed.
bnpl_settlement_entries DEFERREDModeled-but-inactive: only needed if a future provider uses tranched settlement (pays the platform over time). No mainstream Iranian provider does today.Would be N:1 → bnpl_transactions. Adding it later is a purely additive migration.
+
+
+ +
+

Domain 9 — Messaging (Ticket System)

+

All post-booking communication, admin-readable, with no direct nurse↔customer channel — it protects vulnerable patients, creates dispute evidence, and prevents disintermediation.

+
+ + + + + + + +
TableKey fieldsRelationships (FK)
tickets COREid · reference_code (human-facing) · booking_id (opt) · subject · category (coordination/refund/support) · status · priority1:N → ticket_participants, ticket_messages; optionally ↔ bookings, refunds.
ticket_participants COREid · ticket_id · user_id · role_in_ticketUNIQUE(ticket_id, user_id)N:1 → tickets, users.
ticket_messages COREid · ticket_id · sender_user_id · body · is_internal (admin-only) · attachments_jsonN:1 → tickets. is_internal keeps admin notes out of user view.
+
+
+ + +
+

Domain 10 — Reviews & Patient Records

+

One review per completed booking with recompute-on-every-transition, plus a patient-scoped longitudinal clinical history that enables continuity of care.

+
+ + + + + + + + +
TableKey fieldsRelationships (FK)
reviews COREid · booking_id (1:1) · customer_id · nurse_id · rating (CHECK 1–5) · body · status (pending_moderation/published/hidden/rejected) · moderation fields1:1 → bookings; N:1 → customer_profiles, nurse_profiles; 1:N → review_tag_links. Every transition recomputes nurse_profiles aggregates.
review_tags_master MVPid · name_fa/name_en · sentimentN:N reviews via review_tag_links. Phase-2 analytics nicety.
review_tag_links MVPid · review_id · tag_idN:1 → reviews, review_tags_master.
patient_care_records MVPid · patient_id · booking_id · nurse_id · clinical notes (enc) · recorded_atN:1 → patients, bookings, nurse_profiles. Patient-scoped (not booking-scoped) longitudinal history; strict access.
+
+
+ + +
+

Domain 11 — Notifications

+
+ + + + + + +
TableKey fieldsRelationships (FK)
notifications COREid · user_id · type · title/body · data_json (deep-link payload) · is_read · read_atN:1 → users. In-app only (no push at launch); read rows > 90 days are purged.
support_alerts COREid · alert_type (low_rating/no_show/location_mismatch/credential_expiry/fraud_signal/payment_anomaly) · entity_type/entity_id · owner_admin_id · status · resolution_notesPolymorphic (validated at app layer; consider nullable typed FKs booking_id/review_id). Staff worklist items, never shown to users.
+
+
+ + +
+

Domain 12 — Audit, Config & Reference

+
+ + + + + + + + +
TableKey fieldsRelationships (FK)
audit_logs COREid · entity_type/entity_id · action · actor_user_id · changed_fields_json · created_atPolymorphic, append-only. Covers every sensitive transition including platform_configs changes. Plan month-partitioning + 2–3yr archival.
system_events MVPid · event_name · user_id · properties_json · created_atHigh-volume behavioral analytics; pipe to a warehouse at scale rather than the transactional DB.
platform_configs COREkey · value · data_type — keys: platform_fee_rate, booking_payment_deadline_minutes, nurse_response_deadline_hours, nurse_payout_interval_days, evv_location_tolerance_meters, min_rating_for_support_alert, dispute_window_hours NEW, vat_rate NEW, bnpl_merchant_of_record NEW, bnpl_provider_commission_rate NEW, bnpl_settlement_timing NEW, cancellation-tier defaultsReferenced everywhere; changes audited.
iranian_holidays MVP NEWid · holiday_date · name_fa · type (official/religious/national) · is_bank_closedReferenced by payout scheduling (date-shifting) and optionally pricing. PAYA/SATNA fail on closed days.
+
+
+ + +
+

Domain 13 — Partner Centers (launch) & Future

+

partner_centers is the single most launch-critical addition: the legal vehicle, plausibly the merchant-of-record, and the BNPL onboarding gate. The remaining tables are modeled-but-inactive so adding them later is a pure additive migration.

+
+ + + + + + + + +
TableKey fieldsRelationships (FK)
partner_centers MVP NEWid · name · legal_entity_type · moh_establishment_permit_no (پروانه تأسیس) · technical_director_nurse_user_id (مسئول فنی) · technical_director_license_no · enamad_code · settlement_iban (enc) · is_merchant_of_record · commission_rate · admin_user_id · is_active/verified_at1:N → nurse_profiles (sponsors), bookings (legally covered by), invoices (issuer). N:1 → users (technical director, admin).
organizations / organization_nurses DEFERREDThe future employer model (nursing companies adding employed nurses). Kept distinct from partner_centers (launch licensing) to avoid conflating "sponsor for legality" with "employer."Modeled-but-inactive; no launch table references them.
fraud_flags DEFERREDOutput of a future ML fraud service. support_alerts (fraud_signal type) covers rule-based signals manually for now.Inactive stub.
recurring_booking_schedules DEFERREDRFC-5545 recurrence for repeating care patterns. The concrete multi-day need is met by booking_sessions; this remains for true open-ended recurrence.Inactive stub.
+
+
+ + +
+

Relationship summary

+

The load-bearing relationships across the whole schema, at a glance.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RelationshipTypeNotes
usersnurse_profiles / customer_profiles1:1by role
partner_centersnurse_profiles1:Nlaunch sponsor NEW
customer_profilespatients / customer_addresses1:N
nurse_profilesnurse_service_variants / nurse_service_areas / nurse_bank_accounts / nurse_credentials1:N
nurse_service_variantsnurse_service_variant_options1:Noption combination
nurse_profilesnurse_verifications1:1
nurse_verificationsverification_stepsverification_documents1:N → 1:N
booking_requestsbookings1:1on nurse-accept + payment
bookingsbooking_sessions1:NNEW — multi-visit engagements
booking_sessionsvisit_verifications1:1CHG — EVV per session
bookingsbooking_care_instructions / reviews / invoices1:1
bookingspayment_transactions1:Nattempts
payment_transactionsbnpl_transactions1:1if BNPL (replaces installment_plans)
payment_transactionsrefunds1:NCHG — partials allowed
payment_gatewayspayment_webhook_events1:NNEW — idempotency
bookings / nurses → ledger_entries1:NNEW — money source of truth
refundsnurse_clawbacks1:1 (opt)NEW — refund-after-payout
nurse_payout_batchesnurse_payoutsnurse_payout_booking_links1:N → 1:Nbooking_id UNIQUE
nurse_payout_booking_linksbookings1:1exactly one payout per booking
patientspatient_care_records1:Nlongitudinal history
ticketsticket_participants / ticket_messages1:N
Sensitive entities → audit_logs*:Nappend-only
+
+
+ + +
+

Key design decisions (the reasoning, in one place)

+
    +
  1. Escrow as a ledger state, not platform cash — because an Iranian پرداخت‌یار legally cannot custody buyer funds. Everything else in the money domain follows from honestly representing "we don't hold the cash; we hold a claim/obligation tracked in the ledger over funds at a licensed provider." This is also why payouts are provider-side settlement to verified, ownership-checked IBANs.
  2. +
  3. A BNPL order is a net-of-fee inbound payment, full stop — the verified full-upfront settlement model means there is no customer receivable, no default risk, and no installment schedule to track. Deleting installment_entries removed an entire fragile subsystem and replaced it with one reconciliation row.
  4. +
  5. Three separate money amounts so the platform's two fee deductions (its own commission, and the BNPL provider's discount) are never conflated, and the nurse is paid identically regardless of payment method.
  6. +
  7. Double-entry over status flags — the previous model could not answer "how much do we owe nurses right now" without fragile joins, and had nowhere to record a refund-after-payout. One append-only ledger + a nurse_clawbacks receivable fixes both and makes bank/Shaparak reconciliation possible.
  8. +
  9. Dispute window gates payout — preferring a holding period over a clawback, because clawback against an already-paid nurse IBAN is largely unenforceable. The clawback path exists for the cases that slip through.
  10. +
  11. Idempotency before moneypayment_webhook_events keyed on the provider event id, written first, is the cheapest insurance against the most damaging payments bug (double-confirm / double-settle on callback retries).
  12. +
  13. Multi-session engagements are the norm, not an edge casebooking_sessions makes long elder-care arrangements representable, lets escrow release per completed visit instead of holding a month of money, and makes mid-engagement cancellation accounting clean.
  14. +
  15. Partner center is launch-critical — the legal vehicle and likely merchant-of-record; without it the recommended go-to-market and the money flow are not representable.
  16. +
  17. Verified-trust must be queryablenurse_credentials turns the brand promise into renewal alerts, a real badge, and audit defensibility, surviving the future arrival of an INO/MoH API.
  18. +
  19. Keep the configurable service EAV; cut the analytics scaffolding — the category/option model earns its complexity (admin-extensible pricing dimensions without migrations); response_rate/profile_completion_score/system_events-in-SQL do not, at launch.
  20. +
+
+ + +
+

Open items to confirm before building (not schema blockers)

+
+
?Confirm at contracting / with counsel
+
    +
  • BNPL provider contract: does SnappPay/Digipay permit a multi-vendor marketplace re-disbursing to many nurses as a single merchant? (Publicly undocumented.) The schema assumes one lump to Balinyaar/the center, internal allocation to nurses — an ops confirmation, not a schema dependency.
  • +
  • Commission % and settlement SLA per provider (and whether the provider returns its commission on a refund — full or pro-rata).
  • +
  • PSP / تسهیم provider for MVP (ZarinPal Multiplexing vs Vandar vs Jibit) and whether it permits hold-then-weekly-payout timing, or whether a bank-grade escrow (Vandar میندو) is needed.
  • +
  • VAT exemption ruling on the nursing service itself (the commission line is taxable regardless) — vat_rate is config-driven so either ruling is a value change.
  • +
  • مودیان enrollment thresholds for the platform and high-earning nurses.
  • +
+

Confirm decades-old regulations, provider fee/settlement specifics, and tax thresholds against current primary sources and the provider's compliance team before building the payment integration.

+
+ ↑ Back to top +
+
+ + +
+ Diagrams +

Diagrams

+

Five views of the same system, rendered with Mermaid (brand-themed). The first four are reproduced from the data-model document; the fifth is an entity-relationship overview of the core spine.

+ +

1 — Domain map: how the clusters relate

+
+
+flowchart LR
+  PARTNER["Partner Centers (launch)
partner_centers"] + IDENTITY["Identity & Access
users · nurse_profiles · customer_profiles
patients · customer_addresses · nurse_bank_accounts"] + GEO["Geography
provinces · cities · districts · nurse_service_areas"] + VERIFY["Verification
nurse_verifications · step_types · steps
documents · nurse_credentials"] + SERVICES["Services & Pricing
service_categories · option_groups · option_values
variants · variant_options · search_index · availability"] + BOOKING["Booking & Scheduling
booking_requests · bookings · booking_sessions
care_instructions · visit_verifications · cancellation_policies"] + PAY["Payments & Ledger
payment_gateways · payment_transactions · webhook_events
refunds · ledger_entries · nurse_clawbacks · invoices"] + BNPL["BNPL
bnpl_transactions"] + PAYOUT["Payouts
payout_batches · payouts · booking_links"] + REVIEW["Reviews & Records
reviews · review_tags · patient_care_records"] + MSG["Messaging
tickets · participants · messages"] + NOTIFY["Notifications
notifications · support_alerts"] + AUDITCFG["Audit & Config
audit_logs · system_events
platform_configs · iranian_holidays"] + + PARTNER -. "sponsors / merchant-of-record" .-> VERIFY + IDENTITY --> VERIFY + VERIFY --> SERVICES + SERVICES --> GEO + IDENTITY --> BOOKING + SERVICES --> BOOKING + BOOKING --> PAY + PAY --> BNPL + PAY --> PAYOUT + BOOKING --> REVIEW + BOOKING --> MSG + PAY --> NOTIFY + PAY --> AUDITCFG +
+
Diagram 1 — the 13 domains and how data flows between them.
+
+
+ +
+

2 — Core booking spine (who books whom)

+
+
+erDiagram
+  users ||--o| nurse_profiles : "role=nurse"
+  users ||--o| customer_profiles : "role=customer"
+  partner_centers ||--o{ nurse_profiles : "sponsors"
+  customer_profiles ||--o{ patients : "registers"
+  customer_profiles ||--o{ customer_addresses : "saves"
+  nurse_profiles ||--o{ nurse_service_variants : "offers"
+  customer_profiles ||--o{ booking_requests : "submits"
+  nurse_profiles ||--o{ booking_requests : "receives"
+  patients ||--o{ booking_requests : "for patient"
+  nurse_service_variants ||--o{ booking_requests : "selects variant"
+  booking_requests ||--o| bookings : "converts on payment"
+  bookings ||--o{ booking_sessions : "has visits"
+  booking_sessions ||--o| visit_verifications : "EVV per visit"
+  bookings ||--o| booking_care_instructions : "clinical (encrypted)"
+  bookings ||--o| reviews : "one review"
+
+  booking_requests {
+    bigint id PK
+    string status
+    string required_caregiver_gender
+    datetime nurse_response_deadline_at
+    datetime payment_deadline_at
+  }
+  bookings {
+    bigint id PK
+    bigint gross_price_irr
+    bigint balinyaar_commission_irr
+    bigint nurse_payout_amount
+    smallint session_count
+    datetime dispute_window_ends_at
+    string status
+  }
+  booking_sessions {
+    bigint id PK
+    int session_index
+    date scheduled_date
+    string status
+    datetime payout_eligible_at
+  }
+      
+
Diagram 2 — the request → booking → session → EVV spine, with the money split on bookings.
+
+
+ +
+

3 — Payments, ledger & payouts

+
+
+erDiagram
+  bookings ||--o{ payment_transactions : "paid by (attempts)"
+  payment_gateways ||--o{ payment_transactions : "via"
+  payment_gateways ||--o{ payment_webhook_events : "emits"
+  payment_transactions ||--o| bnpl_transactions : "if BNPL"
+  payment_transactions ||--o{ refunds : "may be refunded"
+  refunds ||--o| nurse_clawbacks : "if after payout"
+  nurse_profiles ||--o{ nurse_clawbacks : "owes"
+  bookings ||--o{ ledger_entries : "money postings"
+  bookings ||--o| invoices : "billed"
+  nurse_payout_batches ||--o{ nurse_payouts : "groups"
+  nurse_profiles ||--o{ nurse_payouts : "receives"
+  nurse_bank_accounts ||--o{ nurse_payouts : "to IBAN"
+  nurse_payouts ||--o{ nurse_payout_booking_links : "covers"
+  bookings ||--o| nurse_payout_booking_links : "settled in one"
+
+  ledger_entries {
+    bigint id PK
+    uuid transaction_group_id
+    string account_type
+    string direction
+    bigint amount_irr
+  }
+  refunds {
+    bigint id PK
+    bigint platform_fee_refunded_irr
+    bigint nurse_payout_refunded_irr
+    string refund_channel
+  }
+  bnpl_transactions {
+    bigint id PK
+    string provider_code
+    bigint settled_amount_irr
+    bigint bnpl_commission_irr
+    string status
+  }
+      
+
Diagram 3 — the payment attempt, the BNPL settlement, the double-entry ledger, refunds/clawbacks, and the weekly payout batch.
+
+
+ +
+

4 — Financial lifecycle: escrow → payout → clawback

+
+
+flowchart TD
+  A["Family submits booking_request"] --> B{"Nurse responds in time?"}
+  B -->|"reject / expire"| X["request closed — no money moved"]
+  B -->|"accept"| C["30-min payment window"]
+  C --> D{"Payment method"}
+  D -->|"Card (IPG)"| E["payment_transactions = succeeded"]
+  D -->|"BNPL (SnappPay)"| F["bnpl_transactions = settled
full amount minus provider commission"] + E --> G["Ledger posting:
DR escrow_held / CR nurse_payable + platform_revenue"] + F --> G + G --> H["Booking confirmed (escrow held)"] + H --> I["Nurse EVV check-in / check-out per session"] + I --> J["Booking completed"] + J --> K["dispute_window_ends_at = completed_at + 72h"] + K --> L{"Window passed & no dispute?"} + L -->|"yes"| M["payout_eligible"] + M --> N["Weekly batch — PAYA to nurse IBAN
payout = gross − balinyaar_commission"] + K -.->|"refund BEFORE payout"| O["Clean ledger reversal
PSP refund / bnpl_revert"] + N --> P{"Refund AFTER payout?"} + P -->|"yes"| Q["nurse_clawbacks receivable
netted next batch or written off"] + P -->|"no"| Z["Settled and reconciled"] +
+
Diagram 4 — the money's full journey: request, capture (card or BNPL), escrow-as-ledger, EVV gating, dispute window, weekly payout, and the pre/post-payout refund branches.
+
+
+ +
+

5 — Entity-relationship overview (cross-domain)

+

A wider ER view tying the identity, verification, services, booking, payment, payout, BNPL, messaging, review, and partner clusters together — the relationships a reader needs to navigate the whole model.

+
+
+erDiagram
+  users ||--o| nurse_profiles : "1:1"
+  users ||--o| customer_profiles : "1:1"
+  users ||--o{ user_sessions : "sessions"
+  users }o--o{ roles : "via user_roles"
+  partner_centers ||--o{ nurse_profiles : "sponsors"
+  partner_centers ||--o{ bookings : "covers"
+  partner_centers ||--o{ invoices : "issues"
+
+  nurse_profiles ||--|| nurse_verifications : "header"
+  nurse_verifications ||--o{ verification_steps : "steps"
+  verification_steps ||--o{ verification_documents : "evidence"
+  nurse_profiles ||--o{ nurse_credentials : "licenses"
+  nurse_profiles ||--o{ nurse_bank_accounts : "IBANs"
+  nurse_profiles ||--o{ nurse_service_variants : "offers"
+  nurse_profiles ||--o{ nurse_service_areas : "covers"
+  nurse_service_variants ||--o{ nurse_service_variant_options : "options"
+  nurse_service_variants ||--o{ nurse_search_index : "projected"
+
+  customer_profiles ||--o{ patients : "registers"
+  customer_profiles ||--o{ customer_addresses : "saves"
+  patients ||--o{ patient_care_records : "history"
+
+  customer_profiles ||--o{ booking_requests : "submits"
+  nurse_service_variants ||--o{ booking_requests : "variant"
+  booking_requests ||--o| bookings : "converts"
+  bookings ||--o{ booking_sessions : "visits"
+  booking_sessions ||--o| visit_verifications : "EVV"
+  bookings ||--o| booking_care_instructions : "clinical"
+  bookings ||--o| reviews : "one review"
+  reviews ||--o{ review_tag_links : "tags"
+  bookings ||--o| invoices : "billed"
+
+  bookings ||--o{ payment_transactions : "attempts"
+  payment_gateways ||--o{ payment_transactions : "via"
+  payment_gateways ||--o{ payment_webhook_events : "idempotency"
+  payment_transactions ||--o| bnpl_transactions : "if BNPL"
+  payment_transactions ||--o{ refunds : "partials"
+  refunds ||--o| nurse_clawbacks : "after payout"
+  bookings ||--o{ ledger_entries : "postings"
+
+  nurse_payout_batches ||--o{ nurse_payouts : "groups"
+  nurse_profiles ||--o{ nurse_payouts : "receives"
+  nurse_bank_accounts ||--o{ nurse_payouts : "to IBAN"
+  nurse_payouts ||--o{ nurse_payout_booking_links : "covers"
+  bookings ||--o| nurse_payout_booking_links : "one payout"
+
+  bookings ||--o{ tickets : "coordination"
+  tickets ||--o{ ticket_participants : "who"
+  tickets ||--o{ ticket_messages : "thread"
+  refunds ||--o{ tickets : "anchored"
+      
+
Diagram 5 — a comprehensive ER overview spanning identity, verification, services, booking, payments/ledger, payouts, BNPL, reviews, messaging, and partner centers.
+
+ ↑ Back to top +
+ +
+ +
+

Balinyaar — Business & Data Model Handbook. Synthesized from business-requirements.md, database-model.md (Revision 2), payments-and-installments.md, and the market/legal/verification research report. All monetary values in IRR. This document is the product's source-of-truth narrative; business rules are decisions, not guesses — confirm decades-old regulations, provider fee/settlement specifics, and tax thresholds against current primary sources before building.

+
+ +
+
+ + + + + + diff --git a/product/todo.md b/product/todo.md index 3916819..c81c666 100644 --- a/product/todo.md +++ b/product/todo.md @@ -1,33 +1,2 @@ -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 - -======================================================================= - -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, -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) - +clean the product folder to have strcutured folder for data and not a huge file, so information about each part could be found easily. so the agent should read the docs, categories information then format files and folder and isolate information about the product and then create seprate html doc for it and if two parts are related just link them together in proper place. +and eliminate duplicate information. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1372ec2..7ff1570 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -63,14 +63,21 @@ Server is required to start. ## Quality gates — run before declaring work done -1. `dotnet build Baya.sln` — zero new warnings introduced. +1. `dotnet build Baya.sln` — zero new warnings introduced. Unused `using`s, locals, parameters, + private fields, or members count as failures — delete them, don't suppress them + ([CONVENTIONS.md](CONVENTIONS.md) §2 "No unused code"). 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? +4. If the change alters the architecture, update the **Project map** below in the same change + (see "Keeping the Project map current"). --- ## Project map +This tree is the **canonical description of the server's architecture** — the authoritative list of +projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. + ``` src/ ├── Core/ @@ -95,6 +102,12 @@ src/ Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API — this is a hard rule. +**Keeping the Project map current.** When a change touches the architecture — adds, removes, or +renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer +dependency — you **must** update this Project map (and the dependency rule above, if affected) in the +**same** change. This is the server-specific form of the root "Keep docs honest" rule: the map is +only canonical if it stays accurate. + --- ## Startup wiring @@ -179,6 +192,8 @@ Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials: - `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`. +- No unused code (usings, locals, parameters, private fields/members) and no *what*-comments — explain *why*, prefer self-documenting names (§2). +- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the **Project map** in the same change. - The `Baya.*` namespace is project naming — do not rename without explicit instruction. --- diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index 6beb918..5234387 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -94,6 +94,32 @@ List tags = ["new", "sale"]; No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`). +### No unused code + +Leave nothing dead behind. Remove unused `using` directives, local variables, parameters, private fields, and private members rather than letting them accumulate. + +- These already surface as compiler/analyzer signals — `CS0168` (variable declared, never used), `CS0219` (variable assigned, value never used), `CS0169` (private field never used), `IDE0005` (unnecessary `using`). The quality gate is **zero new warnings**, so treat unused code as a gate failure. +- **Delete it — don't silence it.** Do not add `#pragma warning disable`, throwaway discards, or `_ =` assignments just to quiet the analyzer. +- The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious. + +### Comments — explain *why*, never *what* + +Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose. + +- **Do not** write comments that restate what the code already says — no `// constructor`, `// loop over users`, or XML-doc that merely echoes the method name. +- **Do** add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is *not* evident from the code — explain the reasoning, not the mechanics. +- Keep any necessary comment tight, and delete comments that no longer match the code. + +```csharp +// ❌ restates the obvious +// increment the retry counter +retryCount++; + +// ✅ captures a non-obvious constraint the code can't express on its own +// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream. +if (amount > MaxPerCallRial) ... +``` + --- ## 3. Async / await