Skip to main content

Task Breakdown: Integrate User Quota to Quota Management API

Source RFC: integrate-user-quota.md Jira Epic: BIF-8715 Repository: qontak-launchpad at /Users/mekari/work/qontak-launchpad


Effort Summary

TaskBE daysQA daysTotal
T1 — Quota Management API client2.002.0
T2 — Config + constants0.500.5
T3 — BillingService extension1.501.5
T4 — SsoInvite quota check (handler)1.00.51.5
T5 — SsoInvite deduction (service)1.00.51.5
T6 — Deduction retry background job1.501.5
T7 — Delete refund (service)0.50.51.0
T8 — Backfill command1.50.52.0
Grand total9.52.011.5

Confidence: medium. Key assumptions: (1) billing_code env var value will be confirmed by qontak-billing before 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

ActionFileWhat changes
createinternal/app/api/quota_management/iQuotaManagementClient.goIQuotaManagementClient interface + all request/response types
createinternal/app/api/quota_management/client.goQuotaManagementClient struct + NewQuotaManagementClient(baseURL, apiKey string, timeout time.Duration) constructor using gojek/heimdall
createinternal/app/api/quota_management/check_quota.goCheckQuota(ctx, accessToken string, req CheckQuotaRequest) (CheckQuotaResponse, error)
createinternal/app/api/quota_management/check_quota_test.gohttptest server: 200 sufficient, 200 insufficient, 200 is_unlimited, 5xx, timeout, header assertions
createinternal/app/api/quota_management/deduction.goDeduct(ctx, accessToken string, req DeductionRequest) (DeductionResponse, error)
createinternal/app/api/quota_management/deduction_test.gohttptest server: 200 success, already-deducted, retry-deduction, 5xx, timeout
createinternal/app/api/quota_management/refund.goRefund(ctx context.Context, req RefundRequest) (RefundResponse, error) — no Bearer token, only X-Api-Key
createinternal/app/api/quota_management/refund_test.gohttptest server: 200 success, already-refunded, 5xx

Implementation steps

  1. Explore the loyalty client pattern — Open internal/app/api/loyalty/client.go and internal/app/api/loyalty/iLoyaltyClient.go. Note: constructor takes (baseUrl string, timeout time.Duration) and uses gojek/heimdall/v7/httpclient. The new client needs an additional apiKey string parameter for X-Api-Key header injection.

  2. Write the interface file — Create internal/app/api/quota_management/iQuotaManagementClient.go:

    package 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)
    }

    Define all request/response structs in this file. Key fields:

    • CheckQuotaRequest.ExtraAttrs.ExpectationDeductionmap[string]interface{} (pass {} per OQ-10)
    • CheckQuotaResponse.ExtraAttrs.IsSufficient bool, IsUnlimited bool
    • DeductionRequest.UniqueCode = "create_user_{user_id}", DeductionCode = "create_user_{user_id}", Quantity float64 = 1
    • DeductionResponse.CreditedTo string — check for "already-deducted" or "retry-deduction"
    • RefundRequest.UniqueCode = "delete_user_{user_id}", RefundCode = "delete_user_{user_id}", Quantity float64 = 1
    • RefundResponse.RefundedTo string — check for "already-refunded" or "retry-refund"
  3. Write failing tests — Create check_quota_test.go first. Use net/http/httptest.NewServer pattern from internal/app/api/loyalty/create_user_test.go. Run go test ./internal/app/api/quota_management/... to confirm compile-fail.

  4. Create client.go — Constructor stores baseURL, apiKey, client *httpclient.Client. Copy the httpclientmw.LogMiddleware setup from internal/app/api/loyalty/client.go verbatim.

  5. Implement check_quota.goPOST {baseURL}/iag/v1/quota-managements/check-quota. Headers: Authorization: Bearer {accessToken}, X-Api-Key: {apiKey}, Content-Type: application/json. On 5xx or network error return consts.ErrUnableToReachService("quota management service"). On non-200/non-2xx HTTP status return consts.ErrFailedToProcessRequest.

  6. Implement deduction.goPOST {baseURL}/iag/v1/quota-managements/deduction. Same auth headers as check-quota. credited_to: "already-deducted" and "retry-deduction" → return nil (treated as success — caller should not retry).

  7. Implement refund.goPOST {baseURL}/iag/v1/quota-managements/refund. Header: X-Api-Key: {apiKey} only (no Bearer token). refunded_to: "already-refunded" and "retry-refund" → return nil.

  8. Go greengo test -race ./internal/app/api/quota_management/... until all tests pass.

  9. Regenerate mocksmake mocks. Verify internal/app/api/mocks/IQuotaManagementClient.go is generated. This mock is needed by T3–T7 tests.

Acceptance criteria

  • IQuotaManagementClient interface compiled and exported from internal/app/api/quota_management
  • CheckQuota: returns (response, nil) on 200; returns (zero, ErrUnableToReachService) on 5xx/timeout
  • Deduct: returns (response, nil) when credited_to is "already-deducted" or "retry-deduction" (idempotent)
  • Deduct: returns (zero, ErrUnableToReachService) on 5xx/timeout
  • Refund: sends no Bearer token — only X-Api-Key header (test asserts Authorization header is absent)
  • Refund: returns (response, nil) when refunded_to is "already-refunded" or "retry-refund"
  • All test files use httptest.NewServer (no real network calls)
  • make mocks succeeds; internal/app/api/mocks/IQuotaManagementClient.go generated

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

DisciplineDays
Backend2.0
QA0
Total2.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

ActionFileWhat changes
extendconfig/config.goAdd QuotaManagementAPI struct { BaseURL, APIKey, BillingCode string; Timeout time.Duration } and add QuotaManagementAPI QuotaManagementAPI field to AppConfig
extendconfig/load.goLoad QUOTA_MANAGEMENT_API_BASE_URL, QUOTA_MANAGEMENT_API_KEY, QUOTA_MANAGEMENT_BILLING_CODE, QUOTA_MANAGEMENT_API_TIMEOUT using the existing getStringOrPanic / getDurationOrPanic helpers
extendinternal/pkg/constants/preferences.goAdd FeatureUserQuotaIntegration = "user_quota_integration_enabled"
extendinternal/pkg/consts/worker.goAdd DeductUserQuotaJobName JobName = "DEDUCT_USER_QUOTA"
extendcmd/initializer.goConstruct quotaManagementClient := quotaManagementApi.NewQuotaManagementClient(appConfig.QuotaManagementAPI.BaseURL, appConfig.QuotaManagementAPI.APIKey, appConfig.QuotaManagementAPI.Timeout) after the existing client wiring block

Implementation steps

  1. Explore the config pattern — Open config/config.go:104-124 (ModPanelAPI struct) and config/load.go:191-195 to observe the standard struct + loader pattern.

  2. Add QuotaManagementAPI struct to config/config.go — Place it after ModPanelAPI. Field names must match env var names exactly (see Implementation Plan).

  3. Add loader block to config/load.go — Copy the ModPanelAPI block pattern. Use getStringOrPanic for string fields, getDurationOrPanic for Timeout, getStringDefault("QUOTA_MANAGEMENT_BILLING_CODE", "") for BillingCode (not panic — value TBD from OQ-7).

  4. Add constantsFeatureUserQuotaIntegration to preferences.go following the exact const (...) block pattern. DeductUserQuotaJobName to worker.go following the type JobName string; const (...) pattern.

  5. Wire constructor in cmd/initializer.go — Add import quotaManagementApi "bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/api/quota_management" at the top and add the NewQuotaManagementClient(...) call in the "wiring clients" block (~line 76–82). Do not pass it to any service yet (T3 does that).

  6. Verifygo build ./... must pass.

Acceptance criteria

  • go build ./... passes with the new fields (even with empty QUOTA_MANAGEMENT_* env vars)
  • config.AppConfig.QuotaManagementAPI.BillingCode loads from QUOTA_MANAGEMENT_BILLING_CODE without panicking when empty
  • constants.FeatureUserQuotaIntegration == "user_quota_integration_enabled" (exact string match)
  • consts.DeductUserQuotaJobName == "DEDUCT_USER_QUOTA" (exact string match)
  • cmd/initializer.go constructs quotaManagementClient (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

DisciplineDays
Backend0.5
QA0
Total0.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.NewQuotaManagementClient must 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

ActionFileWhat changes
extendinternal/app/service/billings/iBillingService.goAdd IQuotaManagementClient + ISsoClient fields to BillingService struct; add 3 new method signatures to IBillingService; update NewBillingService constructor
createinternal/app/service/billings/quota_management.goImplement CheckUserQuota, DeductUserQuota, RefundUserQuota
createinternal/app/service/billings/quota_management_test.goUnit tests using mocked IQuotaManagementClient and ISsoClient
extendcmd/initializer.goPass quotaManagementClient and ssoClient to billingService.NewBillingService(...)

Implementation steps

  1. Read the existing billing service — Open internal/app/service/billings/iBillingService.go (full file) and validate_user_quota.go. Note: BillingService currently has modpanel api.IModPanelClient, cache repository.ICacheRepository, repo repository.Repository. The new fields add quotaManagementClient quotaManagementAPI.IQuotaManagementClient and ssoClient ssoAPI.ISsoClient.

  2. Update iBillingService.go:

    • Add imports for quotaManagementAPI and ssoAPI
    • Add two new fields to BillingService struct
    • Update NewBillingService signature: 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) error
      RefundUserQuota(ctx context.Context, externalCompanyID int32, userID uuid.UUID) error
  3. Write failing tests — Create quota_management_test.go. Mock IQuotaManagementClient using internal/app/api/mocks/IQuotaManagementClient (generated in T1). Write test cases for CheckUserQuota before implementing the method.

  4. 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 ErrUnableToReachService or any error → log user_quota_check_error with company_id, error, fail_safe_action: "allow" → return (true, nil) (fail-open)
    • If resp.ExtraAttrs.IsUnlimited → return (true, nil)
    • If resp.ExtraAttrs.IsSufficient → log user_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 return nil
    • 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 → return ErrQuotaDeductionFailed (caller enqueues retry)
    • On success (including "already-deducted", "retry-deduction") → log user_quota_deducted → return nil

    RefundUserQuota(ctx, externalCompanyID int32, userID uuid.UUID) error:

    • Guard: if externalCompanyID <= 0 → fail-open return nil
    • 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") → log user_quota_refunded → return nil
    • On error → return error (caller logs user_quota_refund_failed)

    Add var ErrInsufficientQuota = errors.New("insufficient quota") and var ErrQuotaDeductionFailed = errors.New("quota deduction failed") at top of quota_management.go.

  5. Update cmd/initializer.go — Change billingService.NewBillingService(modPanelClient, billingCacheRepo, repo) to billingService.NewBillingService(modPanelClient, billingCacheRepo, repo, quotaManagementClient, ssoClient).

  6. Regenerate mocksmake mocks. Verify internal/app/service/mocks/IBillingService.go is updated with the 3 new methods.

  7. Go greengo test -race ./internal/app/service/billings/...

Acceptance criteria

  • CheckUserQuota returns (true, nil) when API is unreachable (fail-open)
  • CheckUserQuota returns (false, ErrInsufficientQuota) when is_sufficient: false && !is_unlimited
  • CheckUserQuota returns (true, nil) when is_unlimited: true
  • DeductUserQuota returns nil when credited_to: "already-deducted" (idempotent)
  • DeductUserQuota returns ErrQuotaDeductionFailed on 5xx/unreachable
  • RefundUserQuota returns nil when refunded_to: "already-refunded" (idempotent)
  • Guard: all 3 methods return nil / (true, nil) when externalCompanyID <= 0
  • make mocks succeeds; IBillingService mock updated with new methods
  • Existing validate_user_quota_test.go still 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

DisciplineDays
Backend1.5
QA0
Total1.5

Assumptions: 3 methods with clear fail-open specs; ISsoClient mock already exists (or is generated via make mocks); no DB migration needed.

Run to verify

go test -race ./internal/app/service/billings/...
make mocks
go build ./...

Depends on

  • [Task 1] — IQuotaManagementClient interface + mock
  • [Task 2] — config.AppConfig.QuotaManagementAPI, FeatureUserQuotaIntegration, DeductUserQuotaJobName constants

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

ActionFileWhat changes
extendinternal/app/handler/user_handler.goIn SsoInvite(): add GetCompanyAndPackageById call (for ExternalCompanyID + SsoID); add feature flag check; branch on flag to billingService.CheckUserQuota() or billingService.ValidateUserQuota()
extendinternal/app/handler/user_handler_test.goAdd test cases: flag ON + sufficient, flag ON + insufficient, flag ON + API unreachable (fail-open), flag OFF + falls through to ValidateUserQuota

Implementation steps

  1. Read the current SsoInvite handler — Open internal/app/handler/user_handler.go:104-169. The handler currently: gets inviter, acquires lock, calls ValidateUserQuota(ctx, req.InviterSsoID), calls userService.SsoInvite. The new code inserts after the lock is acquired (line ~154).

  2. Add company lookup — After acquiring the lock, call companyInfo, err := h.repo.GetCompanyAndPackageById(ctx, inviter.CompanyID). Handle err as consts.ErrRepository. This gives us companyInfo.ExternalCompanyID.Int32 and companyInfo.SsoID.

  3. Add feature flag check and branch — Replace the single ValidateUserQuota call 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 check
    isEnabled = false
    }

    if isEnabled {
    sufficient, quotaErr := h.billingService.CheckUserQuota(ctx, companyInfo.ExternalCompanyID.Int32)
    if quotaErr != nil || !sufficient {
    err = billingService.ErrInsufficientQuota
    return
    }
    } else {
    err = h.billingService.ValidateUserQuota(ctx, req.InviterSsoID)
    if err != nil {
    return
    }
    }
  4. Write tests — In user_handler_test.go, add a TestUserHandler_SsoInvite function (or extend the existing one if present). Mock billingService.IBillingService using internal/app/service/mocks/IBillingService. Use httptest.NewRecorder() + httptest.NewRequest() pattern from the existing handler tests.

    Test cases:

    • Flag ON + CheckUserQuota returns (true, nil) → handler proceeds, returns 200
    • Flag ON + CheckUserQuota returns (false, ErrInsufficientQuota) → handler returns 4xx
    • Flag ON + CheckUserQuota fail-open (true, nil) on API error → handler proceeds
    • Flag OFF → ValidateUserQuota called, CheckUserQuota not called
  5. Go greengo test -race ./internal/app/handler/...

Acceptance criteria

  • Flag ON + is_sufficient: trueSsoInvite service is called
  • Flag ON + is_sufficient: false (non-unlimited) → SsoInvite is NOT called; response is 4xx
  • Flag ON + is_unlimited: trueSsoInvite service is called (handled inside CheckUserQuota)
  • Flag ON + API unreachable → SsoInvite is called (fail-open; CheckUserQuota returns true)
  • 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

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: IsEnabledFunc global override pattern is already established in the codebase; IBillingService mock 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, updated IBillingService mock

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

ActionFileWhat changes
extendinternal/app/service/users/sso_invite.goAfter createLaunchpadUser() success: feature flag check → billingService.DeductUserQuota() → on ErrQuotaDeductionFailedjobEnqueuer.EnqueueJob(DEDUCT_USER_QUOTA, payload)
extendinternal/app/service/users/sso_invite_test.goAdd test cases for deduction success, 5xx → job enqueued, already-deducted → no job

Implementation steps

  1. Read sso_invite.go lines 150-165createLaunchpadUser() is called at line ~155 and returns (repository.CreateUserRow, error). userInfo.ID (UUID) is the deduction key. companyInfo (fetched at line ~96) provides ExternalCompanyID.Int32. IsEnabledFunc is already imported in this package (get_crs_permissions.go:26).

  2. 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 IsEnabledFunc are silently ignored (flag treated as OFF) to be consistent with enqueueSyncUserDetail pattern in main.go:147.

  3. Add consumer import — Import "bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/consumer" in sso_invite.go to use consumer.DeductUserQuotaPayload (struct defined in T6 — or define payload in a shared location; see note below).

    Note on import cycle: consumer imports repository and api packages but not service/users. The service/users package can safely import consumer for the payload type. If an import cycle is detected, move DeductUserQuotaPayload to internal/pkg/consts/worker.go or a new internal/pkg/payload/ package instead.

  4. Update sso_invite_test.go:

    • Add mockBilling *mockBillingService field to the test struct
    • Add test cases:
      • IsEnabledFunc = true + DeductUserQuota returns nil → no job enqueued, no error returned
      • IsEnabledFunc = true + DeductUserQuota returns ErrQuotaDeductionFailedjobEnqueuer.EnqueueJob called once; function still returns (userInfo, nil)
      • IsEnabledFunc = falseDeductUserQuota NOT called
  5. Go greengo test -race ./internal/app/service/users/... -run TestUserService_SsoInvite

Acceptance criteria

  • After successful createLaunchpadUser(), DeductUserQuota() is called when flag is ON
  • ErrQuotaDeductionFailed from deduction → EnqueueJob(DEDUCT_USER_QUOTA, ...) called once; SsoInvite still returns (userInfo, nil)
  • DeductUserQuota success → no job enqueued
  • DeductUserQuota returns nil on already-deducted → no job, no error
  • Flag OFF → DeductUserQuota NOT 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

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: billingService mock (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.DeductUserQuotaPayload struct (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

ActionFileWhat changes
extendinternal/app/consumer/iConsumer.goAdd billingService billingService.IBillingService field to Consumer struct; add DeductUserQuotaConsumer to IConsumer interface; update NewConsumer signature
createinternal/app/consumer/deduct_user_quota.goDeductUserQuotaConsumer(ctx, job) error implementation
createinternal/app/consumer/deduct_user_quota_test.goUnit tests: success, already-deducted, 5xx (returns error → triggers retry)
extendinternal/worker/worker_service.goRegister DEDUCT_USER_QUOTA job in registerJob()
extendcmd/initializer.goPass bs (billing service) to consumer.NewConsumer(...)

Implementation steps

  1. Read iConsumer.go and sync_user_detail.go — Open internal/app/consumer/iConsumer.go (full file) and internal/app/consumer/sync_user_detail.go:21-72 for the canonical consumer pattern: unmarshal job.Args["data"], iterate, log per-item errors, continue.

  2. Update iConsumer.go:

    • Add import for billingService "bitbucket.org/terbang-ventures/qontak-launchpad/internal/app/service/billings"
    • Add billingService billingService.IBillingService field to Consumer struct
    • Add DeductUserQuotaConsumer(jctx context.Context, job *work.Job) error to IConsumer interface
    • Update NewConsumer(...) signature to accept billingService billingService.IBillingService
  3. Define DeductUserQuotaPayload in deduct_user_quota.go:

    type DeductUserQuotaPayload struct {
    ExternalCompanyID int32 `json:"external_company_id"`
    UserID string `json:"user_id"`
    }
  4. Implement DeductUserQuotaConsumer — Follow SyncUserDetailConsumer pattern exactly:

    func (c *Consumer) DeductUserQuotaConsumer(ctx context.Context, job *work.Job) error {
    var payload DeductUserQuotaPayload
    jsonStr, 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 retry
    slog.ErrorContext(ctx, "user_quota_deduction_retry", ...)
    return err
    }
    // Non-retriable error (e.g. config issue) — log as failed, don't retry
    slog.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/work moves the job to the dead queue automatically. The consumer always returns ErrQuotaDeductionFailed for 5xx so retries continue until max.

  5. Register in worker_service.go — In registerJob(), add:

    deductUserQuotaConsumer := workerList.DeductUserQuotaConsumer
    registerJobWithOptions(string(consts.DeductUserQuotaJobName), options, deductUserQuotaConsumer, pool)
  6. Wire in cmd/initializer.go — Update consumer.NewConsumer(cs, repo, crmClient, chatClient, ssoClient, cacheRepo, mailer, db) to include bs (billing service).

  7. Write testsdeduct_user_quota_test.go with mocked IBillingService:

    • Success: DeductUserQuota returns nil → consumer returns nil
    • Already-deducted (idempotent): DeductUserQuota returns nil (handled in BillingService) → consumer returns nil
    • 5xx (retriable): DeductUserQuota returns ErrQuotaDeductionFailed → consumer returns error (triggering retry)
  8. Go greengo test -race ./internal/app/consumer/...

Acceptance criteria

  • Consumer correctly unmarshals DeductUserQuotaPayload from job.Args["data"]
  • DeductUserQuota success → consumer returns nil (job completes)
  • ErrQuotaDeductionFailed → consumer returns error (triggers gocraft/work retry)
  • DEDUCT_USER_QUOTA job is registered in registerJob() and appears in worker pool
  • Existing consumer tests (check_expiry, sync_user_detail, etc.) pass — no regression from NewConsumer signature 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

DisciplineDays
Backend1.5
QA0
Total1.5

Assumptions: consumer pattern is verbatim from sync_user_detail.go; NewConsumer signature change requires updating cmd/initializer.go but 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

ActionFileWhat changes
extendinternal/app/service/users/delete.goAfter commitTx() (~line 141): feature flag check → consultant guard → billingService.RefundUserQuota() → log failure
extendinternal/app/service/users/delete_test.goAdd test cases: flag ON + non-consultant → refund called; flag ON + is_consultant=true → refund skipped; flag ON + refund error → Delete returns nil

Implementation steps

  1. Read delete.go lines 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. companyInfo is already fetched at line ~31.

  2. Add refund block immediately after commitTx() (before the deletedUser.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()),
    )
    }
  3. Update delete_test.go — Add test cases to TestUserService_Delete:

    • Flag ON + is_consultant=falseRefundUserQuota called once; Delete returns nil
    • Flag ON + is_consultant=trueRefundUserQuota NOT called; Delete returns nil
    • Flag ON + RefundUserQuota returns error → Delete still returns nil
    • Flag OFF → RefundUserQuota NOT called
  4. Go greengo test -race ./internal/app/service/users/... -run TestUserService_Delete

Acceptance criteria

  • Flag ON + non-consultant → RefundUserQuota(ctx, externalCompanyID, deletedUser.ID) called after commitTx()
  • Flag ON + is_consultant = trueRefundUserQuota NOT called; Delete returns nil
  • RefundUserQuota error → Delete returns nil (best-effort); user_quota_refund_failed logged
  • RefundUserQuota success → user_quota_refunded logged
  • 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

DisciplineDays
Backend0.5
QA0.5
Total1.0

Assumptions: follows the existing "best-effort post-commit" pattern already used in delete.go for CRM/Chat/SSO calls; mockBillingService only 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

ActionFileWhat changes
createcmd/backfill_user_quota.goCobra command backfill-user-quota with --dry-run flag
extendcmd/root.goRegister backfillUserQuotaCmd

Implementation steps

  1. Read cmd/root.go and cmd/seed_encryption.go — Open cmd/seed_encryption.go to observe how existing one-shot commands are structured (Cobra command, DI via InitInjections() or direct repo/service construction, flag parsing).

  2. 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 bool

    func init() {
    backfillUserQuotaCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Log counts without calling the Quota Management API")
    rootCmd.AddCommand(backfillUserQuotaCmd)
    }
  3. Implement runBackfillUserQuota:

    • Init config, DB, billing service (using cmd/initializer.go pattern or direct construction)
    • Fetch all companies: repo.GetAllCompanies(ctx) (or equivalent existing query — check internal/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: log company_id={}, count={} and continue
      • Else: call billingService.DeductUserQuota(ctx, company.ExternalCompanyID.Int32, syntheticBackfillUserID) where syntheticBackfillUserID is a deterministic UUID derived from company.ID to ensure a stable unique_code per CID

        Note on unique_code for backfill: Using create_user_{user_id} format with a synthetic UUID means unique_code = "create_user_backfill_{company_id}" — this avoids collision with real user IDs. Update DeductUserQuota or add an overload that accepts a custom uniqueCode string. Alternatively, call the Quota Management API directly in the backfill command with unique_code = "backfill_{company_id}" and quantity = count (bulk deduction) — pending OQ-12 confirmation on whether the API supports quantity > 1.

      • On error: log user_quota_backfill_failed with company_id and error; continue to next CID
    • Print final summary: total processed, total skipped (count=0), total failed
  4. Check for existing GetAllCompanies query — Run grep -rn "GetAllCompanies\|GetAllActiveCompany\|ListAllCompanies" internal/app/repository/. If no suitable query exists, add -- name: GetAllActiveCompanies :many to db/query/companies.sql and regenerate with make sqlc (or equivalent).

  5. 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.

  6. Go greengo build ./...

Acceptance criteria

  • ./bin/qontak-launchpad backfill-user-quota --dry-run runs 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_failed and continues to next CID (does not halt)
  • Final summary output: total CIDs processed, skipped, failed
  • unique_code scheme is stable across re-runs (idempotent — duplicate run does not double-deduct)
  • CIDs with ExternalCompanyID.Valid = false or count = 0 are 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

DisciplineDays
Backend1.5
QA0.5
Total2.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 and unique_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

  1. 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.

  2. 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.

  3. T6 should merge before T5 — T5 references consumer.DeductUserQuotaPayload which is defined in T6. If merging T5 first, temporarily inline the payload struct and replace it when T6 merges.

  4. 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.

  5. External blocker to push on: QUOTA_MANAGEMENT_BILLING_CODE env var value from the qontak-billing team (OQ-7). All code can be merged without it, but staging enablement (user_quota_integration_enabled = ON for 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.