Skip to main content

Phase 8M: Wizard “Not Ready” Configure-in-Place Implementation Plan

Goal: Implement minimal frontend-only changes to enable:
  • Configure Now opens BusinessDetailDrawer in wizardMode
  • Drafts are frontend-only (no BQ writes before commit)
  • Commit All saves drafted businesses exactly once for the CURRENT scope
  • Drafts do not leak across periods OR orgs

1. REPO FINDINGS

1.1 Wizard Drawer Component

File: dashboard/src/components/admin/onboarding/BusinessDetailDrawer.tsx
  • Component: BusinessDetailDrawer (DetailsTab function)
  • Wizard Mode: wizardMode={true} prop enables Save Draft / Commit Now flow
  • Usage in Wizard: dashboard/src/components/intake/PreflightStep.tsx (line 2003-2015)
  • State Store: useDraftStore from dashboard/src/store/useDraftStore.ts
  • Draft Key: business_id only (NOT scoped by period_labelRED FLAG)

1.2 Legacy Admin Onboarding Drawer

File: Same component (BusinessDetailDrawer.tsx)
  • Usage: dashboard/src/pages/admin/onboarding/businesses.tsx (line 1055-1065)
  • Difference: wizardMode={false} (default) — no Save Draft, only immediate save
  • Why Not Used: Wizard needs draft staging → commit all workflow, legacy is immediate save only

1.3 Draft Store Implementation

File: dashboard/src/store/useDraftStore.ts
  • Key Structure: Map<business_id, DraftEntry>NOT scoped by tenant/org/period (RED FLAG)
  • Current Actions: saveDraft(businessId, payload), discardDraft(businessId), discardAll()
  • Persistence: Uses Zustand persist middleware (localStorage)
  • Issues:
    • Drafts can conflict across periods if same business_id used in different periods
    • Drafts can leak across tenants/orgs (no tenant/org scoping)
    • Migration needed: clear old drafts on rehydrate (safest approach)

1.4 Endpoints Inventory

Discovery Read/Write Paths

  1. Preflight Discovery Check (POST /api/v1/ingestion-wizard/preflight)
    • Reads: get_discovered_businesses() from ingestion_batch_businesses table
    • Writes: discover_batch_businesses() if empty (writes to ingestion_batch_businesses)
  2. Discover Businesses Endpoint (POST /api/v1/ingestion/{batch_id}/discover-businesses)
    • Writes: discover_batch_businesses()ingestion_batch_businesses table
  3. List Businesses with Discovery (GET /api/v1/admin/onboarding/businesses?include_discovered=true)
    • Reads: list_businesses_for_onboarding() with include_discovered=True
    • Source: transaction_events_raw + dim_business_mapping (NOT ingestion_batch_businesses)
    • RED FLAG: Different source than readiness endpoint

Onboarding Endpoints (Phase 8K/8L)

  1. List Businesses: GET /api/v1/admin/onboarding/businesses
    • Function: list_businesses_for_onboarding() in api/bigquery/business_onboarding_queries.py
    • Tables: config_business_onboarding + optionally transaction_events_raw (if include_discovered=True)
  2. Get Business Config: GET /api/v1/admin/onboarding/businesses/{business_id}
    • Function: get_business_config_endpoint() in api/routes/business_onboarding.py
    • Tables: config_business_onboarding (authoritative)
  3. Save Business Config: PUT /api/v1/admin/onboarding/businesses/{business_id}/save
    • Function: save_business_config_endpoint() in api/routes/business_onboarding.py
    • Tables: config_business_onboarding (writes)

Readiness Endpoints

  1. Batch Readiness (GET /api/v1/ingestion/{batch_id}/ingestion-wizard/readiness)
    • Function: get_batch_readiness_endpoint() in api/routes/intake.py (line 1539)
    • Source: get_batch_discovered_business_ids()ingestion_batch_businesses table
    • Then: get_business_readiness() filters to batch businesses
    • Tables: ingestion_batch_businessesconfig_business_onboarding (readiness check)
  2. Business Readiness (GET /api/v1/admin/onboarding/businesses/readiness)
    • Function: get_business_readiness_endpoint() in api/routes/business_onboarding.py
    • Tables: config_business_onboarding only

1.5 Materialization Guarantee Analysis

Question: Are “not ready / discovered” businesses always present in config_business_onboarding for the wizard’s period_label? Answer: NO — Line 1701 in api/routes/intake.py shows:
# Business not in onboarding yet - mark as not ready
batch_readiness_map[business_id] = {
    "is_ready": False,
    "reasons": ["Business not onboarded"]
}
Finding: Discovered businesses from ingestion_batch_businesses are NOT automatically in config_business_onboarding. They must be:
  1. Adopted via POST /api/v1/admin/onboarding/businesses/adopt (bulk adopt)
  2. OR configured via saveBusinessConfig (which calls adoptBusiness internally)
Minimal Backend Change Needed: None — saveBusinessConfig already calls auto_adopt_discovered_business internally (line 4359 in api/bigquery/business_onboarding_queries.py). The wizard can configure discovered businesses directly. STOP CONDITION: If saveBusinessConfig cannot adopt/materialize a discovered-only business, STOP and report exact failing case + logs before adding endpoints.

2. RED FLAGS / ALIGNMENT GAPS

2.1 Discovery Source Drift

Issue: Readiness endpoint reads from ingestion_batch_businesses, but include_discovered reads from transaction_events_raw + dim_business_mapping. Risk: Businesses shown in wizard readiness may not match businesses shown in admin onboarding list with include_discovered=True. Mitigation: Wizard should use get_batch_discovered_business_ids() to get business list (already used by readiness), NOT list_businesses_for_onboarding(include_discovered=True).

2.2 Draft Store Scoping (P0 BLOCKER)

Issue: Draft store keys by business_id only, not scoped by tenant/org/period. Risks:
  • If user drafts business A for period 2025-01-01, then switches to period 2025-02-01, draft from previous period may leak
  • Drafts can leak across tenants (multi-tenant support)
  • Drafts can leak across orgs (platform admin vs org-scoped)
Mitigation: Change draft key to ${tenant_id}:${effective_org_id || 'platform'}:${period_label}:${business_id}. Clear old drafts on rehydrate (migration strategy).

2.3 Org/Tenant Scoping Inconsistencies

Finding:
  • Readiness endpoint: Uses effective_org_id (resolved via resolve_effective_org_scope)
  • Discovery write: Always writes with org_id = NULL for Phase 8D batches
  • Discovery read: Reads with org_id = NULL for Phase 8D batches
  • saveBusinessConfig: Uses effectiveOrgId from resolveOrgDeterministically()
Risk: Org scoping may differ between readiness check and save operation. Mitigation: Ensure wizard uses same org resolution logic for both readiness and save.

2.4 Race/Eventual Consistency Risks

Issue: BigQuery write → read lag (documented in handleDrawerSuccess retry logic, line 499-536 in PreflightStep.tsx). Risk: verifySave() may fail if config not immediately queryable after save. Mitigation: Already handled with retry logic in verifySave() (300ms, 700ms, 1500ms, 2500ms, 4000ms delays).

2.5 UI State Risks

Issues:
  1. Draft store persists across page refreshes (localStorage) — may show stale drafts
  2. Period label changes — drafts from old period may be invalid
  3. Business switching — drawer state may not reset properly
Mitigation:
  • Clear drafts when period_label changes
  • Reset drawer state when business changes
  • Validate draft period_label matches current wizard period_label before showing

2.6 Security/RBAC Risks

Finding:
  • Platform admin without org header → requires X-Org-Id header (400 error)
  • Org-scoped businesses → requires matching org_id in header
  • Phase 8D batches → always org_id = NULL (platform-scope)
Risk: Wizard may fail if org header not set correctly. Mitigation: Wizard should use useOrgScope hook (already used in PreflightStep.tsx line 28) to ensure correct org context.

3. PROPOSED MINIMAL DIFFS

3.1 Frontend Changes

3.1.1 Update Draft Store Key Structure (P0 BLOCKER)

File: dashboard/src/store/useDraftStore.ts
  • Change key from business_id to ${tenant_id}:${effective_org_id || 'platform'}:${period_label}:${business_id}
  • Update method signatures to require: tenantId, orgId (nullable), periodLabel, businessId
  • Update saveDraft()saveDraft(tenantId, orgId, periodLabel, businessId, payload)
  • Update hasDraft(), getDraft(), discardDraft() → require same scope params
  • Update getAllDraftBusinessIds()getAllDraftBusinessIds(tenantId, orgId, periodLabel) (filtered by current scope)
  • Migration: In onRehydrateStorage, clear all old drafts (safest approach - no key migration)
  • Update all call sites in BusinessDetailDrawer.tsx and PreflightStep.tsx

3.1.2 Add Draft Badge to Readiness List

File: dashboard/src/components/intake/PreflightStep.tsx
  • In “Businesses Not Ready” section (line 1806), add badge check:
    • 🟢 “Ready” if readiness.is_ready === true (server readiness true)
    • 🟡 “Draft Saved” if draft exists AND readiness.is_ready === false (draft exists, server not ready)
    • 🔴 “Not Ready” if no draft AND readiness.is_ready === false (no draft, server not ready)
  • Important: Drafts are NOT ready - they are local-only until committed

3.1.3 Add Bulk “Select Remaining → Owner Rollup Draft” Action (P0 BLOCKER)

File: dashboard/src/components/intake/PreflightStep.tsx
  • MUST draft-only in wizard - Remove/avoid BulkAssignModal for wizard bulk action
  • Implement “Select Remaining → Owner Rollup (Draft)” directly in PreflightStep.tsx
  • Add button: “Select Remaining Non-Ready → Owner Rollup (Draft)”
  • On click:
    • Filter pending_review_items to exclude businesses that already have drafts
    • For each selected business, create draft directly using draftStore.saveDraft() with OWNER_ROLLUP mode
    • NO API calls - drafts are frontend-only until Commit All
  • Decimal Safety: All pepm/amount values remain strings in payloads (no JS number math)

3.1.4 Add “Commit All” Button (P0 BLOCKER)

File: dashboard/src/components/intake/PreflightStep.tsx
  • Reuse existing BulkActionBar component (already exists in dashboard/src/components/ui/BulkActionBar.tsx)
  • Wire to draftStore.getAllDraftBusinessIds(tenantId, orgId, periodLabel) to get drafts for CURRENT scope only
  • Commit All filtering: Only commit drafts for current (tenant, org, period)
  • Sequential commit: Concurrency=1 (one save at a time) with progress UI
  • Progress UI: Show {current}/{total} during commit
  • Partial failure handling:
    • On partial failure: Discard only successful drafts; keep failed drafts
    • Show failure list with business IDs and error messages
    • Modal stays open until all succeed or user discards
  • On success: Clear all committed drafts, refresh readiness exactly once at end (or debounced)

3.1.5 Update BusinessDetailDrawer for Discovered Businesses

File: dashboard/src/components/admin/onboarding/BusinessDetailDrawer.tsx
  • Ensure getBusinessConfig handles businesses not yet in onboarding (returns 404 or empty config)
  • Ensure saveBusinessConfig calls auto_adopt_discovered_business internally (already done in backend)
  • Pass scope params into draftStore calls: Update all draftStore.saveDraft(), draftStore.hasDraft(), etc. to include tenantId, orgId, periodLabel
  • Access tenantId via auth.getUser()?.tenantId
  • Access orgId via getEffectiveOrgId() (can be null, use 'platform' in key)

3.1.6 Wiring: Configure Now Opens Drawer

File: dashboard/src/components/intake/PreflightStep.tsx
  • In readiness list: Configure button must open BusinessDetailDrawer (wizardMode=true)
  • Create typed helper convertReadinessItemToDrawerBusiness() with null-safe fields
  • Helper converts readiness item to BusinessOnboardingListItem shape expected by drawer
  • Handle discovered-only businesses (may not have all fields populated)

3.2 Backend Changes

NONE REQUIRED — All endpoints already support:
  • include_discovered in list endpoint
  • saveBusinessConfig with auto_adopt_discovered_business internal call (line 4359 in business_onboarding_queries.py)
  • getBusinessConfig for any business_id (will return 404 if not in onboarding, but save will adopt)
STOP CONDITION: If saveBusinessConfig cannot adopt/materialize a discovered-only business, STOP and report exact failing case + logs before adding endpoints.

4. STEP-BY-STEP EXECUTION CHECKLIST

Phase 1: Draft Store Scoping (P0 BLOCKER)

  • Update useDraftStore.ts to key by ${tenant_id}:${effective_org_id || 'platform'}:${period_label}:${business_id}
  • Update saveDraft() signature to require: tenantId, orgId (nullable), periodLabel, businessId, payload
  • Update hasDraft(), getDraft(), discardDraft() to require same scope params
  • Update getAllDraftBusinessIds()getAllDraftBusinessIds(tenantId, orgId, periodLabel) (filtered by current scope)
  • Add migration: In onRehydrateStorage, clear all old drafts (safest - no key migration)
  • Update all call sites in BusinessDetailDrawer.tsx (pass tenantId, orgId, periodLabel)
  • Update all call sites in PreflightStep.tsx (pass tenantId, orgId, periodLabel)

Phase 2: Draft Badge Display

  • In PreflightStep.tsx readiness list, add draft check
  • Display 🟢 “Ready” badge if readiness.is_ready === true (server readiness true)
  • Display 🟡 “Draft Saved” badge if draft exists AND readiness.is_ready === false
  • Display 🔴 “Not Ready” badge if no draft AND readiness.is_ready === false
  • Important: Drafts are NOT ready - they are local-only until committed

Phase 3: Bulk Owner Rollup Draft (P0 BLOCKER)

  • Remove/avoid BulkAssignModal for wizard bulk action
  • Implement “Select Remaining → Owner Rollup (Draft)” directly in PreflightStep.tsx
  • Add button: “Select Remaining Non-Ready → Owner Rollup (Draft)”
  • Filter pending_review_items to exclude businesses that already have drafts
  • For each selected business, create draft directly using draftStore.saveDraft() with OWNER_ROLLUP mode
  • NO API calls - drafts are frontend-only until Commit All
  • Decimal Safety: Ensure all pepm/amount values remain strings (no JS number math)

Phase 4: Commit All Integration (P0 BLOCKER)

  • Add BulkActionBar to PreflightStep.tsx
  • Wire draftCount to draftStore.getAllDraftBusinessIds(tenantId, orgId, periodLabel).length
  • Implement onCommitAll handler:
    • Get all drafts for CURRENT scope only (tenantId, orgId, periodLabel)
    • Sequential commit: Concurrency=1 (one save at a time)
    • Progress UI: Show {current}/{total} during commit
    • Track failures (business_id + error message)
    • Partial failure handling: Discard only successful drafts; keep failed drafts
    • Show failure list if any failures
    • Refresh readiness exactly once at end (or debounced)
  • Implement onDiscardAll handler:
    • Clear all drafts for current scope (tenantId, orgId, periodLabel)

Phase 5: Drawer Integration for Discovered Businesses

  • Create helper convertReadinessItemToDrawerBusiness() with null-safe fields
  • In readiness list: Configure button opens BusinessDetailDrawer (wizardMode=true)
  • Ensure BusinessDetailDrawer handles businesses not in onboarding
  • If getBusinessConfig returns 404, show empty form (no server config)
  • Ensure saveBusinessConfig adopts business if not in onboarding (already done in backend)
  • STOP CONDITION: If saveBusinessConfig cannot adopt/materialize discovered-only business, STOP and report exact failing case + logs

Phase 6: Testing

  • Vitest test: Draft scoping (tenant/org/period) - drafts don’t leak across scopes
  • Vitest test: Badge rendering - 🟢 Ready vs 🟡 Draft Saved vs 🔴 Not Ready
  • Vitest test: Bulk owner rollup draft creates N drafts correctly (no saves)
  • Vitest test: Commit All only commits current scope (tenant/org/period)
  • Vitest test: Commit All handles partial failure retention (failed drafts kept)
  • Vitest test: Sequential commit (concurrency=1) with progress UI
  • Manual test: Configure discovered business in wizard
  • Manual test: Draft multiple businesses, commit all
  • Manual test: Verify readiness refresh after commit
  • Manual test: Verify drafts don’t leak across periods/orgs

5. TEST PLAN

5.1 Unit Tests (Vitest)

File: dashboard/src/components/intake/__tests__/PreflightStep.test.tsx (create if not exists)
describe('PreflightStep - Draft Badge', () => {
  it('shows 🟢 "Ready" badge when server readiness is ready', () => {
    // Mock readiness.is_ready = true
    // Assert badge text is "Ready" and has green styling
  });
  
  it('shows 🟡 "Draft Saved" badge when draft exists and server not ready', () => {
    // Mock draftStore.hasDraft() to return true AND readiness.is_ready = false
    // Assert badge text is "Draft Saved" and has yellow styling
  });
  
  it('shows 🔴 "Not Ready" badge when no draft and not ready', () => {
    // Mock no draft and readiness.is_ready = false
    // Assert badge text is "Not Ready" and has red styling
  });
});

describe('PreflightStep - Bulk Owner Rollup Draft', () => {
  it('creates drafts for selected businesses (no API calls)', () => {
    // Mock 5 businesses selected
    // Assert draftStore.saveDraft() called 5 times with OWNER_ROLLUP mode
    // Assert NO saveBusinessConfig calls (drafts are frontend-only)
  });
  
  it('excludes businesses that already have drafts', () => {
    // Mock 3 businesses, 1 already has draft
    // Assert draftStore.saveDraft() called only 2 times
  });
  
  it('uses correct scope params (tenant/org/period)', () => {
    // Assert draftStore.saveDraft() called with correct tenantId, orgId, periodLabel
  });
});

describe('PreflightStep - Commit All', () => {
  it('only commits drafts for current scope (tenant/org/period)', async () => {
    // Mock drafts for current scope + other scope
    // Assert only current scope drafts are committed
  });
  
  it('saves all drafts sequentially (concurrency=1)', async () => {
    // Mock 3 drafts
    // Assert saveBusinessConfig called 3 times sequentially (not parallel)
    // Assert progress UI shows {current}/{total}
  });
  
  it('handles partial failures (keeps failed drafts)', async () => {
    // Mock 2 successes, 1 failure
    // Assert only successful drafts are discarded
    // Assert failed drafts remain in store
    // Assert failure list shown with business IDs and errors
  });
  
  it('refreshes readiness exactly once after all saves', async () => {
    // Mock multiple drafts
    // Assert refreshWizardState called exactly once after all saves complete
  });
});

5.2 Integration Tests

Commands:
# Frontend tests
cd dashboard
npx vitest run PreflightStep.test.tsx

# Backend tests (verify endpoints still work)
cd api
pytest tests/test_business_onboarding_phase8k.py -v

5.3 Manual Test Scenarios

  1. Configure Discovered Business:
    • Upload batch → discover → see 🔴 “Not Ready” business
    • Click “Configure” → opens BusinessDetailDrawer (wizardMode=true)
    • Set mode to OWNER_ROLLUP, set date
    • Click “Save Draft” → see 🟡 “Draft Saved” badge
    • Click “Commit All” → see success, badge changes to 🟢 “Ready”
  2. Bulk Owner Rollup Draft:
    • See 5 🔴 “Not Ready” businesses
    • Click “Select Remaining → Owner Rollup (Draft)”
    • Creates 5 drafts directly (no modal, no API calls)
    • See all 5 show 🟡 “Draft Saved” badge
    • Click “Commit All” → see progress , , etc.
    • All 5 save sequentially, badges change to 🟢 “Ready”
  3. Partial Failure:
    • Create 3 drafts
    • Mock 1 save to fail
    • Click “Commit All”
    • See 2 successes, 1 failure in modal
    • Modal stays open, failure list shown
    • Only 2 drafts cleared (successful ones)
  4. Scope Isolation:
    • Create drafts for tenant A, org X, period 2025-01-01
    • Switch to tenant B (or org Y, or period 2025-02-01)
    • Verify old drafts not shown (scoped by tenant/org/period)

6. RISK MITIGATION CHECKLIST

  • Draft store migration: Clear old drafts on rehydrate (safest - no key migration)
  • Scope validation: Ensure drafts match current (tenant, org, period) before showing
  • Org scoping: Use getEffectiveOrgId() consistently (null → ‘platform’ in key)
  • Tenant scoping: Use auth.getUser()?.tenantId consistently
  • Readiness refresh: Debounce or coalesce multiple refresh calls (refresh exactly once)
  • Error handling: Show clear error messages for save failures (business_id + error)
  • Loading states: Show progress UI during commit all (/)
  • Success feedback: Show toast + update badges immediately
  • Decimal safety: All pepm/amount values remain strings (no JS number math)
  • STOP CONDITION: Verify saveBusinessConfig can adopt discovered-only businesses before proceeding

7. FILES TO TOUCH

Frontend

  1. dashboard/src/store/useDraftStore.ts — Period scoping
  2. dashboard/src/components/intake/PreflightStep.tsx — Badge, bulk action, commit all
  3. dashboard/src/components/admin/onboarding/BusinessDetailDrawer.tsx — Period scoping in draft calls
  4. dashboard/src/components/ui/BulkActionBar.tsx — Already exists, reuse
  5. dashboard/src/components/intake/__tests__/PreflightStep.test.tsx — New test file

Backend

NONE — All endpoints already support the required functionality.

8. REUSED VS REFACTORED

Reused (No Changes)

  • BusinessDetailDrawer component (same component, wizardMode prop)
  • saveBusinessConfig endpoint (already adopts businesses)
  • getBusinessConfig endpoint (already handles any business_id)
  • BulkActionBar component (already exists)
  • useDraftStore structure (only key format changes)

Refactored (Minimal Changes)

  • Draft store key format: business_id${tenant_id}:${effective_org_id || 'platform'}:${period_label}:${business_id}
  • Draft store method signatures: Require tenantId, orgId, periodLabel, businessId
  • PreflightStep readiness list: Add draft badge logic (🟢 Ready / 🟡 Draft Saved / 🔴 Not Ready)
  • PreflightStep bulk actions: Implement “Select Remaining → Owner Rollup (Draft)” directly (no BulkAssignModal)
  • PreflightStep commit all: Wire BulkActionBar to draft store with scope filtering
  • BusinessDetailDrawer: Pass scope params into all draftStore calls

New (Minimal)

  • Helper function: convertReadinessItemToDrawerBusiness() (null-safe field conversion)
  • Draft badge display logic in PreflightStep (three states: Ready / Draft Saved / Not Ready)
  • Bulk owner rollup draft handler (direct draft creation, no API calls)
  • Commit all handler with sequential save (concurrency=1) + progress UI + partial failure retention
  • Draft store migration: Clear old drafts on rehydrate

SUMMARY

Status: ✅ GOOD — Single source of truth for drawer (BusinessDetailDrawer), endpoints already support discovered businesses, minimal frontend-only changes needed. P0 BLOCKERS:
  1. Draft store scoping: ${tenant_id}:${effective_org_id || 'platform'}:${period_label}:${business_id}
  2. Commit All filtering: Only current scope, sequential (concurrency=1), partial failure retention
  3. Bulk Owner Rollup: Draft-only, implemented directly in PreflightStep (no BulkAssignModal)
Key Changes:
  1. Draft store scoping (tenant/org/period key format + migration)
  2. Draft badge display (🟢 Ready / 🟡 Draft Saved / 🔴 Not Ready)
  3. Bulk owner rollup draft action (direct draft creation, no API calls)
  4. Commit all integration (sequential, progress UI, failure handling)
  5. Configure Now wiring (opens BusinessDetailDrawer in wizardMode)
  6. Helper function: convertReadinessItemToDrawerBusiness()
No Backend Changes Required — All endpoints already support the workflow. saveBusinessConfig calls auto_adopt_discovered_business internally. STOP CONDITION: If saveBusinessConfig cannot adopt/materialize discovered-only businesses, STOP and report exact failing case + logs before adding endpoints.