--- phase: 05-offer-system verified: 2026-05-30T00:00:00Z status: human_needed score: 7/7 must-haves verified overrides_applied: 0 human_verification: - test: "Visit /admin/offers and create a macro-offer, then a micro-offer under it, then create an offer service and assign it via the checkbox list" expected: "Macro and micro appear in the list; checkbox toggles assignment; cumulative price updates on refresh" why_human: "RSC form actions with router.refresh() — cannot verify round-trip DB mutation without running the app" - test: "Visit /admin/projects/[id], open the Offerte tab, assign a micro-offer, set accepted_total, then remove it" expected: "Assignment persists on refresh; accepted_total saves on blur; removal disappears on refresh" why_human: "Inline onBlur save and useTransition interactions require live browser session" - test: "Visit /client/[token] for a project that has active offers with accepted_total set" expected: "Sidebar shows 'Offerte Attive' section with public_name, cumulative service price, and final accepted price; NO internal names visible" why_human: "Security critical — confirming internal_name never surfaces in the rendered HTML requires browser inspection of a live session with real data" - test: "Visit /admin/forecast after assigning at least one offer with accepted_total" expected: "12-month table shows non-zero totals in the correct months based on start_date + duration_months spread" why_human: "Forecast correctness depends on real start_date values and duration_months; verifying bucket computation requires live data" --- # Phase 5: Offer System Verification Report **Phase Goal:** Implement a complete hierarchical Offer System — macro-offers -> micro-offers -> services — with admin catalog CRUD, project assignment, client dashboard visibility (public_name only), and 12-month revenue forecast. **Verified:** 2026-05-30T00:00:00Z **Status:** human_needed **Re-verification:** No — initial verification ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | Five tables exist in schema: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers | VERIFIED | All five `pgTable` exports confirmed in `src/db/schema.ts` lines 199-268 | | 2 | No existing table (clients, projects, payments, phases) modified or dropped | VERIFIED | All four original tables intact at schema.ts lines 14, 39, 53, 109; no DROP or ALTER COLUMN found | | 3 | /admin/offers page with CRUD for macros, micros, and services | VERIFIED | `src/app/admin/offers/page.tsx` exists; calls `getCatalogWithMicros` + `getAllOfferServices`; forms wired to `createMacro`, `createMicro`, `createOfferService`, `deleteMacro`, `deleteMicro`, `toggleOfferServiceActive` | | 4 | OffersTab in admin project workspace for offer assignment | VERIFIED | `src/components/admin/tabs/OffersTab.tsx` exists; `TabsTrigger value="offers"` at project page line 77; `TabsContent value="offers"` at line 141; `OffersTab` imported and receiving `projectOffers` + `availableMicros` props | | 5 | Client dashboard shows offer public_name ONLY — never internal_name | VERIFIED | `getProjectView()` selects only `offer_micros.public_name` (client-view.ts line 282); `OffersSection.tsx` has zero `internal_name` occurrences; `ClientView.activeOffers` type exposes only `public_name`, `cumulative_price`, `accepted_total` | | 6 | /admin/forecast shows 12-month revenue projection | VERIFIED | `src/app/admin/forecast/page.tsx` exists; calls `getRevenueForecast12Months()`; renders 12-row table with month label, projected total, proportional bar, and totals footer | | 7 | Forecast computed without DB persistence | VERIFIED | `src/lib/forecast-queries.ts` contains zero `db.insert` / `db.update` calls; algorithm builds 12 in-memory buckets and distributes `accepted_total / duration_months` per month via JS loop | **Score:** 7/7 truths verified ### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `src/db/schema.ts` | Five new pgTable definitions appended | VERIFIED | All five tables present with correct FK constraints (`offer_micros.macro_id` cascade, `project_offers.micro_id` restrict, composite PK on `offer_micro_services`) | | `src/lib/offer-queries.ts` | Read queries: getCatalogWithMicros, getAllOfferServices, getMicroAssignedServiceIds | VERIFIED | All three functions exported; real DB queries with joins, no hardcoded data | | `src/app/admin/offers/actions.ts` | Server actions for all offer catalog mutations | VERIFIED | 7 exported actions: createMacro, deleteMacro, createMicro, deleteMicro, createOfferService, toggleOfferServiceActive, updateMicroOfferServices | | `src/app/admin/offers/page.tsx` | RSC page listing macros, micros, offer services | VERIFIED | RSC page with `revalidate = 0`; three sections fully implemented | | `src/components/admin/offers/ServiceCheckboxList.tsx` | Client component for many-to-many service assignment | VERIFIED | "use client"; useState Set; useTransition + router.refresh(); wired to updateMicroOfferServices | | `src/components/admin/tabs/OffersTab.tsx` | Project workspace tab for offer assignment | VERIFIED | "use client"; assignOfferToProject, removeProjectOffer, updateProjectOfferTotal all called | | `src/app/admin/projects/project-actions.ts` | Three new offer server actions | VERIFIED | assignOfferToProject, removeProjectOffer, updateProjectOfferTotal — all call requireAdmin(); all revalidate /admin/forecast | | `src/lib/admin-queries.ts` | Extended with ProjectOfferWithMicro type, getProjectFullDetail extension, getClientActiveOffers | VERIFIED | Lines 189-202 (type); lines 601-602 (return); lines 646-687 (getClientActiveOffers with public_name only) | | `src/lib/client-view.ts` | Extended ProjectView and ClientView with activeOffers | VERIFIED | activeOffers field in both interfaces; query at lines 279-309; returned at line 354 | | `src/lib/forecast-queries.ts` | getRevenueForecast12Months() with 12-bucket JS algorithm | VERIFIED | File exists; DB query + JS bucket fill; rounds to 2 decimal places; no DB writes | | `src/components/client/OffersSection.tsx` | Client-facing offer card (public_name only) | VERIFIED | Zero internal_name occurrences; renders public_name, cumulative_price, accepted_total | | `src/app/admin/forecast/page.tsx` | Admin /admin/forecast RSC page | VERIFIED | RSC page; calls getRevenueForecast12Months(); table with month/total/bar/footer | | `src/components/admin/NavBar.tsx` | NavBar with /admin/offers and /admin/forecast links | VERIFIED | Both links confirmed at lines 21 and 27 | ### Key Link Verification | From | To | Via | Status | Details | |------|-----|-----|--------|---------| | `src/app/admin/offers/page.tsx` | `src/lib/offer-queries.ts` | getCatalogWithMicros() import | WIRED | Direct import; called in Promise.all | | `src/app/admin/offers/actions.ts` | `src/db/schema.ts` | offer_macros, offer_micros, offer_services, offer_micro_services imports | WIRED | All four tables imported and used in mutations | | `src/components/admin/offers/ServiceCheckboxList.tsx` | `src/app/admin/offers/actions.ts` | updateMicroOfferServices import | WIRED | Imported and called in toggle handler | | `src/components/admin/tabs/OffersTab.tsx` | `src/app/admin/projects/project-actions.ts` | assignOfferToProject import | WIRED | All three offer actions imported and called | | `src/app/admin/projects/[id]/page.tsx` | `src/lib/admin-queries.ts` | getProjectFullDetail() — includes projectOffers | WIRED | projectOffers + availableMicros destructured from detail; passed to OffersTab | | `src/app/admin/clients/[id]/page.tsx` | `src/lib/admin-queries.ts` | getClientActiveOffers() import | WIRED | Parallel fetch at line 14-17; activeOffers rendered at lines 112-130 | | `src/lib/client-view.ts` | `src/db/schema.ts` | project_offers + offer_micros join in getProjectView() | WIRED | Explicit select on public_name only; result mapped to activeOffers | | `src/app/client/[token]/page.tsx` | `src/lib/client-view.ts` | projectViewToClientView adapter | WIRED | `activeOffers: view.activeOffers` at line 69 | | `src/components/client-dashboard.tsx` | `src/components/client/OffersSection.tsx` | OffersSection import | WIRED | Imported at line 10; rendered conditionally at lines 58-63 | | `src/app/admin/forecast/page.tsx` | `src/lib/forecast-queries.ts` | getRevenueForecast12Months() import | WIRED | Direct import; called in RSC function body | ### Data-Flow Trace (Level 4) | Artifact | Data Variable | Source | Produces Real Data | Status | |----------|---------------|--------|--------------------|--------| | `src/app/admin/offers/page.tsx` | catalog, allServices | getCatalogWithMicros(), getAllOfferServices() — DB queries on offer_macros, offer_micros, offer_services | Yes — live DB SELECT with joins | FLOWING | | `src/components/admin/tabs/OffersTab.tsx` | projectOffers, availableMicros | getProjectFullDetail() extended queries on project_offers JOIN offer_micros | Yes — live DB SELECT | FLOWING | | `src/components/client/OffersSection.tsx` | offers (activeOffers) | getProjectView() -> projectOfferRows (project_offers JOIN offer_micros) + cumulativePriceRows (SQL SUM) | Yes — live DB queries; returns undefined when empty (not []) | FLOWING | | `src/app/admin/forecast/page.tsx` | forecast | getRevenueForecast12Months() -> DB SELECT project_offers JOIN offer_micros + JS bucket algorithm | Yes — reads from DB; pure JS computation for monthly split | FLOWING | ### Behavioral Spot-Checks | Behavior | Command | Result | Status | |----------|---------|--------|--------| | TypeScript compiles without errors | `npx tsc --noEmit` | Exit 0, no output | PASS | | Five offer tables defined in schema | `grep "^export const offer_macros\|offer_micros\|offer_services\|offer_micro_services\|project_offers" schema.ts` | 5 matches | PASS | | requireAdmin in all offer actions | `grep -c "requireAdmin" src/app/admin/offers/actions.ts` | 8 (definition + 7 calls) | PASS | | revalidatePath in all offer actions | `grep -c "revalidatePath" src/app/admin/offers/actions.ts` | 8 | PASS | | No internal_name in OffersSection | `grep "internal_name" src/components/client/OffersSection.tsx` | 0 matches | PASS | | No internal_name in forecast page | `grep "internal_name" src/app/admin/forecast/page.tsx` | 0 matches | PASS | | activeOffers adapter wired | `grep "activeOffers" src/app/client/[token]/page.tsx` | 1 match: `activeOffers: view.activeOffers` | PASS | | No DB writes in forecast-queries.ts | `grep "db.insert\|db.update" src/lib/forecast-queries.ts` | 0 matches | PASS | | NavBar links present | `grep "/admin/offers\|/admin/forecast" NavBar.tsx` | 2 matches | PASS | ### Requirements Coverage | Requirement | Source Plan | Description | Status | Evidence | |-------------|------------|-------------|--------|----------| | OFFER-01 | 05-02 | Admin crea macro-offerte con nome interno, nome pubblico, promessa trasformazione | SATISFIED | createMacro action + form in /admin/offers page; Zod schema validates all three fields | | OFFER-02 | 05-02 | Macro-offerte hanno micro-offerte figlie con nome, promessa, durata in mesi | SATISFIED | createMicro action + nested form per macro in /admin/offers page; duration_months field included | | OFFER-03 | 05-02 | Admin crea servizi con nome, prezzo, descrizione; assegnabili a micro-offerte via multi-select | SATISFIED | createOfferService action; ServiceCheckboxList with updateMicroOfferServices (delete+re-insert); offer_micro_services junction table | | OFFER-04 | 05-03 | Admin assegna micro-offerte a progetto; vede offerte attive per progetto e cliente | SATISFIED | OffersTab in project workspace; assignOfferToProject/removeProjectOffer actions; getClientActiveOffers on /admin/clients/[id] | | OFFER-05 | 05-04 | Dashboard cliente mostra offerte con nome pubblico, prezzo cumulativo, prezzo finale; mai internal_name | SATISFIED | OffersSection renders public_name + cumulative_price + accepted_total; getProjectView() selects public_name only; 0 internal_name occurrences in client component | | OFFER-06 | 05-04 | Admin ha vista forecast 12 mesi da offerte attive, durata, accepted_total; breakdown mensile | SATISFIED | /admin/forecast page with getRevenueForecast12Months(); JS bucket algorithm; 12 rows with totals footer | All 6 requirements (OFFER-01 through OFFER-06) are SATISFIED. ### Anti-Patterns Found | File | Pattern | Severity | Impact | |------|---------|----------|--------| | None found | — | — | — | No TODO/FIXME/PLACEHOLDER comments in any Phase 5 files. No hardcoded empty arrays returned as final data. No stub implementations. No console.log-only handlers. **Note:** `OffersTab.tsx` renders `offer.micro_internal_name` (line 67) — this is intentional and correct. The tab is admin-only under `/admin/projects/*`, which is Auth.js session-guarded. The plan's threat model (T-05-08) explicitly accepts this as correct admin-visible data. ### Human Verification Required #### 1. Admin offer catalog round-trip **Test:** Create a macro-offer at `/admin/offers`, add a micro-offer under it with duration_months=3, create an offer service with a price, then toggle the service checkbox to assign it to the micro. Refresh the page. **Expected:** Macro appears in the list; micro is nested under it showing cumulative price equal to the service price; checkbox remains checked after refresh. **Why human:** Server action + router.refresh() round-trips require a live browser session against the production DB. #### 2. Project offer assignment and accepted_total save **Test:** At `/admin/projects/[id]`, open the Offerte tab. Assign the micro-offer created above. Enter an accepted_total value and click away (onBlur). Refresh the page. **Expected:** The micro-offer appears in the active assignments list with duration and start date. The accepted_total value persists after refresh. **Why human:** onBlur inline save behavior and server action response cannot be verified without a running browser. #### 3. Client dashboard security — no internal_name visible **Test:** Navigate to `/client/[token]` for a client whose project has at least one micro-offer assigned with an accepted_total set. Inspect the "Offerte Attive" sidebar block. Also inspect the page HTML source. **Expected:** Only public_name (e.g. "Starter Branding") appears — never the internal_name (e.g. "Entry A"). Individual service prices do not appear. Only cumulative price and final accepted price show. **Why human:** Security verification requires confirming the rendered HTML and network responses contain no internal data. Code confirms this structurally, but a live check is required for the security constraint. #### 4. Forecast accuracy **Test:** With at least one project_offer having `accepted_total=1200` and `duration_months=3` starting from the current month, visit `/admin/forecast`. **Expected:** The current month, next month, and the month after each show €400.00 (1200/3). Other months show €0 / dash. **Why human:** Bucket algorithm correctness depends on real `start_date` values stored in production DB relative to current date. ### Gaps Summary No gaps found. All 7 must-haves are verified against the actual codebase. All 6 OFFER requirements are accounted for with satisfying implementation evidence. The phase goal is structurally achieved — human verification is required only to confirm live runtime behavior and the security property (no internal_name visible to clients). --- _Verified: 2026-05-30T00:00:00Z_ _Verifier: Claude (gsd-verifier)_