Skip to main content

Task Breakdown — Billing Expired Handling (Backend RFC)

Source RFC: billing-expired-handling.md · PRD: ../prds/prd-qontak-one-billing-expired-handling.md Slicing: 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 repo qontak-launchpad-fe not checked out — FE paths are [unverified — check repo].

Effort Summary

Phase / AreaFE daysBE daysQA daysTotal
Task 1 — moderator-be: canonical column + rollout flag10.51.5
Task 2 — launchpad: mirror column + sqlc + flag constant11
Task 3 — launchpad: billing-status middleware layer314
Task 4 — hub_core: conditional expired gate213
Task 5 — backfill + cross-DB reconciliation10.51.5
Backend subtotal (this RFC)8311
FE Task A — subscription-end banner + renewal CTA (separate FE RFC)314
FE Task B — permission-driven UI restriction + renew-prompt route guard (separate FE RFC)20.52.5
Frontend subtotal (deferred RFC, est.)51.56.5
Initiative grand total (BE + FE)584.517.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_status is 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

ActionFileWhat changes
createdb/migrate/<ts>_add_show_when_billing_expired_to_permissions.rbadd_column :permissions, :show_when_billing_expired, :boolean, default: true, null: false
extendapp/models/permission.rb(optional) document the new attribute; no logic
createflag registration (per Core::Services::Preference usage)register billing_expired_limited_access (title, target: feature, default OFF)
createspec/.../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 pattern db/migrate/20260521070825_add_is_trial_to_impersonate_accesses.rb.

Implementation steps

  1. Explore — Open db/migrate/20260521070825_add_is_trial_to_impersonate_accesses.rb and copy the add_column … :boolean, default: idiom; open app/domains/core/services/preference.rb to see enabled?(feature, company_id:) and set_company_ids.
  2. Red — Write a model/migration spec asserting the column exists with default true, and a Preference spec asserting enabled?(:billing_expired_limited_access, company_id:) flips with set_company_ids. Run docker compose exec web bundle exec rspec <spec> — confirm fail.
  3. Migrate — Create the migration; run docker compose exec web bundle exec rails db:migrate.
  4. Register flag — Add the billing_expired_limited_access Preference registration following the README usage pattern.
  5. Greendocker compose exec web bundle exec rspec <spec> passes.
  6. Quality gatedocker compose exec web bundle exec rubocop.

Acceptance criteria

  • permissions.show_when_billing_expired exists, boolean, default true, null: false.
  • Core::Services::Preference#enabled?(:billing_expired_limited_access, company_id:) returns true only after set_company_ids.
  • Migration is reversible (db:rollback drops 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

DisciplineDays
Frontend
Backend1
QA0.5
Total1.5

Assumptions: reuses the existing Core::Services::Preference plumbing; 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

ActionFileWhat changes
createdb/migration/<ts>_add_show_when_billing_expired_to_permission_component_codes.up.sqlALTER TABLE permission_component_codes ADD COLUMN show_when_billing_expired BOOLEAN NOT NULL DEFAULT TRUE;
createdb/migration/<ts>_..._to_permission_component_codes.down.sqlALTER TABLE permission_component_codes DROP COLUMN show_when_billing_expired;
extenddb/query/permission_component_codes.sqladd/extend a query selecting show_when_billing_expired by permission_key
generateinternal/app/repository/permission_component_codes.sql.goregenerated via sqlc generate (adds ShowWhenBillingExpired)
extendinternal/pkg/constants/preferences.goadd 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), generated permission_component_codes.sql.go:23, constants internal/pkg/constants/preferences.go:6.

Implementation steps

  1. Explore — Open db/migration/20250812044119_create_permission_component_codes.up.sql (columns id, permission_key, component_codes) and internal/pkg/constants/preferences.go:6 (FeaturePermissionCheck pattern).
  2. Red — Add a repository test asserting the generated query returns ShowWhenBillingExpired for a seeded key. Run go test -race ./internal/app/repository/... — confirm fail/compile-gap.
  3. Migrate — Create the .up.sql/.down.sql pair; run make migrate-up, then make migrate-down to confirm reversibility, then make migrate-up again.
  4. sqlc — Add the query to db/query/permission_component_codes.sql; run sqlc generate.
  5. Constant — Add FeatureBillingExpiredLimitedAccess.
  6. Greengo test -race ./internal/app/... passes; go build (make build).
  7. Quality gatestaticcheck ./....

Acceptance criteria

  • Column exists, NOT NULL DEFAULT TRUE; make migrate-down cleanly drops it.
  • sqlc generate yields a struct field ShowWhenBillingExpired and a by-permission_key accessor.
  • FeatureBillingExpiredLimitedAccess constant 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

DisciplineDays
Frontend
Backend1
QA
Total1

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

ActionFileWhat changes
extendinternal/pkg/middleware/permission_check.gonew checkBillingExpired(ctx, companyID, permissionKey) step after the level == "everything" check; wires unified-billing read, flag check, FALSE-key deny, fail-closed default
extendinternal/pkg/http/default_error.goadd ErrBillingExpiredRestricted (403, code BILLING_EXPIRED_RESTRICTED, ID/EN renew message)
reuseinternal/app/service/billings/get_unified_billing.gocall GetUnifiedBilling (cache Package:Unified:{companyID}, ModPanel fallback)
reuseinternal/pkg/constants/preferences.goFeatureBillingExpiredLimitedAccess (from Task 2)
createinternal/pkg/middleware/permission_check_billing_test.gotable-driven tests for all branches incl. fail-closed

Paths verified: permission_check.go NewPermissionCheck L105, level check L273, flag pattern checkFeatureFlag L176-209, perm cache key permissions:{sso_id}:launchpad L257; get_unified_billing.go:53,79; default_error.go:8 (ErrForbidden).

Implementation steps

  1. Explore — Open internal/pkg/middleware/permission_check.go; read getRequiredPermission (L211), the level=="everything" check (L273), and checkFeatureFlag (L176-209). Open get_unified_billing.go for the GetUnifiedBilling(ctx, companyID, …) signature and cache behavior. Open default_error.go for the ErrForbidden shape.
  2. Red — Create permission_check_billing_test.go with 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_triggered log. Run go test -race ./internal/pkg/middleware/... — confirm fail.
  3. Error code — Add ErrBillingExpiredRestricted to default_error.go.
  4. Logic — Implement checkBillingExpired(...): short-circuit if status != expired or !unified_app or flag off; otherwise read show_when_billing_expired for the required key (folded into the existing permissions:{sso_id}:launchpad cache fill); deny on FALSE. Wrap the unified-billing read so any error → fail-closed (treat as expired) and emit the fail-closed log.
  5. Wire — Insert the call after the level check, before returning allow; thread ctx for slog.
  6. Greengo test -race ./internal/app/... ./internal/pkg/... passes.
  7. Quality gatestaticcheck ./... && 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: active OR unified_app=false → no restriction.
  • ERR-1: unified-billing read error → fail-closed (FALSE keys denied) + billing_expired_fail_closed_triggered emitted.
  • 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

DisciplineDays
Frontend
Backend3
QA1
Total4

Assumptions: reuses GetUnifiedBilling + existing caches (no new round-trip); show_when_billing_expired available 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

ActionFileWhat changes
extendapp/apps/billings/services/check_package.rbbefore returning Failure for expired/inactive, add a guard: if unified_app + limited-access flag on → Success; freeze path unchanged
reuseapp/apps/billings/services/v2/redis/subscriptions/get.rbexisting 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
createspec/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 (current Failure for expired/freeze), v2/redis/subscriptions/get.rb:29-50 (Redis read + ModPanel fallback).

Implementation steps

  1. Explore — Open app/apps/billings/services/check_package.rb and read the status method (the freeze and expired/valid_until branches at L8-21). Trace check_billing_status callers to understand the fan-out (the gate must be narrow). Confirm how to obtain unified_app for the CID inside hub_core (organization/company record or Redis) — this is the one unverified field; resolve it here.
  2. Red — Extend check_package_spec.rb: unified+expired+flag-on → Success; freezeFailure "...frozen mode" (unchanged); non-unified expired → Failure (unchanged); active → Success. Run bundle exec rspec app/apps/billings/services/check_package_spec.rb --tag ~@is_skip_pipeline — confirm fail.
  3. Implement — Add the guard ahead of the expired Failure: return Success(...) if unified_app? && limited_access_flag_on?(company_id). Leave freeze and the non-unified path returning Failure.
  4. Regression — Add/confirm tests proving freeze, non-unified, and active behavior are byte-for-byte unchanged (covers O3 blast radius).
  5. Greenbundle exec rspec app --tag ~@is_skip_pipeline --format=documentation passes.
  6. Quality gatebundle 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 → unchanged Failure ("frozen mode").
  • Non-unified expired → unchanged Failure.
  • BEH-S03/AC-3: when status returns to active, the method returns Success (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

DisciplineDays
Frontend
Backend2
QA1
Total3

Assumptions: unified_app is 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_access flag 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

ActionFileWhat changes
createdb/migrate/<ts>_backfill_show_when_billing_expired.rb (or rake task)UPDATE permissions SET show_when_billing_expired = FALSE WHERE name IN (<FALSE-list>)
createdb/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>)
createreconciliation 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

  1. Explore — Confirm the join key between the two tables (moderator-be permissions.name ↔ launchpad permission_component_codes.permission_key).
  2. Build scripts (actionable now) — Write both UPDATE scripts parameterized by the FALSE-list, and the reconciliation assertion. Use a placeholder list + fixtures for tests.
  3. Stub the list — Until the sheet is locked, drive tests off a fixture FALSE-list; do not run against production (pending external dependency).
  4. 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.
  5. Verify — Reconciliation passes (equal FALSE counts); spot-check that subscriptions_general_view is TRUE and a known operational key is FALSE.

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

DisciplineDays
Frontend
Backend1
QA0.5
Total1.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

DisciplineDays
Frontend3
Backend
QA1
Total4

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 reads billing_status/unified_app already 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

DisciplineDays
Frontend2
Backend
QA0.5
Total2.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_RESTRICTED 403 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_RESTRICTED contract.

Skipped / deferred

ItemTypeReason / unblock condition
FE Task A — BEH-S02 banner + BEH-S03 CTAFE (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 restrictionFE (separate RFC, est. 2.5d)Partially blocked; net-new work is the renew-prompt route guard against Task 3's contract. Sized above.
Mobile enforcementConsumer (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 transitionExternal / cross-squadTreated as upstream dependency; not grounded or modified here. Confirm active→grace→expired + ModPanel notify reliability with the billing squad.