Task Breakdown: Downgrade Webhook — Billing-Side Quota Notification
Derived from
downgrade-webhook.md· RFC revision 2026-06-30 · Slicing: Horizontal (Phase 1 — Schema & Infrastructure; Phase 2 — Logic & Workers) · Repository:qontak-billingatinternal/app/usecase/quota_management/Key codebase findings (reconnaissance):
- Job system:
gocraft/work+ Redis. Delayed jobs viaenqueuer.EnqueueUniqueIn(jobName, secondsFromNow, args)— verifiedcreate_pi_carry_over_quota.go:30. §5 Q1 resolved.- Worker registration:
helper/consts/worker.go(constant) →internal/app/consumer/worker_consumer.go(handler) →internal/app/worker/worker_service.go(registerJobWithOptions) →internal/app/service/job_enquerer/job_enqueuer.go(interface + impl).- Consumer pattern:
internal/app/consumer/<feature>.go+<feature>_test.go. Seequota_management_alert.go+create_pi_carry_over_quota.goas reference.- Enqueuer interface:
IJobEnqueuerininternal/app/service/job_enquerer/job_enqueuer.go.- Test command:
go test -race ./internal/app/...ormake test.- Migration naming:
YYYYMMDDHHMMSS_<description>.up.sql/.down.sqlindb/migrations/.- §5 Q3 resolved:
downgrade_events.statusis the SoT for active/resolved state. Day 0 guard =SELECT WHERE company_id+billing_code AND status='active'. Partial unique index prevents duplicate active rows.
Effort Summary
| Phase / Area | BE days | QA days | Total |
|---|---|---|---|
| Phase 1 — Schema & Infrastructure | 2.5 | 0.5 | 3.0 |
| Phase 2 — Logic & Workers | 4.5 | 1.5 | 6.0 |
| Grand total | 7.0 | 2.0 | 9.0 |
Confidence: high. Key assumptions: (1)
gocraft/workEnqueueUniqueInverified as the delay mechanism — no new infra needed; (2)downgrade_events.status(active|resolved) is the SoT; partial unique index guards concurrent Day 0 inserts; (3) quota re-check inDowngradeNotificationWorkerreads from the same tables used byrecalculateComponentQuota.
Phase 1 — Schema & Infrastructure
Task 1.1: [BE] Migration — triggers_downgrade column on billing_components
Add the
triggers_downgradeflag tobilling_componentsso the billing service can identify which components trigger the downgrade notification flow.
Status: ✅ Actionable — no external dependency. Pre-deploy: verify 6 backfill codes exist in production (§5 Q7).
What to build
SQL migration that adds triggers_downgrade boolean NOT NULL DEFAULT false to billing_components and backfills the 6 component codes specified in the PRD.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.up.sql | ADD COLUMN + backfill UPDATE |
| create | db/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.down.sql | DROP COLUMN |
Implementation Steps
- Explore migration conventions — Open
db/migrations/20260609100200_add_unlimited_value_to_billing_components.up.sqland note the format:ALTER TABLE ... ADD COLUMN ...;followed by a backfill UPDATE where applicable. Use the same timestamp format (YYYYMMDDHHMMSS). - Write up migration — Create
db/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.up.sql:ALTER TABLE billing_componentsADD COLUMN triggers_downgrade boolean NOT NULL DEFAULT false;UPDATE billing_components SET triggers_downgrade = trueWHERE parent_component_code IN ('VOICE-RECORDING-2026-01','LOYALTY-2026-03','CUSTOMFIELD-2026-04','LOYALTY-CURRENCY-2026-04','LOYALTY-TIER-2026-04','LOYALTY-RULE-2026-04'); - Write down migration — Create
.down.sql:ALTER TABLE billing_components DROP COLUMN IF EXISTS triggers_downgrade; - Run and verify —
make migrate-up. ConfirmSELECT parent_component_code, triggers_downgrade FROM billing_components WHERE triggers_downgrade = true;returns exactly 6 rows. Rollback:make migrate-down, confirm column is gone.
Acceptance Criteria
-
billing_components.triggers_downgradecolumn exists, typeboolean NOT NULL DEFAULT false -
SELECT COUNT(*) FROM billing_components WHERE triggers_downgrade = true= 6 -
make migrate-downrolls back cleanly; column gone - Existing rows with
triggers_downgrade = falseremain unaffected
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: pure DDL, no application code. QA = 0 — no user-facing behaviour.
Run to Verify
make migrate-up && make migrate-down && make migrate-up
Depends on
— (no dependency)
Task 1.2: [BE] Migration — downgrade_events + downgrade_schedules tables
Create two new tables:
downgrade_eventsas a state machine per(company_id, billing_code)(active|resolved) anddowngrade_schedulesas the execution record per milestone (scheduled|fired|cancelled).
Status: ✅ Actionable — §5 Q3 resolved: downgrade_events.status is the SoT; partial unique index idx_de_active is the Day 0 guard.
What to build
Migration that creates downgrade_events (state machine active|resolved per company/billing_code) and downgrade_schedules (4 milestone rows per event, lifecycle scheduled→fired/cancelled).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migrations/<timestamp>_create_downgrade_tables.up.sql | CREATE TABLE downgrade_events + downgrade_schedules + indexes |
| create | db/migrations/<timestamp>_create_downgrade_tables.down.sql | DROP TABLE both |
Implementation Steps
- Explore existing table patterns — Open
db/migrations/20260402000000_create_custom_margin_by_packages.up.sqlto see the CREATE TABLE format used (uuid PK, timestamptz, CHECK constraints). - Write up migration:
CREATE TABLE downgrade_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),company_id varchar NOT NULL,billing_code varchar NOT NULL,negative_amount numeric(18,4) NOT NULL,status varchar NOT NULL DEFAULT 'active', -- active | resolvedcreated_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),CONSTRAINT chk_de_status CHECK (status IN ('active','resolved')));-- at most one 'active' row per (company_id, billing_code) — Day 0 guardCREATE UNIQUE INDEX idx_de_active ON downgrade_events (company_id, billing_code) WHERE status = 'active';CREATE INDEX idx_de_company ON downgrade_events (company_id, billing_code);CREATE TABLE downgrade_schedules (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),downgrade_event_id uuid NOT NULL REFERENCES downgrade_events (id),milestone varchar NOT NULL,scheduled_at timestamptz NOT NULL,status varchar NOT NULL DEFAULT 'scheduled',fired_at timestamptz,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),CONSTRAINT chk_ds_milestone CHECK (milestone IN ('week_1','week_2','week_3','month_1')),CONSTRAINT chk_ds_status CHECK (status IN ('scheduled','fired','cancelled')),CONSTRAINT uq_ds_event_milestone UNIQUE (downgrade_event_id, milestone));-- company_id + billing_code not stored here; read via JOIN to downgrade_eventsCREATE INDEX idx_ds_event ON downgrade_schedules (downgrade_event_id, status);CREATE INDEX idx_ds_due ON downgrade_schedules (status, scheduled_at);
- Write down migration:
DROP TABLE IF EXISTS downgrade_schedules; DROP TABLE IF EXISTS downgrade_events; - Run and verify —
make migrate-up. Confirm tables and indexes exist. Insert a row with an invalid milestone — expect CHECK violation. Insert a duplicate(event_id, milestone)— expect unique violation. Insert two rows withstatus='active'for the same pair — expect unique index violation.
Acceptance Criteria
-
downgrade_eventstable exists withstatus varchar DEFAULT 'active'+ CHECK +updated_at -
idx_de_activepartial unique index exists:UNIQUE (company_id, billing_code) WHERE status = 'active' - Inserting two
status='active'rows for the same pair →ERROR: unique constraint -
downgrade_schedulestable exists with FK todowngrade_events, CHECK constraints, UNIQUE index; nocompany_id/billing_codecolumns -
idx_ds_eventandidx_ds_dueindexes exist - Insert with invalid milestone →
ERROR: check constraint - Duplicate
(downgrade_event_id, milestone)→ERROR: unique constraint -
make migrate-downrolls back cleanly
Test Strategy
Migration test: run up migration, insert valid rows, test each constraint violation, run down migration. No unit tests needed — DDL-only change.
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: ~1 day including constraint design and rollback verification. QA 0.5 days for constraint smoke tests.
Run to Verify
make migrate-up && make migrate-down && make migrate-up
Depends on
— (independent of Task 1.1)
Task 1.3: [BE] Payload extension + Query extension
Extend
NegativeBalanceEventwithTriggerSequence intand addtriggers_downgradetoBillingComponentGetComponentCodeRowso the logic layer can distinguish which trigger number this is and gate on the flag.
Status: ✅ Actionable.
What to build
- Add
TriggerSequence inttoNegativeBalanceEventininternal/app/payload/kafka_event.go - Add
triggers_downgradeto theBillingComponentGetComponentCodeSQL query +BillingComponentGetComponentCodeRowstruct + scan ininternal/app/repository/billing_components.sql.go
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/payload/kafka_event.go | add TriggerSequence int \json:"trigger_sequence"`toNegativeBalanceEvent` |
| extend | db/queries/billing_components.sql | add bc.triggers_downgrade to BillingComponentGetComponentCode SELECT |
| extend | internal/app/repository/billing_components.sql.go | add TriggersDowngrade bool field to BillingComponentGetComponentCodeRow + add &i.TriggersDowngrade to scan |
Implementation Steps
- Read existing struct — Open
internal/app/payload/kafka_event.go:10. Note the field naming convention (json:"snake_case"). - Add
TriggerSequence— Add the field afterNegativeAmount:Zero-value (0) is backward-compatible for existing consumers that do not read this field.TriggerSequence int `json:"trigger_sequence"` // 1=day_0, 2=week_1, 3=week_2, 4=week_3, 5=month_1 - Read existing query — Open
db/queries/billing_components.sql, findBillingComponentGetComponentCode :many. Note the SELECT field order — the Go scan order must match exactly. - Add
triggers_downgradeto SELECT — Appendbc.triggers_downgradeto the end of the SELECT list. - Update Go struct — In
internal/app/repository/billing_components.sql.go, openBillingComponentGetComponentCodeRow. Add the field:AddTriggersDowngrade bool `json:"triggers_downgrade"`&i.TriggersDowngradetorows.Scan(...)in the same position as the SELECT order. Important: scan order must match SELECT order exactly. - Run tests —
go test -race ./internal/app/repository/...to confirm no existing tests break from the struct change.
Acceptance Criteria
-
NegativeBalanceEvent.TriggerSequencefield exists; compiles without error -
BillingComponentGetComponentCodeRow.TriggersDowngradefield exists -
go build ./...clean -
go test -race ./internal/app/repository/...passes — existing tests unaffected - Existing consumers that do not read
trigger_sequenceremain compatible (zero-value 0)
Test Strategy
go build ./... as smoke test. Existing repository tests cover query compatibility. No new tests needed in this task — logic tests are in Phase 2.
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0 |
| Total | 1.0 |
Assumptions: struct and query extension are straightforward; 1 day because scan order in the sqlc-generated file requires care.
Run to Verify
go build ./... && go test -race ./internal/app/repository/...
Depends on
- Task 1.1 —
triggers_downgradecolumn must exist in the DB before the query can be tested
Phase 2 — Logic & Workers
Task 2.1: [BE] DowngradeNotificationWorker — consumer, enqueuer, registration
Build a new worker invoked at each milestone (week_1..month_1): re-check the current balance from the DB, publish
NegativeBalanceEventwithTriggerSequence=2..5if still negative, or resolve the event and cancel remaining schedules if balance has recovered.
Status: ✅ Actionable — delayed jobs (EnqueueUniqueIn) verified available.
What to build
Four new files + changes to three existing files, following the CreatePIForCarryOverQuota worker pattern:
- Worker constant (
helper/consts/worker.go) - Consumer handler (
internal/app/consumer/downgrade_notification.go) - Enqueuer interface method + implementation (
internal/app/service/job_enquerer/) - Worker registration (
internal/app/worker/worker_service.go)
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | helper/consts/worker.go | add DowngradeNotificationWorker JobName = "downgrade_notification_worker" |
| create | internal/app/consumer/downgrade_notification.go | DowngradeNotificationWorker(*work.Job) error handler |
| create | internal/app/consumer/downgrade_notification_test.go | unit tests for worker handler |
| extend | internal/app/service/job_enquerer/job_enqueuer.go | add EnqueueDowngradeNotificationWorker to IJobEnqueuer interface |
| create | internal/app/service/job_enquerer/downgrade_notification.go | EnqueueDowngradeNotificationWorker implementation using EnqueueUniqueIn |
| create | internal/app/service/job_enquerer/downgrade_notification_test.go | unit tests for enqueuer |
| extend | internal/app/worker/worker_service.go | registerJobWithOptions(string(consts.DowngradeNotificationWorker), options, workerConsumer.DowngradeNotificationWorker, pool) |
| extend | internal/app/consumer/worker_consumer.go | add DowngradeNotificationWorker method signature (if interface) |
Implementation Steps
- Read reference implementations — Open
internal/app/consumer/quota_management_alert.go(simple worker pattern) andinternal/app/consumer/create_pi_carry_over_quota.go(delayed worker pattern). Note how payload is extracted fromjob.Args["data"], the error handling pattern, and the slog fields used. - Add constant — In
helper/consts/worker.go, addDowngradeNotificationWorker JobName = "downgrade_notification_worker". - Write failing tests — Create
internal/app/consumer/downgrade_notification_test.gowith test cases:- balance still negative →
NegativeBalanceEventpublished with the correctTriggerSequence; schedule row updated tofired;downgrade_eventsrow remainsactive - balance resolved →
downgrade_eventsrow updated toresolved; allscheduledrows updated tocancelled - schedule row already
fired/cancelled(race/retry) → no-op - Kafka publish fails → row not updated to
fired; error returned for job retry
- balance still negative →
- Implement consumer — Create
internal/app/consumer/downgrade_notification.go:func (d *WorkerConsumer) DowngradeNotificationWorker(job *work.Job) error {// extract schedule_id from job.Args["data"]// SELECT downgrade_schedules WHERE id=$schedule_id FOR UPDATE// if status != 'scheduled': return nil (idempotency guard)// SELECT current quota (initialRemaining + additionalRemaining + postpaidRemaining)// map milestone string → TriggerSequence int (week_1=2, week_2=3, week_3=4, month_1=5)// if balance < 0: publish NegativeBalanceEvent + UPDATE downgrade_schedules status=fired// else: UPDATE downgrade_events status=resolved// UPDATE downgrade_schedules status=cancelled WHERE downgrade_event_id=X AND status=scheduled} - Write enqueuer — Create
internal/app/service/job_enquerer/downgrade_notification.go. Usee.enqueuer.EnqueueUniqueIn(string(jobName), secondsFromNow, work.Q{"data": paramsMap, "traceparent": traceparent}). Milestone delays in seconds:- week_1:
7*24*3600 - week_2:
14*24*3600 - week_3:
21*24*3600 - month_1:
30*24*3600
- week_1:
- Register worker — In
internal/app/worker/worker_service.go:registerJob(), add afterQuotaManagementAlertWorker:registerJobWithOptions(string(consts.DowngradeNotificationWorker), options, workerConsumer.DowngradeNotificationWorker, pool) - Go green —
go test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/...
Acceptance Criteria
- Worker registered:
gocraft/workqueue acceptsdowngrade_notification_workerjobs - Balance < 0:
NegativeBalanceEventpublished withTriggerSequence = {2,3,4,5}matching the milestone; schedule rowstatus=fired;downgrade_eventsrow remainsactive - Balance >= 0:
downgrade_eventsrow updated toresolved; allstatus='scheduled'rows for the event updated tocancelled - Schedule row already
fired/cancelledwhen worker runs → no-op (idempotency guard) - Kafka publish fails → row not updated to
fired; job retried by gocraft/work -
EnqueueDowngradeNotificationWorkerusesEnqueueUniqueInwith correct delays (7/14/21/30 days in seconds) -
go test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/...passes
Test Strategy
Unit test the consumer with mocks for: DB queries (schedule SELECT FOR UPDATE, quota SELECT, schedule/event UPDATEs), Kafka publisher. Key test cases: (a) negative balance → publish + fired; (b) balance resolved → downgrade_events resolved + all scheduled cancelled; (c) FOR UPDATE guard → no-op; (d) publish failure → error returned → worker retries. Enqueuer test: verify EnqueueUniqueIn is called with the correct secondsFromNow for each milestone.
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 1.0 |
| Total | 3.5 |
Assumptions: reuses
gocraft/workEnqueueUniqueInpattern fromcreate_pi_carry_over_quota.go; quota re-check uses existing query available inrecalculateComponentQuota.
Run to Verify
go test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/... && make lint
Depends on
- Task 1.2 —
downgrade_schedulestable must exist - Task 1.3 —
NegativeBalanceEvent.TriggerSequencestruct field must exist
Task 2.2: [BE] compareComponents Day 0 extension
Extend
compareComponentsinactivate_or_update_component_quota.goso that when a negative balance is first detected (no activedowngrade_eventsrow), it publishesNegativeBalanceEventwithTriggerSequence=1, inserts adowngrade_eventsrow (status=active) + 4downgrade_schedulesrows, and enqueues 4 delayed workers. When balance recovers and an active row exists, resolve the event and cancel all pending schedules.
Status: ✅ Actionable.
What to build
Modify recalculateComponentQuota (and/or compareComponents) in internal/app/usecase/quota_management/activate_or_update_component_quota.go to add the Day 0 flow and resolve cancellation. Pending Kafka publishes are collected in activateOrUpdateState and processed in publishPendingEvents, following the existing pattern for pendingNegativeBalanceEvents.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/usecase/quota_management/activate_or_update_component_quota.go | add pendingDowngradeDay0 to activateOrUpdateState; extend recalculateComponentQuota to collect Day 0 events; extend publishPendingEvents to process downgrade; add publishDowngradeDay0IfNeeded function |
| extend | internal/app/usecase/quota_management/activate_or_update_component_quota_test.go | test cases for Day 0 flow and resolve cancellation |
| extend | internal/app/usecase/quota_management/IQuotaManagementUsecase.go | add new method signatures if the interface needs updating |
Implementation Steps
- Read
recalculateComponentQuotaandpublishPendingEvents— Openactivate_or_update_component_quota.go. Understand thependingNegativeBalanceEventspattern: events collected inside the loop, published after all transactions commit. The Day 0 downgrade events follow the same pattern. - Write failing tests — Open
activate_or_update_component_quota_test.go. Add test cases:bc.TriggersDowngrade=true, balance first goes negative, no activedowngrade_eventsrow →downgrade_eventsINSERT, 4downgrade_schedulesINSERTs,NegativeBalanceEvent(TriggerSequence=1)published, 4 delayed jobs enqueuedbc.TriggersDowngrade=true, balance negative but activedowngrade_eventsrow exists → skip Day 0 (only originalNegativeBalanceEventpublished without downgrade flow)bc.TriggersDowngrade=true, balance recovers from negative →UPDATE downgrade_events SET status='resolved'+UPDATE downgrade_schedules SET status='cancelled'bc.TriggersDowngrade=false→ no downgrade flow
- Add state struct fields — In
activateOrUpdateState, add:wherependingDowngradeDay0 []downgradeDay0ArgsdowngradeDay0Argsholds{op, bc, negativeAmount}. - Implement Day 0 guard — In
recalculateComponentQuota, after computing the balance: ifbc.TriggersDowngrade && newRemaining < 0, querySELECT id FROM downgrade_events WHERE company_id=$1 AND billing_code=$2 AND status='active'. If no row → append topendingDowngradeDay0. If row exists → skip (flow already active). - Implement resolve cancellation — In
compareComponentsorrecalculateComponentQuota, ifbc.TriggersDowngrade && newRemaining >= 0: check for an activedowngrade_eventsrow; if found →UPDATE downgrade_events SET status='resolved', updated_at=now() WHERE id=$active_event_id+UPDATE downgrade_schedules SET status='cancelled', updated_at=now() WHERE downgrade_event_id=$active_event_id AND status='scheduled'. - Implement
publishDowngradeDay0IfNeeded— InpublishPendingEvents, iteratependingDowngradeDay0and for each:INSERT INTO downgrade_events (company_id, billing_code, negative_amount, status='active')INSERT INTO downgrade_schedules4 rows (week_1..month_1,scheduled_at = now() + delay)u.eventPublisher.Publish(ctx, TopicQuotaManagementNegativeBalance, key, NegativeBalanceEvent{TriggerSequence: 1, ...})u.jobEnqueuer.EnqueueDowngradeNotificationWorker(ctx, scheduleID, milestone)× 4
- Go green —
go test -race ./internal/app/usecase/quota_management/...
Acceptance Criteria
- First negative balance +
triggers_downgrade=true+ no active row → 1downgrade_eventsINSERT (status=active), 4downgrade_schedulesINSERTs,NegativeBalanceEvent(trigger_sequence=1)published, 4EnqueueDowngradeNotificationWorkercalls with correct delays - Negative balance + active
downgrade_eventsrow exists → Day 0 flow skipped (no duplicate inserts) - Balance recovers + active row exists →
downgrade_eventsset toresolved; allscheduledrows set tocancelled -
bc.TriggersDowngrade=false→ no downgrade flow - Day 0 Kafka publish fails → log error; schedule rows already inserted; milestone workers still fire
- No regression in
recalculateComponentQuotafor components withouttriggers_downgrade -
go test -race ./internal/app/usecase/quota_management/...passes
Test Strategy
Unit test with mocks for DB, Kafka publisher, and jobEnqueuer. Key cases: (a) full happy path Day 0 — verify downgrade_events INSERT, 4 schedule INSERTs, Kafka publish TriggerSequence=1, 4 enqueue calls with correct delays; (b) active-row guard — verify no INSERT; (c) resolve path — verify downgrade_events updated to resolved and schedule bulk cancel; (d) regression — existing quota calculation results unchanged. Open activate_or_update_component_quota_test.go to follow existing mock patterns.
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions:
pendingNegativeBalanceEventspattern already exists and can be followed exactly; Day 0 guard is one SELECT; resolve cancellation is two UPDATEs.
Run to Verify
go test -race ./internal/app/usecase/quota_management/... && make lint && make sec
Depends on
- Task 1.2 —
downgrade_events+downgrade_schedulestables must exist - Task 1.3 —
BillingComponentGetComponentCodeRow.TriggersDowngradefield must exist - Task 2.1 —
IJobEnqueuer.EnqueueDowngradeNotificationWorkermust exist in the interface
Ordering Rationale
- Tasks 1.1 and 1.2 can run in parallel — both are pure DDL migrations with no mutual dependency.
- Task 1.3 depends on Task 1.1 (the
triggers_downgradecolumn must exist in the DB before the query can be tested), but coding can start in parallel — only the test run needs the migration to be applied first. - Tasks 2.1 and 2.2 can be developed in parallel — 2.1 (worker consumer) and 2.2 (Day 0 extension) are independent; 2.2 only needs the
IJobEnqueuerinterface which can be mocked. Final integration tests require 2.1 to be complete. - Critical path: 1.2 → 2.1 → 2.2 (schema → worker → Day 0 trigger).
- External gates: §5 Q6 (pgcrypto) and §5 Q7 (backfill codes) must be verified before migrations 1.1 and 1.2 are run in production, but do not block local development.
Skipped Stories
| Item | Reason |
|---|---|
| Email consumer | §5 Q5: the consumer for billing.quota_management.negative_balance (filter trigger_sequence==1 → send email) is outside the scope of qontak-billing. Requires a separate RFC/ticket. |
| §5 Q6 pgcrypto verification | Pre-deploy gate, not a coding task: SELECT 1 FROM pg_extension WHERE extname='pgcrypto'. Resolve before deploying to production. |
| §5 Q7 backfill codes verification | Pre-deploy gate: verify 6 parent_component_code values exist in the production DB before running migration 1.1. |
| Full verification (RFC Task 7) | Not a separate task — each task already has a ### Run to Verify section. Final verification happens at PR review. |