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 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, no X-Api-Key (Kong injects at IAG layer)
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 takes the same signature — no apiKey parameter needed (Kong handles X-Api-Key injection at the IAG layer).

  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, 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}, Content-Type: application/json. Kong injects X-Api-Key at the IAG layer — do not set it in the client. 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 headers as check-quota: Authorization: Bearer {accessToken}, Content-Type: application/json. 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. No Authorization Bearer token and no X-Api-Key header (Kong injects the API key at the IAG layer). 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 and no X-Api-Key header (both absent — Kong injects the API key at the IAG layer)
  • 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, 2*time.Second) directly. Key assertions: correct HTTP method, correct path, correct JSON body shape, Authorization: Bearer present for check/deduct and absent for refund, X-Api-Key absent for all operations (handled by Kong at IAG layer), 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 { UserBillingCode string; Timeout time.Duration } and add QuotaManagementAPI QuotaManagementAPI field to AppConfig
extendconfig/load.goLoad QUOTA_MANAGEMENT_USER_BILLING_CODE using getStringDefault (no panic — value TBD from OQ-7); getDurationOrPanic for QUOTA_MANAGEMENT_API_TIMEOUT; base URL reuses appConfig.SsoAPI.ApiURL — no separate env var
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.SsoAPI.ApiURL, 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:

    type QuotaManagementAPI struct {
    UserBillingCode string
    Timeout time.Duration
    }

    Note: Base URL reuses appConfig.SsoAPI.ApiURL — no separate field needed.

  3. Add loader block to config/load.go — Copy the ModPanelAPI block pattern. Use getStringDefault("QUOTA_MANAGEMENT_USER_BILLING_CODE", "") for UserBillingCode (not panic — value TBD from OQ-7) and getDurationOrPanic for Timeout. Do not add QUOTA_MANAGEMENT_API_BASE_URL or QUOTA_MANAGEMENT_API_KEY — the base URL comes from appConfig.SsoAPI.ApiURL and the API key is injected by Kong.

  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 quotaManagementClient := quotaManagementApi.NewQuotaManagementClient(appConfig.SsoAPI.ApiURL, appConfig.QuotaManagementAPI.Timeout) 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

  • config.AppConfig.QuotaManagementAPI.UserBillingCode loads from QUOTA_MANAGEMENT_USER_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 using appConfig.SsoAPI.ApiURL as base URL (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.UserBillingCode, 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: config.GetAppConfig().QuotaManagementAPI.UserBillingCode, 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: config.GetAppConfig().QuotaManagementAPI.UserBillingCode, 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.