# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
go run ./cmd/server        # run the API (binds :8080, hardcoded in main.go — APP_PORT in .env is unused)
go build -o app ./cmd/server
```

The working tree is checked out CRLF, so `gofmt -l` flags **every** file in the repo and is useless as
a signal here. Do not "fix" it — rewriting line endings would produce whole-file diffs.

`./...` does **not** work for build/vet/test — it walks the checked-in `pkg/` module cache and fails
with "directory ... outside main module". Enumerate the real packages instead:

```bash
go vet ./cmd/... ./controllers/... ./services/... ./services_astrologer/... ./repositories/... \
       ./repositories_astrologer/... ./models/... ./routes/... ./middleware/... ./helpers/... \
       ./utils/... ./configs/... ./dto/... ./dto_astrologer/... ./constants/...
```

There are no `_test.go` files, no linter config, no Makefile, and no Docker setup. `go run` requires a reachable MySQL —
`ConnectDB` calls `log.Fatal` on failure, and `LoadEnv` calls `log.Fatal` if `.env` is missing.

## Architecture

Gin + GORM (MySQL) REST API for an astrology consultation platform (users consult astrologers; wallet,
chat/call history, kundali reports, an "astromall" product store). Single module `astrology-api`,
layered as **routes → controllers → services → repositories → models**, with DTOs at the edges.

### Two parallel stacks

The most important thing to understand: **user-facing and astrologer-facing code are entirely separate
vertical stacks** that do not share layers. Pick the right side before adding anything.

| Concern | User side | Astrologer side |
|---|---|---|
| Routes | `routes/user_routes/` | `routes/astrologer_routes/` |
| Controllers | `controllers/user/` (`package User`) | `controllers/astrologer/` (`package Astrologer`) |
| Services | `services/` | `services_astrologer/` |
| Repositories | `repositories/` | `repositories_astrologer/` |
| DTOs | `dto/` | `dto_astrologer/` |
| Models | `models/usermodel/` | `models/astrologermodel/` |
| Auth middleware | `middleware.JWTAuthMiddleware` | `middleware.AstroAuthMiddleware` |
| JWT validation | `services.ValidateJWT` | `services_astrologer.ValidateJWT` |

Both stacks define their own `Astrologer`, `Language`, `Skill`, `Payment`, `Product*` models over the
same tables. Duplication across the two sides is the established pattern here — do not try to unify
them as a side effect of another task.

There is now a **third, much smaller admin stack** for the settlement lifecycle only:
`routes/admin_routes/`, `controllers/admin/` (`package Admin`), `services_admin/`,
`repositories_admin/`, `dto_admin/`, guarded by `middleware.AdminAuthMiddleware`. It has no models of
its own — it imports `usermodel` and `astrologermodel` (aliased) rather than adding a third copy of
`Consultation` and `WalletSettlement`. The admin panel is a separate application that consumes it
over HTTP.

`models/` is a special case: both subdirectories declare `package models`, so a file importing both
must alias (`usermodel "astrology-api/models/usermodel"`).

### Route registration and dependency wiring

`routes.SetupRoutes` mounts everything under `/api`, then `/api/user` and `/api/astrologer`. Each
route file is also the DI container: repositories, services, and controllers are constructed at the
top of `RegisterUserRoutes` / `RegisterAstrologerRoutes` and closed over by the handlers. New
constructor-injected controllers must be wired there.

Two coexisting handler styles — match whichever the neighbouring code uses:
- **Package-level functions** (older, user side): `usercontroller.Register`, which reach for
  `configs.DB` directly inside the repository functions.
- **Constructor-injected structs** (newer, all of the astrologer side and newer user features):
  `NewActivityController(activityService)`, where the repo takes `*gorm.DB` (`config.DB`) at
  construction time.

Note `RegisterUserRoutes` groups authenticated routes as `api.Group("/")`, which yields double-slash
tolerant paths like `/api/user/profile`. Verb choice is loose (many reads are `POST` with a JSON body);
follow the existing endpoint's convention rather than "correcting" it.

### Consultation billing and settlement

A paid chat / audio / video session is one `consultations` row carrying the whole money trail. The
customer pays `grossAmount`, `platformFeeAmount` stays with the platform, and `astrologerEarning` is
**owed, not paid** — it is never credited when the session ends.

```
start → tick → end (bill, settlementStatus = PENDING)
  → admin review (TO_BE_SETTLED | ON_HOLD | REJECTED)
  → settlement run (SETTLED, astrologer wallet credited)
  → withdraw
```

`settlementStatus`: `NA` (never billed) → `PENDING` → `TO_BE_SETTLED` → `SETTLED`, with `ON_HOLD`
and `REJECTED` as the admin's ways out. `SETTLED` is terminal — the wallet has been credited and the
money may already have been withdrawn, so no review action may touch it.

Four things to know before touching this layer:

- **One billing path.** `services.consultationService.bill` is the only code that bills a session.
  The customer ending it, the balance running out, the astrologer hanging up
  (`POST /api/astrologer/consultation/end`) and the stale-session sweeper all route through it. It
  re-reads the row `FOR UPDATE` and returns the existing receipt for an already-closed session, so a
  double end cannot double-bill.
- **Prices are server-side.** The rate is copied off the astrologer row at `start` (so a mid-session
  rate change cannot alter the quote) and the fee comes from `systemflag.PlatformCommissionPercent`.
  Nothing is taken from a request body. `maxBillableSeconds`, computed at `start` from the balance,
  is the hard cap that keeps a wallet from going negative.
- **The API schedules nothing.** The admin panel owns the cron. The schedule lives in `systemflag`
  (`SettlementFrequency`, `SettlementDayOfWeek`/`DayOfMonth`, `SettlementRunTime`, with
  `SettlementCronExpression` derived from them by `services_admin`), and the panel's scheduler calls
  `POST /api/admin/settlement/run` and `POST /api/admin/consultation/sweep-stale`. The run is
  batched per astrologer in its own transaction and claims rows under a lock, so it is safe to call
  twice and one broken astrologer does not stop the others being paid.
- **`wallet_settlements` holds two opposite movements**, told apart by `settlement_type`:
  `CONSULTATION_BATCH` (the job crediting the wallet) and `WITHDRAW_PAYOUT` (the instant withdraw
  flow debiting it). Always filter on it.

Schema in `migrations/2026_09_11_consultation_settlement.sql` and
`migrations/2026_09_15_settlement_schedule.sql`. Both are idempotent and applied by hand.

### Auth

JWT (HS256, `JWT_SECRET`) with a refresh token. On the user side the access token is **also persisted
on the user row** — `JWTAuthMiddleware` validates the signature *and* looks the token up via
`repositories.FindUserByToken` (`users.jwt_token`), so a valid-but-unstored token is rejected and
logout works by clearing the column. The astrologer middleware validates the signature only.

Both middlewares set `user_id` (a `uint`) in the Gin context; read it with `middleware.GetUserID(c)`
or `ctx.MustGet("user_id").(uint)`. On the astrologer side `user_id` holds the astrologer's **users.id**,
which the repositories map to the `astrologers` row via `GetAstrologerByUserID` — earnings and
settlement queries key on `astrologers.id`, wallets on `users.id`.

`middleware.AdminAuthMiddleware` guards `/api/admin`. It takes either an admin's bearer token (the
signature, plus the admin role via `user_roles` or `users.roleId`) or the admin panel scheduler's
`X-ADMIN-API-KEY` header matching **`ADMIN_API_KEY`** from the environment — the key is rejected
unless that env var is set, so an unset variable is not an open door. It sets `admin_id` (0 for the
scheduler) and records `SYSTEM` rather than `ADMIN` in the settlement trail for key-authenticated
calls. Unlike `JWTAuthMiddleware` it does not require the token to be stored on the user row.

#### OTP and SMS delivery

Both stacks issue OTPs through one place: `services/sms_config.go` and `services/otp_delivery.go`.
`services_astrologer/otp.go` and `services_astrologer/msg91_service.go` are thin delegates to them,
so there is a single MSG91 configuration and a single environment switch.

- **`services.NewOTP()` / `MustNewOTP()` is the only source of an OTP value.** Outside production it
  returns the static code; in production a fresh `crypto/rand` code of `Msg91OtpLength` digits.
  `GenerateOTP()` is always random and is kept only for callers that want that regardless of
  environment.
- **`IsProduction()` is a function, not a package variable.** `APPMode` used to be initialised at
  import time, which runs before `main` calls `LoadEnv`, so it always read an empty `APP_ENV` and the
  production branch was dead. Matching is case-insensitive and trimmed (`production`, `prod`, `live`).
- **`DeliverMobileOTP` / `DeliverEmailOTP` are the only way an OTP goes out.** They no-op outside
  production, log rather than return failures, and are always called *after* the OTP row is
  committed — a carrier failure must never roll back a registration, a login or a reset. Do not call
  `msg91.SendMobileOTP` from a flow directly; that is what made an unset auth key look like
  "registration failed".
- **Configuration is `systemflag` → env → built-in default**, cached 60s (`ResolveMSG91Config`), so an
  admin panel change takes effect without a restart. Flags: `Msg91AuthKey`, `Msg91ApiUrl`,
  `Msg91SenderId`, `Msg91OtpTemplateId`, `Msg91HeaderId`, `Msg91PeId`, `Msg91Route`, `Msg91Country`,
  `Msg91OtpLength`, `Msg91OtpExpiryMinutes`, plus `SmsGatewayEnabled` (kill switch — `0` stops every
  real send, SMS and OTPLESS email alike, while OTPs are still issued and stored),
  `OtpStaticCode` and `AppEnvironment` (only read when `APP_ENV` is unset). Rows in `migrations/2026_09_22_msg91_sms_gateway.sql`.
- **Test it with `go run ./cmd/smstest`** — `-inspect` prints the resolved settings and where each
  came from, `-otp` prints what this environment would issue, `-mobile <number>` sends one real SMS.

### Models

GORM structs with **explicit `gorm:"column:..."` tags and an explicit `TableName()`**, because the
schema is a pre-existing camelCase MySQL database (`contactNo`, `isDelete`, `astrologerCategoryId`).
Always spell the column out; never rely on GORM's snake_case inference. Soft deletes are mostly a
manual `isDelete`/`is_delete` boolean rather than `gorm.DeletedAt`. `AutoMigrate` is never called —
schema changes happen in the database, not in code.

### Response envelopes

Three overlapping conventions exist. Use the one already in the file you are editing:
- `utils.Success/BadRequest/NotFound/...` — richest form, `{status, statusCode, message, data, errors}`.
- `helpers.SuccessResponse(msg, data)` / `helpers.ErrorResponse(msg)` — `gin.H` you pass to
  `ctx.JSON` yourself; `{status, message, data}`.
- Inline `gin.H` in the middlewares (note `JWTAuthMiddleware` emits `success` where everything else
  emits `status`).

Paginated list endpoints read `page`/`limit`/`search`/`status` from the **query string** via
`helpers.GetPaginationRequest` (defaults 1/10/""/"ALL"), even on `POST` routes.

### Astrology services (VedicAstroAPI)

Matching, horoscope, panchang and numerology live in `services/{matching,horoscope,panchang,
numerology}_service.go`, sharing `services/astrology_base.go` (Vedic client + wallet charging) and
`services/vedic_client.go` (HTTP + error mapping), over `repositories/astrology_repository.go` and
`dto/astrology/`. These follow the newer constructor-injected style.

Four things to know before touching them:

- **Vedic wraps failures in an HTTP 200.** The body is `{"status":402,"response":"out of api calls"}`,
  so the status *inside* the envelope decides success. `VedicClient.Get` unwraps and errors on it —
  never call the API with a bare `http.Get`. **`kundali_service.go` is a third, separate code path**:
  its `fetchVedicJSON` checks only the *HTTP* status, so a `{"status":402}` envelope comes back as a
  valid `json.RawMessage` and surfaces as an empty section instead of an error. It also reads its key
  from `kundaliRepo.GetVedicAPIKey()` rather than `astrology_repository.go`. Route new Vedic JSON
  calls through `VedicClient`.
- **Prices are server-side.** Each service reads its own price from `systemflag`
  (`KundaliMatchingPrice`, `HoroscopePrice`, `PanchangPrice`, `NumerologyPrice`); `0` or a missing row
  means free. Never take an amount from the request body — `kundali/add` does, and that lets a
  modified client set its own price.
- **Charge after the upstream call succeeds**, so a failed lookup is never billed. Saved matches do
  the charge and the insert in one transaction.
- **PDF endpoints are unavailable on the current Vedic plan** (`pdf/horoscope`, `pdf/matching-queue`
  both fail), so `kundali/add` cannot produce a PDF and matching has no PDF endpoint.

Dates cross the API boundary as `YYYY-MM-DD` and are converted to Vedic's `DD/MM/YYYY` by
`ToVedicDate`. Ownership of saved rows is the `createdBy` column, not a `user_id`.

#### Service usage and the admin catalogue

Every astrology route is wrapped in `track("<code>")` from `middleware.TrackAstrologyService`, on
both stacks. The code is the row's `code` in the admin panel's `astrology_services` catalogue (47
rows, admin-owned). Before the handler, the middleware refuses with a 403 when the admin has switched
the service off (`isActive`, `access_mode = DISABLED`, `customer_enabled` / `astrologer_enabled`).
After the handler, it writes one `astrology_service_usage` row, read from the response the handler
already wrote. That table feeds the admin's Service Usage report. A new astrology route needs its
`track(...)` and a catalogue row, or it is neither switchable nor counted.

- **Fail open.** An unreadable catalogue or an unknown code serves the request. The catalogue is
  cached 60s. An unknown code is logged once and not recorded, because `service_id` is NOT NULL.
- **The catalogue is the price** (`services/astrology_pricing.go`). The middleware applies
  `access_mode`, `price`, `free_quota` and `quota_period` to customers. A wallet that cannot pay gets
  HTTP 402 with `data.error_code = INSUFFICIENT_BALANCE`, before the provider is called. On success
  it debits under a `FOR UPDATE` lock and writes a `wallettransaction` row (`KundliView`,
  `HoroscopeView` …), holding the response until the debit lands, then sets `charged` in it. While
  the catalogue is readable, `astrologyBase.chargeWallet` / `chargeForRead` skip the `systemflag`
  prices, so nothing is charged twice. Only when it is unreadable does the old `systemflag` charging
  return. Quota counts successful `was_free` usage rows. Guests, astrologers and
  `requires_login = 0` routes are never charged. `cache_minutes` is still not enforced
  (`was_cached` is always 0).
- **Astrologer routes reuse the customer controllers** over `New*ServiceForAstrologer()`, whose
  `astrologyBase.neverCharge` skips every wallet debit. On that side `user_id` is the astrologer's
  own earnings wallet.
- `platform` / `device_type` / `app_version` come from
  `migrations/2026_09_25_astrology_service_usage_platform.sql`. They are resolved by
  `services.ResolveClient`, the same rules as `horoscope_requests`. Until that migration has run,
  rows are written without them.

#### Kundali (`services/kundali_service.go`, ~1400 lines)

The largest service and the odd one out — a package-level `NewKundaliService()` with no injected
`*gorm.DB` (the repo reaches for `configs.DB`). Two generation paths coexist:

- `GenerateKundliViaVedic` — the original single-PDF path behind `kundali/add`.
- `GenerateCompleteKundli` → `GetCompleteVedicDetails` — walks many `horoscope/*` endpoints in
  sequence (planet details, ascendant/personality reports, divisional charts, ashtakvarga, aspects,
  per-planet reports), passing each body through untouched as `json.RawMessage` into
  `dto/kundali/kundali_details.go`. **Partial failure is by design**: a failed endpoint lands in
  `CompleteKundaliResponse.Errors[section]` and the rest of the response still returns, so callers
  must treat missing sections as normal. A new section is a field plus one fetch call — not a typed
  struct.

`DownloadAndSavePDF`'s bare `http.Get` is fine as-is; it is a file download, not an API call.

Schema changes for this layer are in `migrations/` — plain idempotent SQL, applied by hand.
`AutoMigrate` is still never called.

### Push notifications

`notifications/` is a standalone package (not one of the three stacks, so all of them can import it
without a cycle). Send one with:

```go
notifications.Notify().WalletRecharged(userID, amount, balance)
```

One method per product event; `Notify()` is safe before `Init` and every method is fire-and-forget —
dispatch happens on a goroutine, recovers from panics and returns no error, because a failed push
must never abort a wallet debit or a settlement run. The `user_notifications` row is written even
when the push fails.

- **FCM HTTP v1, no Firebase SDK.** `notifications/firebase.go` signs an RS256 JWT with the service
  account key (`golang-jwt`, already a dependency), exchanges it for a bearer, and caches that for
  the hour. Config: `FIREBASE_PROJECT_ID` plus `FIREBASE_CREDENTIALS_FILE` (or
  `FIREBASE_CREDENTIALS_JSON`). With no credentials, push is disabled and in-app rows still get
  written — the API runs either way.
- **Writing a token:** `repositories.SaveDeviceToken(userID, token, deviceType)` is the only correct
  way. `users` carries **three** columns for the same value — `device_token`, `fcm_token` and
  `token` — and only the first was ever populated; the helper writes all three together and ignores
  an empty token so an app that omits the field cannot wipe a live registration. Called from
  register, `verify-mobile-otp`, `verify-login-otp`, social login, and `POST /api/user/device-token`
  (the refresh endpoint — FCM rotates tokens, so login-time capture alone goes stale).
- **Tokens are read from** `user_device_details.fcmToken` and all three `users` columns. `appId` in that table is **numeric** (`"1"`), not a name; the mapping is
  set by `FCM_APP_ID_CUSTOMER` / `_ASTROLOGER` / `_WEB` and a filter that matches nothing falls back
  to every token on the account, so a wrong mapping cannot silence notifications.
- A token FCM rejects as `UNREGISTERED` is pruned from the registry; any other failure is only logged.
- **Test it with `go run ./cmd/notifytest`** — `-token <fcm-token>` for a direct send (no DB),
  `-inspect -user <id>` to see which tokens an account would be reached on, or
  `-event <name> -user/-astrologer <id>` to fire a real product notification.

Events whose decision lives in the admin panel rather than here (profile verification, withdraw
approve/reject) are triggered by the panel through `POST /api/admin/notification/event`, so the
wording stays in this package rather than being reinvented there.

#### The notification centre (read side)

`notifications/center.go` is the in-app list over the same `user_notifications` rows the notifier
writes — seven endpoints mounted **identically** under `/api/user/notifications` and
`/api/astrologer/notifications` (list, count, seen, read, delete, `DELETE /:id`, clear). The web app
at `astroeye.in/notifications` serves both audiences from one screen, so the payloads are the same
on both sides.

It lives in `notifications/` rather than in either stack's `services/` because there is one table
and one set of semantics: every notification is keyed on `users.id`, which is what both middlewares
put in the context. The two stacks still get their own controller
(`controllers/{user,astrologer}/notification_controller.go`), both constructed with
`notifications.NewCenter(DB)`.

**Three states, not two** — the distinction is the whole design:
- `is_read` / `is_seen` — the person opened *that notification*. Cleared by `/read`. This is
  `counts.unseen`, the badge.
- `is_new` — it *arrived since the screen was last opened*, from `users.notifications_seen_at`.
  Cleared by `/seen`, which deliberately marks nothing read. This is `counts.new`, the tab dot.

Every mutating endpoint returns the fresh `counts`, so the app never needs a second call to update
its badge. Ids in a request body are always re-filtered by the token's `userId`, so another
account's id affects nothing and returns `affected: 0` rather than an error.

Removal is the existing soft delete (`isDelete = 1` plus `deleted_at`); nothing is hard-deleted.
Schema in `migrations/2026_09_23_notification_center.sql`, request/response reference in
`docs/notification_api.md`.

### External integrations

- **PhonePe** payments — `configs/phonepe.go` + `services/phonepe_service.go` (X-VERIFY = SHA256 of
  payload+path+salt, then `###saltIndex`), status polling in `phonepe_status_service.go`. Base URL is
  env-switched between sandbox and production.
- **MSG91** mobile OTP, **OTPLESS** email OTP (`services/msg91_service.go`, `otpless_service.go`).
- **VedicAstroAPI** (`api.vedicastroapi.com`) generates kundali PDFs; the API key is read from the
  database (`kundaliRepo.GetVedicAPIKey()`), not from env. Generated PDFs are downloaded and served
  from `uploads/`, which Gin exposes statically at `/uploads`.

### Configuration

`.env` (godotenv, git-ignored) read via bare `os.Getenv` at point of use — there is no config struct.
The `MSG91_*` and `OTPLESS_*` variables are now only a fallback — both gateways read `systemflag`
first (see **OTP and SMS delivery**). OTPLESS reads the pre-existing `otplessClientId` and
`otplessSecretKey` rows; it used to read only the env vars, which are absent from the committed
`.env`, so every production email OTP went out unauthenticated. `ADMIN_API_KEY` is the same: without it the admin panel's scheduler cannot
authenticate to the settlement run endpoint (an admin bearer token still works).

Redis is wired (`configs/redis.go`) but `ConnectRedis()` is commented out in `main.go` and `RDB` is
unused — treat Redis as not in play.

## Repository hygiene

`pkg/` (8,600 files) is a checked-in Go module cache; `app` and `server.exe` are checked-in compiled
binaries. None is used by the build (`GOMODCACHE` points at `~/go/pkg/mod`). Do not add to them, do not
read them when searching, and exclude them from greps — searching `pkg/` will drown any useful result.

`cache/`, `validators/` and `logs/` are empty scaffolding, and `docs/country_code.json` is read by no
Go file. Don't infer a convention from them.
