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)]
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
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 requiresACTIVEstatus 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
clientsstores an organization and whether it is prepaid or postpaid.client_balances.available_balancestores the total client balance. A postpaid balance may fall as low as-credit_limit.client_users.allocated_balancestores a private user balance when it has a value. ANULLvalue 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
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¶
- Direct access to client-service bypasses bearer authentication for
/api/**and exposes actuator endpoints. - Some errors return HTTP
200with a failedresponseCode. Consumers must check both values. - Token code uses the fixed Redis prefixes
sms:token:andsms:refresh:instead of the configured prefix properties. - The Dockerfile health check uses port
8081, while dev and Compose use9014. Align or override the port before production. - Password and status changes do not end active sessions immediately.
- 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.