Task Breakdown — WhatsApp Service-Message Billing (Meta Oct 2026)
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — Code changes (flag-OFF) | 0.5 | 6.5 | 2.0 | 9.0 |
| Phase 2 — Data config | — | 3.0 | — | 3.0 |
| Grand total | 0.5 | 9.5 | 2.0 | 12.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_feemust be ON for every enabled org, elseui_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
servicemessage when Meta sendspricing_type=regularand 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
| Action | File | What changes |
|---|---|---|
| create | app/core/domains/models/billing/preference.rb | Read-only AR model → preferences table (billing DB), table_name, validations |
| create | app/core/domains/models/billing/preference_spec.rb | Column presence + validation specs |
| create | app/core/domains/models/billing/preference_unique_id.rb | Read-only AR model → preference_unique_ids table, belongs_to :preference |
| create | app/core/domains/models/billing/preference_unique_id_spec.rb | Validation specs |
| create | app/core/domains/services/billing/feature_flag.rb | Services::Billing::FeatureFlag with enabled?(feature, unique_id: nil) only — Redis-first, billing-DB fallback |
| create | app/core/domains/services/billing/feature_flag_spec.rb | All enabled? branches (cache hit/miss, global, unique_id present/absent) |
| extend | app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | Add 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) |
| extend | app/core/domains/services/billing/v2/wa_pricing.rb | In get_price_from_cache: when conversation_category == 'service' and no DB row found, return nil (sentinel) instead of DEFAULT_FALLBACK_PRICE |
| extend | app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb | New service billing branch specs (see ACs) |
| extend | app/core/domains/services/billing/v2/wa_pricing_spec.rb | Sentinel return for missing service price |
Implementation steps
- Explore — Open
app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rband note:is_free_deduction?(L199-205 keys onpricing.type);@is_auto_deduct = false if conversation_type == 'UI'(L84);unique_id = status.iddedup (L64-65);create_conversation_log(L289-325). Openapp/core/domains/services/billing/v2/wa_pricing.rband noteget_price_from_cache(L149-157) andDEFAULT_FALLBACK_PRICE = 596.33. - 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. Innew_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; dupmessage_id→ no double deduction; flag OFF → no deduction; price missing → no deduction +service_price_missinglogged. - Create models —
preference.rb:< Models::AbstractModelBilling,self.table_name = 'preferences', validations (featureuniqueness,title/targetpresence).preference_unique_id.rb:< Models::AbstractModelBilling,self.table_name = 'preference_unique_ids',belongs_to :preference, foreign_key: :preference_id,unique_id/preference_idvalidations. - Create service —
feature_flag.rb: constantsSTATE_KEY_PATTERN,GLOBAL_KEY_PATTERN,UNIQUE_KEY_PATTERNmatchingqontak-preferences/service/util.go.enabled?flow: get state fromREDIS_BILLING_R→ on nil, readModels::Billing::Preference.find_by(feature:), populate cache → check global key → check unique_id key with DB fallback. - Extend
wa_pricing.rb— Inget_price_from_cache: add guardreturn nil if conversation_category == 'service' && row.nil?(check row presence before returning the fallback; caller handles nil as missing-price signal). - Extend
new_pricing_wa_deduction.rb— Adduse_service_billing?(org_id)(callsServices::Billing::FeatureFlag). In the service processing branch: afteris_free_deduction?check, add: ifcategory == 'service'AND flag off → skip. If flag on → resolve price (nil = fail-safe: logservice_price_missing, returnSuccess). If price present → set@is_auto_deduct = true(bypass thefalse if UIguard) → deduct. - Go green —
bundle 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 - Quality gate —
bundle exec rubocop --no-color
Acceptance criteria
- Flag ON +
category=service, pricing_type=regular, billable=true, newmessage_id→ one deduction at service price + margin;wa_conversation_logsrow hasconversation_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
serviceprice row → engine skips + logsservice_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)withis_global=truereturns true for any caller - Other categories (marketing/utility/auth)
is_auto_deductand 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
| Discipline | Days |
|---|---|
| Backend | 3.0 |
| QA | 1.0 |
| Total | 4.0 |
Assumptions: models are read-only (no write methods);
wa_pricing.rbchange is a one-line guard; test patterns from existingnew_pricing_wa_deduction_spec.rbare 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_idstables already exist in billing DB)
Task 1.2: [BE] qontak-billing — widen modpanel MCC export filter (WSVC-S08)
Modpanel
download-muv-mccexport 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
| Action | File | What changes |
|---|---|---|
| extend | db/queries/wa_conversation_logs.sql | FetchWaConversationLogsByOrganizationID (L75+): add OR (origin_type = 'UI' AND conversation_category = 'service') to origin_type = 'BI' predicate; same for FetchWaConversationLogsByChannelID |
| regenerate | internal/app/repository/wa_conversation_logs.sql.go | sqlc generate output — commit the regenerated file |
| extend | existing Go test for FetchWaConversationLogsByOrganizationID | Verify widened query returns a service row; excludes free service and other UI types |
Implementation steps
- Explore — Open
db/queries/wa_conversation_logs.sql. Note the WHERE clause forFetchWaConversationLogsByOrganizationID(~L101-102) andFetchWaConversationLogsByChannelID. Confirmconversation_categoryis already SELECTed. - Write failing tests — Add test cases: insert a row with
origin_type='UI', conversation_category='service', is_auto_deduct=true; assert returned. Insertorigin_type='UI', conversation_category='referral_conversion'; assert NOT returned. - Edit SQL — For each of the two queries change
AND origin_type = 'BI'toAND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service')). KeepAND is_auto_deduct = TRUEunchanged. - Regenerate —
sqlc generate(usingsqlc.yaml). Commitinternal/app/repository/wa_conversation_logs.sql.go. - Go green —
make test - Quality gate —
make lint
Acceptance criteria
-
FetchWaConversationLogsByOrganizationIDreturns rows withorigin_type='UI', conversation_category='service', is_auto_deduct=TRUE -
FetchWaConversationLogsByChannelIDsame -
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
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| Total | 1.0 |
Assumptions:
conversation_categoryalready 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
| Action | File | What changes |
|---|---|---|
| extend | db/billingdb/queries/wa_conversation_logs.sql | FetchMCCLogsExport (L12): change AND origin_type = 'BI' (L39) to AND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service')) |
| regenerate | internal/chat/repository/sqlc-billing/wa_conversation_logs.sql.go | sqlc generate output — commit |
| extend | existing Go test for FetchMCCLogsExport | Service row included; referral_conversion UI excluded; free service excluded |
Implementation steps
- Explore — Open
db/billingdb/queries/wa_conversation_logs.sql. IdentifyFetchMCCLogsExport(L12-40). Confirmconversation_category(L19) andCOALESCE(external_id,'n/a') AS message_id(L31) already SELECTed — no SELECT change needed. - Write failing test — Insert a
servicerow withorigin_type='UI', is_auto_deduct=TRUE; assert it appears inFetchMCCLogsExportresults. - Edit SQL — Change L39
AND origin_type = 'BI'→AND (origin_type = 'BI' OR (origin_type = 'UI' AND conversation_category = 'service')). - Regenerate —
sqlc generate(check bothsqlc.yamlandsqlc-billing.yaml). Commitinternal/chat/repository/sqlc-billing/wa_conversation_logs.sql.go. - Go green —
make test - Quality gate —
make lint
Acceptance criteria
-
FetchMCCLogsExportreturnsservicerows (origin_type='UI', conversation_category='service', is_auto_deduct=TRUE) -
conversation_category='service'andmessage_id(fromexternal_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
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| Total | 1.0 |
Assumptions: single query to modify;
conversation_categoryalready 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
serviceconversation-type margin per account (both DB and Chat Panel proxy paths), andupdate_marginpersists 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
| Action | File | What changes |
|---|---|---|
| extend | app/domains/core/repositories/billing/margins/get_margin_list_db.rb | In build_item: if conversation_fees contains no service entry, append { conversation_type: 'service', cost: 0.0 } with a Rails logger warning service_margin_missing |
| extend | app/domains/core/repositories/billing/margins/get_margin_list_db_spec.rb | Spec: package with service ConversationFee → in conversation_fee[]; package without → shows cost: 0.0, not blank |
| verify | app/domains/core/repositories/app_integrations/chat_panel/update_margin.rb | Confirm conversation_fee param forwarded without filtering; if allow-list exists, add 'service' |
| extend | app/domains/core/repositories/app_integrations/chat_panel/update_margin_spec.rb | Spec: service entry in conversation_fee array is present in the Chat Panel request body |
Implementation steps
- Explore — Open
app/domains/core/repositories/billing/margins/get_margin_list_db.rb. Readbuild_item(L63-70):conversation_fee: conversation_fees.map { |cf| { conversation_type: cf.conversation_type, cost: cf.cost } }. Confirm no allow-list filtering onconversation_type. Openupdate_margin.rband checkbuild_params— confirm@conversation_feeis forwarded without filtering. - Write failing specs —
get_margin_list_db_spec.rb: stubBillings::ConversationFeewithconversation_type='service', cost=0.00; assert inconversation_fee[]. Also test absentservicerow →cost: 0.0fallback, not nil/absent.update_margin_spec.rb: passconversation_fee: [{ conversation_type: 'service', cost: 0 }]; assert Chat Panel body includes it. - Add fallback handling — In
build_item, afterconversation_fees.map, add: if result contains noserviceentry → append{ conversation_type: 'service', cost: 0.0 }and log warning. - Verify Chat Panel push — In
update_margin.rb, confirm@conversation_feeis forwarded inbuild_params. If an allow-list exists (e.g.%w[marketing utility authentication]), add'service'. - Go green —
bundle 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 - Quality gate —
bundle exec rubocop --no-color
Acceptance criteria
- Margin list response includes
{ conversation_type: 'service', cost: <value> }inconversation_fee[]when aserviceConversationFee row exists - When no
servicerow exists, margin list showscost: 0.0(not blank); logsservice_margin_missing -
update_marginChat Panel push includes theserviceentry fromconversation_fee - Other margins (
ui_fee,bi_fee, marketing/utility/authconversation_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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions:
build_itempasses allconversation_feeswithout 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.0fallback until seeded)
Task 1.5: [FE] hub-chat — service category label in usage table (WSVC-S06)
Client usage table at
subscriptions/usagesshows "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
| Action | File | What changes |
|---|---|---|
| extend | features/subscriptions/usages/TableComponentWhatsappBalance.vue | Add CONVERSATION_CATEGORY_LABELS map; replace raw {{ usageLog.conversation_category || "" }} (L272) with a categoryLabel() helper |
| create | features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts | Render spec: service → "Service"; existing categories correct; unknown → raw passthrough |
Implementation steps
- Explore — Open
features/subscriptions/usages/TableComponentWhatsappBalance.vue. Read lines ~265-280 (Category cell) andtableHeaders(~L370-392). Note import/constant patterns by checking a neighboring component in the same folder. - Write failing spec — Create
features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts. Mount a minimal stub withusageLogs: [{ conversation_category: 'service', deducted_credit: 100, ... }]; assert rendered cell text is"Service". Add cases for'marketing'→"Marketing"and'unknown_type'→"unknown_type"(passthrough). - 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 - Update template — Replace line 272
{{ usageLog.conversation_category || "" }}with{{ categoryLabel(usageLog.conversation_category) }}. - Go green —
pnpm test -- features/subscriptions/usages/__tests__/TableComponentWhatsappBalance.spec.ts - Quality gate —
pnpm lint && nuxt build
Acceptance criteria
- Row with
conversation_category='service'renders "Service" in the Category cell -
marketing,utility,authenticationrender their respective friendly labels -
deducted_creditandmessage_iddisplay 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
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: one-cell template change; no composable or store work;
@nuxt/test-utilsconfirmed inpackage.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
| Action | File | What changes |
|---|---|---|
| create | db/migrations/20261001000000_seed_service_wa_prices.up.sql | BEGIN; INSERT INTO v2_wa_conversation_prices … VALUES (per active country, cost placeholder); COMMIT; |
| create | db/migrations/20261001000000_seed_service_wa_prices.down.sql | DELETE FROM v2_wa_conversation_prices WHERE conversation_type = 'UI' AND conversation_category = 'service' |
Implementation steps
- Explore — Open
db/migrations/20240401040054_add_existing_schema.up.sql:554-563to reviewv2_wa_conversation_pricesDDL:cost numeric(6,2), unique constraint shape. - List active country codes — Query existing
v2_wa_conversation_pricesfor distinct(country, code)pairs used by marketing/utility rows to build the INSERT list. - Write up.sql —
BEGIN;thenINSERT INTO v2_wa_conversation_prices (country, code, conversation_type, conversation_category, cost) VALUESwith one row per country,cost = 0.00placeholder and-- TODO: OQ-5 — replace with Finance-confirmed Meta service ratecomment per value. End withCOMMIT;. - Write down.sql —
DELETE FROM v2_wa_conversation_prices WHERE conversation_type = 'UI' AND conversation_category = 'service'; - Dry-run —
make migrate-upon local/staging billing DB; confirm rows inserted;make migrate-downconfirms clean rollback. - Replace placeholders — Once Finance confirms rate card (OQ-2/OQ-5), update
costvalues 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',costwithinnumeric(6,2)range - After
migrate-down, allservicerows 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
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| Total | 1.0 |
Assumptions: follows existing
golang-migratepattern; 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_feesrow forconversation_type='service'withcost = 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
| Action | File | What changes |
|---|---|---|
| create | internal/chat/worker/worker_seed_service_margin.go | gocraft/work handler: batch-paginate organization_packages; INSERT INTO conversation_fees … WHERE NOT EXISTS; log seeded/skipped/error counts |
| extend | internal/chat/worker/service_worker_pool.go | Register "seed_service_margin" worker |
| extend | cmd/workenqueue/main.go | Add CLI flag to enqueue seed_service_margin job with batch_size param |
| create | internal/chat/worker/worker_seed_service_margin_test.go | Idempotency test (run 2×, 0 dup rows); partial failure test (per-row error counted, batch continues) |
Implementation steps
- Explore — Open
internal/chat/worker/worker_seed_blind_index.go: note batch loop (offset += batchSize), NOT-EXISTS guard, per-row error counting, andservice_worker_pool.go:84registration. Opencmd/workenqueue/main.goto see job enqueue pattern. - Write failing tests —
worker_seed_service_margin_test.go: (a) run once → N packages getserviceConversationFee rows; (b) run again → 0 new rows; (c) simulate per-row DB error → error logged, job does not abort. - Implement worker — Copy
worker_seed_blind_index.gostructure; replace seed logic:LogINSERT 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 opWHERE op.id = $1AND NOT EXISTS (SELECT 1 FROM conversation_feesWHERE organization_package_id = op.id AND conversation_type = 'service')seeded,skipped,error_countper batch. - Register + enqueue — Add to
service_worker_pool.go; add CLI handler inworkenqueue/main.go. - Go green —
make test - Quality gate —
make lint
Acceptance criteria
- After first run, every eligible
organization_package_idhasconversation_feesrow withconversation_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_feesrows 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
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| Total | 2.0 |
Assumptions: gocraft/work setup exists in report-worker; NOT-EXISTS pattern copied from
worker_seed_blind_index.go; actualcostTBD 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_messagesis 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_feeON for all enabled orgs (elseui_feebreaks the 0 margin)
Skipped stories
| Story | Reason |
|---|---|
| WSVC-S04 | Excluded — custom_margin_by_packages dead end-to-end: no schema in moderator-be, no read path in qontak-billing/hub-core. Deferred (OQ-3). |