SMS Cost Log + Email Cost Estimate (admin)
Three finance-facing endpoints for tracking outbound notification spend. SmsCostLog provides per-message Twilio/Vonage rows; the summary endpoint groups them by country / provider / event / day. Email cost is computed analytically from NotificationEvent counts × per-1k contract rate.
/api/v1/admin/notifications/sms-cost-log* + /email-cost-estimate — 👤 Admin · permission notification:read (inherited class-level)
Three endpoints powering the admin Money + Finance dashboards. Shipped in Wave C3 (SMS) and Wave E B1 (E6) (email).
cost is stringified Decimal(10,6). Postgres stores SMS price with 6-decimal precision (Twilio bills in millionths). JSON numbers cannot hold that precision, so cost is serialised as a string. Finance dashboards must parse with a Decimal library (e.g. decimal.js) — naive parseFloat silently rounds.
priceUnit is always part of GROUP BY. Mixed-currency rows stay separated — (US, USD) and (US, EUR) are distinct groups. The UI must render per-currency lines ("USD: $X / EUR: €Y") rather than silently summing across USD / EUR / etc. NULL priceUnit collapses to the sentinel 'UNK' so the GROUP BY remains stable.
Email has NO per-row mirror. EmailCostLog was scope-cut (advisor 2026-06-22): per-row writes would double transactional send volume for no additional observability — providers don't surface per-message cost like Twilio. Email cost is computed analytically from NotificationEvent count × per-1k rate from cost.email.{resend,sendgrid}_per_1k_usd config keys.
GET /admin/notifications/sms-cost-log — paginated browser
Paginated list of SmsCostLog rows. Filter by country, provider, eventKey, status, userId, or date range.
Query — SmsCostLogQueryDto
| Param | Type | Validation | Notes |
|---|---|---|---|
country | string | Matches(/^[A-Z]{2}$/) | ISO 3166-1 alpha-2. Writers store this shape via deriveCountryFromE164 — exact match. |
provider | enum | IsIn(['twilio', 'vonage']) | |
eventKey | string | MaxLength(128) | Resolved via NotificationJob lookup at write time. May be null for rows where the join missed. |
status | string | MaxLength(64) | Provider status: delivered, failed, undelivered, sent, etc. |
userId | string | IsUUID | |
dateFrom | string | IsISO8601 | Inclusive. Invalid timestamps clamp to default rather than throwing. |
dateTo | string | IsISO8601 | Inclusive. Default = now(). |
page | number | Min(1) | Default 1. |
limit | number | Min(1), Max(200) | Default 50. Server caps at 200. |
Response — PaginatedApiResponseOf<SmsCostLogItemDto>
{
"success": true,
"data": {
"items": [
{
"id": "scl-uuid",
"provider": "twilio",
"providerMessageId": "SM1a2b3c...",
"userId": "usr-uuid",
"eventKey": "verification_code",
"country": "TR",
"segments": 1,
"cost": "0.045000",
"priceUnit": "USD",
"status": "delivered",
"errorCode": null,
"createdAt": "2026-06-22T14:00:00.000Z"
}
],
"total": 12483,
"page": 1,
"limit": 50,
"totalPages": 250
}
}Errors
| HTTP | Reason |
|---|---|
400 | Invalid country shape (must be 2-letter uppercase) |
401 | Missing / invalid admin session |
403 | Permission denied (notification:read required) |
GET /admin/notifications/sms-cost-log/summary — aggregated
Aggregated cost summary grouped by one user-chosen dimension. priceUnit is always in the GROUP BY so mixed currencies stay separated. Capped at 500 rows.
Query — SmsCostSummaryQueryDto
| Param | Type | Validation | Notes |
|---|---|---|---|
groupBy | enum | required, IsIn(['country', 'provider', 'eventKey', 'day']) | day groups by DATE_TRUNC('day', createdAt) formatted YYYY-MM-DD — chronological sort. Other groups sort by total cost DESC. |
dateFrom | string | IsISO8601 | Default = now() - 30 days. Invalid clamps to default. |
dateTo | string | IsISO8601 | Default = now(). Invalid clamps to now(). |
Response — ApiResponseOf<SmsCostSummaryResponseDto>
{
"success": true,
"data": {
"groups": [
{
"key": "TR",
"count": 8421,
"totalCost": "379.450000",
"currency": "USD",
"avgSegments": 1.04
},
{
"key": "US",
"count": 2143,
"totalCost": "16.072500",
"currency": "USD",
"avgSegments": 1.0
}
]
}
}| Field | Type | Notes |
|---|---|---|
key | string | Country code / provider name / event key / YYYY-MM-DD (varies by groupBy) |
count | number | Rows in the bucket |
totalCost | string | SUM(cost) as stringified Decimal |
currency | string | priceUnit for the bucket; 'UNK' when null |
avgSegments | number | AVG(segments) — float |
Errors
| HTTP | Reason / i18nKey |
|---|---|
400 | admin.notification.invalid_group_by — groupBy not in the allowlist |
400 | admin.notification.invalid_date_range — dateFrom > dateTo |
401 | Missing / invalid admin session |
403 | Permission denied (notification:read required) |
GET /admin/notifications/email-cost-estimate — analytic
Aggregate email spend estimate. No per-row log — counts come from NotificationEvent (channel=EMAIL, status=sent) multiplied by per-provider per-1k rate from ConfigService.
Query — EmailCostEstimateQueryDto
| Param | Type | Validation | Notes |
|---|---|---|---|
dateFrom | string | IsISO8601 | Default = now() - 30 days. Invalid clamps. |
dateTo | string | IsISO8601 | Default = now(). Invalid clamps. |
Rate configuration
| ConfigService key | Default | Unit |
|---|---|---|
cost.email.resend_per_1k_usd | 0.20 | USD per 1,000 emails |
cost.email.sendgrid_per_1k_usd | 0.45 | USD per 1,000 emails |
Finance updates these via admin panel when the contract moves; both default to public list prices at integration time.
Response — JSON (no DTO class — service returns raw shape)
{
"success": true,
"data": {
"dateFrom": "2026-05-23T14:00:00.000Z",
"dateTo": "2026-06-22T14:00:00.000Z",
"currency": "USD",
"rates": {
"resendPer1k": 0.20,
"sendgridPer1k": 0.45
},
"resend": { "count": 38421, "costUsd": 7.6842 },
"sendgrid": { "count": 12193, "costUsd": 5.4869 },
"total": { "count": 50614, "costUsd": 13.1711 }
}
}Cost numbers are rounded to 4 decimals — keeps sub-cent precision for low-volume windows without bloating the JSON.
Errors
| HTTP | Reason / i18nKey |
|---|---|
400 | admin.notification.invalid_date_range — dateFrom > dateTo |
401 | Missing / invalid admin session |
403 | Permission denied (notification:read required) |
Side effects (none)
All three endpoints are read-only. No DB writes.
Source
| Source | Path | Lines |
|---|---|---|
Controller (GET /sms-cost-log) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 296–303 |
Controller (GET /sms-cost-log/summary) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 311–319 |
Controller (GET /email-cost-estimate) | apps/api-core/src/modules/notification/admin-notification.controller.ts | 333–345 |
| Request DTO (cost log) | apps/api-core/src/modules/notification/dto/admin-notification.dto.ts | 141–194 (SmsCostLogQueryDto) |
| Request DTO (summary) | apps/api-core/src/modules/notification/dto/admin-notification.dto.ts | 204–218 (SmsCostSummaryQueryDto) |
| Request DTO (email estimate) | apps/api-core/src/modules/notification/dto/admin-notification.dto.ts | 229–239 (EmailCostEstimateQueryDto) |
| Response DTO (cost log item) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 482–522 (SmsCostLogItemDto) |
| Response DTO (summary group) | apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts | 531–551 (SmsCostSummaryGroupDto, SmsCostSummaryResponseDto) |
Service (listSmsCostLog) | apps/api-core/src/modules/notification/admin-notification.service.ts | 361–427 |
Service (summarizeSmsCost) | apps/api-core/src/modules/notification/admin-notification.service.ts | 429–513 |
Service (getEmailCostEstimate) | apps/api-core/src/modules/notification/admin-notification.service.ts | 535–607 |
| Prisma model | packages/prisma/prisma/schema.prisma | SmsCostLog (1833), NotificationEvent (1785) |
| DLR writers (SMS cost source) | apps/api-core/src/modules/notification/twilio-webhook.controller.ts, vonage-webhook.controller.ts | StatusCallback handlers — see existing webhook docs |
| Live response | NOT verified — sourced from DTOs + service shapes cited above |
Spectrum Smoke (admin) — End-to-end dispatch test
Fires one notification per event in SPECTRUM_SMOKE_EVENTS (26 events default) to a single recipient. Useful after a template change to verify rendering + provider delivery end-to-end. Channel overrides force email-only by default; admin can opt into push / web-push for FCM credential testing.
Webhook — Rotate HMAC Secret (admin)
Generate a new cryptographically random HMAC signing secret for a webhook endpoint. The plaintext value is returned EXACTLY ONCE — subsequent GETs redact it. Subscribers must update their verification config before the next delivery.