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 — reasonare 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
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | YAML status: carries draft |
| DRI | addo.hernando@mekari.com | Single accountable owner |
| Team | bifrost | Carried from PRD |
| Author(s) | fauzan.madani@mekari.com | Primary author |
| Reviewers | bifrost-backend, bifrost-tech-lead | Tech reviewers |
| Approver(s) | bifrost-tech-lead, infosec | Tech leaders + infosec |
| Submitted Date | 2026-07-01 | Date RFC opened |
| Last Updated | 2026-07-01 · OQ-8/OQ-9 resolved | Bump on every material edit |
| Target Release | 2026-Q3 | Carried from PRD |
| Target Quarter | 2026-Q3 | Carried from PRD |
| Delivery | not yet handed to delivery | No delivery/ artifacts yet |
| Related | ../prds/prd-integrate-user-quota.md · how-to-integrate-quota-management.md | Source PRD + integration guide |
| Discussion | #bifrost-user-quota | Slack channel |
Type: backend Sub-type: new-feature
Sections at a Glance
- Overview (incl. PRD-to-Schema Derivation, Key Decisions Summary, Per-Story Change Map)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → DDL → APIs → background job spec)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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-quotainside 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/deductionafter a user is successfully persisted to the Launchpad DB. On 5xx/timeout the call is retried via a newgocraft/workbackground job — user creation is never rolled back. - Post-deletion refund: call
POST /iag/v1/quota-managements/refundafter a standard user is confirmed deleted. Log failures; no retry (see OQ-3). - Consultant bypass: users created via
POST /private/users/create_consultanthaveis_consultant = trueon 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 whenextra_attrs.is_sufficient: false(andis_unlimitedis nottrue). - SC-2: Every standard user creation for a flagged CID produces exactly one successful
deduction call (including
gocraft/workretries). 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
- Quota balance visibility in the Launchpad client-facing UI.
- Quota enforcement for any resource other than user seats.
- Blocking the invite form before the check (check happens at creation time).
- Real-time push notifications / emails on quota limit approaches.
- Automatic quota top-up prompts.
- Retroactive reconciliation for over-provisioned CIDs before go-live.
- Non-Qontak One plans — standard Qontak clients are unaffected.
Related Documents
- Source PRD:
../prds/prd-integrate-user-quota.md(v1.2) - Integration guide:
../documentations/how-to-integrate-quota-management.md - Jira: BIF-8715
Assumptions
- A-1:
deduction_code,refund_code, andcompany_idtype are resolved (OQ-8, OQ-9).billing_code(QUOTA_MANAGEMENT_BILLING_CODE) value is still TBD from theqontak-billingteam (OQ-7) — treated as a config constant, not a code blocker. - A-2: The
billing_unique_logstable inqontak-billingenforcesUNIQUEon(billing_code, unique_code)— duplicate deduction calls with the sameunique_codereturncredited_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 thecompany_idto pass to the Quota Management API. Resolved via OQ-9 (2026-07-01). TheGetCompanyAndPackageByIdquery already returnsExternalCompanyID sql.NullInt32— usestrconv.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:
CountUserSQL query (db/query/users.sql:199) already excludes consultant users and soft-deleted users — it is reused unchanged for the backfill command.
Dependencies
| Dependency | Owning team | Deliverable needed | Blocking? |
|---|---|---|---|
| Quota Management API — check, deduction, refund endpoints | Bifrost (qontak-billing) | Stable endpoints in staging; confirmed billing_code, deduction_code, refund_code values | YES |
| Service account credentials | Bifrost / Platform | Bearer JWT (service account) + X-Api-Key provisioned for Launchpad in all environments | YES |
qontak-preferences entry for user_quota_integration_enabled | Bifrost | Preference entry registered and testable in staging | YES |
sync_consultant_to_launchpad flag in moderator-be | Modpanel team | Flag must be ON for Qontak One CIDs so consultant provisioning routes through POST /private/users/create_consultant | YES |
PRD-to-Schema Derivation
| Design entity | Persisted as | Exposed via | Enforced where | Source |
|---|---|---|---|---|
| User quota balance check before invite | No new table — pure API call | Quota Management check-quota response extra_attrs.is_sufficient | user_handler.go SsoInvite() under Redis lock | PRD §7 Behavior 1 |
| Quota deduction record after user creation | billing_logs + billing_unique_logs in qontak-billing (external) | Deduction response credited_to, value_after | sso_invite.go deductUserQuota() + retry job | PRD §7 Behavior 2 |
| Idempotency key for deduction | unique_code: create_user_{user_id} | Not stored in Launchpad DB | Quota Management API | PRD §6 Constraints |
| Quota refund record after user deletion | billing_logs + billing_unique_logs in qontak-billing | Refund response refunded_to, value_after | delete.go refundUserQuota() | PRD §7 Behavior 3 |
| Idempotency key for refund | unique_code: delete_user_{user_id} | Not stored in Launchpad DB | Quota Management API | PRD §6 Constraints |
| Consultant bypass signal | users.is_consultant BOOLEAN (existing, db/schema.sql:37) | FindUserBySsoId query result | delete.go — skip refund if is_consultant = true | PRD §8.1 System Flow step 16 |
Feature flag user_quota_integration_enabled | qontak-preferences service (external) | IsEnabledFunc(ctx, IsEnabledParams{FeatureName: "user_quota_integration_enabled", UniqueID: companySsoID}) | user_handler.go SsoInvite + Delete | PRD §6 Constraints |
| Background deduction retry | gocraft/work job queue (Redis-backed) | Job name DEDUCT_USER_QUOTA | consumer/deduct_user_quota.go | PRD §7 Behavior 2 failure |
| Backfill: existing active user count per CID | No new table — one-shot deduction call per CID | CLI stdout | cmd/backfill_user_quota.go | PRD §8.2 UQI-S05 |
Detail 1.A — PRD Traceability Matrix
| PRD composite AC id | RFC coverage | Divergence |
|---|---|---|
UQI-S01/AC-1 (quota check → proceed) | §2.4 Check Quota API; user_handler.go under Redis lock | none |
UQI-S01/AC-2 (quota check → block) | user_handler.go returns ErrInsufficientQuota | none |
UQI-S01/AC-3 (is_unlimited → treat as sufficient) | handled in checkUserQuota() response evaluation | none |
UQI-S01/ERR-1 (5xx → allow with logging) | checkUserQuota() fail-open path; logs user_quota_check_error | none |
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 success | none |
UQI-S02/ERR-1 (5xx → background retry) | gocraft/work job DEDUCT_USER_QUOTA; exponential backoff | none |
UQI-S02/ERR-2 (retries exhausted → log) | consumer/deduct_user_quota.go final-fail log user_quota_deduction_failed | none |
UQI-S03/AC-1 (refund on deletion) | delete.go refundUserQuota() post-commit | none |
UQI-S03/AC-2 (already-refunded idempotency) | refunded_to: "already-refunded" treated as success | none |
UQI-S03/ERR-1 (refund API failure → log) | delete.go logs user_quota_refund_failed; no retry pending OQ-3 resolution | OQ-3 open |
UQI-S04/AC-1 (consultant creation bypasses quota) | Structural — consultant endpoint never enters quota check path | none |
UQI-S04/AC-2 (consultant deletion skips refund) | delete.go gates refund on !deletedUser.IsConsultant | none |
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 count | none |
UQI-S05/AC-2 (backfill summary log) | stdout summary: processed / failed / skipped | none |
UQI-S05/ERR-1 (backfill per-CID failure → continue) | cmd/backfill_user_quota.go skips to next CID on error | none |
Detail 1.B — Key Decisions Summary
| # | Decision | Chosen option | §2 ADR block |
|---|---|---|---|
| 1 | Where to perform quota check | In user_handler.go under existing Redis lock (same position as ValidateUserQuota) | Decision 1 |
| 2 | Where to perform quota deduction | Inside UserService.SsoInvite() after successful DB write (service layer owns the transaction boundary) | Decision 2 |
| 3 | Deduction failure handling | Async retry via gocraft/work background job — user never rolled back | Decision 3 |
| 4 | Refund retry strategy | Single attempt with structured logging; retry strategy TBD (OQ-3) | Decision 4 |
| 5 | company_id to Quota Management API | companies.external_company_id (integer cast to string) — resolved OQ-9 | Decision 5 |
| 6 | Coexistence with existing Modpanel check | Feature flag gates new path; old path remains for unflagged CIDs; both cannot run simultaneously | Decision 6 |
| 7 | Auth to Quota Management API | Reuse SSO client_credentials token from existing SsoGetToken() + X-Api-Key header config | Decision 7 |
Detail 1.C — Per-Story Change Map
| Story | Title | Layer | Files changed | PRD story | Verifiable AC |
|---|---|---|---|---|---|
| C1 | Quota Management API client | new API client | internal/app/api/quota_management/*.go | UQI-S01/UQI-S02/UQI-S03 | unit tests pass; mock implements interface |
| C2 | Config + service wiring | config + DI | config/config.go, config/load.go, cmd/initializer.go | all | build succeeds; env vars documented |
| C3 | Feature flag constant | constants | internal/pkg/constants/preferences.go | all | constant exported; matches PRD flag name |
| C4 | CheckUserQuota on invite | handler + billing service | internal/app/handler/user_handler.go, internal/app/service/billings/quota_management.go, internal/app/service/billings/iBillingService.go | UQI-S01 | test: flag ON + sufficient → proceed; flag ON + insufficient → blocked; flag ON + 5xx → allow; flag OFF → old path |
| C5 | DeductUserQuota after creation | user service | internal/app/service/users/sso_invite.go | UQI-S02 | test: deduction called after createLaunchpadUser; 5xx → job enqueued; already-deducted → no error |
| C6 | Deduction retry job | consumer + worker wiring | internal/app/consumer/deduct_user_quota.go, internal/app/consumer/iConsumer.go, internal/pkg/consts/worker.go, internal/worker/worker_service.go | UQI-S02/ERR-1 | test: job retries on error; idempotent on already-deducted |
| C7 | RefundUserQuota on deletion | user service | internal/app/service/users/delete.go | UQI-S03/UQI-S04 | test: refund called for non-consultant; skipped for consultant; failure logged |
| C8 | Backfill command | cmd | cmd/backfill_user_quota.go | UQI-S05 | command 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:
| Service | Use | Connection |
|---|---|---|
qontak-launchpad | Invite users, delete users, backfill | Internal REST, PostgreSQL, Redis |
| Mekari SSO | Obtain service-to-service access token | HTTPS POST /auth/oauth2/token |
qontak-billing Quota Management API | check-quota, deduction, refund | HTTPS POST /iag/v1/quota-managements/{op} (Bearer JWT + X-Api-Key) |
qontak-preferences | Feature flag check user_quota_integration_enabled | Internal gRPC / HTTP (existing IsEnabledFunc) |
| Redis | gocraft/work queue (deduction retry), Redis lock for invite | TCP |
| PostgreSQL | Launchpad users + companies | TCP / 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:
| Option | Pros | Cons |
|---|---|---|
Keep in user_handler.go under existing lock (chosen) | Zero concurrency risk; follows existing pattern; minimal change surface | Handler becomes slightly more complex |
Move check into UserService.SsoInvite() | Cleaner handler | Check 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:
| Option | Pros | Cons |
|---|---|---|
Inside UserService.SsoInvite() after createLaunchpadUser() (chosen) | user_id is available; service already owns job enqueuing | Service grows; mock required for tests |
In user_handler.go after SsoInvite() returns | Keeps service lean | user_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:
| Option | Pros | Cons |
|---|---|---|
Async retry via gocraft/work (chosen) | Non-blocking; idempotent via unique_code; reuses existing infrastructure | Failed deductions need monitoring |
| Synchronous retry with backoff | Simple | Adds latency to invite flow; still fails if billing is fully down |
| Dead-letter only (log + alert, no retry) | Simplest | Revenue 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:
| Option | Pros | Cons |
|---|---|---|
| Single attempt + log failure (chosen for Phase 1) | Simpler; observable | Seat not returned on API outage |
Immediate gocraft/work retry (OQ-3 follow-up) | Ensures seat recovery | More 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):
internal/app/handler/user_handler.go:104-169—SsoInvite()handler: Redis lock pattern, existingValidateUserQuota()call position, inviter lookupinternal/app/service/users/sso_invite.go:32-258—SsoInvite()service:createLaunchpadUser()return value (CreateUserRow), existingenqueueSyncUserDetailpattern for post-creation side effectsinternal/app/service/users/delete.go:15-195—Delete()service: transaction pattern, post-commit external calls,is_consultantcheck at line 88–100internal/app/service/billings/iBillingService.go—IBillingServiceinterface +BillingServicestruct to extendinternal/app/service/billings/validate_user_quota.go— existing Modpanel-backed quota check (to be bypassed, not deleted)internal/app/service/users/main.go:63-87—UserServicestruct fields +NewUserServiceconstructor (addquotaManagementClientdependency)internal/app/consumer/sync_user_detail.go+iConsumer.go— pattern for agocraft/workconsumer (payload struct, job arguments, IConsumer extension)internal/app/queue/job_enqueuer.go—IJobEnqueuer.EnqueueJob()call signatureinternal/pkg/constants/preferences.go— existing feature flag constants patterninternal/pkg/consts/worker.go— existing job name constants pattern
Existing Code Anchors:
| Path | What to learn |
|---|---|
internal/app/handler/user_handler.go:126-168 | Redis lock acquisition, ValidateUserQuota call site, defer unlock |
internal/app/service/users/sso_invite.go:154-158 | createLaunchpadUser returns (repository.CreateUserRow, error); userInfo.ID (UUID) is the deduction key |
internal/app/service/users/delete.go:127-141 | Transaction commit → post-commit calls; deletedUser.IsConsultant field |
internal/app/service/users/main.go:63-87 | UserService struct; jobEnqueuer queue.IJobEnqueuer field is available |
internal/app/service/users/loyalty_sync.go:83 | IsEnabledFunc call pattern with IsEnabledParams{FeatureName, UniqueID} |
internal/app/consumer/sync_user_detail.go | gocraft/work consumer pattern: job.Args["data"] → marshal/unmarshal payload |
internal/app/queue/job_enqueuer.go:30-40 | EnqueueJob(ctx, jobName, params) — params serialized to job.Args["data"] |
internal/app/service/billings/iBillingService.go | BillingService struct has modpanel, cache, repo — add quotaManagementClient |
internal/pkg/constants/preferences.go | const FeatureXxx = "..." pattern |
internal/pkg/consts/worker.go | type JobName string; const SyncUserDetailJobName JobName = "SYNC_USER_DETAIL" |
cmd/initializer.go:91 | bs := billingService.NewBillingService(modPanelClient, billingCacheRepo, repo) — constructor to extend |
Patterns to Follow:
| Pattern | Reference file | Note |
|---|---|---|
| Feature flag check | internal/app/service/users/loyalty_sync.go:83 | IsEnabledFunc(ctx, preference.IsEnabledParams{FeatureName: ..., UniqueID: companySsoID.String()}) |
| Post-creation background job | internal/app/service/users/main.go:147-180 | enqueueSyncUserDetail — enqueue only if feature enabled; log error but continue |
gocraft/work consumer | internal/app/consumer/sync_user_detail.go | Unmarshal job.Args["data"], iterate, continue on per-item error |
| HTTP client for external API | internal/app/api/loyalty/client.go | gojek/heimdall client; timeout from config; interface + concrete struct |
| Error constant | internal/pkg/consts/error.go | var ErrXxx = errors.New("...") |
Source Verification:
| Claim | Evidence |
|---|---|
Redis lock key is lock:user_invite:{company_id} | user_handler.go:126 — lockKey := fmt.Sprintf("lock:user_invite:%s", inviter.CompanyID.String()) |
ValidateUserQuota is called under the lock in the handler | user_handler.go:154 — called after locked check, inside defer-unlock |
createLaunchpadUser returns repository.CreateUserRow | sso_invite.go:208 — function signature; userInfo.ID is uuid.UUID |
UserService holds jobEnqueuer queue.IJobEnqueuer | main.go:66 |
is_consultant field is on users table | db/schema.sql:37 — is_consultant BOOLEAN DEFAULT FALSE |
FindUserBySsoId returns is_consultant | db/query/users.sql — SELECT ... is_consultant FROM users WHERE sso_id = $1 |
CountUser excludes consultants and soft-deleted | db/query/users.sql:199 — AND deleted_at IS NULL AND (is_consultant IS NULL OR is_consultant = false) |
companies.external_company_id is available from GetCompanyAndPackageById | internal/app/repository/companies.sql.go — GetCompanyAndPackageByIdRow.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.go | worker.go:6-13 |
IsEnabledFunc is a package-level var in users service | internal/app/service/users/get_crs_permissions.go:26 — var IsEnabledFunc = preference.IsEnabled |
IBillingService interface is in iBillingService.go | iBillingService.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_tois any value except an error) → loguser_quota_deducted credited_to: "already-deducted"→ no-op, log idempotency hit — safe to continuecredited_to: "retry-deduction"→ system auto-requeued; treat as success- 5xx / timeout → enqueue
DEDUCT_USER_QUOTAbackground job; loguser_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 hitrefunded_to: "retry-refund"→ treat as success (system auto-requeued)- Any other error → log
user_quota_refund_failedwithuser_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
| Scenario | Behavior |
|---|---|
| Quota Management API unreachable during invite | Fail-open — invite allowed, user_quota_check_error logged |
| Quota Management API unreachable during deduction | Background retry via gocraft/work; user creation not rolled back |
| Quota Management API unreachable during refund | Log 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 unavailable | IsEnabledFunc 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
| Concern | Mitigation |
|---|---|
| Service account JWT exposure | JWT obtained at call time via client_credentials flow; never stored in DB or logged |
X-Api-Key exposure | Loaded from env var QUOTA_MANAGEMENT_API_KEY; never logged |
unique_code collision | create_user_{user_id} — UUID user_id ensures global uniqueness. billing_unique_logs DB constraint enforces at API level |
| Consultant bypass spoofing | Bypass is structural — route-level (/private/ requires Basic Auth from moderator-be); cannot be triggered by client-facing calls |
| Feature flag bypass | Flag is read from qontak-preferences server-side; client has no influence on it |
| Deduction for already-deleted user | Idempotency via unique_code — no double charge possible |
Monitoring & Alerting
| Event | Trigger | Alert |
|---|---|---|
user_quota_check_error | Quota Check API unreachable or 5xx | Alert if rate > 5% of check calls in 30 min window → Slack #bifrost-alerts |
user_quota_deduction_failed | gocraft/work retries exhausted | Alert on any occurrence → Slack #bifrost-alerts (zero tolerance post-GA) |
user_quota_refund_failed | Refund API call fails | Alert on occurrence → #bifrost-alerts |
user_quota_check_failed | is_sufficient: false blocks invite | Informational; 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 existingbillingService.ValidateUserQuota()(Modpanel-backed) continues running unchanged. - No DB schema changes.
- No new API endpoints on Launchpad.
create_consultant.gorequires no changes — bypass is structural.
Rollout Stages
Per PRD §9:
| Stage | Action | Success Gate |
|---|---|---|
| Stage 0 | Deploy code with flag OFF globally | Build green; zero behavior change in production |
| Stage 1 | Enable for ≤ 5 internal Bifrost test CIDs | 0 P0 bugs; check/deduction/refund all confirmed; consultant bypass verified |
| Stage 2 | Enable for 5–10 Qontak One CIDs (closed pilot) | Deduction success rate ≥ 99.9%; refund success rate ≥ 99.5%; 0 user_quota_deduction_failed |
| Backfill | Run backfill_user_quota command for all Qontak One CIDs | 100% CIDs backfilled; 0 unresolved user_quota_backfill_failed |
| Stage 3 | Staged 25% → 50% → 100% of Qontak One CIDs | Deduction failure rate ≤ 0.1% for 1 week per batch |
| GA | All Qontak One CIDs with flag ON by default | Staged gates sustained for 2 consecutive weeks |
Rollback Plan
- Toggle
user_quota_integration_enabled = OFFfor affected CIDs viaqontak-preferences— instant, no redeploy. - Any
DEDUCT_USER_QUOTAjobs in queue are safe to drain (idempotent) or purge viagocraft/workUI (SERVICE_WORKER_UI_PORT). - Revert code change only required if a bug exists in the flag=OFF path (i.e.
ValidateUserQuotais 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.gointernal/app/api/quota_management/client.gointernal/app/api/quota_management/check_quota.gointernal/app/api/quota_management/deduction.gointernal/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:
IQuotaManagementClientinterface withCheckQuota,Deduct,RefundmethodsQuotaManagementClientstruct implements interface withgojek/heimdallHTTP client (mirrorsinternal/app/api/loyalty/client.go)CheckQuotacorrectly maps responseextra_attrs.is_sufficientandextra_attrs.is_unlimitedDeductsendsunique_code: create_user_{user_id}, handlescredited_to: "already-deducted"as successRefundsends onlyX-Api-Key(no Bearer token); handlesrefunded_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— addQuotaManagementAPIstructconfig/load.go— loadQUOTA_MANAGEMENT_API_BASE_URL,QUOTA_MANAGEMENT_API_KEY,QUOTA_MANAGEMENT_API_TIMEOUTinternal/pkg/constants/preferences.go— addFeatureUserQuotaIntegration = "user_quota_integration_enabled"internal/pkg/consts/worker.go— addDeductUserQuotaJobName 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_URLQUOTA_MANAGEMENT_API_KEYQUOTA_MANAGEMENT_BILLING_CODE← new (value TBD from qontak-billing team — OQ-7)QUOTA_MANAGEMENT_API_TIMEOUT
Commands:
go build ./...
Acceptance criteria:
config.AppConfig.QuotaManagementAPIexported and loaded from envBillingCodeloaded fromQUOTA_MANAGEMENT_BILLING_CODEenv var- Feature flag constant name matches PRD exactly:
"user_quota_integration_enabled" - Job name constant follows
JobNametype pattern inworker.go
Chunk 3: Extend BillingService with quota management methods
Files:
internal/app/service/billings/iBillingService.go— extendIBillingService; addIQuotaManagementClient+ISsoClientfields toBillingServiceinternal/app/service/billings/quota_management.go—CheckUserQuota(),DeductUserQuota(),RefundUserQuota()implementationscmd/initializer.go— extendNewBillingService(...)call to passquotaManagementClientandssoClient
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:
CheckUserQuotafails open on 5xx → returns(true, nil); logsuser_quota_check_errorCheckUserQuotareturns(false, ErrInsufficientQuota)whenis_sufficient: false && !is_unlimitedDeductUserQuotareturnsniloncredited_to: "already-deducted"(idempotent)DeductUserQuotareturnsErrQuotaDeductionFailedon 5xx (signals caller to enqueue job)RefundUserQuotareturnsnilonrefunded_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.go — companyInfo.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 toSsoInvite() - 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 successcredited_to: "already-deducted"→ success, no job enqueued- Deduction NOT called when flag is OFF
- Deduction NOT called when
userInfo.IDis zero (guard)
Chunk 6: New background job consumer
Files:
internal/app/consumer/deduct_user_quota.gointernal/app/consumer/iConsumer.go— addDeductUserQuotaConsumertoIConsumerinterfaceinternal/worker/worker_service.go— registerDEDUCT_USER_QUOTAinregisterJob()cmd/initializer.go— passbillingServicetoconsumer.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
DeductUserQuotaPayloadfromjob.Args["data"] - Calls
BillingService.DeductUserQuota()— retries via job if 5xx credited_to: "already-deducted"→ returnsnil(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 returnsnil
Chunk 8: Backfill command
File: cmd/backfill_user_quota.go
Logic:
- Fetch all companies (or companies with Qontak One package — team to define scope)
- For each company, call
repo.CountUser(ctx, company.ID)— returns active non-consultant user count - 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_codeper 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-runflag 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_passedevents 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-billinglogs - Create consultant user → verify zero Quota Management API calls in Launchpad logs
Rollback steps:
- Disable flag: toggle
user_quota_integration_enabled = OFFfor all enabled CIDs inqontak-preferences - Monitor: confirm
user_quota_check_passedevents stop;user_quota_check_errorrates drop to zero - Drain
DEDUCT_USER_QUOTAqueue via worker UI (SERVICE_WORKER_UI_PORT) if needed - 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
| # | Question | Owner | Deadline |
|---|---|---|---|
| OQ-1 ✅ | Fail-safe behavior when Quota Check API is unreachable | Bifrost PM + Eng | Resolved 2026-06-23 — allow with logging (fail-open) |
| OQ-2 | Retry strategy for DEDUCT_USER_QUOTA job: max attempts and backoff values | Bifrost Eng | Before Stage 1 |
| OQ-3 | Does Refund API need gocraft/work retry? | Bifrost Eng | Before GA (deferred to follow-up RFC) |
| OQ-4 | billing_unique_logs dedup guarantees: DB UNIQUE constraint on key? | Bifrost Eng / qontak-billing | Before Stage 1 |
| OQ-5 | If sync_consultant_to_launchpad flag is toggled ON mid-tenancy, historical consultant users in Launchpad won't have is_consultant=true | Bifrost PM + Modpanel | Before Stage 3 |
| OQ-6 ✅ | Backfill should exclude consultants AND deactivated/suspended users | Bifrost PM | Resolved 2026-06-23 — exclude both |
| OQ-7 | billing_code value for user seat quota | qontak-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-8 | deduction_code and refund_code values for user seat operations | qontak-billing team | ✅ Resolved 2026-07-01 — deduction_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-9 | Which company_id to pass to the Quota Management API? | Bifrost Eng + qontak-billing | ✅ Resolved 2026-07-01 — use companies.external_company_id (integer cast to string). Guard: skip quota call if ExternalCompanyID is null/zero (fail-open). |
| OQ-10 | expectation_deduction in check-quota extra_attrs: empty object {} or specific field(s) for user seats? | Bifrost Eng | Before Stage 1 |
| OQ-11 | Should 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 Eng | Before GA — left in-place per Decision 6 in this RFC |
| OQ-12 | Backfill 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 team | Before backfill execution |
Known Limitations
- OQ-7 is a soft prerequisite — code can be merged with
QUOTA_MANAGEMENT_BILLING_CODEenv 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-billingteam's guidance.
6. Comment logs
| Date | Author | Comment |
|---|---|---|
| 2026-07-01 | fauzan.madani@mekari.com | Initial RFC created based on PRD v1.2. OQ-7, OQ-8, OQ-9 are blockers. OQ-3 deferred to follow-up. |
| 2026-07-01 | fauzan.madani@mekari.com | OQ-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_codevalue confirmed byqontak-billingteam → setQUOTA_MANAGEMENT_BILLING_CODEenv 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_CODEenv 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_QUOTAjob (team to setSERVICE_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-preferencesentry foruser_quota_integration_enabledtestable in staging