Skip to content

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 &amp; 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:

  1. Splits the token into header.payload.signature segments.
  2. Base64-URL-decodes each segment.
  3. Recomputes the HMAC-SHA256 over header.payload using the configured jwt.secret and compares it constant-time against the supplied signature.
  4. Parses the payload and checks iss == jwt.issuer and aud == jwt.audience.
  5. 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 returns nullptr, 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 a LM_WARNING is 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.