CRM¶
The CRM module exposes a single gRPC service, billing.CRMService, defined in proto/types.proto. It is the one and only management surface — every administrative action that the crm2.micro.bss PHP frontend performs goes through it. Authentication is bearer-JWT on every call, transport is TLS by default, and the service runs inside the same server binary, registered with the ACE Reactor like every other module.
Service Surface¶
The RPCs partition cleanly into seven groups:
graph LR
PHP["crm2.micro.bss<br/>(PHP gRPC client)"]
subgraph Service["billing.CRMService"]
direction TB
Sub["Subscribers<br/>GetSubscribers<br/>GetSubscriber<br/>CreateSubscriber<br/>ModifySubscriber<br/>SuspendSubscriber<br/>ReactivateSubscriber<br/>TerminateSubscriber"]
Use["Usage<br/>GetSubscriberUsage<br/>GetUsageData"]
Bill["Bills<br/>GetSubscriberBill<br/>GetSubscriberBills<br/>ExecuteBilling<br/>ExecuteBillCycle<br/>ReformatBill"]
Stat["Statistics & Refdata<br/>GetStatistics<br/>GetRefData<br/>GetAvailableResources"]
Not["Notifications<br/>GetNotifications<br/>AckNotification"]
Vou["Vouchers<br/>SelectVouchers<br/>SelectVoucher<br/>UpdateVoucher<br/>InsertVoucher<br/>RedeemVoucher"]
end
DB[(MySQL)]
Activity[(activities queue)]
Cache["RefData cache<br/>(60s TTL)"]
PHP -->|TLS + JWT| Sub
PHP -->|TLS + JWT| Use
PHP -->|TLS + JWT| Bill
PHP -->|TLS + JWT| Stat
PHP -->|TLS + JWT| Not
PHP -->|TLS + JWT| Vou
Sub --> DB
Use --> DB
Bill --> DB
Bill -. queues .-> Activity
Stat --> Cache
Cache --> DB
Not --> DB
Vou --> DB
Subscriber RPCs¶
GetSubscribers(SubscriberFilter) returns a server-streamed list of subscribers, filtered by prepaid/postpaid type, city, status, plan id, with optional pagination via limit / offset. GetSubscriber(SubscriberRequest) resolves a single subscriber by either id or msisdn. CreateSubscriber inserts a fully-formed billing.Subscriber (with embedded Resource, Address, Card, Balance, and a currency_id selecting the subscriber's display currency). ModifySubscriber updates an existing one.
SuspendSubscriber, ReactivateSubscriber, and TerminateSubscriber are the lifecycle-transition RPCs. Each takes a SubscriberRequest (resolved by id or msisdn) and performs an atomic conditional UPDATE on subscriber.status_id. The allowed transitions are:
| From | To | RPC |
|---|---|---|
ACTIVE (1) |
SUSPENDED (5) |
SuspendSubscriber |
SUSPENDED (5) |
ACTIVE (1) |
ReactivateSubscriber |
ACTIVE (1) or SUSPENDED (5) |
TERMINATED (4) |
TerminateSubscriber |
Calling any of these on a subscriber already in the target state returns OK with message="already ..." — the call is idempotent so an accidental double-click in the CRM UI is not an error. Calls that violate the transition table (e.g. reactivating a terminated subscriber) return FAILED_PRECONDITION with the current and requested status spelled out. A concurrent transition that wins the race returns ABORTED so the caller can retry from a fresh read. Each successful transition inserts a notifications row with a deep-link to the subscriber page. Termination is a soft-delete — the subscriber row stays in the database so historical CDRs, bills, and vouchers continue to resolve.
Usage RPCs¶
GetSubscriberUsage(SubscriberUsageRequest) streams CDRs for one subscriber, optionally bounded by start_time / end_time and filtered by service_type. GetUsageData(UsageFilter) is the broader query path used by the operator-wide usage page, supporting filtering by event type, A/B numbers, time range, and pagination.
Billing RPCs¶
GetSubscriberBill(BillRequest) returns one bill for a (subscriber_id, month, year) triple, or directly by billing_id when that optional field is set. GetSubscriberBills(SubscriberRequest) streams every bill for a subscriber. ExecuteBilling(BillingRequest) and ExecuteBillCycle(BillingCycleRequest) are the trigger RPCs — they do not block on billing; instead, they enqueue an Activity row and return an acknowledgement. The actual billing happens later in the appropriate billing process. The response carries success=true if the queue insert succeeded, with the activity id propagated through the operator notification system once the work completes.
ReformatBill(BillRequest) queues an existing bill for re-rendering. The request addresses a single bill — by billing_id when present (preferred — direct row lookup), otherwise by the (subscriber_id, month, year) triple. The server performs an atomic transition PAID|UNPAID|ERROR → NEW, increments reissue_count, and stamps last_reissue_ts; the bill formatter picks the row up on its next tick and overwrites the PDF at the existing path. A call against a bill already in NEW or PROCESSING returns FAILED_PRECONDITION — there is no silent coalescing of double-clicks. The operator-facing notification deep-links to the invoice page so the new PDF is one click away.
Statistics¶
GetStatistics(StatisticsRequest) returns a StatisticsResponse aggregated over the last months calendar months: per-service prepaid / postpaid revenue and event counts, totals across both, bill counts by status (paid / unpaid / error), bill amounts in those buckets, and a monthly time-series for charts. The dashboard page calls this on every load. GetRefData(RefDataRequest) returns every reference table at once — price plans, services, statuses, bill-cycles, counties, the display-currency table (ref_currency), and the VAT/tax table (ref_tax) — so the frontend can populate dropdowns and resolve numeric ids to operator-readable labels in a single round trip. The response is cached for 60 seconds inside CRMServiceImpl::ref_cache_ (guarded by ref_cache_mutex_) to absorb the bursty pattern of multiple gRPC threads serving the same dashboard. GetAvailableResources(AvailableResourceRequest) returns the pool of unallocated resources of a given type (MSISDN, IMSI, handset, bonus) for use in subscriber provisioning forms.
Notifications¶
GetNotifications(NotificationFilter) streams notifications, optionally filtered by status (-1 all, 0 unseen, 1 seen). AckNotification(AckNotificationRequest) marks one as seen.
Vouchers¶
The voucher subdomain manages prepaid top-up codes. InsertVoucher writes a new voucher with status VOUCHER_STATUS_NEW (1). SelectVouchers(VoucherFilter) streams them filtered by status and paginated. SelectVoucher(VoucherRequest) resolves a single voucher by id or by code. UpdateVoucher updates the metadata. RedeemVoucher(RedeemVoucherRequest) is the operator-facing redemption call: given a subscriber_id and a code, it transactionally credits the subscriber's balance, advances the voucher to VOUCHER_STATUS_USED (2), and returns the new balance.
Server Lifecycle¶
CRMServer (in include/crm/crm_acceptor.hpp) is an ACE_Svc_Handler so that it integrates cleanly with the ACE Reactor and the unified shutdown path. It does not receive socket I/O from ACE — gRPC owns the socket — but using the same handler base type lets main.cpp register a single shutdown callback that drains in-flight RPCs before the reactor exits.
// main.cpp
CRMServer *crm_server = new CRMServer();
crm_server->open(nullptr); // builds + starts grpc::Server, activates worker thread
s_handler.registerShutdownCallback(
[crm_server]() { crm_server->shutdown(); }
);
ACE_Reactor::instance()->run_reactor_event_loop();
CRMServer::open reads server host/port, TLS paths, and JWT settings from UTILS, builds the JWT validator, configures TLS credentials, attaches the auth interceptor, calls BuildAndStart() on the gRPC builder, and activate(THR_DETACHED) to run grpc_server_->Wait() in a detached thread. CRMServer::shutdown issues Shutdown(now + 5s) to drain in-flight RPCs.
POSIX signal interaction¶
Before BuildAndStart(), setupGRPCServer blocks SIGINT, SIGTERM, and SIGHUP via pthread_sigmask(SIG_BLOCK, ...) so that gRPC's worker threads inherit the mask. The mask is restored in the main thread immediately after, so the ACE-registered SignalHandler still receives those signals. This is the standard POSIX pattern for routing signals to one thread in a multi-threaded process; without it, gRPC threads occasionally swallowed SIGINT and prevented graceful shutdown.
Authentication¶
Every call routes through AuthInterceptor, an experimental::Interceptor registered on the ServerBuilder. The interceptor pulls the authorization metadata, strips the Bearer prefix, and hands the token to JWTValidator::validate(). The validator:
- Splits the token into
header.payload.signaturesegments. - Base64-URL-decodes each segment.
- Recomputes the HMAC-SHA256 over
header.payloadusing the configuredjwt.secretand compares it constant-time against the supplied signature. - Parses the payload and checks
iss == jwt.issuerandaud == jwt.audience. - Stores the parsed claims for retrieval by the handler via
getClaim().
Failure at any step yields a grpc::Status(UNAUTHENTICATED, ...) from the interceptor, which is propagated to the client. There is no fallback to anonymous calls.
TLS¶
CRMServer::createTLSCredentials() reads the certificate and key files specified in tls.cert_path and tls.key_path, builds an SslServerCredentials with one PemKeyCertPair, and returns it to the builder. If either file is unreadable or parsing throws, behaviour depends on tls.allow_insecure:
0(default): the function returnsnullptr, the server builder aborts, and the role exits. The platform refuses to expose the gRPC surface unencrypted unless explicitly told otherwise.1:grpc::InsecureServerCredentials()is returned and aLM_WARNINGis logged. This mode is intended for local development only.
Configuration¶
Reference: config/crm.toml.
[server]
port = 50051
host = "0.0.0.0"
[tls]
cert_path = "certs/server.crt"
key_path = "certs/server.key"
ca_cert_path = "certs/ca.crt"
allow_insecure = 0
[jwt]
secret = "your-secret-key-change-this-in-production"
issuer = "crm-server"
audience = "crm-api"
The jwt.secret value as shipped is a placeholder and must be replaced before deployment. The crm2.micro.bss PHP frontend signs tokens with the same secret and the same issuer/audience pair — there is no third-party identity provider in the loop.