Skip to main content

Task Breakdown — WhatsApp Service-Message Billing (Meta Oct 2026)

Effort Summary

Phase / AreaFE daysBE daysQA daysTotal
Phase 1 — Code changes (flag-OFF)0.56.52.09.0
Phase 2 — Data config3.03.0
Grand total0.59.52.012.0

Confidence: medium. OQ-2 (margin unit/value) resolved — margin is 0 (PRD v1.1), so Phase 2 margin values are settled. OQ-5 (Meta rate card cap) still blocks price migration values; OQ-9 (backfill repo owner) must be resolved before Task 2.2 can be fully scoped. All Phase 1 tasks are actionable today. New precondition: OQ-11 — :deduction_conversation_fee must be ON for every enabled org, else ui_fee (5) applies instead of the 0 margin.


Phase 1 — Code changes (all flag-OFF, safe to deploy)


Task 1.1: [BE] hub-core — billing flag layer + deduction guard + fail-safe (WSVC-S01, WSVC-S01-NEG)

The billing engine correctly bills a service message when Meta sends pricing_type=regular and the flag is ON, skips it when free or the flag is OFF, and never charges the 596.33 fallback when no price row exists.

Status: ✅ Actionable

Design reference: n/a — BE only

What to build

Three new files (read-only AR models + enabled?-only service) and targeted changes to new_pricing_wa_deduction.rb + wa_pricing.rb — no new DB schema, no migrations.

Implementation Plan

ActionFileWhat changes
createapp/core/domains/models/billing/preference.rbRead-only AR model → preferences table (billing DB), table_name, validations
createapp/core/domains/models/billing/preference_spec.rbColumn presence + validation specs
createapp/core/domains/models/billing/preference_unique_id.rbRead-only AR model → preference_unique_ids table, belongs_to :preference
createapp/core/domains/models/billing/preference_unique_id_spec.rbValidation specs
createapp/core/domains/services/billing/feature_flag.rbServices::Billing::FeatureFlag with enabled?(feature, unique_id: nil) only — Redis-first, billing-DB fallback
createapp/core/domains/services/billing/feature_flag_spec.rbAll enabled? branches (cache hit/miss, global, unique_id present/absent)
extendapp/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rbAdd use_service_billing?(org_id) helper; add service branch (gate on type=='regular' AND billable != false AND use_service_billing?); fix is_auto_deduct=TRUE for billable service (skip the false if UI guard when service is billable)
extendapp/core/domains/services/billing/v2/wa_pricing.rbIn get_price_from_cache: when conversation_category == 'service' and no DB row found, return nil (sentinel) instead of DEFAULT_FALLBACK_PRICE
extendapp/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rbNew service billing branch specs (see ACs)
extendapp/core/domains/services/billing/v2/wa_pricing_spec.rbSentinel return for missing service price

Implementation steps

  1. Explore — Open app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb and note: is_free_deduction? (L199-205 keys on pricing.type); @is_auto_deduct = false if conversation_type == 'UI' (L84); unique_id = status.id dedup (L64-65); create_conversation_log (L289-325). Open app/core/domains/services/billing/v2/wa_pricing.rb and note get_price_from_cache (L149-157) and DEFAULT_FALLBACK_PRICE = 596.33.
  2. Write failing specs (red) — In feature_flag_spec.rb: cold cache → DB read → returns state; warm-true → true with no DB hit; warm-false → false; is_global=true → true without unique_id check; is_global=false + unique_id in DB → true; unique_id absent → false. In new_pricing_wa_deduction_spec.rb: flag ON + type=regular + billable=true + service → 1 deduction, is_auto_deduct=true, conversation_category='service'; flag ON + free_customer_service → no deduction; flag ON + billable=false → no deduction; dup message_id → no double deduction; flag OFF → no deduction; price missing → no deduction + service_price_missing logged.
  3. Create modelspreference.rb: < Models::AbstractModelBilling, self.table_name = 'preferences', validations (feature uniqueness, title/target presence). preference_unique_id.rb: < Models::AbstractModelBilling, self.table_name = 'preference_unique_ids', belongs_to :preference, foreign_key: :preference_id, unique_id/preference_id validations.
  4. Create servicefeature_flag.rb: constants STATE_KEY_PATTERN, GLOBAL_KEY_PATTERN, UNIQUE_KEY_PATTERN matching qontak-preferences/service/util.go. enabled? flow: get state from REDIS_BILLING_R → on nil, read Models::Billing::Preference.find_by(feature:), populate cache → check global key → check unique_id key with DB fallback.
  5. Extend wa_pricing.rb — In get_price_from_cache: add guard return nil if conversation_category == 'service' && row.nil? (check row presence before returning the fallback; caller handles nil as missing-price signal).
  6. Extend new_pricing_wa_deduction.rb — Add use_service_billing?(org_id) (calls Services::Billing::FeatureFlag). In the service processing branch: after is_free_deduction? check, add: if category == 'service' AND flag off → skip. If flag on → resolve price (nil = fail-safe: log service_price_missing, return Success). If price present → set @is_auto_deduct = true (bypass the false if UI guard) → deduct.
  7. Go greenbundle exec rspec app/core/domains/services/billing/feature_flag_spec.rb app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb app/core/domains/services/billing/v2/wa_pricing_spec.rb
  8. Quality gatebundle exec rubocop --no-color

Acceptance criteria

  • Flag ON + category=service, pricing_type=regular, billable=true, new message_id → one deduction at service price + margin; wa_conversation_logs row has conversation_category='service', pricing_type='regular', is_auto_deduct=TRUE
  • Flag ON + pricing_type=free_customer_service → no deduction
  • Flag ON + pricing_type=regular, billable=false → no deduction
  • Duplicate message_id → no second deduction
  • Flag OFF → no deduction; behavior byte-identical to today
  • No seeded service price row → engine skips + logs service_price_missing; balance unchanged
  • enabled?(:bill_service_messages, unique_id: org_id) — Redis cache hit (warm-true) returns true without DB call
  • enabled?(:bill_service_messages_global) with is_global=true returns true for any caller
  • Other categories (marketing/utility/auth) is_auto_deduct and margins unchanged

Test strategy

feature_flag_spec.rb stubs REDIS_BILLING_R and Models::Billing::Preference to test each cache branch in isolation (hit/miss × state × global × unique_id). new_pricing_wa_deduction_spec.rb stubs Services::Billing::FeatureFlag.new (instance double) and wa_pricing to test the full service billing branch end-to-end; key assertions are the created WaConversationLog attributes and the absence of a deduction when guards fire.

Effort estimate

DisciplineDays
Backend3.0
QA1.0
Total4.0

Assumptions: models are read-only (no write methods); wa_pricing.rb change is a one-line guard; test patterns from existing new_pricing_wa_deduction_spec.rb are reused.

Run to verify

bundle exec rspec app/core/domains/services/billing/feature_flag_spec.rb \
app/core/domains/models/billing/preference_spec.rb \
app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb \
app/core/domains/services/billing/v2/wa_pricing_spec.rb && \
bundle exec rubocop --no-color

Depends on

  • (none — fully self-contained; preferences + preference_unique_ids tables already exist in billing DB)

Task 1.2: [BE] qontak-billing — widen modpanel MCC export filter (WSVC-S08)

Modpanel download-muv-mcc export includes billable service rows alongside marketing/utility/auth rows.

Status: ✅ Actionable

Design reference: n/a — BE only

What to build

Two SQL queries in one file widened + sqlc regeneration. No schema change.

Implementation Plan

ActionFileWhat changes
extenddb/queries/wa_conversation_logs.sqlFetchWaConversationLogsByOrganizationID (L75+): add OR (origin_type = 'UI' AND conversation_category = 'service') to origin_type = 'BI' predicate; same for FetchWaConversationLogsByChannelID
regenerateinternal/app/repository/wa_conversation_logs.sql.gosqlc generate output — commit the regenerated file
extendexisting Go test for FetchWaConversationLogsByOrganizationIDVerify widened query returns a service row; excludes free service and other UI types

Implementation steps

  1. Explore — Open db/queries/wa_conversation_logs.sql. Note the WHERE clause for FetchWaConversationLogsByOrganizationID (~L101-102) and FetchWaConversationLogsByChannelID. Confirm conversation_category is already SELECTed.
  2. Write failing tests — Add test cases: insert a row with origin_type='UI', conversation_category='service', is_auto_deduct=true; assert returned. Insert origin_type='UI', conversation_category='referral_conversion'; assert NOT returned.
  3. Edit SQL — For each of the two queries change AND origin_type = 'BI' to AND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service')). Keep AND is_auto_deduct = TRUE unchanged.
  4. Regeneratesqlc generate (using sqlc.yaml). Commit internal/app/repository/wa_conversation_logs.sql.go.
  5. Go greenmake test
  6. Quality gatemake lint

Acceptance criteria

  • FetchWaConversationLogsByOrganizationID returns rows with origin_type='UI', conversation_category='service', is_auto_deduct=TRUE
  • FetchWaConversationLogsByChannelID same
  • origin_type='UI', conversation_category='referral_conversion' NOT returned
  • origin_type='UI', is_auto_deduct=FALSE (free service) NOT returned
  • Existing origin_type='BI' rows still returned (no regression)

Test strategy

Go table-driven test: three fixture rows (billable service UI, free service UI, BI marketing); assert only BI + billable service returned.

Effort estimate

DisciplineDays
Backend1.0
Total1.0

Assumptions: conversation_category already SELECTed in both queries (confirmed via RFC source verification); no schema migration; sqlc config set up.

Run to verify

sqlc generate && make test && make lint

Depends on

  • (none — independent of hub-core deployment)

Task 1.3: [BE] report-worker — widen client quota export filter (WSVC-S07)

Client reports/export/quota (wa_balance type) includes billable service rows in the generated file.

Status: ✅ Actionable

Design reference: n/a — BE only

What to build

One SQL query widened + sqlc regeneration. No schema change.

Implementation Plan

ActionFileWhat changes
extenddb/billingdb/queries/wa_conversation_logs.sqlFetchMCCLogsExport (L12): change AND origin_type = 'BI' (L39) to AND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service'))
regenerateinternal/chat/repository/sqlc-billing/wa_conversation_logs.sql.gosqlc generate output — commit
extendexisting Go test for FetchMCCLogsExportService row included; referral_conversion UI excluded; free service excluded

Implementation steps

  1. Explore — Open db/billingdb/queries/wa_conversation_logs.sql. Identify FetchMCCLogsExport (L12-40). Confirm conversation_category (L19) and COALESCE(external_id,'n/a') AS message_id (L31) already SELECTed — no SELECT change needed.
  2. Write failing test — Insert a service row with origin_type='UI', is_auto_deduct=TRUE; assert it appears in FetchMCCLogsExport results.
  3. Edit SQL — Change L39 AND origin_type = 'BI'AND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service')).
  4. Regeneratesqlc generate (check both sqlc.yaml and sqlc-billing.yaml). Commit internal/chat/repository/sqlc-billing/wa_conversation_logs.sql.go.
  5. Go greenmake test
  6. Quality gatemake lint

Acceptance criteria

  • FetchMCCLogsExport returns service rows (origin_type='UI', conversation_category='service', is_auto_deduct=TRUE)
  • conversation_category='service' and message_id (from external_id) present in each returned row
  • Free service rows (is_auto_deduct=FALSE) NOT returned
  • origin_type='BI' rows still returned (no regression)

Test strategy

Table-driven Go test: fixture rows covering billable service, free service, BI marketing; assert filter returns exactly the expected subset.

Effort estimate

DisciplineDays
Backend1.0
Total1.0

Assumptions: single query to modify; conversation_category already SELECTed; pattern directly analogous to Task 1.2.

Run to verify

sqlc generate && make test && make lint

Depends on

  • (none — independent)

Task 1.4: [BE] moderator-be — surface service margin in margin list (WSVC-S03, WSVC-S03-NEG)

Modpanel margin list shows a service conversation-type margin per account (both DB and Chat Panel proxy paths), and update_margin persists it to Chat Panel.

Status: 🟠 Descoped (Won't Fix — BIF-8810, per delivery status 2026-07-16) — and the 0-margin decision makes this safe. With the launch margin = 0 (PRD v1.1), correctness does not depend on this margin-list surfacing: with no service ConversationFee row, hub-core WaPricing already falls back to DEFAULT_FALLBACK_UI_COST = 0.00, so service bills at cost regardless. This task therefore remains a visibility nicety (showing the explicit 0 in the modpanel margin list) — deferrable, not on the critical path. If reinstated later, the structural code below is the implementation; the value is simply 0.

Design reference: n/a — server-rendered modpanel; no Figma

What to build

get_margin_list_db.rb already passes all ConversationFee rows via build_item — a seeded service row will surface automatically. Actionable work: add explicit fallback when no service row exists, verify Chat Panel push forwards it, add spec coverage.

Implementation Plan

ActionFileWhat changes
extendapp/domains/core/repositories/billing/margins/get_margin_list_db.rbIn build_item: if conversation_fees contains no service entry, append { conversation_type: 'service', cost: 0.0 } with a Rails logger warning service_margin_missing
extendapp/domains/core/repositories/billing/margins/get_margin_list_db_spec.rbSpec: package with service ConversationFee → in conversation_fee[]; package without → shows cost: 0.0, not blank
verifyapp/domains/core/repositories/app_integrations/chat_panel/update_margin.rbConfirm conversation_fee param forwarded without filtering; if allow-list exists, add 'service'
extendapp/domains/core/repositories/app_integrations/chat_panel/update_margin_spec.rbSpec: service entry in conversation_fee array is present in the Chat Panel request body

Implementation steps

  1. Explore — Open app/domains/core/repositories/billing/margins/get_margin_list_db.rb. Read build_item (L63-70): conversation_fee: conversation_fees.map { |cf| { conversation_type: cf.conversation_type, cost: cf.cost } }. Confirm no allow-list filtering on conversation_type. Open update_margin.rb and check build_params — confirm @conversation_fee is forwarded without filtering.
  2. Write failing specsget_margin_list_db_spec.rb: stub Billings::ConversationFee with conversation_type='service', cost=0.00; assert in conversation_fee[]. Also test absent service row → cost: 0.0 fallback, not nil/absent. update_margin_spec.rb: pass conversation_fee: [{ conversation_type: 'service', cost: 0 }]; assert Chat Panel body includes it.
  3. Add fallback handling — In build_item, after conversation_fees.map, add: if result contains no service entry → append { conversation_type: 'service', cost: 0.0 } and log warning.
  4. Verify Chat Panel push — In update_margin.rb, confirm @conversation_fee is forwarded in build_params. If an allow-list exists (e.g. %w[marketing utility authentication]), add 'service'.
  5. Go greenbundle exec rspec app/domains/core/repositories/billing/margins/get_margin_list_db_spec.rb app/domains/core/repositories/app_integrations/chat_panel/update_margin_spec.rb
  6. Quality gatebundle exec rubocop --no-color

Acceptance criteria

  • Margin list response includes { conversation_type: 'service', cost: <value> } in conversation_fee[] when a service ConversationFee row exists
  • When no service row exists, margin list shows cost: 0.0 (not blank); logs service_margin_missing
  • update_margin Chat Panel push includes the service entry from conversation_fee
  • Other margins (ui_fee, bi_fee, marketing/utility/auth conversation_fees) unchanged (WSVC-S03-NEG)

Test strategy

Specs use factory/stub for Billings::ConversationFee to test both present and absent service rows. Chat Panel push spec stubs pigeon_put and asserts request body shape.

Effort estimate

DisciplineDays
Backend1.5
QA0.5
Total2.0

Assumptions: build_item passes all conversation_fees without type filtering (confirmed in recon); if an allow-list is found, add 0.5d. Actual margin value TBD (OQ-2).

Run to verify

bundle exec rspec app/domains/core/repositories/billing/margins/ \
app/domains/core/repositories/app_integrations/chat_panel/update_margin_spec.rb && \
bundle exec rubocop --no-color

Depends on

  • (actionable now; data seed values blocked on OQ-2 — code ships with cost: 0.0 fallback until seeded)

Task 1.5: [FE] hub-chat — service category label in usage table (WSVC-S06)

Client usage table at subscriptions/usages shows "Service" (friendly label) for service deduction rows, visually consistent with other category labels.

Status: ✅ Actionable (FE code ships safely before service rows appear; rows surface once Task 1.1 is deployed + flag is ON)

Design reference: n/a — no Figma (PRD: "no net-new screens; existing MpTableCell reused")

What to build

Add a CATEGORY_LABELS constant map and apply it to the conversation_category cell at line 272 of TableComponentWhatsappBalance.vue. Create a vitest spec.

Implementation Plan

ActionFileWhat changes
extendfeatures/subscriptions/usages/TableComponentWhatsappBalance.vueAdd CONVERSATION_CATEGORY_LABELS map; replace raw {{ usageLog.conversation_category || "" }} (L272) with a categoryLabel() helper
createfeatures/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.tsRender spec: service → "Service"; existing categories correct; unknown → raw passthrough

Implementation steps

  1. Explore — Open features/subscriptions/usages/TableComponentWhatsappBalance.vue. Read lines ~265-280 (Category cell) and tableHeaders (~L370-392). Note import/constant patterns by checking a neighboring component in the same folder.
  2. Write failing spec — Create features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts. Mount a minimal stub with usageLogs: [{ conversation_category: 'service', deducted_credit: 100, ... }]; assert rendered cell text is "Service". Add cases for 'marketing'"Marketing" and 'unknown_type'"unknown_type" (passthrough).
  3. Add label map — In <script setup>:
    const CATEGORY_LABELS: Record<string, string> = {
    service: 'Service',
    marketing: 'Marketing',
    utility: 'Utility',
    authentication: 'Authentication',
    }
    const categoryLabel = (cat: string) => CATEGORY_LABELS[cat] ?? cat
  4. Update template — Replace line 272 {{ usageLog.conversation_category || "" }} with {{ categoryLabel(usageLog.conversation_category) }}.
  5. Go greenpnpm test -- features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts
  6. Quality gatepnpm lint && nuxt build

Acceptance criteria

  • Row with conversation_category='service' renders "Service" in the Category cell
  • marketing, utility, authentication render their respective friendly labels
  • deducted_credit and message_id display correctly for service rows
  • Unknown category value renders the raw string (no blank or crash)
  • Existing non-service rows visually unchanged

Test strategy

Vitest mount spec with @pinia/testing + @nuxt/test-utils. Parameterised test cases over ['service', 'marketing', 'utility', 'authentication', 'unknown'] asserting rendered label text. No API call needed.

Effort estimate

DisciplineDays
Frontend0.5
QA0.5
Total1.0

Assumptions: one-cell template change; no composable or store work; @nuxt/test-utils confirmed in package.json.

Run to verify

pnpm test -- features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts && \
pnpm lint && \
nuxt build

Depends on

  • Task 1.1 (conceptual — FE code ships independently; service rows appear once flag is ON)

Phase 2 — Data config


Task 2.1: [BE] qontak-billing — seed service base price migration (WSVC-S02)

GetWhatsappBasePrice(code, 'UI', 'service') returns the real Meta rate for each active country instead of the 596.33 fallback.

Status: ⚠️ Partially blocked — migration structure is fully actionable; per-country cost values require Finance/Product sign-off (OQ-2: %-vs-fixed; OQ-5: rate card fits numeric(6,2)). Write the migration with placeholder values and a TODO: OQ-5 comment.

Design reference: n/a — data migration

What to build

golang-migrate up/down SQL pair. Insert (country, code, conversation_type='UI', conversation_category='service', cost) rows into v2_wa_conversation_prices for every active country code.

Implementation Plan

ActionFileWhat changes
createdb/migrations/20261001000000_seed_service_wa_prices.up.sqlBEGIN; INSERT INTO v2_wa_conversation_prices … VALUES (per active country, cost placeholder); COMMIT;
createdb/migrations/20261001000000_seed_service_wa_prices.down.sqlDELETE FROM v2_wa_conversation_prices WHERE conversation_type = 'UI' AND conversation_category = 'service'

Implementation steps

  1. Explore — Open db/migrations/20240401040054_add_existing_schema.up.sql:554-563 to review v2_wa_conversation_prices DDL: cost numeric(6,2), unique constraint shape.
  2. List active country codes — Query existing v2_wa_conversation_prices for distinct (country, code) pairs used by marketing/utility rows to build the INSERT list.
  3. Write up.sqlBEGIN; then INSERT INTO v2_wa_conversation_prices (country, code, conversation_type, conversation_category, cost) VALUES with one row per country, cost = 0.00 placeholder and -- TODO: OQ-5 — replace with Finance-confirmed Meta service rate comment per value. End with COMMIT;.
  4. Write down.sqlDELETE FROM v2_wa_conversation_prices WHERE conversation_type = 'UI' AND conversation_category = 'service';
  5. Dry-runmake migrate-up on local/staging billing DB; confirm rows inserted; make migrate-down confirms clean rollback.
  6. Replace placeholders — Once Finance confirms rate card (OQ-2/OQ-5), update cost values and re-run.

Acceptance criteria

  • After migrate-up, SELECT * FROM v2_wa_conversation_prices WHERE conversation_category = 'service' returns one row per active country code
  • Each row: conversation_type = 'UI', conversation_category = 'service', cost within numeric(6,2) range
  • After migrate-down, all service rows removed; no other rows affected
  • (Pending OQ-5) No cost value overflows numeric(6,2)

Test strategy

Manual migrate-up/down dry-run on a local billing DB. Once values confirmed, a Go test asserts GetWhatsappBasePrice(code, 'UI', 'service') returns the expected cost.

Effort estimate

DisciplineDays
Backend1.0
Total1.0

Assumptions: follows existing golang-migrate pattern; active (country, code) list derived from existing price rows; actual values TBD pending OQ-2/OQ-5.

Run to verify

make migrate-up && make migrate-down # dry-run on local billing DB

Depends on

  • OQ-2 (Finance confirms margin unit/value) + OQ-5 (rate card cap validated) before replacing placeholder values

Task 2.2: [BE] Backfill the service margin = 0 for existing CIDs on release (WSVC-S05)

Every existing active package gets a conversation_fees row for conversation_type='service' with cost = 0, so the service margin is an explicit, auditable 0 (charge at cost) rather than an invisible 0.00 fallback. New CIDs get this 0 at account creation (Task 1.4 / margin config), so the backfill targets existing packages only.

Status: ✅ Unblocked on value — margin value is settled at 0 (PRD v1.1; OQ-2 resolved). Job structure (idempotent batched seed) is actionable; owning repo still depends on OQ-9 (report-worker gocraft vs hub-core rake). Default to report-worker (gocraft/work precedent: internal/chat/worker/worker_seed_blind_index.go) unless OQ-9 resolves otherwise.

Design reference: n/a — background job

What to build

One-off idempotent batched gocraft/work job that seeds a conversation_fees row (conversation_type='service', cost=0.00) for every existing organization_package_id that does not already have one. Pattern from worker_seed_blind_index.go.

Implementation Plan

ActionFileWhat changes
createinternal/chat/worker/worker_seed_service_margin.gogocraft/work handler: batch-paginate organization_packages; INSERT INTO conversation_fees … WHERE NOT EXISTS; log seeded/skipped/error counts
extendinternal/chat/worker/service_worker_pool.goRegister "seed_service_margin" worker
extendcmd/workenqueue/main.goAdd CLI flag to enqueue seed_service_margin job with batch_size param
createinternal/chat/worker/worker_seed_service_margin_test.goIdempotency test (run 2×, 0 dup rows); partial failure test (per-row error counted, batch continues)

Implementation steps

  1. Explore — Open internal/chat/worker/worker_seed_blind_index.go: note batch loop (offset += batchSize), NOT-EXISTS guard, per-row error counting, and service_worker_pool.go:84 registration. Open cmd/workenqueue/main.go to see job enqueue pattern.
  2. Write failing testsworker_seed_service_margin_test.go: (a) run once → N packages get service ConversationFee rows; (b) run again → 0 new rows; (c) simulate per-row DB error → error logged, job does not abort.
  3. Implement worker — Copy worker_seed_blind_index.go structure; replace seed logic:
    INSERT INTO conversation_fees (organization_package_id, conversation_type, cost, tax)
    SELECT op.id, 'service', 0.00, 0.0 -- service margin = 0 (charge at cost; PRD v1.1)
    FROM organization_packages op
    WHERE op.id = $1
    AND NOT EXISTS (
    SELECT 1 FROM conversation_fees
    WHERE organization_package_id = op.id AND conversation_type = 'service'
    )
    Log seeded, skipped, error_count per batch.
  4. Register + enqueue — Add to service_worker_pool.go; add CLI handler in workenqueue/main.go.
  5. Go greenmake test
  6. Quality gatemake lint

Acceptance criteria

  • After first run, every eligible organization_package_id has conversation_fees row with conversation_type='service'
  • Re-run produces 0 new rows and 0 errors (idempotent)
  • Per-row error logged with (package_id, error); job continues and processes remaining batches
  • seeded_count + skipped_count + error_count = total packages processed
  • Existing conversation_fees rows for other types unchanged

Test strategy

Go integration test with test billing DB: seed 5 packages, run job, assert 5 new rows; run again, assert 0 new rows. Inject a deliberate constraint error for 1 package; assert error_count = 1, other 4 packages seeded.

Effort estimate

DisciplineDays
Backend2.0
Total2.0

Assumptions: gocraft/work setup exists in report-worker; NOT-EXISTS pattern copied from worker_seed_blind_index.go; actual cost TBD pending OQ-2. Add 0.5d if OQ-9 resolves to hub-core rake.

Run to verify

make test && make lint

Depends on

  • OQ-9 (confirm report-worker vs hub-core as owning repo)
  • OQ-2 (Finance confirms default service margin value)

Ordering rationale

  • Task 1.1 first — deduction engine + flag layer is the critical path; Tasks 1.2–1.5 are fully independent and can be parallelised across engineers
  • Tasks 1.2, 1.3, 1.4, 1.5 in parallel — no interdependencies; different repos, different engineers
  • Phase 2 before enabling flags — price seed (2.1) and margin backfill (2.2) must both complete and be verified on staging before bill_service_messages is turned ON for any org; all Phase 1 code is safe to deploy flag-OFF
  • Critical unblocks to push on now: OQ-2 (margin unit/value) resolved (margin = 0); OQ-5 (rate card cap) gates Task 2.1 values; OQ-9 (backfill repo owner) gates Task 2.2 structure; OQ-11 — confirm :deduction_conversation_fee ON for all enabled orgs (else ui_fee breaks the 0 margin)

Skipped stories

StoryReason
WSVC-S04Excluded — custom_margin_by_packages dead end-to-end: no schema in moderator-be, no read path in qontak-billing/hub-core. Deferred (OQ-3).