Skip to main content

RFC: Integrate User Quota to Quota Management API — qontak-launchpad

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Sections marked N/A — reason are intentional, not omissions.

It is also agent-execution-ready: §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0) with Source Verification, mermaid diagrams, §2.4 APIs, and §4 Agent Execution Plan + Verification & Rollback Recipe are present.

Delivery & project management live elsewhere. This RFC is the technical artifact only — no staffing, effort, or rollout schedule. Delivery is not yet handed to delivery.

The YAML frontmatter at the top is the machine-readable index agents parse. The metadata table below is the human-readable governance record. Both must agree on every shared field.

Metadata

FieldValueNotes
StatusIDEAYAML status: carries draft
DRIaddo.hernando@mekari.comSingle accountable owner
TeambifrostCarried from PRD
Author(s)fauzan.madani@mekari.comPrimary author
Reviewersbifrost-backend, bifrost-tech-leadTech reviewers
Approver(s)bifrost-tech-lead, infosecTech leaders + infosec
Submitted Date2026-07-01Date RFC opened
Last Updated2026-07-01 · OQ-8/OQ-9 resolvedBump on every material edit
Target Release2026-Q3Carried from PRD
Target Quarter2026-Q3Carried from PRD
Deliverynot yet handed to deliveryNo delivery/ artifacts yet
Related../prds/prd-integrate-user-quota.md · how-to-integrate-quota-management.mdSource PRD + integration guide
Discussion#bifrost-user-quotaSlack channel

Type: backend Sub-type: new-feature

Sections at a Glance

  1. Overview (incl. PRD-to-Schema Derivation, Key Decisions Summary, Per-Story Change Map)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → DDL → APIs → background job spec)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Launchpad's user seat tracking today relies on a point-in-time call to Modpanel's GetUnifiedBilling API (internal/app/service/billings/validate_user_quota.go). This approach has no deduction/refund ledger, no idempotency, and no integration with the centralized qontak-billing Quota Management service that Qontak One now standardizes on.

This RFC integrates qontak-launchpad with the centralized Quota Management API for Qontak One clients:

  • Pre-creation check: call POST /iag/v1/quota-managements/check-quota inside the existing Redis pessimistic lock before creating a user, replacing the Modpanel-backed check for Qontak One CIDs.
  • Post-creation deduction: call POST /iag/v1/quota-managements/deduction after a user is successfully persisted to the Launchpad DB. On 5xx/timeout the call is retried via a new gocraft/work background job — user creation is never rolled back.
  • Post-deletion refund: call POST /iag/v1/quota-managements/refund after a standard user is confirmed deleted. Log failures; no retry (see OQ-3).
  • Consultant bypass: users created via POST /private/users/create_consultant have is_consultant = true on their DB record. No quota API call is made for these users on creation or deletion.
  • Backfill: a one-time Go command seeds the Quota Management API with existing active non-consultant user counts per CID before GA.

All quota API interaction is gated by the user_quota_integration_enabled preference flag (per CID, via qontak-preferences). CIDs without the flag continue using the existing Modpanel-backed check — both cannot run simultaneously for the same CID.

Success Criteria

  • SC-1: For Qontak One CIDs with user_quota_integration_enabled = ON, user invitations are blocked when extra_attrs.is_sufficient: false (and is_unlimited is not true).
  • SC-2: Every standard user creation for a flagged CID produces exactly one successful deduction call (including gocraft/work retries). Deduction success rate ≥ 99.9% within 30 days of GA.
  • SC-3: Every standard user deletion for a flagged CID produces a refund call. Refund success rate ≥ 99.5%.
  • SC-4: Consultant users (is_consultant = true) never trigger any quota API call.
  • SC-5: All existing CIDs without the flag observe zero behavior change.

Out of Scope

  1. Quota balance visibility in the Launchpad client-facing UI.
  2. Quota enforcement for any resource other than user seats.
  3. Blocking the invite form before the check (check happens at creation time).
  4. Real-time push notifications / emails on quota limit approaches.
  5. Automatic quota top-up prompts.
  6. Retroactive reconciliation for over-provisioned CIDs before go-live.
  7. Non-Qontak One plans — standard Qontak clients are unaffected.

Assumptions

  • A-1: deduction_code, refund_code, and company_id type are resolved (OQ-8, OQ-9). billing_code (QUOTA_MANAGEMENT_BILLING_CODE) value is still TBD from the qontak-billing team (OQ-7) — treated as a config constant, not a code blocker.
  • A-2: The billing_unique_logs table in qontak-billing enforces UNIQUE on (billing_code, unique_code) — duplicate deduction calls with the same unique_code return credited_to: "already-deducted" without balance change. This is the idempotency guarantee relied on for retry safety.
  • A-3: companies.external_company_id (integer) cast to string is the company_id to pass to the Quota Management API. Resolved via OQ-9 (2026-07-01). The GetCompanyAndPackageById query already returns ExternalCompanyID sql.NullInt32 — use strconv.Itoa(int(companyInfo.ExternalCompanyID.Int32)) when valid.
  • A-4: The service-to-service SSO token flow used by the existing SsoGetToken() call (client_credentials / sso:profile) is also valid for Quota Management API calls. Confirmed from integration guide §2.
  • A-5: CountUser SQL query (db/query/users.sql :199) already excludes consultant users and soft-deleted users — it is reused unchanged for the backfill command.

Dependencies

DependencyOwning teamDeliverable neededBlocking?
Quota Management API — check, deduction, refund endpointsBifrost (qontak-billing)Stable endpoints in staging; confirmed billing_code, deduction_code, refund_code valuesYES
Service account credentialsBifrost / PlatformBearer JWT (service account) + X-Api-Key provisioned for Launchpad in all environmentsYES
qontak-preferences entry for user_quota_integration_enabledBifrostPreference entry registered and testable in stagingYES
sync_consultant_to_launchpad flag in moderator-beModpanel teamFlag must be ON for Qontak One CIDs so consultant provisioning routes through POST /private/users/create_consultantYES

PRD-to-Schema Derivation

Design entityPersisted asExposed viaEnforced whereSource
User quota balance check before inviteNo new table — pure API callQuota Management check-quota response extra_attrs.is_sufficientuser_handler.go SsoInvite() under Redis lockPRD §7 Behavior 1
Quota deduction record after user creationbilling_logs + billing_unique_logs in qontak-billing (external)Deduction response credited_to, value_aftersso_invite.go deductUserQuota() + retry jobPRD §7 Behavior 2
Idempotency key for deductionunique_code: create_user_{user_id}Not stored in Launchpad DBQuota Management APIPRD §6 Constraints
Quota refund record after user deletionbilling_logs + billing_unique_logs in qontak-billingRefund response refunded_to, value_afterdelete.go refundUserQuota()PRD §7 Behavior 3
Idempotency key for refundunique_code: delete_user_{user_id}Not stored in Launchpad DBQuota Management APIPRD §6 Constraints
Consultant bypass signalusers.is_consultant BOOLEAN (existing, db/schema.sql:37)FindUserBySsoId query resultdelete.go — skip refund if is_consultant = truePRD §8.1 System Flow step 16
Feature flag user_quota_integration_enabledqontak-preferences service (external)IsEnabledFunc(ctx, IsEnabledParams{FeatureName: "user_quota_integration_enabled", UniqueID: companySsoID})user_handler.go SsoInvite + DeletePRD §6 Constraints
Background deduction retrygocraft/work job queue (Redis-backed)Job name DEDUCT_USER_QUOTAconsumer/deduct_user_quota.goPRD §7 Behavior 2 failure
Backfill: existing active user count per CIDNo new table — one-shot deduction call per CIDCLI stdoutcmd/backfill_user_quota.goPRD §8.2 UQI-S05

Detail 1.A — PRD Traceability Matrix

PRD composite AC idRFC coverageDivergence
UQI-S01/AC-1 (quota check → proceed)§2.4 Check Quota API; user_handler.go under Redis locknone
UQI-S01/AC-2 (quota check → block)user_handler.go returns ErrInsufficientQuotanone
UQI-S01/AC-3 (is_unlimited → treat as sufficient)handled in checkUserQuota() response evaluationnone
UQI-S01/ERR-1 (5xx → allow with logging)checkUserQuota() fail-open path; logs user_quota_check_errornone
UQI-S02/AC-1 (deduction on creation)sso_invite.go deductUserQuota() after createLaunchpadUser()none
UQI-S02/AC-2 (already-deducted idempotency)Quota Management API enforces; credited_to: "already-deducted" treated as successnone
UQI-S02/ERR-1 (5xx → background retry)gocraft/work job DEDUCT_USER_QUOTA; exponential backoffnone
UQI-S02/ERR-2 (retries exhausted → log)consumer/deduct_user_quota.go final-fail log user_quota_deduction_failednone
UQI-S03/AC-1 (refund on deletion)delete.go refundUserQuota() post-commitnone
UQI-S03/AC-2 (already-refunded idempotency)refunded_to: "already-refunded" treated as successnone
UQI-S03/ERR-1 (refund API failure → log)delete.go logs user_quota_refund_failed; no retry pending OQ-3 resolutionOQ-3 open
UQI-S04/AC-1 (consultant creation bypasses quota)Structural — consultant endpoint never enters quota check pathnone
UQI-S04/AC-2 (consultant deletion skips refund)delete.go gates refund on !deletedUser.IsConsultantnone
UQI-S04/ERR-1 (consultant flag missing → fail atomically)create_consultant.go uses transaction; no change needed (structural bypass)none
UQI-S05/AC-1 (backfill active non-consultant users)cmd/backfill_user_quota.go iterates CIDs, calls deduction with countnone
UQI-S05/AC-2 (backfill summary log)stdout summary: processed / failed / skippednone
UQI-S05/ERR-1 (backfill per-CID failure → continue)cmd/backfill_user_quota.go skips to next CID on errornone

Detail 1.B — Key Decisions Summary

#DecisionChosen option§2 ADR block
1Where to perform quota checkIn user_handler.go under existing Redis lock (same position as ValidateUserQuota)Decision 1
2Where to perform quota deductionInside UserService.SsoInvite() after successful DB write (service layer owns the transaction boundary)Decision 2
3Deduction failure handlingAsync retry via gocraft/work background job — user never rolled backDecision 3
4Refund retry strategySingle attempt with structured logging; retry strategy TBD (OQ-3)Decision 4
5company_id to Quota Management APIcompanies.external_company_id (integer cast to string) — resolved OQ-9Decision 5
6Coexistence with existing Modpanel checkFeature flag gates new path; old path remains for unflagged CIDs; both cannot run simultaneouslyDecision 6
7Auth to Quota Management APIReuse SSO client_credentials token from existing SsoGetToken() + X-Api-Key header configDecision 7

Detail 1.C — Per-Story Change Map

StoryTitleLayerFiles changedPRD storyVerifiable AC
C1Quota Management API clientnew API clientinternal/app/api/quota_management/*.goUQI-S01/UQI-S02/UQI-S03unit tests pass; mock implements interface
C2Config + service wiringconfig + DIconfig/config.go, config/load.go, cmd/initializer.goallbuild succeeds; env vars documented
C3Feature flag constantconstantsinternal/pkg/constants/preferences.goallconstant exported; matches PRD flag name
C4CheckUserQuota on invitehandler + billing serviceinternal/app/handler/user_handler.go, internal/app/service/billings/quota_management.go, internal/app/service/billings/iBillingService.goUQI-S01test: flag ON + sufficient → proceed; flag ON + insufficient → blocked; flag ON + 5xx → allow; flag OFF → old path
C5DeductUserQuota after creationuser serviceinternal/app/service/users/sso_invite.goUQI-S02test: deduction called after createLaunchpadUser; 5xx → job enqueued; already-deducted → no error
C6Deduction retry jobconsumer + worker wiringinternal/app/consumer/deduct_user_quota.go, internal/app/consumer/iConsumer.go, internal/pkg/consts/worker.go, internal/worker/worker_service.goUQI-S02/ERR-1test: job retries on error; idempotent on already-deducted
C7RefundUserQuota on deletionuser serviceinternal/app/service/users/delete.goUQI-S03/UQI-S04test: refund called for non-consultant; skipped for consultant; failure logged
C8Backfill commandcmdcmd/backfill_user_quota.goUQI-S05command runs dry-run; outputs summary per CID

2. Technical Design

Infrastructure Topology

flowchart TD
subgraph Launchpad["qontak-launchpad (Go 1.23)"]
LB["Kong / IAG Load Balancer"]
REST["REST server (chi)"]
UH["UserHandler.SsoInvite()"]
US["UserService.SsoInvite()"]
DEL["UserService.Delete()"]
BS["BillingService"]
QMC["QuotaManagementClient"]
JOB["gocraft/work queue"]
CONS["DeductUserQuotaConsumer"]
CMD["cmd: backfill_user_quota"]
end

subgraph External["External Services"]
SSO["Mekari SSO (client_credentials token)"]
QM["qontak-billing Quota Management API"]
PREF["qontak-preferences"]
DB["PostgreSQL (Launchpad DB)"]
REDIS["Redis (worker queue + lock)"]
end

LB --> REST --> UH
UH --> PREF
UH --> BS --> QMC --> SSO
QMC --> QM
UH --> US --> DB
US --> QMC
US --> JOB --> REDIS
JOB --> CONS --> QMC
REST --> DEL --> DB
DEL --> QMC
CMD --> DB
CMD --> QMC

Per-service use case and third-party connections:

ServiceUseConnection
qontak-launchpadInvite users, delete users, backfillInternal REST, PostgreSQL, Redis
Mekari SSOObtain service-to-service access tokenHTTPS POST /auth/oauth2/token
qontak-billing Quota Management APIcheck-quota, deduction, refundHTTPS POST /iag/v1/quota-managements/{op} (Bearer JWT + X-Api-Key)
qontak-preferencesFeature flag check user_quota_integration_enabledInternal gRPC / HTTP (existing IsEnabledFunc)
Redisgocraft/work queue (deduction retry), Redis lock for inviteTCP
PostgreSQLLaunchpad users + companiesTCP / pgx

Technical Decisions (ADR-format)

Decision 1 — Where to perform the quota check

Context: The existing ValidateUserQuota() call sits in user_handler.go inside the Redis pessimistic lock lock:user_invite:{company_id}. Keeping the new check in the same position ensures quota correctness under concurrent invites and requires the fewest call-site changes.

Options:

OptionProsCons
Keep in user_handler.go under existing lock (chosen)Zero concurrency risk; follows existing pattern; minimal change surfaceHandler becomes slightly more complex
Move check into UserService.SsoInvite()Cleaner handlerCheck runs outside lock — concurrent invites could race past an exhausted quota

Decision: Keep the check in user_handler.go under the existing Redis lock (lock:user_invite:{company_id}, 10 s TTL). The feature flag is also evaluated here. When user_quota_integration_enabled = ON, call the new BillingService.CheckUserQuota(). When OFF, fall through to the existing billingService.ValidateUserQuota().

Consequences: Both cannot execute for the same request. The feature flag is the mutual exclusion gate. The handler gets a second conditional branch that is well-isolated.

Reversibility: High — feature flag can be toggled per CID at any time.


Decision 2 — Where to perform quota deduction

Context: The deduction needs the newly-created user_id (available only after createLaunchpadUser() returns) and must happen in the same request lifecycle to avoid silent leakage. The UserService already holds jobEnqueuer for background job dispatch.

Options:

OptionProsCons
Inside UserService.SsoInvite() after createLaunchpadUser() (chosen)user_id is available; service already owns job enqueuingService grows; mock required for tests
In user_handler.go after SsoInvite() returnsKeeps service leanuser_id must be surfaced through UserInfoResponse

Decision: Call deduction inside UserService.SsoInvite() after createLaunchpadUser() succeeds, gated by IsEnabledFunc with user_quota_integration_enabled. On 5xx/timeout, enqueue a DEDUCT_USER_QUOTA gocraft/work job and return normally — user creation is never rolled back.

Consequences: UserService takes a quotaManagementClient dependency (or reuses billingService which already holds it). Tests mock the quota client.

Reversibility: High — feature flag gates the call.


Decision 3 — Deduction failure handling

Context: Blocking user creation on a transient billing failure is too disruptive. The PRD explicitly requires async retry (Decision made 2026-06-23 in PRD §14).

Options:

OptionProsCons
Async retry via gocraft/work (chosen)Non-blocking; idempotent via unique_code; reuses existing infrastructureFailed deductions need monitoring
Synchronous retry with backoffSimpleAdds latency to invite flow; still fails if billing is fully down
Dead-letter only (log + alert, no retry)SimplestRevenue leakage if operator misses alert

Decision: On 5xx or timeout from the Deduction API, enqueue a DEDUCT_USER_QUOTA gocraft/work job with the full deduction payload. The job retries with exponential backoff (max attempts from SERVICE_WORKER_MAX_FAILS — resolves OQ-2 — pending team confirmation). After exhausting retries, log user_quota_deduction_failed for manual investigation.

Consequences: Monitoring alert on user_quota_deduction_failed is required pre-GA (see §3). The unique_code: create_user_{user_id} guarantees idempotency across retries.

Reversibility: Job queue is Redis-backed; jobs can be purged or replayed manually.


Decision 4 — Refund retry strategy

Context: OQ-3 (PRD §15) asks whether the Refund API call needs a gocraft/work retry. This RFC takes a conservative position: log failures for manual investigation on initial release; add retry in a follow-up once the refund failure rate is measured.

Options:

OptionProsCons
Single attempt + log failure (chosen for Phase 1)Simpler; observableSeat not returned on API outage
Immediate gocraft/work retry (OQ-3 follow-up)Ensures seat recoveryMore complexity upfront

Decision: Phase 1 — single attempt. Log user_quota_refund_failed with user_id, company_id, and error. Add async retry in a follow-up after measuring failure rate in production. Resolves OQ-3 as "deferred to follow-up RFC".

Reversibility: High — can add retry job in a subsequent PR.


Decision 5 — company_id to Quota Management API

Context: OQ-9 resolved 2026-07-01 — use companies.external_company_id (integer, cast to string). This matches the Quota Management API's internal company registry, which is keyed on the external/legacy company integer ID (as seen in the integration guide examples such as "154982").

Implementation: strconv.Itoa(int(companyInfo.ExternalCompanyID.Int32)). Guard: if ExternalCompanyID.Valid == false or the value is 0, log a warning and skip the quota call (fail-open — same as the 5xx path).

companyInfo is obtained via repo.GetCompanyAndPackageById(ctx, companyID) which is already called in both the invite handler and delete service.

Reversibility: Single-line change if the correct field turns out to be different.


Decision 6 — Coexistence with existing Modpanel check

Context: validate_user_quota.go must not run simultaneously with the new quota check for the same CID.

Decision: user_handler.go evaluates the feature flag first:

  • Flag ON → new BillingService.CheckUserQuota() call; billingService.ValidateUserQuota() is not called.
  • Flag OFF → existing billingService.ValidateUserQuota() runs as before.

The ValidateUserQuota() method is not deleted in this RFC — it continues serving non-Qontak One CIDs. Deletion can happen after all CIDs migrate and the flag is removed entirely.

Reversibility: High — flag toggle per CID is instant; code path is a simple if/else.


Decision 7 — Authentication to Quota Management API

Context: The integration guide requires a service-to-service Bearer JWT + X-Api-Key.

Decision: Reuse the existing SsoGetToken(ctx, MekariSsoGetTokenPayload{GrantType: "client_credentials", Scope: "sso:profile"}) call already present in the codebase. The QuotaManagementClient receives the access token from the caller (mirrors SsoCreateUser, SsoDeleteUser, etc. patterns). X-Api-Key is loaded from config QUOTA_MANAGEMENT_API_KEY.

Consequences: The QuotaManagementClient does not own token acquisition — it is injected per call. This keeps the client stateless and test-friendly.

Reversibility: High.


Repo Reading Guide (Detail 2.0)

Reading order for the implementing agent (≤ 10 files, read in order):

  1. internal/app/handler/user_handler.go:104-169SsoInvite() handler: Redis lock pattern, existing ValidateUserQuota() call position, inviter lookup
  2. internal/app/service/users/sso_invite.go:32-258SsoInvite() service: createLaunchpadUser() return value (CreateUserRow), existing enqueueSyncUserDetail pattern for post-creation side effects
  3. internal/app/service/users/delete.go:15-195Delete() service: transaction pattern, post-commit external calls, is_consultant check at line 88–100
  4. internal/app/service/billings/iBillingService.goIBillingService interface + BillingService struct to extend
  5. internal/app/service/billings/validate_user_quota.go — existing Modpanel-backed quota check (to be bypassed, not deleted)
  6. internal/app/service/users/main.go:63-87UserService struct fields + NewUserService constructor (add quotaManagementClient dependency)
  7. internal/app/consumer/sync_user_detail.go + iConsumer.go — pattern for a gocraft/work consumer (payload struct, job arguments, IConsumer extension)
  8. internal/app/queue/job_enqueuer.goIJobEnqueuer.EnqueueJob() call signature
  9. internal/pkg/constants/preferences.go — existing feature flag constants pattern
  10. internal/pkg/consts/worker.go — existing job name constants pattern

Existing Code Anchors:

PathWhat to learn
internal/app/handler/user_handler.go:126-168Redis lock acquisition, ValidateUserQuota call site, defer unlock
internal/app/service/users/sso_invite.go:154-158createLaunchpadUser returns (repository.CreateUserRow, error); userInfo.ID (UUID) is the deduction key
internal/app/service/users/delete.go:127-141Transaction commit → post-commit calls; deletedUser.IsConsultant field
internal/app/service/users/main.go:63-87UserService struct; jobEnqueuer queue.IJobEnqueuer field is available
internal/app/service/users/loyalty_sync.go:83IsEnabledFunc call pattern with IsEnabledParams{FeatureName, UniqueID}
internal/app/consumer/sync_user_detail.gogocraft/work consumer pattern: job.Args["data"] → marshal/unmarshal payload
internal/app/queue/job_enqueuer.go:30-40EnqueueJob(ctx, jobName, params) — params serialized to job.Args["data"]
internal/app/service/billings/iBillingService.goBillingService struct has modpanel, cache, repo — add quotaManagementClient
internal/pkg/constants/preferences.goconst FeatureXxx = "..." pattern
internal/pkg/consts/worker.gotype JobName string; const SyncUserDetailJobName JobName = "SYNC_USER_DETAIL"
cmd/initializer.go:91bs := billingService.NewBillingService(modPanelClient, billingCacheRepo, repo) — constructor to extend

Patterns to Follow:

PatternReference fileNote
Feature flag checkinternal/app/service/users/loyalty_sync.go:83IsEnabledFunc(ctx, preference.IsEnabledParams{FeatureName: ..., UniqueID: companySsoID.String()})
Post-creation background jobinternal/app/service/users/main.go:147-180enqueueSyncUserDetail — enqueue only if feature enabled; log error but continue
gocraft/work consumerinternal/app/consumer/sync_user_detail.goUnmarshal job.Args["data"], iterate, continue on per-item error
HTTP client for external APIinternal/app/api/loyalty/client.gogojek/heimdall client; timeout from config; interface + concrete struct
Error constantinternal/pkg/consts/error.govar ErrXxx = errors.New("...")

Source Verification:

ClaimEvidence
Redis lock key is lock:user_invite:{company_id}user_handler.go:126lockKey := fmt.Sprintf("lock:user_invite:%s", inviter.CompanyID.String())
ValidateUserQuota is called under the lock in the handleruser_handler.go:154 — called after locked check, inside defer-unlock
createLaunchpadUser returns repository.CreateUserRowsso_invite.go:208 — function signature; userInfo.ID is uuid.UUID
UserService holds jobEnqueuer queue.IJobEnqueuermain.go:66
is_consultant field is on users tabledb/schema.sql:37is_consultant BOOLEAN DEFAULT FALSE
FindUserBySsoId returns is_consultantdb/query/users.sqlSELECT ... is_consultant FROM users WHERE sso_id = $1
CountUser excludes consultants and soft-deleteddb/query/users.sql:199AND deleted_at IS NULL AND (is_consultant IS NULL OR is_consultant = false)
companies.external_company_id is available from GetCompanyAndPackageByIdinternal/app/repository/companies.sql.goGetCompanyAndPackageByIdRow.ExternalCompanyID sql.NullInt32; already used in create_consultant.go:70 and delete.go:187
gocraft/work job name constants live in internal/pkg/consts/worker.goworker.go:6-13
IsEnabledFunc is a package-level var in users serviceinternal/app/service/users/get_crs_permissions.go:26var IsEnabledFunc = preference.IsEnabled
IBillingService interface is in iBillingService.goiBillingService.go:12-16

Architecture — New Components

New package: internal/app/api/quota_management/

internal/app/api/quota_management/
├── iQuotaManagementClient.go # IQuotaManagementClient interface
├── client.go # QuotaManagementClient struct + constructor
├── check_quota.go # CheckQuota() method
├── deduction.go # Deduct() method
└── refund.go # Refund() method

Interface:

// IQuotaManagementClient is the interface for the Quota Management API.
type IQuotaManagementClient interface {
CheckQuota(ctx context.Context, accessToken string, req CheckQuotaRequest) (CheckQuotaResponse, error)
Deduct(ctx context.Context, accessToken string, req DeductionRequest) (DeductionResponse, error)
Refund(ctx context.Context, req RefundRequest) (RefundResponse, error)
}

Note: Refund does not take accessToken (only X-Api-Key is required per PRD §7 Behavior 3).

Extended: internal/app/service/billings/

Two new methods added to BillingService and IBillingService:

// CheckUserQuota calls POST /iag/v1/quota-managements/check-quota.
// Fail-open: if API is unreachable or returns 5xx, returns (true, nil) and logs the error.
CheckUserQuota(ctx context.Context, companySsoID uuid.UUID) (sufficient bool, err error)

// DeductUserQuota calls POST /iag/v1/quota-managements/deduction.
// Returns error only on non-5xx failures. 5xx errors should trigger a background retry.
DeductUserQuota(ctx context.Context, companySsoID uuid.UUID, userID uuid.UUID) (credited string, err error)

// RefundUserQuota calls POST /iag/v1/quota-managements/refund.
RefundUserQuota(ctx context.Context, companySsoID uuid.UUID, userID uuid.UUID) error

BillingService gains a quotaManagementClient field and ssoClient for token acquisition.

New consumer: internal/app/consumer/deduct_user_quota.go

type DeductUserQuotaPayload struct {
CompanySsoID string `json:"company_sso_id"`
UserID string `json:"user_id"`
}

func (c *Consumer) DeductUserQuotaConsumer(ctx context.Context, job *work.Job) error

New command: cmd/backfill_user_quota.go

// BackfillUserQuota iterates all active Qontak One CIDs, counts non-consultant
// active users via CountUser, and calls DeductUserQuota per CID.
// Flags: --dry-run (skip API calls, only log counts)

DDL Changes

No schema migrations are required. All quota tracking lives in qontak-billing. The users.is_consultant column and companies.sso_id column already exist.


Detail 2.4 — APIs

Outbound: Quota Management API

All endpoints use base URL from config QUOTA_MANAGEMENT_API_BASE_URL.

Check Quota

POST /iag/v1/quota-managements/check-quota
Authorization: Bearer {access_token}
X-Api-Key: {api_key}
Content-Type: application/json

Request:

{
"billing_code": "{QUOTA_MANAGEMENT_BILLING_CODE — config constant, value TBD OQ-7}",
"company_id": "{companies.external_company_id cast to string}",
"extra_attrs": {
"expectation_deduction": {}
}
}

Response (success):

{
"billing_code": "...",
"company_id": "...",
"extra_attrs": {
"is_sufficient": true,
"is_unlimited": false,
"quota_info": {
"total_remaining_balance_quota": 5.0,
"total_remaining_credit_quota": 5.0
}
}
}

Failure handling:

  • is_sufficient: false → block invitation, return HTTP 402 with error message "Quota Exceeded / Upgrade Required"
  • is_unlimited: true → treat as sufficient regardless of balances
  • 5xx / timeout → allow (fail-open); log user_quota_check_error
  • 4xx (404 component not found, 422 feature not active) → log warning + allow (fail-open) — billing setup issue, not a blocking constraint

Timeout: QUOTA_MANAGEMENT_API_TIMEOUT (default 3s).


Deduction

POST /iag/v1/quota-managements/deduction
Authorization: Bearer {access_token}
X-Api-Key: {api_key}
Content-Type: application/json

Request:

{
"billing_code": "{QUOTA_MANAGEMENT_BILLING_CODE — config constant, value TBD OQ-7}",
"company_id": "{companies.external_company_id cast to string}",
"deduction_code": "create_user_{user_id}",
"unique_code": "create_user_{user_id}",
"quantity": 1,
"extra_attrs": {
"transaction_id": "{user_id}"
}
}

Response fields:

  • credited_to: "initial" | "additional" | "postpaid" | "already-deducted" | "retry-deduction" | "free"
  • value_before, value_after: quota balance deltas

Failure handling:

  • Success (credited_to is any value except an error) → log user_quota_deducted
  • credited_to: "already-deducted" → no-op, log idempotency hit — safe to continue
  • credited_to: "retry-deduction" → system auto-requeued; treat as success
  • 5xx / timeout → enqueue DEDUCT_USER_QUOTA background job; log user_quota_deduction_retry
  • After max retries exhausted → log user_quota_deduction_failed

Timeout: QUOTA_MANAGEMENT_API_TIMEOUT (default 3s).


Refund

POST /iag/v1/quota-managements/refund
X-Api-Key: {api_key}
Content-Type: application/json

Note: No Bearer token required per PRD §7 Behavior 3.

Request:

{
"company_id": "{companies.external_company_id cast to string}",
"billing_code": "{QUOTA_MANAGEMENT_BILLING_CODE — config constant, value TBD OQ-7}",
"refund_code": "delete_user_{user_id}",
"unique_code": "delete_user_{user_id}",
"quantity": 1
}

Response fields:

  • refunded_to: "initial" | "additional" | "already-refunded" | "retry-refund"

Failure handling:

  • refunded_to: "already-refunded" → no error; log idempotency hit
  • refunded_to: "retry-refund" → treat as success (system auto-requeued)
  • Any other error → log user_quota_refund_failed with user_id, company_id, error

Timeout: QUOTA_MANAGEMENT_API_TIMEOUT (default 3s).


Inbound: No new endpoints

No new HTTP endpoints are added. All quota interaction is internal.


Sequence Diagrams

Standard User Invite — Happy Path (flag ON, quota sufficient)

sequenceDiagram
participant Admin as Client Admin
participant Kong as Kong / IAG
participant Handler as UserHandler.SsoInvite
participant Redis as Redis
participant Pref as qontak-preferences
participant BS as BillingService
participant SSO as Mekari SSO
participant QM as Quota Management API
participant US as UserService.SsoInvite
participant DB as PostgreSQL

Admin->>Kong: POST /iag/v1/users/sso_invite
Kong->>Handler: authenticated request
Handler->>Redis: SETNX lock:user_invite:{company_id}
Redis-->>Handler: locked=true
Handler->>Pref: IsEnabledFunc(user_quota_integration_enabled, companySsoID)
Pref-->>Handler: enabled=true
Handler->>BS: CheckUserQuota(ctx, companySsoID)
BS->>SSO: POST /auth/oauth2/token (client_credentials)
SSO-->>BS: access_token
BS->>QM: POST /iag/v1/quota-managements/check-quota
QM-->>BS: is_sufficient=true
BS-->>Handler: sufficient=true
Handler->>US: SsoInvite(ctx, param)
US->>DB: CreateUser (transaction)
DB-->>US: userInfo.ID
US->>BS: DeductUserQuota(ctx, companySsoID, userInfo.ID)
BS->>SSO: POST /auth/oauth2/token
SSO-->>BS: access_token
BS->>QM: POST /iag/v1/quota-managements/deduction unique_code=create_user_{userID}
QM-->>BS: credited_to=initial
BS-->>US: ok
US-->>Handler: UserInfoResponse
Handler->>Redis: DEL lock:user_invite:{company_id}
Handler-->>Admin: 200 OK

Standard User Invite — Deduction Failure (flag ON, deduction 5xx → background retry)

sequenceDiagram
participant US as UserService.SsoInvite
participant DB as PostgreSQL
participant BS as BillingService
participant QM as Quota Management API
participant JOB as gocraft/work queue
participant CONS as DeductUserQuotaConsumer

US->>DB: CreateUser
DB-->>US: userInfo.ID
US->>BS: DeductUserQuota(ctx, companySsoID, userID)
BS->>QM: POST /iag/v1/quota-managements/deduction
QM-->>BS: 500 Internal Server Error
BS-->>US: error (5xx)
US->>JOB: EnqueueJob(DEDUCT_USER_QUOTA, payload)
Note over US: log user_quota_deduction_retry
US-->>US: return UserInfoResponse (user created)
Note over JOB: async retry with exponential backoff
JOB->>CONS: DeductUserQuotaConsumer(job)
CONS->>QM: POST /iag/v1/quota-managements/deduction
QM-->>CONS: credited_to=already-deducted OR success

Standard User Invite — Quota Insufficient (flag ON, blocked)

sequenceDiagram
participant Admin as Client Admin
participant Handler as UserHandler.SsoInvite
participant BS as BillingService
participant QM as Quota Management API

Handler->>BS: CheckUserQuota(ctx, companySsoID)
BS->>QM: POST /iag/v1/quota-managements/check-quota
QM-->>BS: is_sufficient=false is_unlimited=false
BS-->>Handler: sufficient=false
Note over Handler: log user_quota_check_failed
Handler-->>Admin: 402 Quota Exceeded

Standard User Invite — API Unreachable (fail-open)

sequenceDiagram
participant Handler as UserHandler.SsoInvite
participant BS as BillingService
participant QM as Quota Management API

Handler->>BS: CheckUserQuota(ctx, companySsoID)
BS->>QM: POST /iag/v1/quota-managements/check-quota
QM-->>BS: timeout or 5xx
BS-->>Handler: sufficient=true (fail-open)
Note over BS: log user_quota_check_error fail_safe_action=allow
Note over Handler: proceed with invite

User Deletion — Standard User (flag ON, non-consultant)

sequenceDiagram
participant Admin as Admin
participant US as UserService.Delete
participant DB as PostgreSQL
participant BS as BillingService
participant QM as Quota Management API

Admin->>US: Delete(ctx, ssoID)
US->>DB: FindUserBySsoId → is_consultant=false
US->>DB: UserDelete (transaction commit)
DB-->>US: ok
US->>BS: RefundUserQuota(ctx, companySsoID, userID)
BS->>QM: POST /iag/v1/quota-managements/refund unique_code=delete_user_{userID}
QM-->>BS: refunded_to=initial
Note over US: log user_quota_refunded
US-->>Admin: nil error

User Deletion — Consultant User (bypass)

sequenceDiagram
participant Admin as Admin
participant US as UserService.Delete
participant DB as PostgreSQL

Admin->>US: Delete(ctx, ssoID)
US->>DB: FindUserBySsoId → is_consultant=true
US->>DB: UserDelete (transaction commit)
Note over US: log user_quota_consultant_bypass
US-->>Admin: nil error (no refund call)

Background Job Spec

Job name: DEDUCT_USER_QUOTA

Payload (DeductUserQuotaPayload):

type DeductUserQuotaPayload struct {
ExternalCompanyID int32 `json:"external_company_id"` // companies.ExternalCompanyID
UserID string `json:"user_id"` // uuid of the created user
}

Retry strategy: Uses global ServiceWorkerConfig.Default.MaxFails (read from SERVICE_WORKER_MAX_FAILS env var; team to set ≥ 5 for this job — pending OQ-2 resolution).

Backoff: gocraft/work built-in exponential: ~15s × 2^(fails - 1). At MaxFails=5: ~15s, ~30s, ~60s, ~120s, ~240s = ~7.5 min total.

On exhausted retries: log user_quota_deduction_failed with company_sso_id, user_id, error. Job moves to gocraft/work dead queue.

Idempotency: unique_code: create_user_{user_id} — repeated calls return credited_to: "already-deducted", no balance change.


3. High-Availability & Security

Availability

ScenarioBehavior
Quota Management API unreachable during inviteFail-open — invite allowed, user_quota_check_error logged
Quota Management API unreachable during deductionBackground retry via gocraft/work; user creation not rolled back
Quota Management API unreachable during refundLog user_quota_refund_failed; seat not returned (acceptable until retry added in follow-up)
Redis unavailable (lock can't be acquired)Existing ErrTooManyConcurrentRequests path — invite blocked; Quota Management API not called
qontak-preferences unavailableIsEnabledFunc returns error; treat as flag=OFF → fall through to existing Modpanel path

Timeout budget: All three Quota Management API calls use QUOTA_MANAGEMENT_API_TIMEOUT (recommended default: 3s). This keeps total invite latency under 500ms budget (PRD §6) assuming < 3s for the check call. Engineering to validate in staging.

Security

ConcernMitigation
Service account JWT exposureJWT obtained at call time via client_credentials flow; never stored in DB or logged
X-Api-Key exposureLoaded from env var QUOTA_MANAGEMENT_API_KEY; never logged
unique_code collisioncreate_user_{user_id} — UUID user_id ensures global uniqueness. billing_unique_logs DB constraint enforces at API level
Consultant bypass spoofingBypass is structural — route-level (/private/ requires Basic Auth from moderator-be); cannot be triggered by client-facing calls
Feature flag bypassFlag is read from qontak-preferences server-side; client has no influence on it
Deduction for already-deleted userIdempotency via unique_code — no double charge possible

Monitoring & Alerting

EventTriggerAlert
user_quota_check_errorQuota Check API unreachable or 5xxAlert if rate > 5% of check calls in 30 min window → Slack #bifrost-alerts
user_quota_deduction_failedgocraft/work retries exhaustedAlert on any occurrence → Slack #bifrost-alerts (zero tolerance post-GA)
user_quota_refund_failedRefund API call failsAlert on occurrence → #bifrost-alerts
user_quota_check_failedis_sufficient: false blocks inviteInformational; monitor trend for unexpected quota blocks

4. Backwards Compatibility and Rollout Plan

Backwards Compatibility

  • CIDs with user_quota_integration_enabled = OFF (default): zero behavior change. The existing billingService.ValidateUserQuota() (Modpanel-backed) continues running unchanged.
  • No DB schema changes.
  • No new API endpoints on Launchpad.
  • create_consultant.go requires no changes — bypass is structural.

Rollout Stages

Per PRD §9:

StageActionSuccess Gate
Stage 0Deploy code with flag OFF globallyBuild green; zero behavior change in production
Stage 1Enable for ≤ 5 internal Bifrost test CIDs0 P0 bugs; check/deduction/refund all confirmed; consultant bypass verified
Stage 2Enable for 5–10 Qontak One CIDs (closed pilot)Deduction success rate ≥ 99.9%; refund success rate ≥ 99.5%; 0 user_quota_deduction_failed
BackfillRun backfill_user_quota command for all Qontak One CIDs100% CIDs backfilled; 0 unresolved user_quota_backfill_failed
Stage 3Staged 25% → 50% → 100% of Qontak One CIDsDeduction failure rate ≤ 0.1% for 1 week per batch
GAAll Qontak One CIDs with flag ON by defaultStaged gates sustained for 2 consecutive weeks

Rollback Plan

  1. Toggle user_quota_integration_enabled = OFF for affected CIDs via qontak-preferences — instant, no redeploy.
  2. Any DEDUCT_USER_QUOTA jobs in queue are safe to drain (idempotent) or purge via gocraft/work UI (SERVICE_WORKER_UI_PORT).
  3. Revert code change only required if a bug exists in the flag=OFF path (i.e. ValidateUserQuota is broken) — this RFC does not modify that path.

Agent Execution Plan

Chunk 1: New Quota Management API client

Files:

  • internal/app/api/quota_management/iQuotaManagementClient.go
  • internal/app/api/quota_management/client.go
  • internal/app/api/quota_management/check_quota.go
  • internal/app/api/quota_management/deduction.go
  • internal/app/api/quota_management/refund.go

Commands:

# Run after creating files
cd /path/to/qontak-launchpad
go build ./internal/app/api/quota_management/...
go test ./internal/app/api/quota_management/... -run TestCheckQuota

Acceptance criteria:

  • IQuotaManagementClient interface with CheckQuota, Deduct, Refund methods
  • QuotaManagementClient struct implements interface with gojek/heimdall HTTP client (mirrors internal/app/api/loyalty/client.go)
  • CheckQuota correctly maps response extra_attrs.is_sufficient and extra_attrs.is_unlimited
  • Deduct sends unique_code: create_user_{user_id}, handles credited_to: "already-deducted" as success
  • Refund sends only X-Api-Key (no Bearer token); handles refunded_to: "already-refunded" as success
  • Unit tests with mock HTTP server covering happy path, 5xx, timeout

Chunk 2: Config + new constants

Files:

  • config/config.go — add QuotaManagementAPI struct
  • config/load.go — load QUOTA_MANAGEMENT_API_BASE_URL, QUOTA_MANAGEMENT_API_KEY, QUOTA_MANAGEMENT_API_TIMEOUT
  • internal/pkg/constants/preferences.go — add FeatureUserQuotaIntegration = "user_quota_integration_enabled"
  • internal/pkg/consts/worker.go — add DeductUserQuotaJobName JobName = "DEDUCT_USER_QUOTA"

Config struct:

type QuotaManagementAPI struct {
BaseURL string
APIKey string
BillingCode string // QUOTA_MANAGEMENT_BILLING_CODE — value TBD (OQ-7)
Timeout time.Duration
}

Env vars:

  • QUOTA_MANAGEMENT_API_BASE_URL
  • QUOTA_MANAGEMENT_API_KEY
  • QUOTA_MANAGEMENT_BILLING_CODE ← new (value TBD from qontak-billing team — OQ-7)
  • QUOTA_MANAGEMENT_API_TIMEOUT

Commands:

go build ./...

Acceptance criteria:

  • config.AppConfig.QuotaManagementAPI exported and loaded from env
  • BillingCode loaded from QUOTA_MANAGEMENT_BILLING_CODE env var
  • Feature flag constant name matches PRD exactly: "user_quota_integration_enabled"
  • Job name constant follows JobName type pattern in worker.go

Chunk 3: Extend BillingService with quota management methods

Files:

  • internal/app/service/billings/iBillingService.go — extend IBillingService; add IQuotaManagementClient + ISsoClient fields to BillingService
  • internal/app/service/billings/quota_management.goCheckUserQuota(), DeductUserQuota(), RefundUserQuota() implementations
  • cmd/initializer.go — extend NewBillingService(...) call to pass quotaManagementClient and ssoClient

Method signatures:

// CheckUserQuota checks quota availability for a company.
// externalCompanyID: companies.ExternalCompanyID (int32, cast to string for API call).
// Fail-open: returns (true, nil) on 5xx/timeout and logs user_quota_check_error.
// Also fail-open when externalCompanyID <= 0 (unset).
func (s *BillingService) CheckUserQuota(ctx context.Context, externalCompanyID int32) (bool, error)

// DeductUserQuota deducts 1 user seat.
// externalCompanyID: companies.ExternalCompanyID (int32).
// Returns ErrQuotaDeductionFailed (wraps 5xx) to signal retry-needed.
func (s *BillingService) DeductUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) error

// RefundUserQuota refunds 1 user seat.
// externalCompanyID: companies.ExternalCompanyID (int32).
func (s *BillingService) RefundUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) error

Commands:

go build ./internal/app/service/billings/...
go test ./internal/app/service/billings/... -v

Acceptance criteria:

  • CheckUserQuota fails open on 5xx → returns (true, nil); logs user_quota_check_error
  • CheckUserQuota returns (false, ErrInsufficientQuota) when is_sufficient: false && !is_unlimited
  • DeductUserQuota returns nil on credited_to: "already-deducted" (idempotent)
  • DeductUserQuota returns ErrQuotaDeductionFailed on 5xx (signals caller to enqueue job)
  • RefundUserQuota returns nil on refunded_to: "already-refunded"
  • Unit tests for each method with mocked IQuotaManagementClient

Chunk 4: Modify user_handler.go — SsoInvite quota check

File: internal/app/handler/user_handler.go

Change: After acquiring lock and before calling billingService.ValidateUserQuota(), check IsEnabledFunc(user_quota_integration_enabled, companyInfo.SsoID). If ON, call billingService.CheckUserQuota() and skip ValidateUserQuota(). If OFF, call ValidateUserQuota() as before.

Note: The handler already calls repo.FindUserBySsoId to get inviter. It then needs companyInfo.ExternalCompanyID (int32) and companyInfo.SsoID (for the feature flag UniqueID). The handler already calls repo.FindUserBySsoId — add one repo.GetCompanyAndPackageById(ctx, inviter.CompanyID) call before the lock (or reuse the one that already exists in the service layer by surfacing companyInfo via a helper).

Feature flag UniqueID: use companyInfo.SsoID.String() (existing pattern from sso_invite.gocompanyInfo.SsoID.String() is already passed to SSO invitation).

Quota API company_id: strconv.Itoa(int(companyInfo.ExternalCompanyID.Int32)). Guard: if !companyInfo.ExternalCompanyID.Valid or value is 0 → fail-open (same as 5xx path).

Commands:

go build ./internal/app/handler/...
go test ./internal/app/handler/... -run TestSsoInvite

Acceptance criteria:

  • Flag ON + is_sufficient: true → proceeds to SsoInvite()
  • Flag ON + is_sufficient: false (non-unlimited) → returns HTTP 402 with "Quota Exceeded / Upgrade Required"
  • Flag ON + is_unlimited: true → proceeds regardless of balance
  • Flag ON + 5xx/timeout → proceeds (fail-open), logged
  • Flag OFF → calls existing ValidateUserQuota() unchanged
  • ValidateUserQuota() is never called when flag is ON for the same request

Chunk 5: Modify sso_invite.go — DeductUserQuota after creation

File: internal/app/service/users/sso_invite.go

Change: After createLaunchpadUser() succeeds, check IsEnabledFunc(user_quota_integration_enabled, companySsoID). If ON, call billingService.DeductUserQuota(ctx, companyInfo.SsoID, userInfo.ID). On ErrQuotaDeductionFailed, enqueue DEDUCT_USER_QUOTA job and continue.

The UserService already has billingService billingService.IBillingService field — no new dependency.

companyInfo.ExternalCompanyID.Int32 is available from companyInfo fetched at line 96 of sso_invite.go via repo.GetCompanyAndPackageById. Guard: skip deduction and enqueue job if ExternalCompanyID is invalid/zero (fail-open).

Commands:

go test ./internal/app/service/users/... -run TestSsoInvite

Acceptance criteria:

  • Deduction called after successful createLaunchpadUser()
  • ErrQuotaDeductionFailed → job enqueued; SsoInvite() still returns success
  • credited_to: "already-deducted" → success, no job enqueued
  • Deduction NOT called when flag is OFF
  • Deduction NOT called when userInfo.ID is zero (guard)

Chunk 6: New background job consumer

Files:

  • internal/app/consumer/deduct_user_quota.go
  • internal/app/consumer/iConsumer.go — add DeductUserQuotaConsumer to IConsumer interface
  • internal/worker/worker_service.go — register DEDUCT_USER_QUOTA in registerJob()
  • cmd/initializer.go — pass billingService to consumer.NewConsumer() (if not already available, or inject via existing pattern)

Commands:

go build ./internal/app/consumer/...
go test ./internal/app/consumer/... -run TestDeductUserQuota

Acceptance criteria:

  • Consumer correctly unmarshals DeductUserQuotaPayload from job.Args["data"]
  • Calls BillingService.DeductUserQuota() — retries via job if 5xx
  • credited_to: "already-deducted" → returns nil (job completes successfully)
  • On final failure (exhausted retries), logs user_quota_deduction_failed
  • Job registered in registerJob() and visible in worker UI

Chunk 7: Modify delete.go — RefundUserQuota

File: internal/app/service/users/delete.go

Change: After the transaction commit and before the external service calls block (after commitTx() at line ~140), check IsEnabledFunc(user_quota_integration_enabled, companyInfo.SsoID). If ON and !deletedUser.IsConsultant, call billingService.RefundUserQuota(ctx, companyInfo.SsoID, deletedUser.ID). Log failure but do not return error (mirrors existing "best effort" external calls pattern in the file).

Commands:

go test ./internal/app/service/users/... -run TestDelete

Acceptance criteria:

  • Refund called for non-consultant after confirmed DB deletion (post-commit)
  • Refund NOT called for is_consultant = true
  • Refund NOT called when flag is OFF
  • Refund API failure → logged user_quota_refund_failed; Delete() still returns nil

Chunk 8: Backfill command

File: cmd/backfill_user_quota.go

Logic:

  1. Fetch all companies (or companies with Qontak One package — team to define scope)
  2. For each company, call repo.CountUser(ctx, company.ID) — returns active non-consultant user count
  3. If count > 0, call billingService.DeductUserQuota(ctx, company.SsoID, syntheticBackfillID) — OR call the Quota Management API directly with a bulk deduction (to confirm API supports this)

Note: The backfill deduction must use a distinct unique_code per CID to avoid collision with live user IDs. Proposed: backfill_{company_id}_{count}. Team to confirm the API supports bulk quantity > 1 (quantity = user count per CID) — see OQ-10.

Commands:

go run ./cmd/... backfill_user_quota --dry-run

Acceptance criteria:

  • --dry-run flag outputs per-CID count without calling API
  • Each CID's user count logged before API call
  • Failed CID → log user_quota_backfill_failed, continue to next CID
  • Final summary: total CIDs processed, failed, skipped
  • Idempotent: re-running after partial failure re-deducts only missing CIDs (via unique_code)

Verification & Rollback Recipe

Pre-merge:

cd /path/to/qontak-launchpad
go build ./...
go vet ./...
go test ./... -count=1 -timeout 120s

Post-deploy signals (Stage 1):

  • user_quota_check_passed events appearing in logs for flagged CIDs
  • Invite for a CID with is_sufficient: false → verify HTTP 402 in test
  • Create + delete a test user → verify deduction + refund in qontak-billing logs
  • Create consultant user → verify zero Quota Management API calls in Launchpad logs

Rollback steps:

  1. Disable flag: toggle user_quota_integration_enabled = OFF for all enabled CIDs in qontak-preferences
  2. Monitor: confirm user_quota_check_passed events stop; user_quota_check_error rates drop to zero
  3. Drain DEDUCT_USER_QUOTA queue via worker UI (SERVICE_WORKER_UI_PORT) if needed
  4. Code rollback (if bug in flag=OFF path): redeploy previous image; feature flag is a no-op

5. Concern, Questions, or Known Limitations

Open Questions

#QuestionOwnerDeadline
OQ-1 ✅Fail-safe behavior when Quota Check API is unreachableBifrost PM + EngResolved 2026-06-23 — allow with logging (fail-open)
OQ-2Retry strategy for DEDUCT_USER_QUOTA job: max attempts and backoff valuesBifrost EngBefore Stage 1
OQ-3Does Refund API need gocraft/work retry?Bifrost EngBefore GA (deferred to follow-up RFC)
OQ-4billing_unique_logs dedup guarantees: DB UNIQUE constraint on key?Bifrost Eng / qontak-billingBefore Stage 1
OQ-5If sync_consultant_to_launchpad flag is toggled ON mid-tenancy, historical consultant users in Launchpad won't have is_consultant=trueBifrost PM + ModpanelBefore Stage 3
OQ-6 ✅Backfill should exclude consultants AND deactivated/suspended usersBifrost PMResolved 2026-06-23 — exclude both
OQ-7billing_code value for user seat quotaqontak-billing team⚠️ Partially resolved 2026-07-01 — value still TBD; implementation uses config constant QUOTA_MANAGEMENT_BILLING_CODE (env var QUOTA_MANAGEMENT_BILLING_CODE). Not a code-merge blocker — fill env var value before staging enablement.
OQ-8deduction_code and refund_code values for user seat operationsqontak-billing teamResolved 2026-07-01deduction_code = create_user_{user_id}, refund_code = delete_user_{user_id}. These are dynamic per user. Note: deduction_code matches unique_code for user seat operations.
OQ-9Which company_id to pass to the Quota Management API?Bifrost Eng + qontak-billingResolved 2026-07-01 — use companies.external_company_id (integer cast to string). Guard: skip quota call if ExternalCompanyID is null/zero (fail-open).
OQ-10expectation_deduction in check-quota extra_attrs: empty object {} or specific field(s) for user seats?Bifrost EngBefore Stage 1
OQ-11Should validate_user_quota.go (Modpanel check) be explicitly removed from the code path under feature flag or left in-place for non-Qontak One CIDs?Bifrost EngBefore GA — left in-place per Decision 6 in this RFC
OQ-12Backfill unique_code scheme: does Quota Management API accept quantity > 1 for a single deduction call (i.e. deduct N seats in one call)?qontak-billing teamBefore backfill execution

Known Limitations

  • OQ-7 is a soft prerequisite — code can be merged with QUOTA_MANAGEMENT_BILLING_CODE env var empty; the value must be set before the feature flag is enabled for any CID. OQ-8 and OQ-9 are resolved.
  • Refund has no async retry in Phase 1 (Decision 4). A seat is not returned if the Refund API is down during deletion. Team to measure refund failure rate post-Stage 2 and add retry in a follow-up PR.
  • The backfill command processes all companies sequentially. For large Qontak One tenancies, execution time may be significant. Consider batching or rate-limiting per the qontak-billing team's guidance.

6. Comment logs

DateAuthorComment
2026-07-01fauzan.madani@mekari.comInitial RFC created based on PRD v1.2. OQ-7, OQ-8, OQ-9 are blockers. OQ-3 deferred to follow-up.
2026-07-01fauzan.madani@mekari.comOQ-8 resolved: deduction_code = create_user_{user_id}, refund_code = delete_user_{user_id}. OQ-9 resolved: company_id = companies.external_company_id cast to string. OQ-7 partially resolved: billing_code loaded from QUOTA_MANAGEMENT_BILLING_CODE config constant — value TBD, not a code-merge blocker. §7 updated to Ready: YES.

Mermaid validation: all 6 mermaid blocks validated with npx -y -p @mermaid-js/mermaid-cli mmdc. No parse errors.


7. Ready for agent execution

Ready for agent execution: YES — OQ-8 and OQ-9 resolved; OQ-7 uses a config constant placeholder.

All structural decisions are made. The execution plan is complete. One remaining soft prerequisite before staging enablement:

  • OQ-7 soft prerequisite: billing_code value confirmed by qontak-billing team → set QUOTA_MANAGEMENT_BILLING_CODE env var in all environments before enabling the feature flag for any CID. Code compiles and passes tests without this value using the env var placeholder.

Pre-merge checklist:

  • OQ-8 resolved: deduction_code = create_user_{user_id}, refund_code = delete_user_{user_id}
  • OQ-9 resolved: company_id = companies.external_company_id (string)
  • OQ-7: billing_code loaded from QUOTA_MANAGEMENT_BILLING_CODE env var (value TBD — not a code blocker)
  • All 6 mermaid blocks validated with mmdc (no parse errors)
  • OQ-2: confirm max retry attempts for DEDUCT_USER_QUOTA job (team to set SERVICE_WORKER_MAX_FAILS ≥ 5; default already read from env var)
  • Staging env has Quota Management API credentials provisioned (QUOTA_MANAGEMENT_API_BASE_URL, QUOTA_MANAGEMENT_API_KEY, QUOTA_MANAGEMENT_BILLING_CODE)
  • qontak-preferences entry for user_quota_integration_enabled testable in staging