Task Breakdown: Integrate User Quota to Quota Management API
Source RFC:
integrate-user-quota.mdJira Epic: BIF-8715 Repository:qontak-launchpadat/Users/mekari/work/qontak-launchpad
Effort Summary
| Task | BE days | QA days | Total |
|---|---|---|---|
| T1 — Quota Management API client | 2.0 | 0 | 2.0 |
| T2 — Config + constants | 0.5 | 0 | 0.5 |
| T3 — BillingService extension | 1.5 | 0 | 1.5 |
| T4 — SsoInvite quota check (handler) | 1.0 | 0.5 | 1.5 |
| T5 — SsoInvite deduction (service) | 1.0 | 0.5 | 1.5 |
| T6 — Deduction retry background job | 1.5 | 0 | 1.5 |
| T7 — Delete refund (service) | 0.5 | 0.5 | 1.0 |
| T8 — Backfill command | 1.5 | 0.5 | 2.0 |
| Grand total | 9.5 | 2.0 | 11.5 |
Confidence: medium. Key assumptions: (1)
billing_codeenv var value will be confirmed byqontak-billingbefore staging enablement — code can be merged without it; (2) Quota Management API endpoint contract is stable in staging; (3) all 8 tasks are individually actionable now (OQ-8 and OQ-9 resolved).
Task 1: [BE] Quota Management API client (C1 — UQI-S01/UQI-S02/UQI-S03)
The system has a typed, testable HTTP client for the three Quota Management API operations: check-quota, deduction, and refund.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
A new package internal/app/api/quota_management/ with an interface file (iQuotaManagementClient.go), a constructor file (client.go), and one file per operation (check_quota.go, deduction.go, refund.go), each with a co-located _test.go. Mirrors the internal/app/api/loyalty/ package structure exactly.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/api/quota_management/iQuotaManagementClient.go | IQuotaManagementClient interface + all request/response types |
| create | internal/app/api/quota_management/client.go | QuotaManagementClient struct + NewQuotaManagementClient(baseURL, apiKey string, timeout time.Duration) constructor using gojek/heimdall |
| create | internal/app/api/quota_management/check_quota.go | CheckQuota(ctx, accessToken string, req CheckQuotaRequest) (CheckQuotaResponse, error) |
| create | internal/app/api/quota_management/check_quota_test.go | httptest server: 200 sufficient, 200 insufficient, 200 is_unlimited, 5xx, timeout, header assertions |
| create | internal/app/api/quota_management/deduction.go | Deduct(ctx, accessToken string, req DeductionRequest) (DeductionResponse, error) |
| create | internal/app/api/quota_management/deduction_test.go | httptest server: 200 success, already-deducted, retry-deduction, 5xx, timeout |
| create | internal/app/api/quota_management/refund.go | Refund(ctx context.Context, req RefundRequest) (RefundResponse, error) — no Bearer token, only X-Api-Key |
| create | internal/app/api/quota_management/refund_test.go | httptest server: 200 success, already-refunded, 5xx |
Implementation steps
-
Explore the loyalty client pattern — Open
internal/app/api/loyalty/client.goandinternal/app/api/loyalty/iLoyaltyClient.go. Note: constructor takes(baseUrl string, timeout time.Duration)and usesgojek/heimdall/v7/httpclient. The new client needs an additionalapiKey stringparameter forX-Api-Keyheader injection. -
Write the interface file — Create
internal/app/api/quota_management/iQuotaManagementClient.go:package apitype 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)}Define all request/response structs in this file. Key fields:
CheckQuotaRequest.ExtraAttrs.ExpectationDeduction→map[string]interface{}(pass{}per OQ-10)CheckQuotaResponse.ExtraAttrs.IsSufficient bool,IsUnlimited boolDeductionRequest.UniqueCode="create_user_{user_id}",DeductionCode="create_user_{user_id}",Quantity float64 = 1DeductionResponse.CreditedTo string— check for"already-deducted"or"retry-deduction"RefundRequest.UniqueCode="delete_user_{user_id}",RefundCode="delete_user_{user_id}",Quantity float64 = 1RefundResponse.RefundedTo string— check for"already-refunded"or"retry-refund"
-
Write failing tests — Create
check_quota_test.gofirst. Usenet/http/httptest.NewServerpattern frominternal/app/api/loyalty/create_user_test.go. Rungo test ./internal/app/api/quota_management/...to confirm compile-fail. -
Create
client.go— Constructor storesbaseURL,apiKey,client *httpclient.Client. Copy thehttpclientmw.LogMiddlewaresetup frominternal/app/api/loyalty/client.goverbatim. -
Implement
check_quota.go—POST {baseURL}/iag/v1/quota-managements/check-quota. Headers:Authorization: Bearer {accessToken},X-Api-Key: {apiKey},Content-Type: application/json. On 5xx or network error returnconsts.ErrUnableToReachService("quota management service"). On non-200/non-2xx HTTP status returnconsts.ErrFailedToProcessRequest. -
Implement
deduction.go—POST {baseURL}/iag/v1/quota-managements/deduction. Same auth headers as check-quota.credited_to: "already-deducted"and"retry-deduction"→ returnnil(treated as success — caller should not retry). -
Implement
refund.go—POST {baseURL}/iag/v1/quota-managements/refund. Header:X-Api-Key: {apiKey}only (no Bearer token).refunded_to: "already-refunded"and"retry-refund"→ returnnil. -
Go green —
go test -race ./internal/app/api/quota_management/...until all tests pass. -
Regenerate mocks —
make mocks. Verifyinternal/app/api/mocks/IQuotaManagementClient.gois generated. This mock is needed by T3–T7 tests.
Acceptance criteria
-
IQuotaManagementClientinterface compiled and exported frominternal/app/api/quota_management -
CheckQuota: returns(response, nil)on 200; returns(zero, ErrUnableToReachService)on 5xx/timeout -
Deduct: returns(response, nil)whencredited_tois"already-deducted"or"retry-deduction"(idempotent) -
Deduct: returns(zero, ErrUnableToReachService)on 5xx/timeout -
Refund: sends no Bearer token — onlyX-Api-Keyheader (test assertsAuthorizationheader is absent) -
Refund: returns(response, nil)whenrefunded_tois"already-refunded"or"retry-refund" - All test files use
httptest.NewServer(no real network calls) -
make mockssucceeds;internal/app/api/mocks/IQuotaManagementClient.gogenerated
Test strategy
Each _test.go creates an httptest.NewServer that captures the request and returns a scripted response, then calls NewQuotaManagementClient(server.URL, "test-key", 2*time.Second) directly. Key assertions: correct HTTP method, correct path, correct JSON body shape, correct header presence/absence (Authorization for check/deduct, absent for refund), correct error type on 5xx/timeout.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0 |
| Total | 2.0 |
Assumptions: mirrors the loyalty client pattern (verified in
internal/app/api/loyalty/) — no novel patterns. 3 operation files + interface + tests = ~2 dev-days.
Run to verify
go test -race ./internal/app/api/quota_management/...
make mocks
go build ./...
Depends on
- Nothing (first task)
Task 2: [BE] Config + constants (C2 — all stories)
The app can load Quota Management API credentials from environment and expose them via typed config; the feature flag name and job name constants exist for use throughout the codebase.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
Four small changes: new QuotaManagementAPI config struct + loader, two new constants, and DI wiring in cmd/initializer.go to construct the new client (constructor only — service wiring comes in T3).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | config/config.go | Add QuotaManagementAPI struct { BaseURL, APIKey, BillingCode string; Timeout time.Duration } and add QuotaManagementAPI QuotaManagementAPI field to AppConfig |
| extend | config/load.go | Load QUOTA_MANAGEMENT_API_BASE_URL, QUOTA_MANAGEMENT_API_KEY, QUOTA_MANAGEMENT_BILLING_CODE, QUOTA_MANAGEMENT_API_TIMEOUT using the existing getStringOrPanic / getDurationOrPanic helpers |
| extend | internal/pkg/constants/preferences.go | Add FeatureUserQuotaIntegration = "user_quota_integration_enabled" |
| extend | internal/pkg/consts/worker.go | Add DeductUserQuotaJobName JobName = "DEDUCT_USER_QUOTA" |
| extend | cmd/initializer.go | Construct quotaManagementClient := quotaManagementApi.NewQuotaManagementClient(appConfig.QuotaManagementAPI.BaseURL, appConfig.QuotaManagementAPI.APIKey, appConfig.QuotaManagementAPI.Timeout) after the existing client wiring block |
Implementation steps
-
Explore the config pattern — Open
config/config.go:104-124(ModPanelAPIstruct) andconfig/load.go:191-195to observe the standard struct + loader pattern. -
Add
QuotaManagementAPIstruct toconfig/config.go— Place it afterModPanelAPI. Field names must match env var names exactly (see Implementation Plan). -
Add loader block to
config/load.go— Copy theModPanelAPIblock pattern. UsegetStringOrPanicfor string fields,getDurationOrPanicforTimeout,getStringDefault("QUOTA_MANAGEMENT_BILLING_CODE", "")forBillingCode(not panic — value TBD from OQ-7). -
Add constants —
FeatureUserQuotaIntegrationtopreferences.gofollowing the exactconst (...)block pattern.DeductUserQuotaJobNametoworker.gofollowing thetype JobName string; const (...)pattern. -
Wire constructor in
cmd/initializer.go— Addimport quotaManagementApi "bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/api/quota_management"at the top and add theNewQuotaManagementClient(...)call in the "wiring clients" block (~line 76–82). Do not pass it to any service yet (T3 does that). -
Verify —
go build ./...must pass.
Acceptance criteria
-
go build ./...passes with the new fields (even with emptyQUOTA_MANAGEMENT_*env vars) -
config.AppConfig.QuotaManagementAPI.BillingCodeloads fromQUOTA_MANAGEMENT_BILLING_CODEwithout panicking when empty -
constants.FeatureUserQuotaIntegration == "user_quota_integration_enabled"(exact string match) -
consts.DeductUserQuotaJobName == "DEDUCT_USER_QUOTA"(exact string match) -
cmd/initializer.goconstructsquotaManagementClient(variable in scope for T3)
Test strategy
No unit tests needed for config loading (no business logic). Build + go vet is sufficient. The existing config/load_test.go provides the pattern if a smoke test is desired.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: pure wiring — no business logic. Pattern already exists for 6 other API clients in the same files.
Run to verify
go build ./...
go vet ./...
Depends on
- [Task 1] —
quotaManagementApi.NewQuotaManagementClientmust exist
Task 3: [BE] BillingService extension — CheckUserQuota, DeductUserQuota, RefundUserQuota (C3 — UQI-S01/UQI-S02/UQI-S03)
The billing service exposes three new methods the rest of the app calls to interact with the Quota Management API, with all fail-open/idempotency logic encapsulated here.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
A new file internal/app/service/billings/quota_management.go implementing CheckUserQuota, DeductUserQuota, and RefundUserQuota on BillingService. Extend IBillingService and BillingService struct to add the quotaManagementClient and ssoClient dependencies. Wire through cmd/initializer.go.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/billings/iBillingService.go | Add IQuotaManagementClient + ISsoClient fields to BillingService struct; add 3 new method signatures to IBillingService; update NewBillingService constructor |
| create | internal/app/service/billings/quota_management.go | Implement CheckUserQuota, DeductUserQuota, RefundUserQuota |
| create | internal/app/service/billings/quota_management_test.go | Unit tests using mocked IQuotaManagementClient and ISsoClient |
| extend | cmd/initializer.go | Pass quotaManagementClient and ssoClient to billingService.NewBillingService(...) |
Implementation steps
-
Read the existing billing service — Open
internal/app/service/billings/iBillingService.go(full file) andvalidate_user_quota.go. Note:BillingServicecurrently hasmodpanel api.IModPanelClient,cache repository.ICacheRepository,repo repository.Repository. The new fields addquotaManagementClient quotaManagementAPI.IQuotaManagementClientandssoClient ssoAPI.ISsoClient. -
Update
iBillingService.go:- Add imports for
quotaManagementAPIandssoAPI - Add two new fields to
BillingServicestruct - Update
NewBillingServicesignature:func NewBillingService(modpanel api.IModPanelClient, cache repository.ICacheRepository, repo repository.Repository, quotaManagementClient quotaManagementAPI.IQuotaManagementClient, ssoClient ssoAPI.ISsoClient) IBillingService - Add 3 method signatures to
IBillingService:CheckUserQuota(ctx context.Context, externalCompanyID int32) (bool, error)DeductUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) errorRefundUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) error
- Add imports for
-
Write failing tests — Create
quota_management_test.go. MockIQuotaManagementClientusinginternal/app/api/mocks/IQuotaManagementClient(generated in T1). Write test cases forCheckUserQuotabefore implementing the method. -
Implement
quota_management.go:CheckUserQuota(ctx, externalCompanyID int32) (bool, error):- Guard: if
externalCompanyID <= 0→ log warning, return(true, nil)(fail-open) - Get SSO token via
s.ssoClient.SsoGetToken(ctx, MekariSsoGetTokenPayload{GrantType: "client_credentials", Scope: "sso:profile"}) - Call
s.quotaManagementClient.CheckQuota(ctx, token.AccessToken, CheckQuotaRequest{BillingCode: config.GetAppConfig().QuotaManagementAPI.BillingCode, CompanyID: strconv.Itoa(int(externalCompanyID)), ExtraAttrs: ...}) - On
ErrUnableToReachServiceor any error → loguser_quota_check_errorwithcompany_id,error,fail_safe_action: "allow"→ return(true, nil)(fail-open) - If
resp.ExtraAttrs.IsUnlimited→ return(true, nil) - If
resp.ExtraAttrs.IsSufficient→ loguser_quota_check_passed→ return(true, nil) - Otherwise → log
user_quota_check_failed→ return(false, ErrInsufficientQuota)
DeductUserQuota(ctx, externalCompanyID int32, userID uuid.UUID) error:- Guard: if
externalCompanyID <= 0→ fail-open returnnil - Get SSO token
- Build
DeductionRequest{BillingCode: ..., CompanyID: strconv.Itoa(...), DeductionCode: "create_user_" + userID.String(), UniqueCode: "create_user_" + userID.String(), Quantity: 1, ExtraAttrs: {TransactionID: userID.String()}} - Call
s.quotaManagementClient.Deduct(...) - On
ErrUnableToReachService/ timeout → returnErrQuotaDeductionFailed(caller enqueues retry) - On success (including
"already-deducted","retry-deduction") → loguser_quota_deducted→ returnnil
RefundUserQuota(ctx, externalCompanyID int32, userID uuid.UUID) error:- Guard: if
externalCompanyID <= 0→ fail-open returnnil - Build
RefundRequest{CompanyID: strconv.Itoa(...), BillingCode: ..., RefundCode: "delete_user_" + userID.String(), UniqueCode: "delete_user_" + userID.String(), Quantity: 1} - Call
s.quotaManagementClient.Refund(...) - On success (including
"already-refunded","retry-refund") → loguser_quota_refunded→ returnnil - On error → return error (caller logs
user_quota_refund_failed)
Add
var ErrInsufficientQuota = errors.New("insufficient quota")andvar ErrQuotaDeductionFailed = errors.New("quota deduction failed")at top ofquota_management.go. - Guard: if
-
Update
cmd/initializer.go— ChangebillingService.NewBillingService(modPanelClient, billingCacheRepo, repo)tobillingService.NewBillingService(modPanelClient, billingCacheRepo, repo, quotaManagementClient, ssoClient). -
Regenerate mocks —
make mocks. Verifyinternal/app/service/mocks/IBillingService.gois updated with the 3 new methods. -
Go green —
go test -race ./internal/app/service/billings/...
Acceptance criteria
-
CheckUserQuotareturns(true, nil)when API is unreachable (fail-open) -
CheckUserQuotareturns(false, ErrInsufficientQuota)whenis_sufficient: false && !is_unlimited -
CheckUserQuotareturns(true, nil)whenis_unlimited: true -
DeductUserQuotareturnsnilwhencredited_to: "already-deducted"(idempotent) -
DeductUserQuotareturnsErrQuotaDeductionFailedon 5xx/unreachable -
RefundUserQuotareturnsnilwhenrefunded_to: "already-refunded"(idempotent) - Guard: all 3 methods return
nil/(true, nil)whenexternalCompanyID <= 0 -
make mockssucceeds;IBillingServicemock updated with new methods - Existing
validate_user_quota_test.gostill passes (no regression)
Test strategy
quota_management_test.go uses mocks.NewIQuotaManagementClient(t) (generated by mockery) and mocks.NewISsoClient(t). Each method gets table-driven tests covering: success, already-deducted/already-refunded idempotency, 5xx fail-open, externalCompanyID=0 guard. slog output is not asserted — side-effect logging is best-effort.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0 |
| Total | 1.5 |
Assumptions: 3 methods with clear fail-open specs;
ISsoClientmock already exists (or is generated viamake mocks); no DB migration needed.
Run to verify
go test -race ./internal/app/service/billings/...
make mocks
go build ./...
Depends on
- [Task 1] —
IQuotaManagementClientinterface + mock - [Task 2] —
config.AppConfig.QuotaManagementAPI,FeatureUserQuotaIntegration,DeductUserQuotaJobNameconstants
Task 4: [BE] SsoInvite quota check in handler (C4 — UQI-S01)
Client admins for Qontak One CIDs are blocked from inviting users when their seat quota is exhausted, via a real-time check against the Quota Management API inside the existing Redis lock.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
Modify user_handler.go SsoInvite(): after acquiring the Redis lock, check the user_quota_integration_enabled preference flag. If ON, call billingService.CheckUserQuota() and skip ValidateUserQuota(). If OFF, continue with ValidateUserQuota() as before. Update the handler test.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/handler/user_handler.go | In SsoInvite(): add GetCompanyAndPackageById call (for ExternalCompanyID + SsoID); add feature flag check; branch on flag to billingService.CheckUserQuota() or billingService.ValidateUserQuota() |
| extend | internal/app/handler/user_handler_test.go | Add test cases: flag ON + sufficient, flag ON + insufficient, flag ON + API unreachable (fail-open), flag OFF + falls through to ValidateUserQuota |
Implementation steps
-
Read the current
SsoInvitehandler — Openinternal/app/handler/user_handler.go:104-169. The handler currently: gets inviter, acquires lock, callsValidateUserQuota(ctx, req.InviterSsoID), callsuserService.SsoInvite. The new code inserts after the lock is acquired (line ~154). -
Add company lookup — After acquiring the lock, call
companyInfo, err := h.repo.GetCompanyAndPackageById(ctx, inviter.CompanyID). Handleerrasconsts.ErrRepository. This gives uscompanyInfo.ExternalCompanyID.Int32andcompanyInfo.SsoID. -
Add feature flag check and branch — Replace the single
ValidateUserQuotacall with:isEnabled, prefErr := preference.IsEnabled(ctx, preference.IsEnabledParams{FeatureName: constants.FeatureUserQuotaIntegration,UniqueID: companyInfo.SsoID.String(),})if prefErr != nil {slog.WarnContext(ctx, "failed to check quota integration preference", slog.Any("error", prefErr))// treat as flag OFF — fall through to existing checkisEnabled = false}if isEnabled {sufficient, quotaErr := h.billingService.CheckUserQuota(ctx, companyInfo.ExternalCompanyID.Int32)if quotaErr != nil || !sufficient {err = billingService.ErrInsufficientQuotareturn}} else {err = h.billingService.ValidateUserQuota(ctx, req.InviterSsoID)if err != nil {return}} -
Write tests — In
user_handler_test.go, add aTestUserHandler_SsoInvitefunction (or extend the existing one if present). MockbillingService.IBillingServiceusinginternal/app/service/mocks/IBillingService. Usehttptest.NewRecorder()+httptest.NewRequest()pattern from the existing handler tests.Test cases:
- Flag ON +
CheckUserQuotareturns(true, nil)→ handler proceeds, returns 200 - Flag ON +
CheckUserQuotareturns(false, ErrInsufficientQuota)→ handler returns 4xx - Flag ON +
CheckUserQuotafail-open(true, nil)on API error → handler proceeds - Flag OFF →
ValidateUserQuotacalled,CheckUserQuotanot called
- Flag ON +
-
Go green —
go test -race ./internal/app/handler/...
Acceptance criteria
- Flag ON +
is_sufficient: true→SsoInviteservice is called - Flag ON +
is_sufficient: false(non-unlimited) →SsoInviteis NOT called; response is 4xx - Flag ON +
is_unlimited: true→SsoInviteservice is called (handled insideCheckUserQuota) - Flag ON + API unreachable →
SsoInviteis called (fail-open;CheckUserQuotareturnstrue) - Flag OFF →
ValidateUserQuota()is called;CheckUserQuota()is NOT called - Preference check failure → treated as flag OFF (fail-safe)
- Existing handler tests pass without modification
Test strategy
TestUserHandler_SsoInvite uses mocks.NewIBillingService(t) to stub CheckUserQuota and ValidateUserQuota. The IsEnabledFunc global var (from internal/app/service/users/get_crs_permissions.go:26) is overridden in-test using the existing pattern: original := IsEnabledFunc; defer func() { IsEnabledFunc = original }(); IsEnabledFunc = func(...) (bool, error) { return true, nil }. Each test asserts mock call count and HTTP response status.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: IsEnabledFunc global override pattern is already established in the codebase;
IBillingServicemock is regenerated in T3.
Run to verify
go test -race ./internal/app/handler/...
go build ./...
Depends on
- [Task 2] —
constants.FeatureUserQuotaIntegration - [Task 3] —
billingService.CheckUserQuota,billingService.ErrInsufficientQuota, updatedIBillingServicemock
Task 5: [BE] SsoInvite deduction after user creation (C5 — UQI-S02)
Every successful standard user creation for a Qontak One CID triggers a quota deduction; failures are queued for background retry without blocking the invite response.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
Modify internal/app/service/users/sso_invite.go: after createLaunchpadUser() succeeds, check the feature flag, call billingService.DeductUserQuota(), and on ErrQuotaDeductionFailed enqueue a DEDUCT_USER_QUOTA background job. Update sso_invite_test.go.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/users/sso_invite.go | After createLaunchpadUser() success: feature flag check → billingService.DeductUserQuota() → on ErrQuotaDeductionFailed → jobEnqueuer.EnqueueJob(DEDUCT_USER_QUOTA, payload) |
| extend | internal/app/service/users/sso_invite_test.go | Add test cases for deduction success, 5xx → job enqueued, already-deducted → no job |
Implementation steps
-
Read
sso_invite.golines 150-165 —createLaunchpadUser()is called at line ~155 and returns(repository.CreateUserRow, error).userInfo.ID(UUID) is the deduction key.companyInfo(fetched at line ~96) providesExternalCompanyID.Int32.IsEnabledFuncis already imported in this package (get_crs_permissions.go:26). -
Add deduction block after
createLaunchpadUser():isQuotaEnabled, _ := IsEnabledFunc(ctx, preference.IsEnabledParams{FeatureName: constants.FeatureUserQuotaIntegration,UniqueID: companyInfo.SsoID.String(),})if isQuotaEnabled {if deductErr := s.billingService.DeductUserQuota(ctx, companyInfo.ExternalCompanyID.Int32, userInfo.ID); deductErr != nil {if errors.Is(deductErr, billingService.ErrQuotaDeductionFailed) {payload := consumer.DeductUserQuotaPayload{ExternalCompanyID: companyInfo.ExternalCompanyID.Int32,UserID: userInfo.ID.String(),}if _, enqErr := s.jobEnqueuer.EnqueueJob(ctx, string(consts.DeductUserQuotaJobName), payload); enqErr != nil {slog.ErrorContext(ctx, "failed to enqueue deduct user quota job", slog.Any("error", enqErr))}slog.WarnContext(ctx, "user_quota_deduction_retry",slog.String("company_id", strconv.Itoa(int(companyInfo.ExternalCompanyID.Int32))),slog.String("user_id", userInfo.ID.String()),)}// Never return error — user creation succeeded}}Note: errors from
IsEnabledFuncare silently ignored (flag treated as OFF) to be consistent withenqueueSyncUserDetailpattern inmain.go:147. -
Add
consumerimport — Import"bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/consumer"insso_invite.goto useconsumer.DeductUserQuotaPayload(struct defined in T6 — or define payload in a shared location; see note below).Note on import cycle:
consumerimportsrepositoryandapipackages but notservice/users. Theservice/userspackage can safely importconsumerfor the payload type. If an import cycle is detected, moveDeductUserQuotaPayloadtointernal/pkg/consts/worker.goor a newinternal/pkg/payload/package instead. -
Update
sso_invite_test.go:- Add
mockBilling *mockBillingServicefield to the test struct - Add test cases:
IsEnabledFunc = true+DeductUserQuotareturnsnil→ no job enqueued, no error returnedIsEnabledFunc = true+DeductUserQuotareturnsErrQuotaDeductionFailed→jobEnqueuer.EnqueueJobcalled once; function still returns(userInfo, nil)IsEnabledFunc = false→DeductUserQuotaNOT called
- Add
-
Go green —
go test -race ./internal/app/service/users/... -run TestUserService_SsoInvite
Acceptance criteria
- After successful
createLaunchpadUser(),DeductUserQuota()is called when flag is ON -
ErrQuotaDeductionFailedfrom deduction →EnqueueJob(DEDUCT_USER_QUOTA, ...)called once;SsoInvitestill returns(userInfo, nil) -
DeductUserQuotasuccess → no job enqueued -
DeductUserQuotareturnsnilon already-deducted → no job, no error - Flag OFF →
DeductUserQuotaNOT called; no job enqueued -
createLaunchpadUser()failure (before deduction) → deduction NOT called (guard on existing error check)
Test strategy
Uses the existing mockBillingService in billing_mock_test.go. mockBillingService gets a new DeductUserQuota(ctx, externalCompanyID int32, userID uuid.UUID) error stub. queueMocks.NewIJobEnqueuer(t) stubs EnqueueJob. The IsEnabledFunc global is overridden per test.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions:
billingServicemock (billing_mock_test.go) is already in scope; payload type is defined in T6 first (or temporarily inlined and moved).
Run to verify
go test -race ./internal/app/service/users/... -run TestUserService_SsoInvite
go build ./...
Depends on
- [Task 2] —
consts.DeductUserQuotaJobName,constants.FeatureUserQuotaIntegration - [Task 3] —
billingService.DeductUserQuota,billingService.ErrQuotaDeductionFailed - [Task 6] —
consumer.DeductUserQuotaPayloadstruct (T6 should be merged first or payload extracted to shared package)
Task 6: [BE] Deduction retry background job consumer (C6 — UQI-S02/ERR-1)
Failed quota deductions are retried automatically via a background job, ensuring eventual deduction without blocking the invite flow.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
New internal/app/consumer/deduct_user_quota.go implementing DeductUserQuotaConsumer. Extend IConsumer interface and Consumer struct. Add billingService dependency to Consumer. Register the job in worker_service.go. Wire in cmd/initializer.go.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/consumer/iConsumer.go | Add billingService billingService.IBillingService field to Consumer struct; add DeductUserQuotaConsumer to IConsumer interface; update NewConsumer signature |
| create | internal/app/consumer/deduct_user_quota.go | DeductUserQuotaConsumer(ctx, job) error implementation |
| create | internal/app/consumer/deduct_user_quota_test.go | Unit tests: success, already-deducted, 5xx (returns error → triggers retry) |
| extend | internal/worker/worker_service.go | Register DEDUCT_USER_QUOTA job in registerJob() |
| extend | cmd/initializer.go | Pass bs (billing service) to consumer.NewConsumer(...) |
Implementation steps
-
Read
iConsumer.goandsync_user_detail.go— Openinternal/app/consumer/iConsumer.go(full file) andinternal/app/consumer/sync_user_detail.go:21-72for the canonical consumer pattern: unmarshaljob.Args["data"], iterate, log per-item errors, continue. -
Update
iConsumer.go:- Add import for
billingService "bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/service/billings" - Add
billingService billingService.IBillingServicefield toConsumerstruct - Add
DeductUserQuotaConsumer(jctx context.Context, job *work.Job) errortoIConsumerinterface - Update
NewConsumer(...)signature to acceptbillingService billingService.IBillingService
- Add import for
-
Define
DeductUserQuotaPayloadindeduct_user_quota.go:type DeductUserQuotaPayload struct {ExternalCompanyID int32 `json:"external_company_id"`UserID string `json:"user_id"`} -
Implement
DeductUserQuotaConsumer— FollowSyncUserDetailConsumerpattern exactly:func (c *Consumer) DeductUserQuotaConsumer(ctx context.Context, job *work.Job) error {var payload DeductUserQuotaPayloadjsonStr, err := json.Marshal(job.Args["data"])// ... unmarshal pattern ...userID, err := uuid.Parse(payload.UserID)if err != nil { ... }err = c.billingService.DeductUserQuota(ctx, payload.ExternalCompanyID, userID)if err != nil {if errors.Is(err, billingService.ErrQuotaDeductionFailed) {// Return error → gocraft/work will retryslog.ErrorContext(ctx, "user_quota_deduction_retry", ...)return err}// Non-retriable error (e.g. config issue) — log as failed, don't retryslog.ErrorContext(ctx, "user_quota_deduction_failed", ...)return nil // Return nil to prevent further retries for permanent failures}slog.InfoContext(ctx, "user_quota_deducted", ...)return nil}On exhausted retries (
job.Fails >= maxFails),gocraft/workmoves the job to the dead queue automatically. The consumer always returnsErrQuotaDeductionFailedfor 5xx so retries continue until max. -
Register in
worker_service.go— InregisterJob(), add:deductUserQuotaConsumer := workerList.DeductUserQuotaConsumerregisterJobWithOptions(string(consts.DeductUserQuotaJobName), options, deductUserQuotaConsumer, pool) -
Wire in
cmd/initializer.go— Updateconsumer.NewConsumer(cs, repo, crmClient, chatClient, ssoClient, cacheRepo, mailer, db)to includebs(billing service). -
Write tests —
deduct_user_quota_test.gowith mockedIBillingService:- Success:
DeductUserQuotareturnsnil→ consumer returnsnil - Already-deducted (idempotent):
DeductUserQuotareturnsnil(handled inBillingService) → consumer returnsnil - 5xx (retriable):
DeductUserQuotareturnsErrQuotaDeductionFailed→ consumer returns error (triggering retry)
- Success:
-
Go green —
go test -race ./internal/app/consumer/...
Acceptance criteria
- Consumer correctly unmarshals
DeductUserQuotaPayloadfromjob.Args["data"] -
DeductUserQuotasuccess → consumer returnsnil(job completes) -
ErrQuotaDeductionFailed→ consumer returns error (triggersgocraft/workretry) -
DEDUCT_USER_QUOTAjob is registered inregisterJob()and appears in worker pool - Existing consumer tests (
check_expiry,sync_user_detail, etc.) pass — no regression fromNewConsumersignature change
Test strategy
deduct_user_quota_test.go uses mock.IBillingService stub. Each test constructs Consumer{billingService: mockBilling} directly (no need for full DI). Job payload is constructed as work.Job{Args: work.Q{"data": DeductUserQuotaPayload{...}}}. Key assertions: return value (nil vs error), mock call count.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0 |
| Total | 1.5 |
Assumptions: consumer pattern is verbatim from
sync_user_detail.go;NewConsumersignature change requires updatingcmd/initializer.gobut no other callers exist.
Run to verify
go test -race ./internal/app/consumer/...
go test -race ./internal/worker/...
go build ./...
Depends on
- [Task 2] —
consts.DeductUserQuotaJobName - [Task 3] —
billingService.IBillingService.DeductUserQuota,billingService.ErrQuotaDeductionFailed
Task 7: [BE] Delete refund after user deletion (C7 — UQI-S03/UQI-S04)
When a standard user is deleted, their seat quota is returned to the company's balance; consultant users are skipped automatically.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
Modify internal/app/service/users/delete.go: after commitTx(), check the feature flag and deletedUser.IsConsultant. If flag ON and not consultant, call billingService.RefundUserQuota(). Log failures; never return an error for refund failure. Update delete_test.go.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/users/delete.go | After commitTx() (~line 141): feature flag check → consultant guard → billingService.RefundUserQuota() → log failure |
| extend | internal/app/service/users/delete_test.go | Add test cases: flag ON + non-consultant → refund called; flag ON + is_consultant=true → refund skipped; flag ON + refund error → Delete returns nil |
Implementation steps
-
Read
delete.golines 103–155 — The transaction commits at ~line 140 (commitTx()). Post-commit external calls start at line ~152 (SSO token, CRM delete, CRS delete, etc.). The refund call fits naturally in this block as a "best-effort" operation that never fails the Delete function.companyInfois already fetched at line ~31. -
Add refund block immediately after
commitTx()(before thedeletedUser.Status.String != "pending"check):// Quota refund (best-effort — never fails Delete)isQuotaEnabled, _ := IsEnabledFunc(ctx, preference.IsEnabledParams{FeatureName: constants.FeatureUserQuotaIntegration,UniqueID: companyInfo.SsoID.String(),})if isQuotaEnabled && !deletedUser.IsConsultant {if refundErr := s.billingService.RefundUserQuota(ctx, companyInfo.ExternalCompanyID.Int32, deletedUser.ID); refundErr != nil {slog.ErrorContext(ctx, "user_quota_refund_failed",slog.String("user_id", deletedUser.ID.String()),slog.String("company_id", strconv.Itoa(int(companyInfo.ExternalCompanyID.Int32))),slog.Any("error", refundErr),)} else {slog.InfoContext(ctx, "user_quota_refunded",slog.String("user_id", deletedUser.ID.String()),slog.String("unique_code", "delete_user_"+deletedUser.ID.String()),)}} else if isQuotaEnabled && deletedUser.IsConsultant {slog.InfoContext(ctx, "user_quota_consultant_bypass",slog.String("user_id", deletedUser.ID.String()),slog.String("company_id", companyInfo.SsoID.String()),)} -
Update
delete_test.go— Add test cases toTestUserService_Delete:- Flag ON +
is_consultant=false→RefundUserQuotacalled once;Deletereturnsnil - Flag ON +
is_consultant=true→RefundUserQuotaNOT called;Deletereturnsnil - Flag ON +
RefundUserQuotareturns error →Deletestill returnsnil - Flag OFF →
RefundUserQuotaNOT called
- Flag ON +
-
Go green —
go test -race ./internal/app/service/users/... -run TestUserService_Delete
Acceptance criteria
- Flag ON + non-consultant →
RefundUserQuota(ctx, externalCompanyID, deletedUser.ID)called aftercommitTx() - Flag ON +
is_consultant = true→RefundUserQuotaNOT called;Deletereturnsnil -
RefundUserQuotaerror →Deletereturnsnil(best-effort);user_quota_refund_failedlogged -
RefundUserQuotasuccess →user_quota_refundedlogged - Flag OFF → no quota interaction; existing delete behavior unchanged
- Refund call happens post-commit (after
commitTx()) — never inside the transaction
Test strategy
Extends TestUserService_Delete using the existing mockBillingService (billing_mock_test.go). Add RefundUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) error stub to mockBillingService. IsEnabledFunc is overridden per test. Key assertions: mock call count and that Delete never returns an error due to refund failure.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: follows the existing "best-effort post-commit" pattern already used in
delete.gofor CRM/Chat/SSO calls;mockBillingServiceonly needs one new stub method.
Run to verify
go test -race ./internal/app/service/users/... -run TestUserService_Delete
go build ./...
Depends on
- [Task 2] —
constants.FeatureUserQuotaIntegration - [Task 3] —
billingService.RefundUserQuota
Task 8: [BE] Backfill command (C8 — UQI-S05)
Engineering can run a one-time CLI command to seed the Quota Management API with existing active non-consultant user counts per CID, so go-live balances are accurate.
Status: ✅ Actionable
Design reference: n/a — backend only
What to build
A new Go sub-command cmd/backfill_user_quota.go (wired into cmd/root.go or as a standalone cobra command). It iterates all companies, calls repo.CountUser per company, and calls billingService.DeductUserQuota for each CID with a count > 0. Supports --dry-run flag and outputs a summary log.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | cmd/backfill_user_quota.go | Cobra command backfill-user-quota with --dry-run flag |
| extend | cmd/root.go | Register backfillUserQuotaCmd |
Implementation steps
-
Read
cmd/root.goandcmd/seed_encryption.go— Opencmd/seed_encryption.goto observe how existing one-shot commands are structured (Cobra command, DI viaInitInjections()or direct repo/service construction, flag parsing). -
Create
cmd/backfill_user_quota.go:var backfillUserQuotaCmd = &cobra.Command{Use: "backfill-user-quota",Short: "Seed user quota counts into the Quota Management API for all active CIDs",RunE: runBackfillUserQuota,}var dryRun boolfunc init() {backfillUserQuotaCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Log counts without calling the Quota Management API")rootCmd.AddCommand(backfillUserQuotaCmd)} -
Implement
runBackfillUserQuota:- Init config, DB, billing service (using
cmd/initializer.gopattern or direct construction) - Fetch all companies:
repo.GetAllCompanies(ctx)(or equivalent existing query — checkinternal/app/repository/) - For each company:
- Call
repo.CountUser(ctx, company.ID)→ returns non-consultant, non-deleted count - If count == 0: log and skip
- If
--dry-run: logcompany_id={}, count={}and continue - Else: call
billingService.DeductUserQuota(ctx, company.ExternalCompanyID.Int32, syntheticBackfillUserID)wheresyntheticBackfillUserIDis a deterministic UUID derived fromcompany.IDto ensure a stableunique_codeper CIDNote on unique_code for backfill: Using
create_user_{user_id}format with a synthetic UUID meansunique_code = "create_user_backfill_{company_id}"— this avoids collision with real user IDs. UpdateDeductUserQuotaor add an overload that accepts a customuniqueCodestring. Alternatively, call the Quota Management API directly in the backfill command withunique_code = "backfill_{company_id}"andquantity = count(bulk deduction) — pending OQ-12 confirmation on whether the API supports quantity > 1. - On error: log
user_quota_backfill_failedwithcompany_idand error; continue to next CID
- Call
- Print final summary: total processed, total skipped (count=0), total failed
- Init config, DB, billing service (using
-
Check for existing
GetAllCompaniesquery — Rungrep -rn "GetAllCompanies\|GetAllActiveCompany\|ListAllCompanies" internal/app/repository/. If no suitable query exists, add-- name: GetAllActiveCompanies :manytodb/query/companies.sqland regenerate withmake sqlc(or equivalent). -
Write smoke test — A simple table-driven test constructing the command and running
backfillUserQuotaCmd.Flags().Lookup("dry-run")to verify flag registration. Full integration test requires a real DB — out of scope for unit tests. -
Go green —
go build ./...
Acceptance criteria
-
./bin/qontak-launchpad backfill-user-quota --dry-runruns without error and logs per-CID counts without calling the API - Without
--dry-run:DeductUserQuota(or equivalent bulk call) invoked for each CID with count > 0 - Per-CID failure → logs
user_quota_backfill_failedand continues to next CID (does not halt) - Final summary output: total CIDs processed, skipped, failed
-
unique_codescheme is stable across re-runs (idempotent — duplicate run does not double-deduct) - CIDs with
ExternalCompanyID.Valid = falseorcount = 0are skipped
Test strategy
No full integration test. Unit-test the flag registration and that --dry-run prevents API calls using a mocked billing service. The actual end-to-end is validated by a dry run against staging data before executing against production.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: an existing
GetAllCompanies-equivalent query exists or is trivial to add; bulk deduction (quantity > 1) is confirmed supported by qontak-billing (OQ-12) — if not, loop with individual calls andunique_code = "backfill_{company_id}".
Run to verify
make build
./bin/qontak-launchpad backfill-user-quota --dry-run
go test -race ./cmd/...
Depends on
- [Task 2] — config, billing service DI
- [Task 3] —
billingService.DeductUserQuota
Ordering rationale
-
T1 → T2 → T3 are the foundation and must ship in order. T1 (API client) is a pure library with no dependencies; T2 (config + constants) makes the client usable; T3 (billing service) provides the high-level methods the rest of the app calls.
-
T4, T5, T6, T7, T8 can start in parallel after T3 — they are independent modifications to different files. Recommended parallel split: (T4 + T7) on one engineer, (T5 + T6) on another, T8 on a third if bandwidth allows.
-
T6 should merge before T5 — T5 references
consumer.DeductUserQuotaPayloadwhich is defined in T6. If merging T5 first, temporarily inline the payload struct and replace it when T6 merges. -
Critical path: T1 (2d) → T2 (0.5d) → T3 (1.5d) → T5+T6 (2.5d) = 6.5 engineering days to full invite-side quota integration. Deletion refund (T7) and backfill (T8) can trail without blocking the invite path.
-
External blocker to push on:
QUOTA_MANAGEMENT_BILLING_CODEenv var value from theqontak-billingteam (OQ-7). All code can be merged without it, but staging enablement (user_quota_integration_enabled = ONfor any CID) requires this value to be provisioned first.
Skipped stories
No stories were fully blocked or excluded. All 5 PRD user stories (UQI-S01 through UQI-S05) are addressed in the 8 tasks above. OQ-7 (billing_code value) is a pre-staging-enablement prerequisite but not a code-merge blocker.