---
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)_
