BIO.RE
Notifications

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

ParamTypeValidationNotes
countrystringMatches(/^[A-Z]{2}$/)ISO 3166-1 alpha-2. Writers store this shape via deriveCountryFromE164 — exact match.
providerenumIsIn(['twilio', 'vonage'])
eventKeystringMaxLength(128)Resolved via NotificationJob lookup at write time. May be null for rows where the join missed.
statusstringMaxLength(64)Provider status: delivered, failed, undelivered, sent, etc.
userIdstringIsUUID
dateFromstringIsISO8601Inclusive. Invalid timestamps clamp to default rather than throwing.
dateTostringIsISO8601Inclusive. Default = now().
pagenumberMin(1)Default 1.
limitnumberMin(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

HTTPReason
400Invalid country shape (must be 2-letter uppercase)
401Missing / invalid admin session
403Permission 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

ParamTypeValidationNotes
groupByenumrequired, 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.
dateFromstringIsISO8601Default = now() - 30 days. Invalid clamps to default.
dateTostringIsISO8601Default = 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
      }
    ]
  }
}
FieldTypeNotes
keystringCountry code / provider name / event key / YYYY-MM-DD (varies by groupBy)
countnumberRows in the bucket
totalCoststringSUM(cost) as stringified Decimal
currencystringpriceUnit for the bucket; 'UNK' when null
avgSegmentsnumberAVG(segments) — float

Errors

HTTPReason / i18nKey
400admin.notification.invalid_group_bygroupBy not in the allowlist
400admin.notification.invalid_date_rangedateFrom > dateTo
401Missing / invalid admin session
403Permission 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

ParamTypeValidationNotes
dateFromstringIsISO8601Default = now() - 30 days. Invalid clamps.
dateTostringIsISO8601Default = now(). Invalid clamps.

Rate configuration

ConfigService keyDefaultUnit
cost.email.resend_per_1k_usd0.20USD per 1,000 emails
cost.email.sendgrid_per_1k_usd0.45USD 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

HTTPReason / i18nKey
400admin.notification.invalid_date_rangedateFrom > dateTo
401Missing / invalid admin session
403Permission denied (notification:read required)

Side effects (none)

All three endpoints are read-only. No DB writes.

Source

SourcePathLines
Controller (GET /sms-cost-log)apps/api-core/src/modules/notification/admin-notification.controller.ts296–303
Controller (GET /sms-cost-log/summary)apps/api-core/src/modules/notification/admin-notification.controller.ts311–319
Controller (GET /email-cost-estimate)apps/api-core/src/modules/notification/admin-notification.controller.ts333–345
Request DTO (cost log)apps/api-core/src/modules/notification/dto/admin-notification.dto.ts141–194 (SmsCostLogQueryDto)
Request DTO (summary)apps/api-core/src/modules/notification/dto/admin-notification.dto.ts204–218 (SmsCostSummaryQueryDto)
Request DTO (email estimate)apps/api-core/src/modules/notification/dto/admin-notification.dto.ts229–239 (EmailCostEstimateQueryDto)
Response DTO (cost log item)apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts482–522 (SmsCostLogItemDto)
Response DTO (summary group)apps/api-core/src/modules/notification/dto/admin-notification-response.dto.ts531–551 (SmsCostSummaryGroupDto, SmsCostSummaryResponseDto)
Service (listSmsCostLog)apps/api-core/src/modules/notification/admin-notification.service.ts361–427
Service (summarizeSmsCost)apps/api-core/src/modules/notification/admin-notification.service.ts429–513
Service (getEmailCostEstimate)apps/api-core/src/modules/notification/admin-notification.service.ts535–607
Prisma modelpackages/prisma/prisma/schema.prismaSmsCostLog (1833), NotificationEvent (1785)
DLR writers (SMS cost source)apps/api-core/src/modules/notification/twilio-webhook.controller.ts, vonage-webhook.controller.tsStatusCallback handlers — see existing webhook docs
Live responseNOT verified — sourced from DTOs + service shapes cited above

On this page