Sms & Email

This commit is contained in:
2026-10-20 10:45:03 +05:30
parent a40e463e8b
commit 0e423b806c
29 changed files with 13845 additions and 26 deletions
+251
View File
@@ -0,0 +1,251 @@
# Day-End Owner Report — Implementation Plan
At the end of each business day, send the owner a summary of the day's revenue and key details
by **SMS** and **email**. The owner's mobile number(s) and email address(es) are set in
**Settings → Configuration**.
> **Naming:** this builds on the existing read-only **Day-End Sales Report**
> (`docs/DAY_END_SALES_REPORT_PLAN.md`, permission `sales.day-end-report`). It does not change the
> cashier close-out `SalesDayEnd` (shown as "Approved Invoices"). Access: the existing `settings`
> nav permission, like the other configuration screens.
>
> **Status (2026-10-19): implemented** — phases 1–5 (PDF attachment, phase 6, not done). Deviations
> from the design below: mobile numbers are stored as `+94XXXXXXXXX` (AuthHex converts to the gateway's `94…` form), one
> `IMessageSender` replaces separate SMS/email senders, and the API uses the `settings` policy.
---
## 1. Requirements
| # | Requirement |
|---|---|
| R1 | Once per business day, at a configured time (default **21:00 Asia/Colombo**), send the day's summary to the owner. |
| R2 | Owner contacts are configured in Settings: **one or more mobile numbers** and **one or more email addresses**. |
| R3 | Each channel (SMS, email) can be switched on/off independently; the whole feature has a master on/off. |
| R4 | SMS carries a short revenue summary; email carries the full details (summary + breakdowns). |
| R5 | A **"Send test now"** button sends today's report immediately to the configured contacts. |
| R6 | Every send attempt is logged (date, channel, recipient, success/failure, error) and visible in Settings. |
| R7 | A day is sent at most once per channel per recipient, even if the server restarts or runs on several instances. |
| R8 | If the server was down at send time, the missed day is sent when it comes back (same day only — no backfill of older days). |
| R9 | Branch-aware: one company-wide summary, plus a per-branch breakdown when branching is enabled. |
### Message content
**SMS** (≤ 2 SMS segments, ~300 chars, English, plain text):
```
Hexa ERP – Day end 19/10/2026
Sales: LKR 125,400.00 (42)
Paid: 98,200.00 | Due: 27,200.00
Cash: 61,000.00 | Card: 37,200.00
Expenses: 8,500.00
Net profit: 21,300.00
Cash in hand: 54,300.00
```
**Email** (HTML + plain-text alternative), subject `Day-end report — 19 Oct 2026 — Sales LKR 125,400.00`:
| Section | Content (all from `DayEndReportDto`) |
|---|---|
| Summary | Total sales, sales count, paid, due, paid by cash / card / other, expenses, income, COGS, gross profit, net profit (labelled **Loss** when negative) |
| Cash | Opening cash in hand, net cash today, deposited to bank, closing cash in hand |
| Collections | Collected on earlier invoices |
| Per branch | One summary row per branch (when branching is on) |
| Top sales | Largest 10 sales of the day (doc no., customer, total, paid by) |
| Expenses | Every expense of the day (doc no., category · title, amount) |
| Footer | Link to `/dashboard/sales/day-end-report?date=YYYY-MM-DD`; "Sent automatically by Hexa ERP" |
Optional (phase 2): attach the day's PDF (same layout as the Day-End Report's **Download PDF**).
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Day figures | `DayEndReportService.GetDayAsync(date, branchId)` → `DayEndReportDto` (summary, sales, expenses, deposits) | Branch scoping reads `ICurrentBranchContext` (the HTTP user) — **no user in a background job** |
| Business day | `BusinessDayClock` (`DayEndReport:TimeZone`, `Asia/Colombo`) | — |
| Config pattern | Singleton config rows + service + `ETag`/`If-Match` (e.g. `SalesConfig`, `GrnConfig`, `NotificationConfig`) | — |
| Settings UI | `app/dashboard/settings/configuration/page.tsx` (card per config screen) | No owner-report card / page |
| Phone validation | `lib/validations/phone.ts` (Sri Lankan: `0XXXXXXXXX` / `+94XXXXXXXXX`), backend `PhoneRules` in `Dtos/Warehouses/WarehouseDtos.cs` | Move `PhoneRules` to a shared place |
| In-app notifications | `NotificationService`, `NotificationConfig` (per-module in-app alerts) | In-app only — **no SMS or email sending anywhere** |
| AuthHex (`D:\HexDive\ERP_Auth_Service`) | Owns the **SMTP** (`SmtpSettings` — Hostinger, port 587, SSL) and **SMS gateway** (`Sms` — Introps `Sms_common/send_sms`) credentials. `Utility/ThirdPartyService` already sends a custom-subject/HTML-body email (`EmailConfiguration`) and a custom-text SMS (`SendSmsAsync`), but only from its own OTP/recovery flows | **No endpoint** lets another service send a message — needs one (§3.2) |
| ERPCore → AuthHex | `IAuthHexClient` with `AuthHex:BaseUrl`; admin calls send `AuthHex:AdminApiKey` (`AdminHeaders()`) | — |
| Background jobs | None (no `BackgroundService`, Hangfire or Quartz) | **Need a scheduler** |
---
## 3. Design
### 3.1 Data model (migration `AddOwnerReportConfig`)
**`owner_report_config`** — singleton (`ConfigId = 1`, CHECK constraint like the other config rows):
| Column | Type | Notes |
|---|---|---|
| `ConfigId` | int PK | always 1 |
| `Enabled` | bool | master switch, default `false` |
| `SmsEnabled` | bool | default `true` |
| `EmailEnabled` | bool | default `true` |
| `SendAtLocalTime` | time | default `21:00`, in `DayEndReport:TimeZone` |
| `IncludeBranchBreakdown` | bool | default `true` |
| `UpdatedAt`, `UpdatedBy`, `RowVersion` | | same as other configs |
**`owner_report_recipients`**:
| Column | Type | Notes |
|---|---|---|
| `RecipientId` | int PK | |
| `Name` | varchar(100) | e.g. "Owner", "Director" |
| `Mobile` | varchar(20) null | normalised to `+94XXXXXXXXX`; validated with the shared Sri Lankan phone rule |
| `Email` | varchar(200) null | validated email |
| `IsActive` | bool | |
At least one of `Mobile` / `Email` required. Max **5** recipients.
**`owner_report_dispatch`** — the send log *and* the idempotency guard:
| Column | Type | Notes |
|---|---|---|
| `DispatchId` | bigint PK | |
| `BusinessDate` | date | |
| `Channel` | enum `Sms` / `Email` | |
| `RecipientId` | int FK | |
| `Destination` | varchar(200) | number/email at send time (kept if the recipient changes later) |
| `Status` | enum `Pending` / `Sent` / `Failed` | |
| `Attempts` | int | |
| `Error` | varchar(1000) null | provider error, truncated |
| `ProviderMessageId` | varchar(100) null | |
| `IsTest` | bool | "Send test now" sends |
| `CreatedAt`, `SentAt` | datetime | |
Unique index `(BusinessDate, Channel, RecipientId, IsTest)` → R7: a second worker or a restart can't
insert the same automatic send twice (`INSERT` wins once; the loser skips).
### 3.2 Sending channels — through AuthHex
**SMTP and SMS details come from AuthHex.** ERPCore does not hold any mail or SMS credentials; it
asks AuthHex to send, and AuthHex uses the `SmtpSettings` / `Sms` configuration it already has.
One place to rotate a password, one sender identity, nothing new to secure in ERPCore.
```
OwnerReportJob ──► IEmailSender / ISmsSender (ERPCore)
│ AuthHexEmailSender / AuthHexSmsSender
▼ POST {AuthHex:BaseUrl}/api/notify/... + AdminApiKey header
AuthHex NotifyController ──► ThirdPartyService ──► Hostinger SMTP / Introps SMS
```
**AuthHex side (`ERP_Auth_Service`) — new, small:**
| Method | Route | Body | Uses |
|---|---|---|---|
| `POST` | `/api/notify/email` | `{ to: string[], subject, htmlBody, textBody? }` | `ThirdPartyService.EmailConfiguration(to, code: "", subject, htmlBody)` per recipient |
| `POST` | `/api/notify/sms` | `{ to: string[], text }` | `ThirdPartyService.SendSmsAsync(to, otpCode: "-", message: text)` per recipient |
- Protected by the **same admin API key** ERPCore already sends for admin calls (`AuthHex:AdminApiKey`); no user session. Reject anything without it (401).
- Response: per-recipient `{ to, ok, error }` in the usual AuthHex envelope.
- Changes needed in `ThirdPartyService` (keep the OTP flows working):
- `SendSmsAsync` rejects an empty `otpCode` even when a custom `message` is given → allow an empty code when `message` is set (or add a `SendTextAsync(mobile, text)` overload).
- `SendEmailAsync` runs `body.Replace("{code}", code)` — harmless for the report, but add a `SendCustomEmailAsync(to, subject, html)` that skips OTP templating.
- `SmtpSettings:SenderName` is currently **"Gym Os"** — set it to **"Hexa ERP"** so owner emails show the right sender (`From: Hexa ERP <sender address>`).
- Uses `System.Net.Mail.SmtpClient`, 10 s timeout, SSL — fine for a few emails a day.
- Limits: ≤ 10 recipients per call; email body ≤ 200 KB; SMS text ≤ 480 chars.
**ERPCore side (`Infra/Messaging/`):**
```csharp
public interface ISmsSender { Task<IReadOnlyList<SendResult>> SendAsync(IReadOnlyList<string> toE164, string text, CancellationToken ct); }
public interface IEmailSender { Task<IReadOnlyList<SendResult>> SendAsync(EmailMessage message, CancellationToken ct); }
public sealed record SendResult(string To, bool Ok, string? Error);
```
| Implementation | Used when |
|---|---|
| `AuthHexEmailSender`, `AuthHexSmsSender` — two new methods on `IAuthHexClient` (`SendEmailAsync`, `SendSmsAsync`) reusing its HTTP client, base URL and `AdminHeaders()` | `Messaging:Provider = AuthHex` (default in Development/Production) |
| `LogOnlyEmailSender`, `LogOnlySmsSender` — write the message to the log instead of sending | `Messaging:Provider = Log` (local dev, tests, or while AuthHex lacks the endpoint) |
No new secrets in ERPCore. Each call has a 20 s timeout and no retries of its own — the job retries.
If AuthHex is unreachable the dispatch row is marked `Failed` with the error and retried (§3.4).
### 3.3 Building the report (`OwnerReportService`)
- `BuildAsync(DateOnly date)` → `OwnerReportContent { SmsText, EmailSubject, EmailHtml, EmailText }`.
- Calls `DayEndReportService.GetDayAsync(date, branchId: null)` for the company total, and once per active branch when `IncludeBranchBreakdown` and branching is on.
- **Branch scoping fix:** add an explicit "system / all branches" path to `DayEndReportService` (e.g. an overload taking `bool allBranches`) instead of relying on `ICurrentBranchContext`, which has no user inside the job. Must not widen access for HTTP callers.
- Amounts use the same formatting as the UI (`#,##0.00`, `LKR` prefix on the headline); a negative net profit is written as `Loss 1,200.00`.
- Email HTML is a simple inline-styled table template (`Infra/Messaging/Templates/OwnerReport.html`) — no external CSS/images.
### 3.4 Scheduler (`OwnerReportJob : BackgroundService`)
- Wakes every **60 s** (`PeriodicTimer`).
- If `Enabled` and local time (`BusinessDayClock`) ≥ `SendAtLocalTime` and today has no `Sent`/`Pending` dispatch rows → build once, then for each active recipient × enabled channel insert a `Pending` dispatch row (unique index guards duplicates) and send.
- Failures: retry up to **3** times, 5 min apart (`Attempts`, `Status = Failed` after the last).
- Missed send (server down at 21:00): picked up on the next tick the same day (R8). Days before today are never sent automatically.
- Runs in its own DI scope per tick; any exception is logged and the loop continues — it must never crash the host.
- Multi-instance safe through the unique index (no distributed lock needed).
### 3.5 API (`SettingsOwnerReportController`, policy `settings.owner-report`)
| Method | Route | Body / result |
|---|---|---|
| `GET` | `/api/v1/settings/owner-report` | config + recipients (ETag) |
| `PUT` | `/api/v1/settings/owner-report` | config + full recipients list, `If-Match` required |
| `POST` | `/api/v1/settings/owner-report/test` | `{ date?: "YYYY-MM-DD" }` → sends now to all active recipients (`IsTest = true`); returns per-recipient results |
| `GET` | `/api/v1/settings/owner-report/dispatches?from&to&page&pageSize` | send log, newest first |
| `GET` | `/api/v1/settings/owner-report/preview?date=` | `{ smsText, emailSubject, emailHtml }` — renders without sending |
Validation (422): recipient needs mobile or email; mobile must pass the Sri Lankan phone rule; email
must be valid; ≤ 5 recipients; `SendAtLocalTime` between 00:00 and 23:59. `POST /test` is rate-limited
(1 per minute) so the button can't be used to spam the owner.
### 3.6 Frontend — `app/dashboard/settings/owner-report/page.tsx`
- New card on **Settings → Configuration**: *"Day-End Owner Report — Send the day's revenue to the owner by SMS and email"* (icon `Send`).
- Page sections (mobile-first, same `StatCard`/card styles as the rest of the app):
1. **Delivery** — master switch, SMS on/off, Email on/off, send time (time input), "include branch breakdown".
2. **Recipients** — list of name / mobile / email rows with add / remove (max 5). Mobile uses `limitPhoneInput` + `phoneError` from `lib/validations/phone.ts`; email validated client-side too. All errors also shown as toasts.
3. **Preview** — SMS text in a phone-style bubble, email rendered in a sandboxed iframe (`srcdoc`), date picker to preview another day.
4. **Send test now** — confirm dialog, then per-recipient result toast.
5. **Send log** — paginated table (cards on phones): date, channel, recipient, status badge, error tooltip.
- Sidebar: no new entry (reached from Settings → Configuration), permission code `settings.owner-report` added to the role permission tree.
---
## 4. Security & privacy (02-SECURITY checklist items)
- SMTP and SMS credentials stay in AuthHex's configuration only — ERPCore never sees them. The
new AuthHex `/api/notify/*` endpoints accept only the admin API key, so they can't be used as an
open relay; the key itself stays in ERPCore's configuration/secrets, never in git or API responses.
- Owner contact data visible only to `settings.owner-report`; the send log masks numbers (`+94 77 *** 4567`) for other viewers if it is ever exposed elsewhere.
- SMS/email bodies contain financial figures — sent only to configured recipients, never to user-supplied addresses on the test endpoint.
- Audit log entries for config changes (who changed recipients / switched it on).
- The email footer link requires login — no figures are exposed through the link itself.
---
## 5. Phases & tracking
| Phase | Scope | Done when |
|---|---|---|
| 1 | Data model + migration, config API, Settings page (delivery + recipients), `LogOnly` senders | Config saves/loads with validation; test send writes to the log |
| 2 | `OwnerReportService` content + preview endpoint/UI; branch-scoping fix in `DayEndReportService` | Preview matches the Day-End Report figures for the same day |
| 3 | AuthHex: `POST /api/notify/email` + `ThirdPartyService` custom-email method + sender name; ERPCore: `AuthHexEmailSender`; send log UI | Test email arrives from the right sender with correct figures |
| 4 | AuthHex: `POST /api/notify/sms` + empty-OTP fix; ERPCore: `AuthHexSmsSender` | Test SMS arrives, ≤ 2 segments |
| 5 | `OwnerReportJob` scheduler, retries, idempotency | Sends once at the set time; restart and 2-instance tests don't double-send |
| 6 (opt.) | PDF attachment | Email carries the day's PDF |
Record each phase in `Backend/PROGRESS.md` / `Frontend/PROGRESS.md` per `01-DOC-GUIDE.md`.
---
## 6. Open questions
| # | Question | Default if not answered |
|---|---|---|
| Q1 | ~~Which SMS gateway?~~ **Decided:** AuthHex's existing gateway (Introps), via the new AuthHex endpoint. | — |
| Q2 | ~~Which SMTP account?~~ **Decided:** AuthHex's `SmtpSettings` (Hostinger), sender name **"Hexa ERP"**. Still open: the sender *address* — today `tester@hexdive.com`. | Keep the current address until a dedicated one (e.g. `erp@hexdive.com`) exists |
| Q2b | Is the AuthHex repo (`ERP_Auth_Service`) ours to change, or owned by another team who must add `/api/notify/*`? | We add it in the same release |
| Q3 | One company-wide report, or a separate report per branch to each branch's manager? | Company-wide + per-branch breakdown section |
| Q4 | Send time — fixed 21:00, or after the last cashier closes the day (Approved Invoices / `SalesDayEnd`)? | Fixed configurable time |
| Q5 | Should a day with no sales still send ("No sales today")? | Yes — confirms the system ran |
| Q6 | Languages — English only, or Sinhala/Tamil SMS too (Unicode halves SMS length)? | English only |