Skip to main content

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

ResponsibilityDescription
Organization and location managementCRUD operations for organization/location profiles and configurations
User managementCreate, update, disable users; manage roles via Firebase Custom Claims
SAML SSO administrationConfigure and manage SAML identity providers in Firebase
ReportingGenerate transaction reports by querying the Processing Service
Audit loggingRecord all administrative actions
Subscription oversightView, search, and manage recurring subscriptions across organizations/locations
ConfigurationManage 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:

  1. 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_log is 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 shared auditLogQueries insert). 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.

  2. 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, and customer_contacts). The first three already route through processing; the customer_contacts write 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, treat customer_contacts as 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:

  1. Read the target resource from the owning service via InternalServiceClient (a Cloud Run IAM ID token fetched from the metadata server authenticates the hop).
  2. 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.
  3. 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 toProxy 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): authorizes TRANSACTIONS_WRITE against 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 into auth.details; omitting them yields a 403, because the @RequireRole aspect early-returns for the bare ROLE_INTERNAL_SERVICE authority 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 *Present booleans, 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 CustomerProxyController writes append the owning merchantId, learned from the authorized read, as a scope param (updateCustomer and setDefaultPaymentMethod use this), so processing can re-bind the write to that tenant. This is the literal "re-scope" case.
  • Same-aggregate consistency at the owner. mergeCustomer does not append a merchantId scope param — it forwards only the ForwardedUserHeaders, and processing's CustomerMergeController takes 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: every CredentialProfileProxyService forward targets /internal/credential-profiles….
  • They are gated to the ROLE_INTERNAL_SERVICE authority that InternalServiceAuthFilter stamps 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=...
ReportDescription
Subscription listAll subscriptions for a location or organization, filterable by status, date, customer
Billing historyPer-subscription charge history with success/failure details
Churn reportCancellations and suspensions over time period
MRR reportMonthly 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

ComponentTechnology
LanguageKotlin (JVM 25)
FrameworkSpring Boot 4.0.2
AuthFirebase Admin SDK (Kotlin)
DatabaseCloud Spanner (PostgreSQL dialect) — merchants, users schemas
BuildBazel
TestingJUnit 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.

Endpointsuper_adminadminlocation_adminlocation_userreadonly
Manage SAMLYesNoNoNoNo
Manage all locationsYesYesNoNoNo
Manage own locationYesYesYesNoNo
View all transactionsYesYesNoNoNo
View own transactionsYesYesYesYesYes
Void/refundYesYesYesNoNo
Manage usersYesYesYes (own merchant)NoNo
View all subscriptionsYesYesNoNoNo
View own subscriptionsYesYesYesYesYes
Cancel/resume subscriptionsYesYesYes (own merchant)NoNo
Subscription reports (MRR, churn)YesYesYes (own merchant)NoNo
View audit logYesYesNoNoNo

7. Health Check

GET /health

Response:
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"firebase": { "status": "UP" }
}
}