Task Breakdown: Downgrade User — Phase 1: Quota Enforcement
Generated from
downgrade-user.md(RFC re-review score: 7.5/10 Strong, PROCEED with notes). Mode: Horizontal (Phase 1 UI mocked → Phase 2 API integration). Scope: full-stack, all stories included.
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 4.0 | — | 1.0 | 5.0 |
| Phase 2 — API Integration | 1.5 | 10.0 | 4.0 | 15.5 |
| Grand total | 5.5 | 10.0 | 5.0 | 20.5 |
Confidence: medium. Biggest unknowns: (1) FE-1/FE-2 visual layout is Figma-blocked — shell/store estimates assume placeholder markup, real design could add rework once frames land; (2) the restoration branch (Task 2.8) depends on P8 shipping the
Resolvedfield (Decision 6) and theUSER_SEAT_BILLING_CODEvalue (Decision 7) — code can be written now but can't be verified end-to-end in staging until both land.
Phase 1 — UI (APIs mocked)
Task 1.1: [FE] DowngradeWarningModal shell + store (DU-S04)
A logged-in Owner/Admin/Supervisor sees a warning that their company is over its user quota, with a list of at-risk users, when they log in.
Status: ⚠️ Partially blocked — Figma frame for the Warning Popup is pending (§1 Design References says n/a — design pending). Store, fetch call (mocked response), 404/error handling, and a11y wiring are NOT blocked (per Detail 2.G/2.H) and can be built now with placeholder markup, then restyled once the frame lands.
Design reference: n/a — design pending. DS version: @mekari/pixel3@1.0.8 (confirmed in package.json). Frame: TBD. Design QA: TBD.
What to build
downgradeWarningStore.ts (Pinia store, composition API) holding warningActive, milestone, atRiskUsers, popupAcknowledged, plus DowngradeWarningModal.vue — a placeholder MpModal wired to the store, rendered from InitComponent.vue alongside the existing auth/menu init calls.
Correction from RFC: Detail 4.C FE-1 states the store path as app/stores/downgradeWarningStore.ts — that top-level directory doesn't exist in this repo. Every existing Pinia store (authStore.ts, menuStore.ts, ssoCallbackStore.ts) lives in app/common/store/, imported via ~/common/store/xxxStore. Using the corrected path below.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/common/store/downgradeWarningStore.ts | Pinia store: warningActive, milestone, atRiskUsers, popupAcknowledged state; fetchDowngradeStatus() action (mocked for now) |
| create | app/layouts/components/DowngradeWarningModal.vue | Placeholder MpModal (no is-disable-focus-trap, per Detail 2.H), renders atRiskUsers, "Remind me later" dismiss |
| extend | app/layouts/components/InitComponent.vue | Add downgradeStore.fetchDowngradeStatus() call after menuStore.initializeMenu() (line 30) — separate call, not merged into fetchAuthLaunchpad(), per Detail 2.G |
| create | app/common/store/__tests__/downgradeWarningStore.spec.ts | State transitions, snake_case→camelCase mapping |
| create | app/layouts/components/__tests__/DowngradeWarningModal.spec.ts | Renders when warningActive=true, hidden when false or list empty |
Implementation steps
- Open
app/layouts/components/InitComponent.vueandapp/common/store/menuStore.ts— note thedefineStore("name", () => {...})composition style and theonBeforeMountsequencing pattern already used forauthStore/menuStore. - Write failing tests:
app/common/store/__tests__/downgradeWarningStore.spec.ts— assert store defaults, assertfetchDowngradeStatus()mapsat_risk_users→atRiskUsersetc. (Detail 2.G mapping table). Runpnpm test -- downgradeWarningStore.spec.ts, confirm red. - Scaffold
downgradeWarningStore.tswith state + afetchDowngradeStatus()stub returning a mocked response shape ({ warning_active, milestone, at_risk_users, popup_acknowledged }) — real call added in Task 2.9. - Scaffold
DowngradeWarningModal.vuewithMpModal/MpModalContent/MpModalBody/MpModalCloseButton(same subcomponents asModalDeleteUser.vue), bindis-opentowarningActive, renderatRiskUsersas a plain list (placeholder markup — restyle once Figma lands). - Wire
InitComponent.vue: importuseDowngradeWarningStore, callfetchDowngradeStatus()aftermenuStore.initializeMenu(). - Run
pnpm test -- downgradeWarningStore.spec.ts DowngradeWarningModal.spec.tsuntil green. - Run
pnpm lint && pnpm type-check.
Acceptance criteria
- Store correctly maps all snake_case API fields to camelCase state
- Modal renders when
warningActive=trueandatRiskUsers.length > 0 - Modal does not render when
warningActive=falseoratRiskUsersis empty (Detail 2.H empty-state spec) -
MpModaldoes not passis-disable-focus-trap(default focus trap stays on, per Detail 2.H) - (Pending Figma) Final visual layout/copy — placeholder markup only for now
Test strategy
Mock fetchDowngradeStatus()'s HTTP call; assert the store's camelCase state matches the mocked snake_case response. Assert DowngradeWarningModal renders/hides based on store state, not on a direct prop.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2.0 |
| Backend | — |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: new composable + new component (no existing pattern to fully reuse, though
ModalDeleteUser.vuegives the MpModal skeleton); real API wiring excluded (Task 2.9).
Run to verify
pnpm test -- downgradeWarningStore.spec.ts DowngradeWarningModal.spec.ts && pnpm lint
Depends on
None — actionable now.
Task 1.2: [FE] DowngradeRestrictedModal shell (DU-S07)
A logged-in Owner/Admin/Supervisor sees a final notification listing which users were restricted, after the 1-month enforcement fires, and can acknowledge it.
Status: ⚠️ Partially blocked — same Figma gate as Task 1.1 (Final Restriction Popup frame n/a — design pending). Acknowledge-button wiring (mocked) and error-state handling (Detail 2.H: keep modal open on PATCH failure, allow retry) are actionable now.
Design reference: n/a — design pending. DS version: @mekari/pixel3@1.0.8. Frame: TBD. Design QA: TBD.
What to build
DowngradeRestrictedModal.vue, rendered from InitComponent.vue after Task 1.1's modal, gated on popupAcknowledged=false in the same store.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/layouts/components/DowngradeRestrictedModal.vue | Placeholder MpModal, "Acknowledge" button (mocked action for now) |
| extend | app/common/store/downgradeWarningStore.ts | Add acknowledgePopup() action (mocked for now — real call in Task 2.10) |
| extend | app/layouts/components/InitComponent.vue | Wire DowngradeRestrictedModal after DowngradeWarningModal (per Detail 4.C FE-2) |
| create | app/layouts/components/__tests__/DowngradeRestrictedModal.spec.ts | Renders when popupAcknowledged=false; button click calls acknowledgePopup(); failure keeps modal open |
Implementation steps
- Open
app/layouts/components/DowngradeWarningModal.vue(from Task 1.1) — reuse the sameMpModalsubcomponent set and store-driven visibility pattern. - Write failing tests in
DowngradeRestrictedModal.spec.tscovering render-gate and the error-state AC below. Runpnpm test -- DowngradeRestrictedModal.spec.ts, confirm red. - Scaffold
DowngradeRestrictedModal.vue, bindis-opento!popupAcknowledged. - Add
acknowledgePopup()stub to the store returning a mocked{ acknowledged: true }— realPATCHadded in Task 2.10. - Wire the button's
@clicktoacknowledgePopup(); on failure (mocked reject for the test), keepis-opentrue and re-enable the button (Detail 2.H error-state spec). - Wire in
InitComponent.vuebelowDowngradeWarningModal. - Run
pnpm test -- DowngradeRestrictedModal.spec.tsuntil green, thenpnpm lint && pnpm type-check.
Acceptance criteria
- Modal renders when
popupAcknowledged=false - Acknowledge button click sets
popupAcknowledged=trueoptimistically only after a 200 (Detail 2.G — no optimistic update on failure) - On mocked failure, modal stays open and button re-enables for retry
- (Pending Figma) Final visual layout/copy
Test strategy
Mock acknowledgePopup() success and failure paths; assert modal visibility and button state react correctly to each.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2.0 |
| Backend | — |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses Task 1.1's store and modal subcomponent pattern — cheaper than 1.1 despite same nominal size class.
Run to verify
pnpm test -- DowngradeRestrictedModal.spec.ts && pnpm lint
Depends on
- Task 1.1 (shares
downgradeWarningStore.tsandInitComponent.vuewiring point)
Phase 2 — API Integration
Task 2.1: [BE] DB migrations — is_system, previous_role_id, popup_acknowledged, feature seed (DU-S01, DU-S02, DU-S07)
No direct user-facing behavior — this lays the schema foundation every other BE task depends on.
Status: ✅ Actionable now.
What to build
Four migrations per Detail 2.3: company_roles.is_system, user_roles.previous_role_id, users.downgrade_popup_acknowledged, and the downgrade_user_enforcement_enabled feature seed row.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migration/20260701000001_add_is_system_to_company_roles.up.sql (+.down.sql) | ALTER TABLE company_roles ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT false + partial index |
| create | db/migration/20260701000002_add_previous_role_id_to_user_roles.up.sql (+.down.sql) | ALTER TABLE user_roles ADD COLUMN previous_role_id UUID REFERENCES company_roles(id) ON DELETE SET NULL |
| create | db/migration/20260701000003_add_downgrade_popup_acknowledged_to_users.up.sql (+.down.sql) | ALTER TABLE users ADD COLUMN downgrade_popup_acknowledged BOOLEAN NOT NULL DEFAULT false |
| create | db/migration/20260701000004_seed_downgrade_enforcement_feature.up.sql (+.down.sql) | INSERT INTO features (...) VALUES (..., 'downgrade_user_enforcement_enabled', ...) ON CONFLICT (code) DO NOTHING |
Implementation steps
- Open
db/migration/000010_add_status_to_users.up.sql(cited in RFC Detail 2.0 as the migration-style reference) — note the minimal-SQL, no-transaction-wrapper convention. - Write each
.up.sql/.down.sqlpair per the RFC's Detail 2.3 exact DDL (already fully specified — copy verbatim). - Run
make migrate-uplocally against dev DB. - Verify:
SELECT is_system FROM company_roles LIMIT 1returnsfalse;SELECT previous_role_id FROM user_roles LIMIT 1returnsnull;featurestable has the new row. - Run
make migrate-downonce to confirm rollback is clean, thenmake migrate-upagain.
Acceptance criteria
- All 4 migrations apply cleanly via
make migrate-up - All 4
.down.sqlroll back cleanly - New columns have correct defaults; existing rows unaffected (per RFC Compatibility section)
Test strategy
No unit tests — verified via make migrate-up/migrate-down round-trip and manual SELECT checks.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: DDL is already fully specified in the RFC (Detail 2.3) — this is transcription + verification, not design.
Run to verify
make migrate-up && make migrate-down && make migrate-up
Depends on
None.
Task 2.2: [BE] SQLC query — GetCompanyFeatureByCode (DU-S01)
No direct user-facing behavior — enables the feature-toggle check every downgrade flow gates on.
Status: ✅ Actionable now.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/query/company_features.sql | -- name: GetCompanyFeatureByCode :one — join company_features → features filtered by company_id, code |
Implementation steps
- Open
db/query/user_roles.sql— note the SQLC annotation style and parameterized-query convention. - Write the
GetCompanyFeatureByCodequery. - Run
sqlc generate. - Run
go build ./...— confirm the generated function compiles and is callable.
Acceptance criteria
-
GetCompanyFeatureByCode(ctx, company_id, code)compiles and returns the expected row shape - Query is parameterized (no string interpolation)
Test strategy
No standalone test file — exercised indirectly via Task 2.5's EvaluateToggle() unit tests with a mocked IStore.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions:
company_featurestable (existing) has zero SQLC queries today (confirmed) — this is the first one, but the join is simple.
Run to verify
sqlc generate && go build ./...
Depends on
None.
Task 2.3: [BE] SQLC queries — roles, user_roles, users extensions (DU-S02, DU-S06, DU-S07, DU-S08)
No direct user-facing behavior — provides every query the enforcement/restoration/popup-reset logic needs.
Status: ✅ Actionable now.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | db/query/company_roles.sql | Add UpsertInactiveRole, GetInactiveRoleByCompanyID |
| extend | db/query/user_roles.sql | Add GetExcessMemberRoleUsers (with LIMIT 500, Decision 4 batch guard), BulkAssignInactiveRole, GetInactiveUsersForCompany, RestoreInactiveUserRoles |
| extend | db/query/users.sql | Add GetUserDowngradeStatus, ResetDowngradePopupAcknowledgedForCompany (exact SQL in Decision 5) |
Implementation steps
- Open
db/query/user_roles.sql(existingInsertUserRole,DeleteUserRoleByUserId,CountAssociatedUsers) for annotation style. - Add each new query. For
GetExcessMemberRoleUsers, order bycurrent_sign_in_at ASC NULLS FIRSTand capLIMIT 500(Decision 4). ForResetDowngradePopupAcknowledgedForCompany, use the exact SQL from Decision 5 (WHERE user_access IN ('owner','admin','supervisor')). - Run
sqlc generate. - Run
go build ./....
Acceptance criteria
- All 7 new query functions compile
-
GetExcessMemberRoleUsersincludesLIMIT 500and correctORDER BY -
ResetDowngradePopupAcknowledgedForCompanyrestricts to owner/admin/supervisor only (Decision 5)
Test strategy
No standalone test file — exercised via Task 2.5's service-layer unit tests with a mocked IStore.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | — |
| Total | 1.5 |
Assumptions: 7 queries across 3 files; ordering/limit logic (
GetExcessMemberRoleUsers) is the only non-trivial one — rest are straightforward parameterized UPDATE/SELECT.
Run to verify
sqlc generate && go build ./...
Depends on
Task 2.1 (needs the new columns to exist).
Task 2.4: [BE] Roles API — filter is_system (DU-S02)
An admin viewing the roles list never sees the internal "Inactive" system role.
Status: ✅ Actionable now.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | existing roles list query in db/query/company_roles.sql | Add WHERE is_system = false |
Implementation steps
- Open the existing roles-list query in
db/query/company_roles.sql. - Add the
is_system = falsefilter clause. - Run
sqlc generate && go build ./.... - Write/extend a handler-level unit test asserting a mocked
is_system=truerow is excluded from the response.
Acceptance criteria
-
GET /iag/v1/rolesresponse contains zero rows withis_system=true(unit test with mock returning one such row) - Existing roles-list behavior for
is_system=falserows unchanged
Test strategy
Unit test on the handler/repository layer with a mocked IStore returning one is_system=true and one is_system=false row; assert only the latter appears in the response.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: single
WHEREclause addition to an existing, already-tested query.
Run to verify
make test
Depends on
Task 2.1 (needs is_system column).
Task 2.5: [BE] DowngradeService + Mailer methods (DU-S01, DU-S03, DU-S05, DU-S06, DU-S08)
The core business logic: evaluates the toggle, sends warning/final emails, and bulk-assigns/restores roles — everything downstream depends on this.
Status: ✅ Actionable now.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/downgrade/service.go | IDowngradeService interface, constructor |
| create | internal/app/service/downgrade/evaluate_toggle.go | EvaluateToggle() — Decision 1 branch logic |
| create | internal/app/service/downgrade/warning.go | Warning-flow logic (Redis HSET, SendWarningEmail) |
| create | internal/app/service/downgrade/enforcement.go | EnforceInactiveRole() — upsert + bulk assign, Decision 4 |
| create | internal/app/service/downgrade/restore.go | RestoreRoles() — Decision 6/7 aware |
| create | internal/app/service/downgrade/popup_status.go | GetPopupStatus() for the handler |
| extend | internal/app/mailer/IMailer.go | Add SendWarningEmail, SendFinalRestrictionEmail |
| create | internal/app/mailer/downgrade_mailer.go | SendGrid implementations |
Implementation steps
- Open
internal/app/service/users/assign_role.go— the RFC's own cited pattern for context propagation, repo call, cache invalidation, audit log (auditLogger.CreateAuditLog(ctx, event, data)at line 150+). - Open
internal/app/service/users/update_status.go:56-77— read to confirm what NOT to do (users.statustriggers Chat/CRM deletion; downgrade must use role-based restriction instead, per RFC's explicit warning). - Write failing tests per method (toggle OFF/ON, warning idempotency, bulk assign ordering, restore skip-null-previous_role_id). Run
go test -race ./internal/app/service/downgrade/..., confirm red. - Implement
EvaluateToggle()using Task 2.2's query. - Implement warning flow using Task 2.3's
GetExcessMemberRoleUsers, Redis HSET (Decision 2 JSON-array serialization),downgrade_email_sent:{cid}:{seq}idempotency key. - Implement
EnforceInactiveRole(): upsert Inactive role (Decision 3 JSONB values — query an existing Member role from staging per the RFC's explicit instruction, don't hardcode), thenBulkAssignInactiveRole. - Implement
RestoreRoles()per Decision 6: branches onevent.Resolved, restores inprevious_role_idorder by most recent sign-in up toNewRemainingcapacity, usesquota_restored_processed:{company_id}:{billing_code}idempotency key. - Add the two
IMailermethods, mirroringinternal/app/mailer/IMailer.go's existingWorkflowApprovalRequestMailpattern. - Run
go test -race ./internal/app/service/downgrade/... ./internal/app/mailer/...until green. - Run
go fmt ./... && go vet ./... && make lint.
Acceptance criteria
- Toggle OFF → returns false, logs
downgrade_flow_skipped - Toggle ON,
trigger_sequence=5→BulkAssignInactiveRolecalled with users ordered oldest-sign-in-first - Duplicate
trigger_sequence(Redis key hit) → no second email, no second enforcement -
RestoreRoles()skips users with nullprevious_role_id, logs a warning, continues others -
RestoreRoles()filtersevent.BillingCodeper Decision 7 before doing anything else — pendingUSER_SEAT_BILLING_CODEreal value (Decision 7); test uses a placeholder config value
Test strategy
Table-driven tests per method using mockery-generated IStore/IMailer/Redis mocks. Assert exact call arguments (user ID ordering, Redis keys, JSONB shape) rather than just "no error".
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 3.0 |
| QA | 1.0 |
| Total | 4.0 |
Assumptions: this is the largest single task — 6 new files, the RFC's core domain logic, all decisions (1–7) converge here. Sized as the RFC's own "business logic + DB changes" tier (2–3 days) plus one extra day for the volume of files.
Run to verify
make mocks && go test -race ./internal/app/service/downgrade/... ./internal/app/mailer/... && make lint
Depends on
Tasks 2.1, 2.2, 2.3.
Task 2.6: [BE] Kafka consumer — NegativeBalanceConsumer (DU-S03, DU-S06, DU-S08)
No direct user-facing behavior — this is what turns billing events into the warning/enforcement/restoration flow.
Status: ⚠️ Partially blocked — the BillingCode filter (Decision 7) and Resolved branch (Decision 6) can be written and unit-tested now against a placeholder config value and a hand-built test event; end-to-end verification against real Kafka messages needs P8 to ship the Resolved field and the real USER_SEAT_BILLING_CODE.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/consumer/negative_balance.go | Consumer struct, Process(ctx, msg); filters BillingCode, then branches on Resolved vs trigger_sequence |
| extend | internal/kafka/topics.go | Add TopicQuotaManagementNegativeBalance = "billing.quota_management.negative_balance" (confirmed absent today) |
| extend | config/config.go + config/load.go | Add USER_SEAT_BILLING_CODE config constant (Decision 7) |
| extend | cmd/server/main.go (or consumer runner) | Wire the new consumer into the consumer group |
Implementation steps
- Open
internal/app/consumer/update_user_role_change.go(confirmed real file) — the RFC's cited closest existing consumer; note struct shape, message deserialization, error handling. - Write failing tests:
event.BillingCode != config→ no-op;trigger_sequence 2/3/4→ warning path;trigger_sequence 5→ enforcement path;event.Resolved=true→ restore path; duplicate event (Redis idempotency hit) → no-op. - Add
TopicQuotaManagementNegativeBalancetointernal/kafka/topics.go(verified: does not exist in the repo today, perKafkaTopicconstants list). - Add
USER_SEAT_BILLING_CODEto config — placeholder/empty value acceptable for now (same non-blocking pattern P9 uses forQUOTA_MANAGEMENT_BILLING_CODE). - Implement
Process(): filter → toggle check (via Task 2.5) → branch. - Wire the consumer into the runner alongside existing consumers.
- Run
go test -race ./internal/app/consumer/...until green;go build ./....
Acceptance criteria
-
BillingCodefilter is the first check, before any other branching (Decision 7) -
trigger_sequence2/3/4 → warning path; 5 → enforcement path -
event.Resolved=true→ restoration path — end-to-end verification pending P8 (Decision 6) - Duplicate Kafka message (Redis idempotency hit) → no-op, commits offset
Test strategy
Table-driven consumer tests constructing NegativeBalanceEvent values directly (not via real Kafka) — this is the standard pattern per update_user_role_change.go's existing test style.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: consumer skeleton follows an existing pattern closely (lower risk than 2.5); most complexity is in branching logic, not infrastructure.
Run to verify
go test -race ./internal/app/consumer/... && go build ./...
Depends on
Task 2.5.
Task 2.7: [BE] DowngradeHandler + routes (DU-S04, DU-S07)
An Owner/Admin/Supervisor's browser gets a real answer from
GET /iag/v1/downgrade-statuson login, and can acknowledge the final popup viaPATCH.
Status: ✅ Actionable now.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/handler/downgrade_handler.go | GET /iag/v1/downgrade-status, PATCH /iag/v1/downgrade-status/acknowledge |
| extend | internal/server/rest_router.go | Register routes with JWT + role middleware (Owner/Admin/Supervisor only) |
Implementation steps
- Open
internal/app/handler/webhook_handler.go(confirmed real file,SsoUserUpdateat line 76) — handler shape, chi pattern, middleware usage. - Open
internal/server/rest_router.go:63-70(confirmedr.Route("/v1/webhook", ...)pattern) for route-group registration style. - Write failing handler tests: 200 with correct schema when warning active; 403 for Member role; PATCH sets
acknowledged=true; 404 semantics N/A (route always exists once deployed — 404 is the FE's pre-deploy concern, not this handler's). - Implement both handlers calling Task 2.5's
GetPopupStatus()/service methods. - Register routes in
rest_router.gowith the existing JWT + role middleware. - Run
go test -race ./internal/app/handler/...until green;make build.
Acceptance criteria
-
GET /iag/v1/downgrade-statusreturns 200 with{warning_active, milestone, at_risk_users, popup_acknowledged}when warning active - Member role → 403 on both endpoints
-
PATCH /acknowledgesetsdowngrade_popup_acknowledged=true, idempotent on repeat call
Test strategy
Handler-level unit tests with mocked IDowngradeService, asserting HTTP status + response body shape per role.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: two simple endpoints on top of already-built service methods (Task 2.5) — this is wiring, not new logic.
Run to verify
go test -race ./internal/app/handler/... && make build
Depends on
Task 2.5.
Task 2.8: [BE] Restoration branch verification + idempotency (DU-S08)
A company that pays down its negative balance gets its restricted users automatically restored, without ops intervention.
Status: 🚫 Blocked for end-to-end verification — P8 (downgrade-webhook) has not shipped the Resolved bool field on NegativeBalanceEvent yet (Decision 6); the code path exists (built in Tasks 2.5/2.6) but cannot be exercised against a real event until P8 ships. Unblocks when: (1) P8 adds Resolved field + publishes from both resolve call sites, (2) USER_SEAT_BILLING_CODE real value confirmed (Decision 7).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| — | internal/app/service/downgrade/restore.go (from Task 2.5) | No new file — this task is staging/integration verification once P8 ships |
Implementation steps
- Once P8 ships the
Resolvedfield to staging: publish a real test event withResolved: trueagainst a Bifrost test CID with Inactive-role users. - Verify
previous_role_idrestoration order (most recent sign-in first, per Decision 6). - Verify idempotency: replay the same event, confirm no double-restore.
- Verify partial restoration when
new_remainingcapacity is less than the number of Inactive users.
Acceptance criteria
- Real
Resolved=trueevent restores the correct users in the correct order - Replaying the same event is a no-op (Redis idempotency key holds)
- Partial-capacity restoration respects
new_remaining
Test strategy
Integration/staging verification, not a new unit test file — unit coverage for this logic already lives in Task 2.5's restore.go tests.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: pure verification effort once P8 ships — no new code, but nontrivial staging setup (seeding Inactive users, triggering a real restoration event).
Run to verify
# staging-only, once P8's Resolved field is live
Depends on
Task 2.5, Task 2.6, and externally: P8 shipping Decision 6 + USER_SEAT_BILLING_CODE value (Decision 7).
Task 2.9: [FE] Wire real GET /iag/v1/downgrade-status (DU-S04)
The warning popup now reflects real backend state instead of a mock.
Status: ✅ Actionable once Task 2.7 ships an endpoint to point at; the FE-side work itself has no blocker.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/common/store/downgradeWarningStore.ts | Replace mocked fetchDowngradeStatus() with a real HTTP call; add 404/401/403/5xx handling per Detail 2.G/3.B |
| extend | app/common/store/__tests__/downgradeWarningStore.spec.ts | Replace mock-shape assertions with real HTTP mock (401/403/404/5xx/200 cases) |
Implementation steps
- Open
app/features/settings/company/composables/useCompanySettings.ts— the RFC's cited manual snake_case→camelCase mapping pattern (no global Axios interceptor exists in this repo, confirmed). - Update tests first: 404 →
warningActive=false(suppress silently); 401 → defer to existing auth middleware redirect; 403 → suppress silently; 5xx → suppress silently + log. - Replace the mocked call with the real
GET /iag/v1/downgrade-statusrequest. - Run
pnpm test -- downgradeWarningStore.spec.tsuntil green. - Run
pnpm lint && pnpm type-check.
Acceptance criteria
- 404 treated as
warningActive: false(BE-not-yet-deployed tolerance, per §4 Rollout) - 401/403/5xx handled per Detail 3.B's error catalog
- 200 response correctly populates store state
Test strategy
Mock the HTTP client per status code (200/401/403/404/5xx); assert store state and no unhandled exceptions.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: integration wiring only — store shape and modal already built in Task 1.1.
Run to verify
pnpm test -- downgradeWarningStore.spec.ts && pnpm lint
Depends on
Task 1.1, Task 2.7.
Task 2.10: [FE] Wire real PATCH /iag/v1/downgrade-status/acknowledge (DU-S07)
Acknowledging the final popup now persists to the backend instead of a mock.
Status: ✅ Actionable once Task 2.7 ships the endpoint.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/common/store/downgradeWarningStore.ts | Replace mocked acknowledgePopup() with a real PATCH call |
| extend | app/layouts/components/__tests__/DowngradeRestrictedModal.spec.ts | Real HTTP mock assertions (200/401/403/5xx) |
Implementation steps
- Reuse the HTTP client setup from Task 2.9.
- Update tests: 200 →
popupAcknowledged=true; 401 → redirect (matches GET's behavior, per today's Detail 3.B fix); 403 → suppress silently (shouldn't be called for Member anyway); 5xx → keep modal open, allow retry (Detail 2.H). - Replace the mocked call with the real
PATCHrequest. - Run
pnpm test -- DowngradeRestrictedModal.spec.tsuntil green;pnpm lint && pnpm type-check.
Acceptance criteria
- 200 →
popupAcknowledged=true, modal closes - 5xx/network failure → modal stays open, button re-enables (Detail 2.H)
- 401 → redirect via auth middleware, not a retry prompt
Test strategy
Mock the PATCH call per status code; assert store state and modal visibility react correctly.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: smallest integration task — single field, no ordering/pagination concerns.
Run to verify
pnpm test -- DowngradeRestrictedModal.spec.ts && pnpm lint
Depends on
Task 1.2, Task 2.7.
Ordering rationale
- Backend is the critical path. Tasks 2.1→2.2/2.3→2.5→2.6/2.7 form a strict dependency chain (schema → queries → service → consumer/handler); FE Phase 2 wiring (2.9, 2.10) can't start until 2.7 ships an endpoint to call.
- Phase 1 FE work is parallelizable with all of Phase 2 BE work — Tasks 1.1/1.2 only need the store shape and
MpModalpattern, not a real backend, so an FE dev can start on day 1 alongside the BE dev starting 2.1. - Push externally on two things now, not later: P8 shipping the
Resolvedfield (blocks Task 2.8) and the billing team confirmingUSER_SEAT_BILLING_CODE(blocks 2.8's real verification and technically weakens 2.5/2.6's test realism until then) — both are config/data changes on someone else's side, not code this team can accelerate by itself. - Figma is the other external blocker, gating only the final visual pass on Tasks 1.1/1.2 — everything else in those tasks is unblocked today.
- 2.4 (roles API filter) has no hard dependency chain beyond 2.1 and can slot in anywhere convenient — good filler task if someone's blocked elsewhere.
Skipped stories
No stories were fully excluded — "include everything" was selected, so all 8 PRD stories (DU-S01 through DU-S08) and both FE popups appear above, either as actionable/partially-blocked tasks (with their exact unblocking condition stated) or, for Task 2.8, explicitly marked 🚫 Blocked with the external dependency named.