Task Breakdown — Billing Expired Handling (Backend RFC)
Source RFC:
billing-expired-handling.md· PRD:../prds/prd-qontak-one-billing-expired-handling.mdSlicing: by repo + layer · Blocked tasks: omitted from main list (see Skipped / deferred) · FE + external: included as estimated stubs Repos: moderator-be (master, Rails) · qontak-launchpad (main, Go) · hub_core (production, Rails gem). FE repoqontak-launchpad-fenot checked out — FE paths are[unverified — check repo].
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Task 1 — moderator-be: canonical column + rollout flag | — | 1 | 0.5 | 1.5 |
| Task 2 — launchpad: mirror column + sqlc + flag constant | — | 1 | — | 1 |
| Task 3 — launchpad: billing-status middleware layer | — | 3 | 1 | 4 |
| Task 4 — hub_core: conditional expired gate | — | 2 | 1 | 3 |
| Task 5 — backfill + cross-DB reconciliation | — | 1 | 0.5 | 1.5 |
| Backend subtotal (this RFC) | — | 8 | 3 | 11 |
| FE Task A — subscription-end banner + renewal CTA (separate FE RFC) | 3 | — | 1 | 4 |
| FE Task B — permission-driven UI restriction + renew-prompt route guard (separate FE RFC) | 2 | — | 0.5 | 2.5 |
| Frontend subtotal (deferred RFC, est.) | 5 | — | 1.5 | 6.5 |
| Initiative grand total (BE + FE) | 5 | 8 | 4.5 | 17.5 |
Confidence: medium for backend; low for frontend. Backend is medium because three unknowns can move it: O1 — the moderator-be→launchpad permission-catalog sync mechanism is unverified (gates the backfill), O2 — flag semantics await PM confirmation, O3 — hub_core's
check_billing_statusis called in 40+ places so the gate needs careful regression coverage. Frontend is low because the banner architecture is unresolved (PRD Open Question #3) and the FE repo wasn't available to verify paths or confirm whether existing permission-key hiding already applies. If the existing banner is reusable, FE Task A drops ~4 → ~1.5 (grand total ~15). If permission-key hiding auto-applies, FE Task B drops ~2.5 → ~1.
Tasks (backend — by repo + layer)
Task 1: [BE] moderator-be — canonical show_when_billing_expired column + rollout flag (BEH-S01)
A permission key can be marked "stays accessible after expiry," and ops can turn limited-access mode on/off per company.
Status: ✅ Actionable
What to build
Add a defaulted boolean column to the permissions catalog and register the company-scoped billing_expired_limited_access Preference flag (default OFF).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migrate/<ts>_add_show_when_billing_expired_to_permissions.rb | add_column :permissions, :show_when_billing_expired, :boolean, default: true, null: false |
| extend | app/models/permission.rb | (optional) document the new attribute; no logic |
| create | flag registration (per Core::Services::Preference usage) | register billing_expired_limited_access (title, target: feature, default OFF) |
| create | spec/.../add_show_when_billing_expired_spec.rb (or model spec) | column default true/null: false; flag enable/disable per company toggles |
Paths verified:
app/models/company_account.rb:47(enum),app/domains/core/services/preference.rb:9-21,81-88(flag API), migration patterndb/migrate/20260521070825_add_is_trial_to_impersonate_accesses.rb.
Implementation steps
- Explore — Open
db/migrate/20260521070825_add_is_trial_to_impersonate_accesses.rband copy theadd_column … :boolean, default:idiom; openapp/domains/core/services/preference.rbto seeenabled?(feature, company_id:)andset_company_ids. - Red — Write a model/migration spec asserting the column exists with default
true, and a Preference spec assertingenabled?(:billing_expired_limited_access, company_id:)flips withset_company_ids. Rundocker compose exec web bundle exec rspec <spec>— confirm fail. - Migrate — Create the migration; run
docker compose exec web bundle exec rails db:migrate. - Register flag — Add the
billing_expired_limited_accessPreference registration following the README usage pattern. - Green —
docker compose exec web bundle exec rspec <spec>passes. - Quality gate —
docker compose exec web bundle exec rubocop.
Acceptance criteria
-
permissions.show_when_billing_expiredexists,boolean, defaulttrue,null: false. -
Core::Services::Preference#enabled?(:billing_expired_limited_access, company_id:)returns true only afterset_company_ids. - Migration is reversible (
db:rollbackdrops the column).
Test strategy
RSpec model/migration spec asserts column metadata; Preference spec mocks Redis list and asserts enabled? honors company scope. Key assertion: flag OFF by default.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: reuses the existing
Core::Services::Preferenceplumbing; no new table; backfill of FALSE values is Task 5.
Run to verify
docker compose exec web bundle exec rails db:migrate && docker compose exec web bundle exec rspec spec && docker compose exec web bundle exec rubocop
Depends on
- None (start immediately)
Task 2: [BE] qontak-launchpad — mirror column + sqlc query + flag constant (BEH-S01)
The Go service can read, per permission key, whether it stays accessible after expiry, and knows the rollout flag name.
Status: ✅ Actionable
What to build
ALTER permission_component_codes to add the mirror boolean, expose it through sqlc, and add the qontak-preferences flag constant. Plumbing only — enforcement is Task 3.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migration/<ts>_add_show_when_billing_expired_to_permission_component_codes.up.sql | ALTER TABLE permission_component_codes ADD COLUMN show_when_billing_expired BOOLEAN NOT NULL DEFAULT TRUE; |
| create | db/migration/<ts>_..._to_permission_component_codes.down.sql | ALTER TABLE permission_component_codes DROP COLUMN show_when_billing_expired; |
| extend | db/query/permission_component_codes.sql | add/extend a query selecting show_when_billing_expired by permission_key |
| generate | internal/app/repository/permission_component_codes.sql.go | regenerated via sqlc generate (adds ShowWhenBillingExpired) |
| extend | internal/pkg/constants/preferences.go | add FeatureBillingExpiredLimitedAccess = "launchpad_billing_expired_limited_access" |
Paths verified: migration dir
db/migration/*.up.sql(golang-migrate,Makefile:140-155),sqlc.yaml(sqlc v1.31.1), generatedpermission_component_codes.sql.go:23, constantsinternal/pkg/constants/preferences.go:6.
Implementation steps
- Explore — Open
db/migration/20250812044119_create_permission_component_codes.up.sql(columnsid,permission_key,component_codes) andinternal/pkg/constants/preferences.go:6(FeaturePermissionCheckpattern). - Red — Add a repository test asserting the generated query returns
ShowWhenBillingExpiredfor a seeded key. Rungo test -race ./internal/app/repository/...— confirm fail/compile-gap. - Migrate — Create the
.up.sql/.down.sqlpair; runmake migrate-up, thenmake migrate-downto confirm reversibility, thenmake migrate-upagain. - sqlc — Add the query to
db/query/permission_component_codes.sql; runsqlc generate. - Constant — Add
FeatureBillingExpiredLimitedAccess. - Green —
go test -race ./internal/app/...passes;go build(make build). - Quality gate —
staticcheck ./....
Acceptance criteria
- Column exists,
NOT NULL DEFAULT TRUE;make migrate-downcleanly drops it. -
sqlc generateyields a struct fieldShowWhenBillingExpiredand a by-permission_keyaccessor. -
FeatureBillingExpiredLimitedAccessconstant compiles.
Test strategy
Repository unit test seeds a row and asserts the generated query maps the new boolean. Key assertion: default true when unset.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | — |
| Total | 1 |
Assumptions: existing golang-migrate + sqlc toolchain; no behavior change yet (pure plumbing) so QA 0.
Run to verify
make migrate-up && sqlc generate && go test -race ./internal/app/... && staticcheck ./...
Depends on
- None (can run in parallel with Task 1)
Task 3: [BE] qontak-launchpad — billing-status enforcement layer in the permission middleware (BEH-S01/AC-1..3, ERR-1, ERR-2)
An expired Qontak One user is blocked from operational (FALSE) permission keys but keeps access to essential (TRUE) keys — enforced server-side, role-agnostic — and the system fails closed if billing status can't be read.
Status: ✅ Actionable
What to build
Add a billing-status check inside permission_check.go that runs after the existing level check: read unified_app + billing_status from the unified-billing cache, read the rollout flag, and deny the request when status==expired && unified_app && flag-on && show_when_billing_expired==FALSE. Fail closed on read error; return a BILLING_EXPIRED_RESTRICTED 403 (renew prompt), not a generic forbidden page.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/pkg/middleware/permission_check.go | new checkBillingExpired(ctx, companyID, permissionKey) step after the level == "everything" check; wires unified-billing read, flag check, FALSE-key deny, fail-closed default |
| extend | internal/pkg/http/default_error.go | add ErrBillingExpiredRestricted (403, code BILLING_EXPIRED_RESTRICTED, ID/EN renew message) |
| reuse | internal/app/service/billings/get_unified_billing.go | call GetUnifiedBilling (cache Package:Unified:{companyID}, ModPanel fallback) |
| reuse | internal/pkg/constants/preferences.go | FeatureBillingExpiredLimitedAccess (from Task 2) |
| create | internal/pkg/middleware/permission_check_billing_test.go | table-driven tests for all branches incl. fail-closed |
Paths verified:
permission_check.goNewPermissionCheckL105, level check L273, flag patterncheckFeatureFlagL176-209, perm cache keypermissions:{sso_id}:launchpadL257;get_unified_billing.go:53,79;default_error.go:8(ErrForbidden).
Implementation steps
- Explore — Open
internal/pkg/middleware/permission_check.go; readgetRequiredPermission(L211), thelevel=="everything"check (L273), andcheckFeatureFlag(L176-209). Openget_unified_billing.gofor theGetUnifiedBilling(ctx, companyID, …)signature and cache behavior. Opendefault_error.gofor theErrForbiddenshape. - Red — Create
permission_check_billing_test.gowith table cases: (a) expired+unified+flag+FALSE→403, (b) expired+unified+flag+TRUE→pass, (c) active→pass, (d) non-unified expired→pass, (e) flag-off→pass, (f) unified-billing read error→403 fail-closed +billing_expired_fail_closed_triggeredlog. Rungo test -race ./internal/pkg/middleware/...— confirm fail. - Error code — Add
ErrBillingExpiredRestrictedtodefault_error.go. - Logic — Implement
checkBillingExpired(...): short-circuit ifstatus != expiredor!unified_appor flag off; otherwise readshow_when_billing_expiredfor the required key (folded into the existingpermissions:{sso_id}:launchpadcache fill); deny on FALSE. Wrap the unified-billing read so any error → fail-closed (treat as expired) and emit the fail-closed log. - Wire — Insert the call after the level check, before returning allow; thread
ctxforslog. - Green —
go test -race ./internal/app/... ./internal/pkg/...passes. - Quality gate —
staticcheck ./...&&make build.
Acceptance criteria
- AC-1: expired+unified+flag, FALSE key → 403
BILLING_EXPIRED_RESTRICTED. - AC-2: same context, TRUE key → request proceeds.
- AC-3:
activeORunified_app=false→ no restriction. - ERR-1: unified-billing read error → fail-closed (FALSE keys denied) +
billing_expired_fail_closed_triggeredemitted. - ERR-2: restricted route returns the renew-prompt code, not the generic forbidden message.
- Added latency on cache hit is a boolean compare (no new network hop) — within the ≤100ms budget.
Test strategy
Table-driven Go test mocking the unified-billing service + permission cache + flag client. Key mock: GetUnifiedBilling returning {status, unified_app} and an error variant. Key assertion: the fail-closed branch denies FALSE keys and logs the event.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 3 |
| QA | 1 |
| Total | 4 |
Assumptions: reuses
GetUnifiedBilling+ existing caches (no new round-trip);show_when_billing_expiredavailable locally from Task 2; flag constant from Task 2.
Run to verify
go test -race ./internal/app/... ./internal/pkg/middleware/... && staticcheck ./... && make build
Depends on
- Task 2 (column + sqlc accessor + flag constant)
Task 4: [BE] hub_core — make the expired hard-block conditional for limited-access CIDs (BEH-S01/AC-3, NEG-2, BEH-S03/AC-3)
Expired Qontak One users can actually reach the modules (so permission-key enforcement can grant limited access), while frozen and non-Qontak-One accounts keep the current hard block, and renewed accounts are restored automatically.
Status: ✅ Actionable
What to build
Gate the expired failure path in check_package.rb: when the account is unified_app=true and the limited-access flag is on, return Success (let downstream permission enforcement decide); keep Failure for freeze, for non-unified expired accounts, and for the legacy path.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/apps/billings/services/check_package.rb | before returning Failure for expired/inactive, add a guard: if unified_app + limited-access flag on → Success; freeze path unchanged |
| reuse | app/apps/billings/services/v2/redis/subscriptions/get.rb | existing billing-status read (Package:{company_id}:{moderator_account_id}) |
| verify | (org/company source of unified_app within hub_core) | confirm where hub_core can read unified_app for the CID — see assumption |
| create | spec/apps/billings/services/check_package_spec.rb (extend) | cases: unified+expired+flag→Success; freeze→Failure; non-unified expired→Failure; active→Success |
Paths verified:
check_package.rb:8-21(currentFailureforexpired/freeze),v2/redis/subscriptions/get.rb:29-50(Redis read + ModPanel fallback).
Implementation steps
- Explore — Open
app/apps/billings/services/check_package.rband read thestatusmethod (thefreezeandexpired/valid_untilbranches at L8-21). Tracecheck_billing_statuscallers to understand the fan-out (the gate must be narrow). Confirm how to obtainunified_appfor the CID inside hub_core (organization/company record or Redis) — this is the one unverified field; resolve it here. - Red — Extend
check_package_spec.rb: unified+expired+flag-on →Success;freeze→Failure "...frozen mode"(unchanged); non-unified expired →Failure(unchanged); active →Success. Runbundle exec rspec app/apps/billings/services/check_package_spec.rb --tag ~@is_skip_pipeline— confirm fail. - Implement — Add the guard ahead of the expired
Failure:return Success(...) if unified_app? && limited_access_flag_on?(company_id). Leavefreezeand the non-unified path returningFailure. - Regression — Add/confirm tests proving freeze, non-unified, and active behavior are byte-for-byte unchanged (covers O3 blast radius).
- Green —
bundle exec rspec app --tag ~@is_skip_pipeline --format=documentationpasses. - Quality gate —
bundle exec rubocop&&brakeman --no-exit-on-warn --no-exit-on-error.
Acceptance criteria
- AC-3 / pass-through: unified+expired+flag-on →
Success(no hard block). - NEG-2:
freeze→ unchangedFailure("frozen mode"). - Non-unified expired → unchanged
Failure. - BEH-S03/AC-3: when status returns to
active, the method returnsSuccess(restore is automatic — no extra write). - No behavior change for any caller where status ∉ {expired} or
unified_app=false(regression suite green).
Test strategy
RSpec on check_package.rb mocking the Redis subscription read + the flag; assert the gate only flips behavior for unified+expired+flag-on and is a no-op everywhere else. Key assertion: freeze + non-unified failure messages identical to current.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 1 |
| Total | 3 |
Assumptions:
unified_appis readable inside hub_core for the CID (step 1 verification); the gate is one conditional; the bulk of effort is regression coverage on a high-fan-in method (O3).
Run to verify
bundle exec rspec app --tag ~@is_skip_pipeline --format=documentation && bundle exec rubocop && brakeman --no-exit-on-warn --no-exit-on-error
Depends on
- Task 1 (the
billing_expired_limited_accessflag must exist in the Preference system hub_core reads)
Task 5: [BE] Backfill show_when_billing_expired from the sheet + cross-DB reconciliation (BEH-S01)
The curated list of which permission keys stay accessible after expiry is loaded into both repos and proven consistent.
Status: ⚠️ Partially blocked — the backfill scripts + reconciliation harness are actionable now; running them needs (a) the locked permission-sheet TRUE/FALSE values (external PM/CS dependency) and (b) O1 resolved (whether the moderator-be→launchpad sync propagates, or both sides must be set manually).
What to build
Idempotent backfill setting show_when_billing_expired = FALSE for the curated keys in both permissions (moderator-be) and permission_component_codes (launchpad), plus a reconciliation check asserting the FALSE-key set matches across the two tables.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migrate/<ts>_backfill_show_when_billing_expired.rb (or rake task) | UPDATE permissions SET show_when_billing_expired = FALSE WHERE name IN (<FALSE-list>) |
| create | db/migration/<ts>_backfill_show_when_billing_expired.up.sql (launchpad) | UPDATE permission_component_codes SET show_when_billing_expired = FALSE WHERE permission_key IN (<FALSE-list>) |
| create | reconciliation script/test (either repo or CI step) | assert count(FALSE) equal across both tables (the §4.D guard) |
Paths verified: both tables/migrators confirmed in Tasks 1–2. The
<FALSE-list>source is the external permission sheet.
Implementation steps
- Explore — Confirm the join key between the two tables (moderator-be
permissions.name↔ launchpadpermission_component_codes.permission_key). - Build scripts (actionable now) — Write both
UPDATEscripts parameterized by the FALSE-list, and the reconciliation assertion. Use a placeholder list + fixtures for tests. - Stub the list — Until the sheet is locked, drive tests off a fixture FALSE-list; do not run against production (pending external dependency).
- Run (when unblocked) — Once the sheet is locked and O1 is resolved, populate the real list, run both backfills in the same change window, then the reconciliation.
- Verify — Reconciliation passes (equal FALSE counts); spot-check that
subscriptions_general_viewisTRUEand a known operational key isFALSE.
Acceptance criteria
- Backfill scripts are idempotent (re-running is a no-op).
- (pending external) FALSE-key set is identical across both tables (reconciliation green).
- (pending external) No critical path (billing/subscription view) is accidentally
FALSE(PRD Open Question #1 guard).
Test strategy
Run the backfill against a seeded fixture and assert the resulting FALSE set equals the input list in both tables; assert idempotency by running twice.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: scriptable backfill; the only blockers are data (sheet) + O1, not engineering.
Run to verify
# launchpad side
make migrate-up && go test -race ./internal/app/...
# moderator-be side
docker compose exec web bundle exec rails db:migrate && docker compose exec web bundle exec rspec spec
Depends on
- Task 1 + Task 2 (columns must exist) · External: locked permission sheet · O1 (sync mechanism)
FE tasks (separate RFC — RFC-derived estimates, paths [unverified — check repo])
These belong to the deferred FE RFC (
qontak-launchpad-fe) and are not part of the actionable backend list. Sized here so the whole initiative is visible. The FE repo was not checked out, so paths are unverified and the banner is design-pending.
FE Task A: [FE] Subscription-end banner + renewal CTA (BEH-S02, BEH-S03/AC-1,AC-2)
An expired Qontak One user sees a persistent banner explaining the limited-access state and can click through to the subscription page to renew.
Status: 🚫 Blocked (design) — PRD Open Question #3: expired.vue is a full-page component (layout: landing), not an inline banner. Needs a discovery spike to locate or build a reusable inline banner before build.
Design reference: n/a — design pending (PRD OQ#3) — CHG-001 assumes "no new screens," which the spike may invalidate.
What to build
A persistent top-of-page banner shown when billing_status = expired AND unified_app = true, with a renewal CTA that navigates to the subscription page; banner failure must not block page load or permission enforcement (ERR-1).
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 3 |
| Backend | — |
| QA | 1 |
| Total | 4 |
Assumptions: worst case — a new inline banner component must be built/extracted from
expired.vue(paths[unverified — check repo]). Drops to ~1.5 FE if an existing reusable banner is found. Banner trigger readsbilling_status/unified_appalready exposed by the BE contract (no new endpoint).
Depends on
- FE RFC + OQ#3 resolution (design) · BE contract (this RFC, Tasks 1–3) already exposes the needed state.
FE Task B: [FE] Permission-driven UI restriction + restricted-route renew prompt (BEH-S01/NEG-1, ERR-2)
Operational action buttons tied to FALSE permission keys are hidden (not rendered) for expired users, and direct navigation to a restricted route shows a renew prompt rather than a generic error.
Status: ⚠️ Partially blocked — button-hiding likely rides the existing permission-key mechanism once the BE marks keys restricted (verify in the FE repo); the renew-prompt route guard (ERR-2) is net-new FE work that can be built against the BILLING_EXPIRED_RESTRICTED 403 contract from Task 3.
Design reference: n/a — behavioral, no new screens (PRD §8 CHG-002).
What to build
Consume the BE restriction so FALSE-key actions are not rendered (NEG-1), and handle the BILLING_EXPIRED_RESTRICTED 403 by routing the user to a renew prompt instead of a generic forbidden page (ERR-2). No flash of restricted content (server-evaluated before render).
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| Backend | — |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: existing Chat/CRM permission-key hiding does most of the button work (paths
[unverified — check repo]); the bulk of net-new effort is the 403→renew-prompt interceptor + cross-module verification. Drops to ~1 if hiding is fully automatic.
Depends on
- Task 3 (the
BILLING_EXPIRED_RESTRICTED403 contract) · FE RFC.
Ordering rationale
- Migrations first, in parallel: Task 1 (moderator-be) and Task 2 (launchpad) have no cross-deps — assign to two devs simultaneously. Both are additive/defaulted, so production is safe in the intermediate state.
- Critical path runs through Task 3: the launchpad middleware layer (4 days, the biggest single task) is the feature's heart and depends only on Task 2 — start Task 2 first so Task 3 isn't waiting.
- Task 4 (hub_core) is the sleeper risk: small code change, large regression surface (O3 — 40+ callers). It depends on Task 1's flag. Budget the QA day for proving freeze/active/non-unified are unchanged.
- Push externally to unblock Task 5: the backfill is engineering-ready but data-blocked. Chase the PM/CS team to lock the permission-sheet values and resolve O1 (catalog sync) in parallel with Tasks 1–4 so backfill isn't the long pole at the end.
- Confirm O2 before GA: the flag-as-kill-switch interpretation (vs the PRD's "auto-enable per CID" wording) should be PM-signed-off before rollout, though it doesn't block coding.
- FE follows the BE contract: the FE RFC's banner spike (OQ#3) can start in parallel, but FE Task B's route guard depends on Task 3's
BILLING_EXPIRED_RESTRICTEDcontract.
Skipped / deferred
| Item | Type | Reason / unblock condition |
|---|---|---|
| FE Task A — BEH-S02 banner + BEH-S03 CTA | FE (separate RFC, est. 4d) | Blocked on PRD Open Question #3 (expired.vue is a full-page component, not an inline banner). Sized above; belongs to the FE RFC in qontak-launchpad-fe. |
| FE Task B — BEH-S01/NEG-1, ERR-2 UI restriction | FE (separate RFC, est. 2.5d) | Partially blocked; net-new work is the renew-prompt route guard against Task 3's contract. Sized above. |
| Mobile enforcement | Consumer (no net-new BE) | Mobile reuses the same permission-key contract via existing Chat/CRM enforcement — no backend task; validate during QA. |
qontak-billing — DailyDeactivatePackage status transition | External / cross-squad | Treated as upstream dependency; not grounded or modified here. Confirm active→grace→expired + ModPanel notify reliability with the billing squad. |