# Handoff — resource_picker + slot pricing

## 01/09/2026 — a finished chat, and one place to verify a receipt

**496 backend tests and 17 Playwright specs pass; the frontend builds.**

1. **A finished chat is no longer a dead end.** Both the flow chat and the vendor's own room offer
   what else that vendor sells at the foot of the transcript (`ServiceOffer` in `Phone.jsx`,
   `FlowChat`'s `renderEnded`), and pressing one starts THAT service's flow **in the same room** —
   `bookFromChat` re-reads `/service/get` and only falls back to the detail screen when something
   needs deciding (not joined, closed, no flow). The room's service list used to vanish the moment
   it held one message. `/service/list` now carries `flow_id` (published + org-checked, one bulk
   query) so a list can tell a bookable service from an unfinished one. Starting a flow keeps the
   vendor's room selected, so backing out of the guided chat lands there; the composer is hidden
   while a run is open.
2. **An unfinished run outranks every offer.** A room MIRRORS a live run's messages as plain
   bubbles but cannot answer one — the chips, times and receipt picker are drawn by the guided
   chat — so backing out mid-flow left the member staring at a question with nothing to press,
   under a cheerful recommendation to buy something else. The foot of the chat now shows
   **"Still in progress → Pick up where you left off"** while any run in that room is open
   (`liveRunFor` / `chatFooter` in `Phone.jsx`, fed by a lifted `/conversation/list`), and the
   service offer only appears once nothing is running. **Known gap:** a run nobody ever finishes
   holds that spot forever — there is no "abandon this chat" control yet.
3. **A receipt is verified in ONE place.** `Org\DashboardController::verifyPayment` /
   `rejectPayment` now also resolve the run's `verify` gate (`resolveVerifyGateFor`), and the
   inbox's Approve/Reject card is hidden while the parked run's own payment is unresolved. It
   stays for EVIDENCE gates — nothing else has a screen of its own. A rejection carries the
   staffer's reason into the flow as the verdict note.
4. **flow-schema v0.5: `verify.payment_var`** — a gate can name the Take-payment step it checks.
   The engine stays pure (it resolves the variable and emits `payment: <id>`), the server prefers
   that binding when deciding which gate a receipt closes, the editor offers "What is being
   checked", and `DemoFlowSeeder` binds it.
5. **The editor speaks English.** "Await ack" → *Ask them to confirm*, "Await evidence" → *Ask
   them to send something*, and every palette entry carries a plain hint. `docs/mvp-test-plan.html`
   gained a group with **one row per node type** plus rows for both behaviours above.
   It is also **manual-only again**: the header now says so, and two rows that were not things a
   person can do — "Org-only is not a back door" (a hand-rolled POST to `/service/join`) and
   "Reminder race" (a timer and the sweep firing together) — were removed into a new `REMOVED`
   batch, each citing the PHPUnit test that already covers it.
6. **Roles STACK on a service** (owner decision 01/09/2026). A person may be the Employer AND the
   Helper on one listing, exactly as roles have always worked at organization level.
   `2026_09_01_000200` moves the roster's unique key from `(service_id, user_id)` to
   `(service_id, user_id, user_type_id)`; `join` ADDS a role (idempotent on the triple) and
   `leave` takes an optional `user_type_id` to drop ONE side. The atomic `/service/switch-role`
   built earlier the same day is **gone** — it existed only because the old index made a change
   of side a replace; correcting a mis-join is now add-the-right-one then drop-the-wrong-one,
   and neither step can leave a member on nothing.
   - **The two consistency fixes that came with it.** A service-scoped join link was the one door
     that quietly TOOK a role away (`updateOrCreate` keyed on service+user, while the org-level
     grant beside it was additive) — it adds now. And `2026_09_01_000300` stamps
     `booking.user_type_id` at the sale, so taking the other side later no longer rewrites what
     you were on every engagement you ever made.
   - **The panel**: "You are on this service as Employer and Helper.", one chip per role in the
     list, *Also join as X* / *Take another role too*, and *Stop being X here* once a second role
     exists. `/service/get` and `/service/list` return `joined` as a LIST.
   - A RESTRICTED role is refused as a second role exactly as it is as a first — and now LOOKS
     refused: `.wg-welcome-go:disabled` had no styling at all, so the confirm was full brand red
     with a drop shadow and did nothing on click, for joining as much as for anything else. The
     dialog carries the reason when there is only one role on offer (no list to hang it on) and
     says what would make it work: the vendor grants the role, or sends a join link carrying it.
   - **Dead code found, not touched:** `user/Explore.jsx`, `user/Dashboard.jsx` and
     `user/shared/SideBar.jsx` are unreachable (`App` → `Home` → `Phone` is the whole panel), and
     Explore still reads the old single-object `joined`.
7. **A member could not see what they had been charged.** `/payment/list` found invoices by
   walking back from a BOOKING (`whereHas("booking")`), and a flow's `payment` node raises one
   before there is anything to book — `booking_id` has been nullable since 2026_09_01_000100. So
   every invoice raised in chat was invisible to the person paying it while the org panel listed
   all five. It queries `payment.user_id` now (with the old booking join kept as a fallback for
   rows written before that column was set), and falls back to the payment's own organization and
   note when there is no booking to name it. The tab shows each row's STATUS — invoice sent /
   receipt uploaded / verified / rejected — and "Total paid" counts VERIFIED rows only; it used to
   add every row up and present an unpaid or rejected invoice as money spent. Two cases in
   `PaymentNodeTest`, including that another member's invoice is never listed.
8. **A flow can promise a booking it never makes.** Found by the owner, 01/09/2026: Appointments
   and the Calendar were empty for an org whose customers had been told "Booking Confirmed" in
   chat — correctly, because that flow was Message → Pick a resource → Pick a time → *Message*
   ("Booking Confirmed"), with **no Action node**. Taking a time is not a booking. `nodeIssues()`
   in the editor now flags a `slot_picker` from which no `bookings.create` Action is REACHABLE
   (breadth-first over the graph's own edges, so option and branch targets count), and an Action
   whose `input` carries none of the pickers' `save_as` — that one answers `invalid_booking_input`
   at runtime and drops the customer on the On-error branch. Warnings, not refusals: collecting a
   preferred time for staff to arrange by hand is a legitimate flow. **Two published flows in the
   owner's own data trip the first rule right now** (`Secret Service Flow`, `Shop Program Flow`).
9. **Bank details is its own Settings tab.** "How customers pay you" was the bottom half of
   Workspace, under the org's detail form and a Save button that saves something else — two forms,
   two saves, and the one every paying chatflow depends on was below the fold. It also says so
   when nothing is filled in.
10. **Archiving a flow is no longer a one-way door.** `POST /flow/restore/{id}`
   (`/api/org/flow/restore` on the RPC surface) brings an archived flow back as a **draft** —
   never straight to published, since it may sell a service — and the card grew a Restore button
   where Archive sits on every other card. `flow_key` is unique per org, so a mis-click used to
   mean rebuilding the flow under a different handle. Three cases in `OrgFlowsApiTest`.

## 01/09/2026 — A BOOKING IS THE ENGAGEMENT

**502 backend tests and 18 Playwright specs pass; the frontend builds.**

Owner decision, in their words: *"a booking does not really mean reserving a resource — Day Day
Help's booking means the connection, or the two-year contract between helpers and employers"*, and
*"when a booking is started, that means the user entered the first step of the chatflow"*. Some
orgs talk for a fortnight before agreeing anything; others reserve a resource in the first minute.
The flow decides which, because the flow is the business model.

- **The engagement opens at step one.** `FlowRunner::start()` calls the new
  `BookingService::open()` — no time, no resource, no lock — and remembers it in the run's
  reserved var `booking_id`. `BookingStatus::Started = 4` ("In progress") is deliberately NOT in
  `active()`, so an abandoned chat holds nothing from anybody.
- **The flow's step AGREES it.** `bookings.create` no longer needs a time: with a slot it takes
  the resource lock and writes the appointment (`BookingService::confirm`), without one it simply
  agrees the engagement — a placement that is never scheduled is a complete booking, not a
  half-made one. Old flows still fall back to `create()`.
- **The org can SEE engagements.** New `Bookings` screen (`Org\BookingController::list`) with
  status filters and per-status counts — Appointments and the Calendar can only show things with
  a time, so a placement was invisible to the org that made it. Filters rather than the panel
  picking one meaning of "booking": the owner's answer when asked whether in-progress ones count.
- **The member sees theirs**, as In progress with "Carry on in the chat", and a status filter
  beside the vendor one. Its row leads back into the conversation.
- Six cases in `tests/Feature/EngagementLifecycleTest.php`, covering both shapes: agreed with a
  time (salon) and agreed with none, ever (Day Day Help).
- **Deferred, recorded:** contract TERMS (`starts_on` / `ends_on` on the booking) — the owner
  chose "open engagements first". An abandoned engagement stays In progress until someone cancels
  it; there is no sweep.

### Also
- **The org calendar reads by MONTH as well as by week** — a week says when in the day, a month
  says how the weeks are shaped. Six-week grid every time so it never changes height, two
  meetings per cell then "+N more", and the days either side of the month recede rather than
  vanish (removing them would break their week rows). One spec in `org-panel.spec.js`.

### Found on the way, unrelated to the above

**Every staff reply from the org Inbox was failing.** `Api\Org\ConversationController::storeMessage`
read its id with `$request->input("id")`, which cannot see a ROUTE parameter — the exact trap
`FlowController::ownFlow` documents — so the panel's `POST /conversation/messages/{id}` arrived
with an empty id and answered "Conversation not found." It also resolved the id as a FlowRun while
the panel holds a ROOM id. Both fixed; either identifier now reaches the same conversation.
Nothing caught it because `org-panel.spec.js` posted to `/conversation/reply`, a different
controller from the one the button calls — the spec now presses the button.

## STATUS 01/09/2026 — STEPS 2 AND 3 ARE BOTH DONE

460 backend tests pass; the frontend builds. Nothing below is outstanding except the follow-ups
listed here. The step descriptions are kept as the record of what was asked for.

- Step 2 shipped: `User\ServiceResourceController` (`GET /service/resources`),
  `/availability/list?resource_id=`, the chat `resource_picker` renderer, the editor's
  `resource_picker` palette entry + `slot_picker.resource_var` inspector, `docs/flow-schema.md`
  at v0.3, and `tests/Unit/EngineV2Test.php` + `tests/Feature/ResourcePickerTest.php`.
- Step 3 shipped: window surcharges and a per-resource surcharge on `resource_detail`
  (`price_extra` at both levels), `App\Services\SlotPricing` (**base + resource + window**,
  absolute non-negative amounts — the owner chose amounts over multipliers, then ADDITIVE over
  override, both on 01/09/2026), prices on every slot, `booking.price` (2026_08_31_000200)
  stamped by `BookingService::create`, the org Schedule/Edit dialogs, and
  `tests/Feature/SlotPricingTest.php` + three rows in `OrgResourcePanelTest`.
  Known limit of additive: an extra can only RAISE the price — no free or discounted window.

## 01/09/2026 — the editor stopped asking which service

A flow sells exactly one service (1:1 binding), so the visual editor no longer offers
`service_var` on either picker: it names the bound service instead (`/flow/get` now attaches
`service_id`/`service_name` via `FlowController::attachService`). `bookings.create` gained the
same fallback through the action context (`FlowRunner::boundServiceId`) — without it, "leave the
service blank" was only true up to the booking, which then failed `invalid_booking_input`.
A picker that still carries a `service_var` from the JSON tab shows a one-click way to drop it.
`slot_picker.resource_var` is a dropdown of what upstream Pick-a-resource nodes saved.
`tests/Unit/NodeTypeCoverageTest.php` now holds one case per node type plus two censuses — every
`NodeType::ALL` entry must have a case AND appear in the editor's palette.

## 01/09/2026 — three panel bugs, and the E2E suite is green again

1. **/home blanked when signed out.** `$appUser` is *defined as null* on a signed-out page, so
   Phone's `typeof !== "undefined"` guard handed it a null and `user.name` threw. `Home` now
   refuses to render during render (`<Navigate>`), not from a `useEffect` one paint too late.
2. **Nothing could be booked, ever.** `normalizeService` in `Phone.jsx` whitelists fields and
   dropped `flow_id`, so every service said "Not open for booking yet" however well the vendor
   had set it up. The server had it right the whole time.
3. **Date/time/date-time detail fields are pickers** (`shared/DetailForm.jsx`), not text boxes
   whose format you learned from the validator's error toast. Local-time conversion is
   deliberate — see the comment before `dateToValue`.

The Playwright suite went from **8 passed / 6 failed to 14 passed / 1 skipped**. Its fixtures had
rotted, not the product: no demo end user exists (seeding one stopped 2026-08-19 — one is created
by hand, see below), `DEMO_ORG` still named a login the seeder had changed, "Demo Salon" was
hardcoded in specs while the seeder deliberately never renames an existing org, and neither panel's
post-login onboarding modal was dismissed. `DemoFlowSeeder` also now gives the Haircut service a
**Customer** role and binds it to the booking flow — without a joinable role `BookingService`
refuses every booking with "Join this service before booking it".

**The demo tenant is now its own organization** — `demo_salon@plantoo.test` / `abcd1234`, seeded
with its user (`demo_user@plantoo.test`), three services (Haircut / Home visit / Deposit payment),
one per interaction pattern, each bound to its flow and with the demo user on the roster. It used
to attach to `admin@daydayhelp.com`, a REAL organization, and filled the owner's panel with demo
furniture; that was removed by hand on 01/09/2026 and the seeder can no longer reach a real tenant.
**Do not point any seeder or spec at the owner's four orgs.**

## 01/09/2026 — asking for money is a node

`payment` (flow-schema **v0.4**). Raises an invoice, tells the customer what to pay and WHERE, and
takes their receipt as a file:

- `App\Services\OrganizationDetailFields` → `organization_detail.setting.payment` (bank / account /
  name / fps / note), edited in **Settings › Workspace**. Org-level so a flow author cannot forget
  it — the dead end this replaced was "upload your payment receipt" with no amount and no account.
- The engine stays pure: it emits `{type: "payment", amount, note}` and raises nothing.
  `FlowRunner::raiseInvoiceFor()` prices a null amount from the bound service, writes the `payment`
  row, and attaches `payment_id` + `pay_to`. `amount` and `amount_var` are mutually exclusive.
- `payment.booking_id` is now **nullable** (2026_09_01_000100) — a deposit can be taken before
  there is anything to book. The `down()` refuses while a null-booking payment exists.
- The member panel draws a card with a real file picker (`.wg-pay`), uploads through the existing
  `/payment/upload-receipt`, and only then advances the flow.
- The chat launcher's "Start something new" list is **gone**: a member starts a flow by pressing
  Start on the service that sells it. Every spec starts that way now (`startFlow(page, SERVICE.x)`).

Also fixed: chat bubbles clipped short outgoing text (`max-width: 78%` was resolving against a
shrink-to-fit wrapper, i.e. 78% of the bubble's own width), and `MessageText` had no prose for a
`payment` payload, which the `ChatroomMessageShapeTest` invariant caught.

**Follow-ups, none blocking:**
1. A customer's own chat bubble after choosing a resource shows the raw resource id, not its name
   — `labelForValue` in `resources/js/user/chat/NodeRenderer.jsx` has no list to resolve it from.
   The fix is either a names cache in `client.js` or having `FlowRunner::resolveServiceFor` put
   the candidate names in the payload the way it already puts the service.
2. `docs/db-schema.html` had `booking.price` recorded as **rejected 2026-08-13**. The note is kept
   and annotated with the reversal rather than deleted — read it before anyone re-litigates.
3. `CLAUDE.md`'s "382 of 382 columns signed off" is now 383 columns with one unticked.

---

Continue work in `/Users/arfu/plantoo/plantoo_tech`. Read `CLAUDE.md` first and follow it.
Run `composer test` and `npx vite build --mode development` before declaring anything done.
Do not commit — the user handles commits.

## Context: decisions already made (31/08/2026)

- **A booking is a chatflow from start to finish.** The customer's service screen has no time list
  and no Confirm — one **Start** button that joins the service and enters the flow. A service with
  no published flow says "Not open for booking yet".
- **A service names the flow that sells it**: `service_detail.setting.flow_id`
  (`ServiceDetailFields::setFlowId/flowIdFor`). Creating or editing a service can GENERATE a starter
  flow (`App\Services\ServiceFlowTemplates`, templates `booking_with_time` / `enquiry_no_time`),
  published immediately, or open the flow editor inline ("Build one…").
- **Flow ↔ service is 1:1.** `Org\ServiceController::assertFlowOwned` refuses a flow already bound to
  another service; `/flow/list` reports `service_id` / `service_name`;
  `ServiceDetailFields::serviceIdsByFlow()` is the reverse lookup.
- **`slot_picker.service_var` is OPTIONAL.** The engine stays pure and emits `service: null`;
  `FlowRunner::resolveServiceFor()` fills it from the flow's bound service when it writes the
  message. A picker that names a variable still wins.
- Availability is per RESOURCE (`resource_detail.availability` weekly windows). A flow never names a
  resource today; it names a service, and `BookingService` binds whichever candidate resource is free.
- Required details gate bookings in BOTH paths: `User\BookingController::create` and the flow's
  `bookings.create` action in `AppServiceProvider` (returns `{error: "details_required"}`).

## STEP 2 — `resource_picker` (PARTLY DONE, finish it)

Goal: a flow can ask WHICH resource before offering times, because resources differ in hours and
(step 3) price.

**Already committed to the working tree — do not redo:**
- `app/Flow/NodeType.php` — added `RESOURCE_PICKER = "resource_picker"` to the constants and `ALL`;
  header comment bumped to schema v0.3.
- `app/Flow/FlowDefinition.php` — allowed fields + `validateResourcePicker()`
  (`service_var` optional, `save_as` + `next` required). `slot_picker` gained an optional
  `resource_var`.
- `app/Flow/Engine.php` — `RESOURCE_PICKER` emits
  `RequestInput(node, {type: "resource_picker", service: …})` and waits for input; `UserInput`
  handling treats both pickers alike. `slot_picker`'s payload now also carries `resource`
  (from `resource_var`, else null).
- `app/Flow/FlowRunner.php` — `resolveServiceFor()` fills the bound service for BOTH picker types.
- 436 tests pass and the frontend builds with these in place.

**Still to do:**
1. **User-side endpoint** listing a service's candidate resources: e.g.
   `GET /service/resources?service_id=` in `App\Http\Controllers\User\` returning `[{id, name}]`
   from `ServiceDetailFields::resourceIdsFor()`. Scope it like `AvailabilityController::list`
   (member of the org / `ServiceAccess`), and register it in `routes/web.php` under the user group.
2. **`/availability/list` must accept an optional `resource_id`** and narrow the slots to it
   (`AvailabilityService::slotsForService`). Without this, choosing a resource changes nothing.
3. **Client** (`resources/js/user/chat/`): render `payload.type === "resource_picker"` in
   `NodeRenderer.jsx` (same shape as the existing `slot_picker` renderer — fetch, loading state,
   empty state, post the chosen id back as a `user_input` event); add the fetch to `client.js`.
   The `slot_picker` renderer must pass `payload.resource` to `/availability/list` when present.
4. **Visual editor** (`resources/js/org/FlowEditor.jsx`): add `resource_picker` to `TYPE_PALETTE`,
   `REFERENCE_FIELDS` (`["next"]`), `seedNode`, and an inspector panel (save_as + next, and the
   same "nothing to configure" note about the service). Add `resource_var` to the `slot_picker`
   inspector. Extend the existing `nodeIssues()` guard: flag a `slot_picker` whose `resource_var`
   is never set before it, exactly as it already does for `service_var`.
5. **Docs**: `docs/flow-schema.md` — add a `### resource_picker` section next to `slot_picker`, and
   bump the stated schema version to v0.3.
6. **Tests**: `tests/Unit/EngineV2Test.php` — a `resource_picker` emits `RequestInput` and waits;
   `UserInput` writes `save_as` and advances. Add a definition-parse case, and one asserting a
   `slot_picker` with `resource_var` carries `resource` in its payload.

## STEP 3 — pricing by resource and by time window

Today price is a single figure: `service_detail.customization.pricing.base`, surfaced as
`service.price`. `booking` has NO price column — the amount is read from the service when displayed.

Build:
1. **Price per availability window.** Each weekly window in `resource_detail.availability.weekly`
   (`{weekdays, start_time, end_time, slot_minutes}`) gains an optional price (an absolute amount —
   confirm with the user whether a multiplier is wanted instead). The org edits it in the Schedule
   dialog, `resources/js/org/Availability.jsx`, beside From / To / Slot. Keep that screen's existing
   validation intact (a window whose span does not divide by its slot is refused, server-side too in
   `Org\ResourceController::setAvailability`).
2. **Price override per resource** on `resource_detail` (a senior stylist costs more).
3. **A resolver** — new service class — that answers "what does THIS slot cost": service base,
   overridden by resource, overridden by the window the slot falls in. `AvailabilityService` should
   return the price with each slot so the picker can show it.
4. **Stamp the price on the booking.** Add a `price` column to `booking` (reversible migration,
   nullable, backfilled from the service's base) and have `BookingService::create` record what the
   slot was sold for. Without this, editing next month's schedule silently rewrites the price of a
   booking already made — that is the whole reason this step exists.
5. **Tests**: the resolver's precedence (window > resource > service base); a slot in a
   Saturday window priced differently from the same service on Monday; a booking keeps its stamped
   price after the window's price changes.

## House rules that bite here

- Enums stored in columns are `: int`, one per file in `app/Enums/`, mirrored in
  `resources/js/shared/status.js`.
- Migrations must be reversible and are never edited once pushed; check `git log` first.
- Mongo models set `protected $table`; collection names are singular.
- `docs/mvp-test-plan.html` is the MVP's manual test plan, **closed** on 2026-09-07 at 143 of 143
  passing and renamed from `test-plan.html` so it is not mistaken for a live one. It is gitignored.
  Full-product work does not add rows to it, and there is no successor plan yet. If one is ever
  written, note the trap this file records: tick state is keyed `panel|group|index`, so rows are
  APPENDED to a group — inserting or deleting one silently re-points every tick after it.
- Tests are class-based PHPUnit. Several suites answer whatever `user_detail_config` currently marks
  required before booking (see `answerWhatThePlatformAlreadyAsks` in `BookingDetailGateTest` /
  `UserChatEntryTest`) — copy that helper if a new test books anything.
