Management Service
1. Overview
The Management Microservice is the backend for the Management Portal. It handles organization/location management, user management, reporting, configuration, and SAML SSO provider management. It validates Firebase Auth tokens and enforces RBAC.
2. Responsibilities
| Responsibility | Description |
|---|---|
| Organization and location management | CRUD operations for organization/location profiles and configurations |
| User management | Create, update, disable users; manage roles via Firebase Custom Claims |
| SAML SSO administration | Configure and manage SAML identity providers in Firebase |
| Reporting | Generate transaction reports by querying the Processing Service |
| Audit logging | Record all administrative actions |
| Subscription oversight | View, search, and manage recurring subscriptions across organizations/locations |
| Configuration | Manage location-specific settings (branding, webhooks, etc.) |
2.1 Tenant model guidance
Current integration and product docs should treat the management service as organization/location-first:
- the service is organization/location-first in its tenant model
- location CRUD is served through
/api/v1/locations - location-owned request and response examples use
locationId - organization-owned request and response examples use
organizationId
2.2 Webhook event operations
Webhook ownership is split between two services, deliberately. The management service owns webhook configuration / subscriptions (durable merchant-admin state); online-txn owns webhook delivery (ephemeral edge dispatch rows). Each entity has exactly one writer and neither writes the other's tables — see Ownership: configuration vs. delivery.
The management service owns the merchant-facing and operator-facing webhook event lifecycle:
- outbound webhook events are durably stored before fan-out
- delivery attempts are linked to persisted event rows and retain bounded diagnostics, including allowlisted response headers
- admins can list events, inspect deliveries, replay an event, submit bulk replay jobs, retry DLQ delivery rows, and manage retention configuration
- merchant-facing dashboards expose the same event-log and DLQ-retry workflow within the caller's organization scope
- the daily retention purge and DLQ exhaustion sweeps run through the internal scheduler endpoints
- Kotlin and TypeScript SDKs expose helpers for retrieval, replay, retention, bulk replay, and DLQ retry
Event subscriptions have organization and location-filter columns. Legacy merchant-scoped subscription data remains supported during the organization / location compatibility window, but new code should prefer the explicit organization/location fields.
Detailed operator and SDK examples live in Webhook Event Log and Replay. DLQ alert triage lives in the Webhook DLQ depth runbook.
2.3 Management as a BFF / proxy tier (governance)
The management service is two things at once, and conflating them leads to mislabeling it as either a "god service" or pure sprawl:
-
A domain owner — sole writer of
merchants,portal_users,user_merchant_access, SAML provider config, and webhook configuration (see §2.2). For these, management is the source of truth.audit_logis the exception: it is an append-only multi-writer table (auth, processing, online-txn, merchant-onboarding, and management each insert their own rows via the sharedauditLogQueriesinsert). Management does not own audit writes — it owns the audit search / read surface (the filtered, paginated query endpoint that gives operators cross-service visibility), and is the source of truth for that read model. -
An explicit Backend-for-Frontend (BFF) / proxy tier for the management portal — for transactions, settlements, subscriptions, customers, gift cards, tax config, credential-profile lifecycle, API-key admin, and more, management owns (almost) none of the data. It terminates portal auth (Firebase ID token / OAuth), enforces tenant scoping and RBAC, shapes the request/response DTOs, and forwards the call to the service that does own the data over
InternalServiceClient.The one tail still being retired: processing is the target sole writer of the whole Customer aggregate (
customers,customer_identities,customer_payment_methods, andcustomer_contacts). The first three already route through processing; thecustomer_contactswrite path (CustomerContactService) is the last management-resident customer write and is being moved to processing as the D1 tail (in progress). Until that move lands, treatcustomer_contactsas a known transitional exception, not as durable management ownership.
This second role is load-bearing, not accidental. The owning services it
forwards to — processing and merchant-onboarding — are internal-only:
Cloud Run IAM, no load-balancer route, no public ingress. A browser cannot
reach them directly. The management BFF (together with the online-txn edge) is
the only path from the public portal into the internal money/credential
core. Removing the proxy tier would not simplify the system; it would sever the
portal from the data it administers.
2.3.1 The authorize-then-forward seam
The common seam for proxy forwarding is ProxyControllerSupport (in the
management controller package). It is a small set of helpers
(getAuthorizedUpstreamResource, postAuthorizedUpstreamResource, put…,
patch…, delete…, and the withAuthorizedUpstreamResource core) for the
resource-backed pattern, which does the same thing in the same order:
- Read the target resource from the owning service via
InternalServiceClient(a Cloud Run IAM ID token fetched from the metadata server authenticates the hop). - Authorize locally —
ManagementAuthorizer.requireResourceAccess(...)checks the portal user's role and tenant scope against the just-read resource, so authorization is decided on the real upstream record, not on the caller's say-so. - Forward the action (the write or the follow-up read) to the owner only after the local check passes.
This helper is the common seam, not a universal funnel. Where the
authorization boundary is already carried in the request (a path or body
parameter such as merchantId / locationId) rather than in an upstream
record, several proxy controllers authorize the actor directly via
ManagementAuthorizer (for example requireAccessibleMerchantOrLocation or
requireMerchantOrLocationInBody) and then forward without a prior read —
GiftCardProxyController.issueGiftCard and HostedPaymentProxyController
.createButton are examples. These direct-proxy paths follow the same
discipline (authorize before the RPC, forward the human actor on writes;
see §2.3.3 rule (c)); they just skip the read-first step because there is no
upstream record to gate against. Treating both shapes as one recognized tier —
rather than ~18 independent controllers that happen to look similar — is the
point of this section.
2.3.2 The proxy-controller surface (recognized tier, not sprawl)
The proxy controllers, grouped by the owning service they forward to:
| Forwards to | Proxy controllers |
|---|---|
| processing (money core) | TransactionProxyController, SettlementProxyController, SubscriptionProxyController, SubscriptionScheduleProxyController, CustomerProxyController, GiftCardProxyController, TaxConfigProxyController, ScopedTaxConfigProxyController, TaxConfigBridgeProxyController, CreditNoteProxyController, DunningConfigProxyController, ReconciliationProxyController, VirtualTerminalProxyController |
| online-txn (storefront edge) | CheckoutConfigProxyController, HostedPaymentProxyController |
| auth (issuer) | OAuthClientProxyController, ApiKeyManagementController (API-key admin surface) |
| merchant-onboarding (CDE) | ProvisioningProxyController, CardPresentDeviceController (device admin) |
In addition, a few domain services proxy outbound rather than exposing a
dedicated proxy controller — for example credential-profile lifecycle writes
flow through CredentialProfileProxyService (called by OrganizationService)
to merchant-onboarding's owner-side credential API. These follow the same
authorize-then-forward and actor-forwarding rules below.
The InternalServiceClient beans management wires are declared once in
InternalServiceClientConfig: processingClient, authClient,
merchantOnboardingClient, and onlineTxnClient — one per owning service.
2.3.3 Governance rules
These rules keep the BFF tier disciplined and prevent it from drifting back into an undifferentiated controller pile.
(a) Never point public/browser clients directly at internal-only services.
processing and merchant-onboarding are IAM-only with no LB route. The
portal (and any browser-facing client) MUST reach them through the management
BFF or the online-txn edge. The proxy is the only path; do not propose
"simplifying" it away by exposing those services publicly.
(b) Thin or remove a proxy only where it adds zero auth/shaping value. A proxy earns its place when it terminates portal auth, enforces tenant scope, reshapes DTOs, attributes audit, or relays the actor. Where a passthrough does none of those, it is a candidate for removal — but the default is to keep the tier, because the IAM boundary in rule (a) means the hop itself is rarely optional.
(c) Authorize before the RPC, and forward the human actor on writes. When
a proxy forwards a write to an owner it MUST requireScoped… / authorize the
caller before issuing the upstream call, and forward the acting portal user
via ForwardedUserHeaders (X-Forwarded-User-Role / X-Forwarded-User-Id,
optionally email / client IP). Two reference patterns:
- Customer merge (
CustomerProxyController.mergeCustomer): authorizesTRANSACTIONS_WRITEagainst the just-read source customer, then forwards the destructive merge to processing with the forwarded-user headers. Processing's internal auth filter maps those headers intoauth.details; omitting them yields a 403, because the@RequireRoleaspect early-returns for the bareROLE_INTERNAL_SERVICEauthority and the destructive endpoint re-checks the forwarded human role to clear its floor. - Credential-profile lifecycle (
CredentialProfileProxyService): every create / update / rotate / approve forwards the actor headers so merchant-onboarding attributes the audit row to the human admin rather than the IAM service account. Plaintext TransIT secrets travel only inbound over the internal TLS hop; responses carry*Presentbooleans, never secrets.
Two mechanisms let the owner re-check ownership server-side instead of trusting management blindly, and they are distinct:
- Re-scope by appended param. Some
CustomerProxyControllerwrites append the owningmerchantId, learned from the authorized read, as a scope param (updateCustomerandsetDefaultPaymentMethoduse this), so processing can re-bind the write to that tenant. This is the literal "re-scope" case. - Same-aggregate consistency at the owner.
mergeCustomerdoes not append amerchantIdscope param — it forwards only theForwardedUserHeaders, and processing'sCustomerMergeControllertakes no tenant/scope parameter. Instead, processing reads both customers itself and enforces that source and destination belong to the same organization (rejecting cross-organization merges), which is the ownership invariant the owner can assert without a forwarded scope.
2.3.4 Owner-side internal-route convention
The preferred convention for new endpoints that exist only to be called by the BFF (never by a public client) is:
- They live under an
/internal/...path prefix (for example merchant-onboarding's credential-profile lifecycle API, or processing's scheduler and internal setup-intent surfaces). Credential-profile lifecycle is the cleanest current example: everyCredentialProfileProxyServiceforward targets/internal/credential-profiles…. - They are gated to the
ROLE_INTERNAL_SERVICEauthority thatInternalServiceAuthFilterstamps after validating the Cloud Run IAM ID token; a destructive one additionally requires a forwarded human role per rule (c). - They are excluded from SDK-drift checks — they are not part of any public SDK or the published API surface, so they are intentionally absent from the generated client contract.
This is the convention for new / preferred routes; it is not yet universal.
Several established BFF writes still forward to versioned owner routes on
the owning service rather than an /internal/… path — gift-card writes
(GiftCardProxyController), location- and organization-level tax-config writes
(TaxConfigProxyController, ScopedTaxConfigProxyController,
TaxConfigBridgeProxyController), and transaction writes such as the manual /
keyed-entry sale (TransactionProxyController) all post to the owner's
versioned admin routes. Those routes are still BFF-only — they sit behind
Cloud Run IAM and ROLE_INTERNAL_SERVICE, reachable only through the proxy,
and are not public SDK surfaces despite the versioned path shape. They are
exceptions to the path convention, not to the internal-only boundary.
When adding an owner-side endpoint that the BFF will call, prefer the
/internal/... convention so the public/internal boundary stays legible.
3. API Specification
Authentication
All endpoints require a Firebase ID token:
Authorization: Bearer <firebase-id-token>
The service validates the token via Firebase Admin SDK and extracts custom claims for RBAC.
Endpoints
Location Management
POST /api/v1/locations
GET /api/v1/locations
GET /api/v1/locations/{locationId}
PUT /api/v1/locations/{locationId}
PATCH /api/v1/locations/{locationId}/status
POST /api/v1/locations
Authorization: Bearer <token>
Merchant classification is MCC-backed. Send a four-digit `mcc`; legacy
`businessType`, location-level `industryType`, and `transitConfig.industryType`
fields are not part of the management API.
Request:
{
"businessName": "Acme Vape Shop",
"dba": "Acme Vapes",
"mcc": "5993",
"contactName": "Jane Doe",
"contactEmail": "jane@acmevapes.com",
"contactPhone": "555-0123",
"address": {
"street": "123 Main St",
"city": "Charlotte",
"state": "NC",
"zip": "28202"
},
"branding": {
"logoUrl": "https://...",
"primaryColor": "#1a73e8"
},
"webhookUrl": "https://acmevapes.com/webhooks/payment"
}
Response (201):
{
"merchantId": "loc_abc123",
"businessName": "Acme Vape Shop",
"status": "ACTIVE",
"createdAt": "2026-03-03T15:00:00Z",
...
}
Read the merchantId response field above as the concrete location id carried
by the current wire contract. The response also exposes locationId as an alias;
neither field represents a different top-level tenant type from Location.
Location Activation (TransIT TID)
POST /api/v1/locations/{locationId}/activate-transit
Request:
{
"transitMid": "<TSYS Merchant ID>",
"transitTid": "<TSYS Terminal ID>"
}
Response (200):
{
"merchantId": "loc_abc123",
"transitActivationStatus": "ACTIVE",
"activatedAt": "2026-03-03T15:00:00Z"
}
Simplified merchant activation — stores TID/MID configuration and activates the merchant in our system. The support team enters credentials obtained from the TransIT onboarding process. No external portal dependency.
User Management
POST /api/v1/users
GET /api/v1/users
GET /api/v1/users/{userId}
PUT /api/v1/users/{userId}
DELETE /api/v1/users/{userId}
POST /api/v1/users
Request:
{
"email": "john@acmevapes.com",
"displayName": "John Smith",
"role": "location_user",
"locationAccess": [{ "locationId": "loc_abc123", "role": "location_user" }]
}
Response (201):
{
"userId": "firebase-uid-xyz",
"email": "john@acmevapes.com",
"role": "location_user",
"status": "ACTIVE",
"createdAt": "2026-03-03T15:00:00Z"
}
User grants are location/org scoped and exposed to clients through
locationAccess and organizationAccess.
SAML SSO Configuration
POST /api/v1/saml-providers # super_admin only
GET /api/v1/saml-providers
GET /api/v1/saml-providers/{id}
PUT /api/v1/saml-providers/{id}
DELETE /api/v1/saml-providers/{id}
POST /api/v1/saml-providers/{id}/test
POST /api/v1/saml-providers
Request:
{
"providerId": "saml.acme-corp",
"displayName": "Acme Corp SSO",
"idpEntityId": "https://idp.acmecorp.com/saml/metadata",
"ssoUrl": "https://idp.acmecorp.com/saml/sso",
"x509Certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
"rpEntityId": "pinpoint-gateway",
"locationIds": ["loc_abc123"]
}
Reporting and Processing-Backed Activity
GET /api/v1/transactions?merchantId=...&from=...&to=...
GET /api/v1/transactions/{transactionId}
POST /api/v1/transactions/{transactionId}/void
POST /api/v1/transactions/{transactionId}/refund
POST /api/v1/transactions/manual
GET /api/v1/settlements?merchantId=...&date=...
GET /api/v1/reports/daily?locationId=...&date=...
GET /api/v1/reports/monthly?locationId=...&month=...
GET /api/v1/reports/custom?locationId=...&from=...&to=...
GET /api/v1/reports/export?format=csv&locationId=...&from=...&to=...
GET /api/v1/reports/card-brand?locationId=...&from=...&to=...
Transaction and settlement routes proxy processing-service records through the
management authorization layer. Report routes are management-service endpoints
that aggregate processing transaction data and return API responses or CSV/PDF
exports. They accept either current locationId or compatibility merchantId
scope parameters.
Test merchant accounts are managed entirely within the Management Portal. The management service owns merchant lifecycle, batch management, and reporting rather than depending on TSYS Merchant Center.
Subscription Management (Admin Portal)
Proxied from Processing Microservice. Provides admin visibility into all recurring billing across merchants.
GET /api/v1/subscriptions?merchantId=...&status=...&from=...&to=...
GET /api/v1/subscriptions/{subscriptionId}
GET /api/v1/subscriptions/{subscriptionId}/billing-history
POST /api/v1/subscriptions/{subscriptionId}/cancel # Admin force-cancel
POST /api/v1/subscriptions/{subscriptionId}/resume # Admin force-resume
GET /api/v1/reports/subscriptions?merchantId=...&from=...&to=...
GET /api/v1/reports/subscriptions/churn?merchantId=...&from=...&to=...
GET /api/v1/reports/subscriptions/mrr?merchantId=...&from=...&to=...
| Report | Description |
|---|---|
| Subscription list | All subscriptions for a location or organization, filterable by status, date, customer |
| Billing history | Per-subscription charge history with success/failure details |
| Churn report | Cancellations and suspensions over time period |
| MRR report | Monthly Recurring Revenue aggregation by location or organization |
RBAC: super_admin and admin can view/manage all subscriptions.
location_admin can view/manage subscriptions for the caller's assigned
organization/location scope. location_user and readonly can view only.
Audit Log
GET /api/v1/audit-log?userId=...&action=...&from=...&to=...
Response:
{
"entries": [
{
"id": "audit_001",
"userId": "firebase-uid-xyz",
"userEmail": "admin@pinpoint.com",
"action": "MERCHANT_CREATED",
"resourceType": "merchant",
"resourceId": "loc_abc123",
"details": { "businessName": "Acme Vape Shop" },
"ipAddress": "203.0.113.42",
"timestamp": "2026-03-03T15:00:00Z"
}
],
"total": 156,
"limit": 50,
"offset": 0
}
4. Technical Stack
| Component | Technology |
|---|---|
| Language | Kotlin (JVM 25) |
| Framework | Spring Boot 4.0.2 |
| Auth | Firebase Admin SDK (Kotlin) |
| Database | Cloud Spanner (PostgreSQL dialect) — merchants, users schemas |
| Build | Bazel |
| Testing | JUnit 5, Testcontainers, MockMvc |
5. Database Schema
CREATE TABLE merchants (
id VARCHAR(64) PRIMARY KEY,
business_name VARCHAR(255) NOT NULL,
dba VARCHAR(255),
mcc VARCHAR(4),
contact_name VARCHAR(255),
contact_email VARCHAR(255),
contact_phone VARCHAR(20),
address_street VARCHAR(255),
address_city VARCHAR(100),
address_state VARCHAR(2),
address_zip VARCHAR(10),
transit_mid VARCHAR(64),
transit_tid VARCHAR(64),
industry_type VARCHAR(4) DEFAULT 'RE',
logo_url VARCHAR(512),
primary_color VARCHAR(7),
webhook_url VARCHAR(512),
webhook_secret VARCHAR(128),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE portal_users (
id VARCHAR(128) PRIMARY KEY, -- Firebase UID
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255),
role VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_merchant_access (
user_id VARCHAR(128) REFERENCES portal_users(id),
merchant_id VARCHAR(64) REFERENCES merchants(id),
PRIMARY KEY (user_id, merchant_id)
);
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(128),
user_email VARCHAR(255),
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(30),
resource_id VARCHAR(64),
details JSONB,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log(action, created_at DESC);
6. RBAC Enforcement
// Firebase custom claims structure
mapOf(
"role" to "location_admin",
"locationAccess" to listOf(
mapOf("locationId" to "loc_abc123", "role" to "location_admin")
)
)
Permissions are not stored in Firebase custom claims. They are derived server-side from the
platform role and organization/location access entries, and exposed to the portal through the /api/v1/me
response when needed.
| Endpoint | super_admin | admin | location_admin | location_user | readonly |
|---|---|---|---|---|---|
| Manage SAML | Yes | No | No | No | No |
| Manage all locations | Yes | Yes | No | No | No |
| Manage own location | Yes | Yes | Yes | No | No |
| View all transactions | Yes | Yes | No | No | No |
| View own transactions | Yes | Yes | Yes | Yes | Yes |
| Void/refund | Yes | Yes | Yes | No | No |
| Manage users | Yes | Yes | Yes (own merchant) | No | No |
| View all subscriptions | Yes | Yes | No | No | No |
| View own subscriptions | Yes | Yes | Yes | Yes | Yes |
| Cancel/resume subscriptions | Yes | Yes | Yes (own merchant) | No | No |
| Subscription reports (MRR, churn) | Yes | Yes | Yes (own merchant) | No | No |
| View audit log | Yes | Yes | No | No | No |
7. Health Check
GET /health
Response:
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"firebase": { "status": "UP" }
}
}