Skip to content

Client Service Architecture (client-service)

client-service manages clients, users, login sessions, balances, reports, and channel alerts. sms-router-service calls it directly for balance operations, and it consumes Kafka events for reporting and alerts.

The development profile uses port 9014. External callers use gateway-service on port 8000. Keep the client-service port on a private network.

Responsibilities and boundaries

Area Client-service responsibility Other service responsibility
Clients and users Stores clients, users, password hashes, status, and optional user balance allocations Gateway validates sessions; router trusts identity headers from gateway
Sessions Creates JWTs and refresh tokens, stores sessions in Redis, rotates tokens, and handles logout Gateway checks the JWT and Redis access session
Billing Stores balances, postpaid credit, user allocations, deductions, refunds, and reversals Router calculates the SMS price and calls internal balance APIs
Reporting Builds CDRs, campaign reports, dashboards, summaries, and CSV exports Router publishes the source events to sms.reporting
Channel alerts Counts recent failures and creates alert outbox events Webhook or client.channel-alerts delivers the alert

Client-service does not route or send SMS, call Infozillion, process delivery-report callbacks, or manage campaign states. The module contains an unused MNP validation client, but no current controller or business flow calls it.

Context and data flow

flowchart LR
    App[Client application] -->|login and refresh| GW[Gateway :8000]
    Operator[Operator automation] -->|X-Admin-API-Key| GW
    GW --> CS[Client service :9014]
    GW -->|Bearer + trusted identity headers| Router[SMS router]
    Router -->|billing settings, deduct, refund, and reverse| CS
    Router -->|reporting events| Kafka[(Kafka sms.reporting)]
    Kafka --> CS
    CS --> PG[(PostgreSQL client_service)]
    CS --> Redis[(Redis DB 5)]
    CS -->|webhook| Hook[Client alert endpoint]
    CS -->|when no webhook is configured| AlertTopic[(client.channel-alerts)]
Hold "Ctrl" to enable pan & zoom

Component model

flowchart TB
    subgraph HTTP[HTTP adapters]
      Auth[Auth controller]
      Admin[Client/user admin]
      Internal[Internal balance]
      Reports[Reports and exports]
      Me[Session context]
    end
    subgraph Domain[Application/domain services]
      Token[Token service]
      Client[Client management]
      Balance[Balance service]
      Projection[Reporting data builder]
      Alert[Channel alert service]
      Outbox[Alert outbox publisher]
    end
    subgraph State[State]
      DB[(PostgreSQL)]
      Redis[(Redis)]
      Kafka[(Kafka)]
    end
    HTTP --> Domain
    Token --> Redis
    Client --> DB
    Balance --> DB
    Kafka --> Projection --> DB
    Projection --> Alert
    Alert --> Redis
    Alert --> DB
    Outbox --> DB
    Outbox --> Kafka
Hold "Ctrl" to enable pan & zoom

Security and trust model

Three route classes coexist:

Route Intended audience Enforcement
/auth/** External clients Public through gateway; credentials/refresh token authenticate the operation
/api/** External clients Gateway requires bearer JWT and adds trusted identity headers
/admin/**, /internal/** Operators and peer services X-Admin-API-Key checked by client-service

Client-service checks the admin key on /admin/** and /internal/**. Its other security rule allows all remaining direct requests. As a result, client-service itself does not protect /api/me/whoami, actuator, or generated Swagger endpoints. Gateway rules and network isolation provide that protection. Do not expose port 9014 to an untrusted network.

The admin-key filter hashes the configured and supplied keys with SHA-256 and compares them safely. Client-service will not start if the configured key is blank, shorter than 32 characters, or set to CHANGE_ME_IN_PRODUCTION. A missing or incorrect key returns HTTP 403 without the normal API response body.

Authentication and session design

Access token

  • Client-service creates an HS512 JWT using the Base64-decoded JWT_SECRET_KEY.
  • The token contains the username, client ID, user ID, and status.
  • Redis stores the session at sms:token:{jwt} until the access token expires.
  • Gateway requires both a valid JWT and the matching Redis session.

Refresh token

  • The token contains 32 secure random bytes encoded as a URL-safe string.
  • Client-service returns the raw token once and does not store it.
  • Redis stores its SHA-256 hash at sms:refresh:{sha256(rawToken)} with the session details and expiry.
  • Refresh uses Redis GETDEL, so the token can be used only once. Client-service then reloads the user and requires ACTIVE status before creating a new token pair.

Logout removes the access session named by the bearer token and the refresh session named in the optional request body. Clients should send both tokens for a complete logout.

Important lifecycle semantics

Login checks the password and user status, but it does not check the parent client status. Changing a password or setting a user to INACTIVE or SUSPENDED does not remove existing access sessions. Those tokens continue to work until logout, Redis expiry, or manual session removal. An inactive user cannot refresh a token. The current API also cannot change client status.

Tenant and balance model

erDiagram
    CLIENT ||--|| CLIENT_BALANCE : owns
    CLIENT ||--o{ CLIENT_USER : has
    CLIENT ||--o{ DEDUCT_LOG : records
    CLIENT ||--o{ REFUND_LOG : records
    CLIENT ||--o{ REPORT_MESSAGE_FACT : projects
    CLIENT ||--o{ REPORT_JOB : projects
    CLIENT ||--o{ CHANNEL_FAILURE_ALERT : raises
    CLIENT ||--o{ OUTBOX_EVENT : publishes
Hold "Ctrl" to enable pan & zoom
  • clients stores an organization and whether it is prepaid or postpaid.
  • client_balances.available_balance stores the total client balance. A postpaid balance may fall as low as -credit_limit.
  • client_users.allocated_balance stores a private user balance when it has a value. A NULL value makes the user spend from the shared pool.
  • The shared pool is available_balance - sum(all user allocations).

Balance operations lock the client balance and related users so concurrent requests cannot overspend. The API rejects an allocation when all user allocations would exceed the available balance.

Deduction rules

For a user with a private allocation, both the allocation and the client balance are reduced. The allocation must cover the full charge. For a shared user, the shared pool plus postpaid credit must cover the charge; only the client balance is reduced. An inactive user cannot be charged.

When a deduction has an idempotency key, client-service stores (client_id, idempotency_key) in deduct_log. Retrying an unreversed deduction returns the original result. Client-service does not compare the new userId or amount with the saved request, so callers must never reuse a key for another deduction. A deduction without a key creates no log entry and must not be retried after an unclear timeout without checking the result.

A reversal locks the deduction record and returns reversed:false when the record does not exist or was already reversed. A valid deduction is credited once. Refunds use a separate unique (client_id, idempotency_key) record and return alreadyApplied:true when repeated.

Current caveats:

  • A refund is trusted. Client-service does not match it to an earlier deduction or amount.
  • A refund or reversal restores the user's private allocation only if the user has an allocation when the credit is applied. The original balance pool is not saved.
  • A reversed deduction key remains used because the database record still exists. Never use it for a new charge.
  • Admin top-up updates the balance in one transaction but has no idempotency key or permanent top-up record.

Reporting projection

client-service consumes sms.reporting as group client-service-reporting:

Event Projection behavior
MESSAGE_SUBMITTED Creates or updates one report_message_fact row per recipient with submission, identity, route, and price data
MESSAGE_FINALIZED Saves final status, delivery-report or error data, and completion time; it can create a partial row when it arrives first
CAMPAIGN_UPSERT Updates the campaign copy in report_job when the event version is the same or newer

An older final event cannot overwrite a newer final result. If the submitted event arrives later, it fills missing details without removing the final status. Unknown event types are logged and skipped. Invalid events go to sms.reporting.dlt. Temporary database errors retry with increasing delays for about five minutes before moving to the DLT. Client-service does not replay DLT records automatically.

Reports read the client-service reporting tables, not router tables. They may be delayed by Kafka or database replication. CDR counts represent recipients, while dashboard and summary SMS totals represent message segments. For personalized campaigns, segmentCount uses the highest recipient value, so campaign SMS totals are estimates when message lengths differ.

Channel-failure alert pipeline

sequenceDiagram
    participant K as Reporting consumer
    participant R as Redis failure window
    participant DB as PostgreSQL
    participant P as Outbox publisher
    participant D as Webhook/Kafka
    K->>R: add recipient once to the client and channel window
    R-->>K: rolling failure count
    alt threshold reached and cooldown elapsed
      K->>DB: lock, then save alert and outbox event
      P->>DB: claim pending rows
      P->>D: call webhook or publish Kafka event
      P->>DB: save published, retry, or failed status
    end
Hold "Ctrl" to enable pan & zoom

Failures include FAILED, REJECTED, EXPIRED, UNDELIVERED, INVALID_MSISDN, DND, and NOT_AVAILABLE. A Redis script adds each recipient ID only once within the configured time window, so a repeated reporting event does not increase the count. If Redis fails, client-service logs the error and continues saving the report.

When the count reaches the threshold, a PostgreSQL lock prevents two instances from creating the same alert during cooldown. The alert and outbox event are saved together. The publisher checks every second, claims up to 50 rows, and calls the configured webhook with an optional X-Api-Key, or publishes to Kafka. After five delivery failures, the event becomes FAILED. A recovery task checks failed events every 60 seconds and retries after a five-minute cooldown for up to three rounds. Operators must handle events that still fail. There is currently no API for managing alerts.

Persistence and consistency

Flyway manages the client_service schema. Its main tables are clients, client_balances, client_users, deduct_log, refund_log, report_message_fact, report_job, channel_failure_alert, and outbox_event.

Read-only transactions use the read datasource. When DB_SLAVE_URL is not set, reads use the main database. When a replica is configured, list, report, configuration, login, and refresh reads may be delayed. Locks and updates always use the main database.

Architecture decisions

Decision Reason Trade-off
Client-service owns balances Keeps money and client policy in one transaction owner SMS submission depends on a synchronous client-service call
Database locks for balances and allocations Prevents overspending during concurrent requests Requests for the same client may wait
JWT plus Redis session Carries identity in the token while allowing immediate logout Gateway authorization depends on Redis
Single-use refresh token Limits token replay and avoids storing the raw token Only one concurrent refresh succeeds
Kafka-based reporting Keeps report queries away from router operational tables Reports update later and DLT records need operator handling
Alert outbox Saves the alert and delivery request together Requires background publishing and recovery tasks
Shared admin key Provides simple protection for operators and internal services One key protects many operations, so it must be rotated and network-restricted

Known risks and operational implications

  1. Direct access to client-service bypasses bearer authentication for /api/** and exposes actuator endpoints.
  2. Some errors return HTTP 200 with a failed responseCode. Consumers must check both values.
  3. Token code uses the fixed Redis prefixes sms:token: and sms:refresh: instead of the configured prefix properties.
  4. The Dockerfile health check uses port 8081, while dev and Compose use 9014. Align or override the port before production.
  5. Password and status changes do not end active sessions immediately.
  6. Reporting and alerts can be delayed when Kafka, Redis, or the database is unhealthy.

See Client service processes, configuration, and the domain APIs under API reference.