Skip to content

release: dev → prod 2026-09-06 (booking closure train: availability day semantics, typed cancellation policy, reschedule confirm, dashboard fixes, cron gates, E2E fixes) - #1523

Merged
teetangh merged 14 commits into
prodfrom
release/dev-to-prod-2026-09-06
Sep 6, 2026
Merged

release: dev → prod 2026-09-06 (booking closure train: availability day semantics, typed cancellation policy, reschedule confirm, dashboard fixes, cron gates, E2E fixes)#1523
teetangh merged 14 commits into
prodfrom
release/dev-to-prod-2026-09-06

Conversation

@teetangh

@teetangh teetangh commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Release of the 2026-09-05/06 booking backlog closure train plus the fixes found by the end-to-end pass on the dev deploy. Base: prod at 576b97c; head: dev at 9b45a82.

Schema

Additive only, already pushed to the shared database on 2026-09-06 with sidecars applied and asserted (45 constraints / 6 indexes / 3 triggers): CancellationPolicy, CancellationPolicyTier, Appointment.cancellationPolicyId (+ index), enum CancellationPolicyStatus. The platform default policy row is provisioned. Backup: familiarise-pre-schema-push-20260906-0423.dump. No push is needed for this release.

Commits (dev not in prod)

Verification

  • Every PR merged green (Lint, TypeScript/Tests/Build, SonarCloud gate OK, CodeRabbit threads triaged and resolved).
  • End-to-end on the dev branch deploy (Chrome DevTools + Supabase MCP): availability day anchoring, Home badge parity, org tier editor incl. negative cases, org-funded checkout stamping the org policy version and refunding 100% on the INTERNAL rail, personal booking stamping the platform row, DEGRADED-gate probe 401, no console errors or 5xx.
  • Post-fix re-verification on the same deploy (9b45a82): planner class cards show the first session (payload firstSessionAt 2026-09-10T10:00Z, card 3:30 PM IST); a consultee proposal on a green slot auto-confirms (autoConfirmed: true, request AUTO_ACCEPTED, slots moved, history rows PENDING → RESCHEDULED ×2 → APPROVED → AUTO_ACCEPTED).

Owner items unchanged by this release

🤖 Generated with Claude Code

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1

teetangh and others added 13 commits September 5, 2026 18:43
…n, webinar and class plans (#1507)

* feat(plans): sole-owner consultants can archive and restore their own offerings

The only existing archivedAt writer was the org-catalog bulk archive
endpoint, so a consultant without an organization behind their plan (the
majority) had no way to stop selling a consultation, subscription, webinar
or class. Adds a PATCH { archived: boolean } to each plan family's [id]
route, sharing one owner-check/idempotent-toggle helper in
lib/api/plans/archive.ts so the four routes cannot drift, plus an
Archive/Restore action on the consultant planner dashboard and a
table-driven test pinning owner-archives/non-owner-403/restore per family.

Closes #1494

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(plans): the archive routes answer a bad body with 400, and the planner reads one archive state

Review round 1 on #1507. A malformed or empty PATCH body rejected inside
`request.json()`, reached the outer catch, and was reported to Sentry before
answering a client fault with a 5xx while the zod branch beside it answered
400 for the same class of fault; `parsePlanArchiveBody` in the shared helper
now covers both and every family returns 400. The planner kept a second copy
of `getPlanArchivedAt`, so the "Archived" badge and the value the toggle PATCHes
could drift apart; both components now import one `lib/planner/archive-state`
(which also absorbs the type guards and `getPlanId` that were duplicated
between them). The failed-response handler in `patchPlanArchived` parsed a body
that a 502 HTML page or an empty 401 does not have, replacing the consultant's
message with a SyntaxError. Also clears the two SonarCloud blockers: the
archive helper is single-intent instead of boolean-switched (S2301) and the
toggle icon is named rather than a nested ternary (S3358).

Part of #1494

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(plans): an org-governed plan is refused by the sole-owner archive routes

The four PATCH routes authorized on the plan's consultantProfile.userId
alone, so a consultant could withdraw a plan their organisation governs with
no org authorization at all — the org catalog endpoint reaches the identical
archivedAt write only through requireOrgAccess and an organizationId-scoped
update. A plan carrying an organizationId now answers 403 PLAN_ORG_GOVERNED
before the write, after the ownership check so only the owning consultant
learns why, pointing them at their organisation admin. One case per family
pins it (#1494).

Part of #1494

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…check (#1480)

* chore(ci): stop the Claude review/mention workflows failing loudly on the expired OAuth token

CodeRabbit is already the reviewer of record on every PR, and
claude-code-review.yml duplicated it on every finance PR the train
orchestrator tags. Since CLAUDE_CODE_OAUTH_TOKEN expired, that workflow
has failed 401 on every run, leaving a permanent red non-required check.

- claude-code-review.yml: drop the automatic pull_request(labeled) trigger,
  run only via workflow_dispatch with a pr_number input (checked out with
  gh pr checkout), keep a concurrency group per #1448 hygiene, and pin to
  the current major tag (@v1) instead of @beta. The claude-review label is
  now a plain tag with no workflow attached.
- claude.yml: gate the @claude-mention job behind a CLAUDE_ACTION_ENABLED
  repository variable so a stray "@claude" mention doesn't trip the same
  expired-token failure, and pin to @v1.
- docs/authentication/betterauth/06-ci-deployment.md: correct the stale
  "AI-assisted code review on PR" line to reflect the on-demand workflow
  and CodeRabbit as reviewer of record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(ci): isolate fork PR checkout from the trusted workspace root, rename direct_prompt to prompt

The on-demand review job checked the PR out over the workspace root before
claude-code-action ran with id-token: write, letting a fork PR's files sit
where the authenticated action reads from. Check the PR out into pr/
instead and hand that path to the action via claude_args: "--add-dir pr".

v1 of claude-code-action renamed direct_prompt to prompt; the old input
name meant this dispatched run silently waited for an @claude mention
instead of reviewing. claude.yml doesn't set either input, so it needs no
change.

SHA-pinning claude-code-action was declined as policy on #1393 and is
left at @v1 per that decision.

Part of #1480

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… domain hierarchy (#1483)

* chore(skills,agents): reorganise Claude Code skills and agents into a domain hierarchy

Twelve flat project skills and a flat agents folder had grown past the point
where a description alone could route a request to the right one, and the
finance/enterprise domains had no skill at all despite the 2026-09-03 finance
train shipping seven ADRs and a compliance model with no doc home. This groups
skills into six domains (booking, finance, enterprise, stream, maintenance,
workflow), each with an index SKILL.md and references/ for the detail, and
renames the four finance-tier agents to domain-neutral names so the same
tiering (opus-high builder, opus-medium triager, sonnet-medium sweeper,
sonnet-low mechanic) can be dispatched for any subsystem, not just money.

New content (finance/references/doctrine.md, verification.md, compliance.md;
enterprise/references/money-seam.md, org-payouts-and-tds.md;
maintenance/references/cron-ticker.md) is written from the repo's own ADRs and
compliance docs, cited at the foot of each page. Every internal cross-reference
to an old skill or agent path was grepped and fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(skills): the reorganisation's dangling cross-references resolve, and the drift guard scans the moved bundle (#1483)

Round-1 review triage. Five link and boundary defects the domain move left behind:

- `references/concurrency.md` and `references/money-boundary.md` cited the
  booking doctrine as `SKILL.md`, which resolves inside `references/` where no
  such file exists; both now say `../SKILL.md`.
- `booking/references/verification.md` still pointed at the retired
  `.claude/skills/razorpay/` path for the worktree-jest claim it contradicts.
- `stream/SKILL.md` carried `name: stream-sdk` while living in `stream/`. The
  folder is what Claude Code keys discovery on, so the mismatched name was a
  trap for anyone reading the frontmatter as authoritative.
- `check-doc-drift.sh` derived ROOT from a pre-move depth, so both TARGETS
  pointed at directories that no longer exist and grep's suppressed error left
  it printing OK while scanning nothing. It now resolves the repo root, fails
  loudly on a missing target, and no longer swallows grep's errors.
- The `razorpay-subscription` agent sent the reader to a `not-used-here/`
  reference while still opening as a build-a-subscription-flow brief. It now
  states up front that recurring billing is in-house and that reversing that is
  a product decision, not one the agent makes.

Also refreshes the stale skill names in `docs/stream/README.md` and
`docs/booking/05-troubleshooting-and-changelog.md`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* docs(compliance,skills): the TDS portal claim is hedged to its source, and verification states what a run leaves behind (#1483)

Round-2 review triage, resolving the two decisions held open in round 1.

The TDS filing note asserted flatly that the income-tax portal rejects an upload
carrying a legacy section number. The CBDT "Updated FAQs on Interplay &
Transitions" supports the weaker claim: the portal validates section codes
against the Income-tax Act 2025 numbering, so a legacy number is expected to
fail that validation. All three sites that carried the absolute wording now
carry the hedged, sourced one, so the skill cannot drift from its sources. The
payout-pipeline gap note also credited a translation it went on to describe as
missing; the quarterly return draft does translate (`lib/compliance/tds-return.ts`
carries a per-line `paymentCode` and refuses to serialize without one, #1389),
and it is the challan and form generators that remain open.

The money-verification recipe keeps its premise — one Supabase project, no
branches, by decision — and now says plainly what a run leaves in it, why that
is acceptable while the dataset is mock and a reset precedes launch, the four
disciplines that keep the residue harmless (record the ids, use recognisable
fixture names, never delete a ledger row, repair caches to the journal), and
the escalation to a dedicated project once real customers exist.

Three secret-hygiene and correctness fixes in files this PR authored:

- The stream env audit piped `netlify env:list --json` straight to output,
  putting every value in the transcript; it now projects keys only, with a jq
  expression that tolerates either shape the CLI returns.
- The env-sync examples carried plaintext values on the command line, which a
  shell history and this transcript both capture. Placeholders now, with the
  reason stated.
- The thread-listing GraphQL snippet interpolated the PR number into the query
  instead of binding it; it now declares `$pr: Int!` and passes `-F`, matching
  the resolve mutation beside it. Verified against this PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… in the payment-success notification (#1484, #1485) (#1489)

* fix(notifications): the payment-success message names the plan, not its UUID (#1484)

The PAYMENT_SUCCESS and APPOINTMENT_BOOKED payloads were built with
`planTitle: metadata.planId || planTitle`. `metadata.planId` is the plan's
id and sat on the LEFT of the `||`, so on every normal capture the buyer's
confirmation told them they had bought a UUID. The fallback it shadowed was
broken independently: both branches of its ternary returned
`metadata.appointmentType`, so it could only ever have read "CONSULTATION",
never a plan name.

The title is now resolved from the appointment Phase 2 already fetches for
these notifications. That read's select is widened by exactly one scalar per
shape — the plan's own `title` — so there is no extra database round trip
inside a block that runs post-commit on a single-connection pool. A TRIAL is
labelled "Trial session" rather than taking its parent subscription plan's
title, which names the paid programme and not the free session, and anything
unresolvable degrades to a humanised appointment type. No id can reach the
payload on any path.

Refs #1484

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(content): the public stats show real figures or none at all (#1485)

/explore/experts fell back to "10K+" active experts and a "4.9" rating
whenever the real numbers were zero — which is the entire pre-launch state —
and rendered "50K+ Sessions Completed" that was read from nothing at all. The
landing page carried a hardcoded 4.9 "Average Rating" counter and the trust
badge "4.9★ average session rating". Fabricated numbers on a public marketing
surface are a misleading-advertisement exposure under the Consumer Protection
Act 2019, not a positioning question.

The experts hero is now built by `buildExpertsHeroStats`, which emits an entry
only when its figure means something: consultants above zero, a rating only
once at least five published reviews stand behind it, and sessions above zero.
When nothing qualifies the grid is replaced by a line of copy that states no
number. The metadata loader behind it gained the two figures that gate makes
necessary — completed sessions, counted as held meetings (COMPLETED slots,
since an Appointment carries no status of its own and a subscription spans
many meetings), and the published-review denominator — and its average now
reads `publishedRating` rather than the raw `rating` mean, which every
unreviewed profile drags towards its zero default.

The landing counter and the trust-badge copy are removed rather than wired to
the same loader: that loader is cached on a five-minute window, and Next
resolves a route's revalidate to the minimum of the segment value and every
data-cache entry read during a render, so reading it on `/` would silently cut
that page's one-hour ISR window to five minutes on the surface where LCP
matters most.

Refs #1485

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(content): every remaining public figure is derived from data or absent (#1490)

The sibling of #1485 that its text did not name. The landing hero counted
"10,000+ Active Users", "500+ Expert Mentors" and "50,000+ Sessions Completed"
and badged "Trusted by 10,000+ professionals worldwide"; each category card
claimed a count like "150+ experts"; and /explore/programs fell back to "500+
Classes Available", "200+ Live Webinars" and "25K+ Students Enrolled" — that
last one on the data path too, so it was fabricated even when its neighbours
were real. None of these were read from anything.

The gate that #1485 introduced for /explore/experts now lives in
lib/data/public-stats.ts and is shared by all three heroes, so "is this number
real?" has one answer rather than one per page. A figure is rendered only when
it is derived and meaningful; below that the stat is dropped and the surface
says something true without a number.

The landing page is wired to its own loader rather than to getExpertsMetadata.
That loader is cached for five minutes to match /explore/experts, and Next
resolves a route's revalidate to the minimum of the segment value and every
data-cache entry read during a render, so reading it on `/` would have cut that
page's one-hour ISR window to five minutes. Rather than shorten `/`, the counts
it needs are cached at 3600 to match the segment, exactly as every sibling
loader in lib/data/home.ts already does, and tagged "experts" so the existing
purge at the verify/edit/delete write sites still clears them on demand. `/` is
prerendered, so awaiting that one read in the page component costs the build,
not the visitor.

Programs now counts PUBLIC plans rather than every row including the org-only
ones a visitor cannot see, and counts enrolled learners as distinct people
holding a CONFIRMED or ATTENDED seat, so a held-but-unpaid seat is not a
student and one person across four webinars is one learner. The hero-stats pin
is extended to both builders and to every literal removed here.

Refs #1490

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(public-stats): empty states drop the publication-cadence claim and the rating gate counts only the reviews behind the number

The two hero empty states rendered "New classes and webinars are published
every week" / "New experts are joining every week" precisely when there were
zero published programs or zero derived stats, which is an unverifiable cadence
claim standing in for the number the PR just removed — the same
misleading-advertisement exposure, one layer down. Neutral copy instead.

The rating aggregate summed `reviewCount` across every verified profile while
`_avg` skipped the NULL `publishedRating` of profiles suppressed under #705, so
reviews that contributed nothing to the displayed average could still clear the
5-review gate that licenses displaying it. Scoping the aggregate to
`publishedRating: { not: null }` makes the denominator the same population as
the numerator; it can only withhold the figure, never inflate it.

Also extends the fallback-title assertion to both notification payloads, which
read the same `resolvedPlanTitle` (#1484).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(public-stats): the public "Average Rating" is the mean over reviews, not over consultants

`_avg.publishedRating` gave every consultant one vote, so a five-review
newcomer at 5.0 could pull the directory figure as hard as an expert with a
hundred reviews at 4.0 — that pair reads 4.5 unweighted and 4.05 weighted, and
only the second is a number the platform could defend to a visitor who read the
label as "what a session here scores". It is also the same population the
5-review display gate counts, so the figure and its threshold now describe the
same thing.

Prisma's `_avg` cannot express a weighted mean, so both loaders fetch the gated
rows and call one shared `deriveDirectoryRating` in public-stats.ts — the
landing hero and the directory cannot drift, which they could while each held
its own copy of the aggregate. The row set is the verified directory and both
loaders stay cached at their existing windows, so the landing page keeps its
1-hour ISR interval. The maths stays exact; rounding remains a display concern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…o-show detector instead of closing it first (#1504) (#1505)

* fix(booking): auto-complete hands an unattended consultation to the no-show detector instead of closing it first (#1504)

The platform's promised full refund for a consultant who never joined could
not fire in production. `auto-complete-appointments` runs at :07 with a
one-hour buffer and moved every APPROVED/SCHEDULED consultation whose slots
had ended to COMPLETED; `detect-consultant-no-shows` runs at :57, reads the
same two statuses, and only looks at bookings past a two-hour grace window. The
completing job always got there first, so by the time the only job that can
cancel and refund a no-show looked, the row was COMPLETED and excluded — the
consultee's money stayed with the consultant's earnings.

Option (a) from the issue: the completing job now skips a consultation in the
no-show shape and leaves it for the detector. The shape itself moves to
lib/booking/attendance.ts so the two jobs cannot hold different opinions about
the same booking; the detector's own predicate is unchanged and now reads from
there, as does NO_SHOW_GRACE_MINUTES. The deferral is bounded by
NO_SHOW_HANDOFF_MINUTES (grace plus two hours) so a candidate the detector
declined — Stream contradicted our attendance rows, or nobody joined at all —
completes rather than sitting live forever: the two sets partition the
candidates. Both jobs keep their CAS transitions, lock keys, workflow names and
schedules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

* fix(maintenance): the consultant no-show detector counts as a financial job

Review triage on #1505. The job cancels and refunds through
refundBookingPayment, yet it was missing from FINANCIAL_JOB_NAMES, so
DEGRADED maintenance held every other refunding job and let this one keep
moving money. The cron reference row now says so too.

Closes #1506

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ke the card below it, class cards show their first session, and a trial's Pay Now lands on the branded checkout (#1345, #1346, #1429) (#1514)

## Summary

### #1345 — the Home badge, "Needs you" and the mini list were three scopes on one screen

The Pending Requests badge on consultant Home counted every `PENDING`
consultation and subscription with no org filter at all, while the "Needs you"
card immediately below it and the mini request list on the same screen counted
personal (B2C) rows. A consultant who also delivers through an organisation
therefore saw up to three different totals for what reads as one cohort.

`pendingConsultationWhere` and `pendingSubscriptionWhere` are now exported from
`lib/data/needs-you.ts`, and the two badge counts in
`lib/data/consultant-dashboard.ts` call them with the personal scope — the same
scope `PERSONAL_ORG_PIN` pins the earnings, completion-rate and active-book
reads on that page to. Sharing the builder rather than re-typing the filter is
what keeps the badge and the card identical. `RequestSlotAllocationTabMini.tsx`
now sends `orgScope=personal` explicitly instead of relying on the list API's
implicit default, which resolves to personal only for non-privileged callers and
left an ADMIN or STAFF consultant looking at an unfiltered list.

### #1346 — a class card showed its authoring window, not its first session

`getEventStartDate` in `components/planner/components/EventCard.tsx` read
`schedulingPeriodStartsAt` for a class while the webinar arm beside it read the
allocated slot. The class arm now reads the earliest live slot on the run's
appointments and keeps `schedulingPeriodStartsAt` only as the fallback for a
class with no sessions yet. A class fans out over many appointments and the
include carries no ordering, so the earliest slot is computed rather than read
off position 0.

The planner's class query did not actually carry those slots — the consultant
arm of `GET /api/bookings/classes` included `appointments: true` — so the
include was added there.

The issue's "heatmap" half is a **false premise** and was not touched: the only
`schedulingPeriodStartsAt` reads in `RequestSlotAllocationTab.tsx` are the
subscription window bounds.

### #1429 F2 — a trial's "Pay Now" skipped the branded checkout

A priced trial is paid for on our own `/checkout/plans/trial/[trialId]` page,
which names the amount and the hold deadline before handing off to the gateway
(#1167). That branch existed inline in `AppointmentSheet.tsx`, but the second
"Pay Now" entry point added to the appointment detail page by #1428 shipped
without it and sent buyers straight to the raw gateway link.

The decision now lives once, in `lib/appointments/trial-checkout-href.ts`
(`trialCheckoutHref(vm): string | null`), and all three call sites use it: both
raw entry points in `AppointmentDetailClient.tsx` (`openPendingPayment` and the
"Pay Now to Confirm" button, which now simply calls it) and the sheet.
`createHandleCheckoutSuccess` in `app/checkout/plans/utils.ts` gained the
`"TRIAL"` arm its message lookup needs, so a trial can never read
`undefined.successTitle` once that page mints its own order.

## Files touched

| File | Why |
| --- | --- |
| `lib/data/needs-you.ts` | Export the two pending-request predicate builders. |
| `lib/data/consultant-dashboard.ts` | Badge counts call those builders in personal scope. |
| `components/dashboard/shared/requests/RequestSlotAllocationTabMini.tsx` | Explicit `orgScope=personal`. |
| `components/planner/components/EventCard.tsx` | Class arm reads the first allocated slot. |
| `app/api/bookings/classes/route.ts` | Consultant arm selects `appointments.slotsOfAppointment`. |
| `lib/appointments/trial-checkout-href.ts` | New shared helper (#1429 F2). |
| `components/appointments/AppointmentSheet.tsx` | Uses the helper instead of the inline branch. |
| `components/appointments/detail/AppointmentDetailClient.tsx` | Both Pay Now entry points use the helper. |
| `app/checkout/plans/utils.ts` | `"TRIAL"` arm on the success toast. |
| `__tests__/dashboards/consultant-home-read-shape.test.ts` | The PR's single pin. |
| `docs/dashboard/dashboard-architecture-assessment.md` | New "Scope of the Home Counts" section. |
| `docs/booking/09-trial-sessions.md` | New "Paying for a trial" section. |

## Verification

- `npx jest __tests__/dashboards __tests__/appointments __tests__/plans --coverage=false` → exit 0, **22 suites / 242 tests passed**.
- The new pin is non-vacuous: with the badge's pre-fix predicate restored it fails with `Expected: 5, Received: 9`, and passes on the fix. It gives the consultant 3 personal + 5 org-funded pending consultations and 2 personal + 4 org-funded pending subscriptions, then asserts the badge (5) equals the personal "Needs you" context while `needsYou.total` is still 14.
- `npx eslint` over all ten changed code files → exit 0, **0 problems**.
- `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` → exit 0, **0 errors** (run again after rebasing onto `a0d6c093b`).
- `npx prettier --check` passes on every changed code file. The two docs files were already Prettier-dirty on `dev`, so they were left as they are rather than reformatted wholesale; the added prose is itself Prettier-stable. One unrelated three-line reformat rides in `RequestSlotAllocationTabMini.tsx` (a `Sentry.captureException` call that was over-long on `dev`), because that file has to be Prettier-clean now that it is changed.
- No `next dev` / `next build` / `prisma generate` / `db push` was run.

## Not done

- **#1429 F1** (the `isTentative: false` mapper) and **#1429 F3** (the slot-picker unions) are out of scope for this PR and stay open on the issue.
- **#1346's "heatmap" half** is a false premise, as described above; the issue body should be amended when it is closed. `RequestSlotAllocationTab.tsx` is deliberately untouched.
- `docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched — PR-D of this train writes one consolidated section there.

Closes #1345
Closes #1346
Part of #1429
Part of #1433

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…y everywhere, and day segments are half-open (#1343, #1342, #1326, #1348, #1415, #1416) (#1512)

## Summary

**The one rule this PR establishes:** `SlotOfAvailabilityWeekly.startDay` is the day the **consultant** published, in their own local calendar, and the UTC weekday is always derived from it through the row's own frozen `utcOffsetMinutes`. Four surfaces had each answered that question for themselves, and the disagreements are the six issues below.

| Issue | What was wrong | Fix |
| --- | --- | --- |
| **#1343** | The settings save path shifted the day forward or back to the UTC day the converted instant landed on, while onboarding stored the local day. An Asia/Kolkata row starting before 05:30 local walked back one weekday on every save — Monday 01:00 saved as Sunday, reloaded into Sunday's form row, saved again as Saturday. | `shiftDayOfWeek` and the `startDayOffset` block are gone. `utils/schedule/formatting.ts` now has one builder, `weeklySlotForSave`, that both save paths use; `formatWeeklySlot` is a thin adapter over it and keeps the #1125 all-or-nothing throw contract. |
| **#1342** | The calendar grid bucketed rows on the **viewer's** weekday and did not even select `utcOffsetMinutes`, so an overseas customer was shown an IST pre-05:30 row a day away from the day checkout would accept. | New `utils/schedule/weekly-projection.ts` owns `utcStartDayIndex`, `weeklyRowDurationMinutes` and `weeklyRowOccurrencesInRange` — the single generator the grid, `isMinuteWithinWeeklySlot`, `getNextOccurrenceWeekly` and `matchWeeklySlotToDay` all share. The grid endpoint carries the stored columns through instead of flattening them onto a 1970 reference date, and `processWeeklySlots` loses its `timezone` parameter because display zoning belongs downstream in `splitSlotsByDay`. |
| **#1326**, **#1348 item 1** | Onboarding and the per-row PATCH defaulted a consultant with no profile timezone to UTC 0 rather than to the launch offset, and nothing checked a caller who supplied an offset of their own. | New `lib/scheduling/weeklyUtcOffset.ts` is the only answer: derived from `User.timezone`, 330 when the profile carries no usable zone (ADR 17), `WeeklyOffsetConflictError` → 400 `UTC_OFFSET_CONFLICT` when a caller contradicts it, and one Sentry warning per write (never per row) when the consultant publishes from outside Asia/Kolkata. `PIN_TO_LAUNCH_OFFSET` is the one-line switch to hard-pin every row to IST. Onboarding also stamped the offset *after* `mergeAdjacentWeeklyRows`, so that fold's cross-offset guard was comparing `undefined` with `undefined`; it is stamped before the fold now. |
| **#1415** | `splitSlotsByDay` cut day segments at `endOfDay` (23:59:59.999). A 23:30–23:59:59.999 remainder is not a thirty-minute atom, so a block published up to local midnight silently lost its last bookable slot on every surface. | Segments are half-open: a segment ends at the next local day's midnight. |
| **#1416** | The expert page's display merge carried a 60-second tolerance where booking requires exact adjacency, so the availability card advertised a window whose seam no row publishes and checkout's per-atom union coverage then refused the booking. | `mergeConsecutiveSlotsForDisplay` requires exact adjacency, and its docblock now states the truth: the only difference from `mergeConsecutiveSlots` is which atoms are eligible (same-status versus available-only). |

Per **user decision 6 / #872**, all four weekly write paths additionally **dual-write** the five DST columns (`timezone`, `localStartMinutes`, `localEndMinutes`, `localStartDay`, `localEndDay`) from the same resolver, computed after the merge so they describe the row actually stored. Nothing reads them. `coalesceConsultantWeeklyRows` deletes and recreates rows, so it recomputes them for the merged row rather than losing them on the next coalesce. The `schema.prisma` doc-comment changes from "unwritten" to "written from 2026-09-05, read by nothing until the reader flip" — comment only, **no schema change, no `db push` needed**.

**No data repair.** The rows the old settings path wrote are pre-MVP mock data; the no-backfill rule applies and the pre-MVP reset wipes them.

### Rebands worth recording

- **#1415's headline is false.** `availability-window-merge.test.ts:240-245` already proves a two-hour block yields one two-hour window across rows. The real residual was the `endOfDay` bound described above, and that is what this PR fixes.
- **#1348 is a three-item bucket.** Item 1 (the zero-offset default) is fixed here, together with the regression test across the write paths that the issue asks for. Item 2 is explicitly scope-hygiene only and defers to #1206, which is now built. Item 3 asks for three tests, of which only the `mergeAdjacentWeeklyRows` cross-offset bullet is addressed (by the ordering fix above); see follow-ups.

## Files touched

**New:** `utils/schedule/weekly-projection.ts`, `lib/scheduling/weeklyUtcOffset.ts`, `__tests__/booking-algorithm/weekly-day-semantics.test.ts`.

**Changed:** `utils/schedule/formatting.ts`, `utils/timeSlotsProcessing.ts`, `utils/slotAllocation/slotTimeUtils.ts`, `utils/slotAllocation/SlotAllocationService.ts`, `utils/slotAllocation/mergeAdjacentWeeklyRows.ts`, `utils/onboarding-server.ts`, `app/api/slots/availability-with-allocation/[consultantId]/route.ts`, `app/api/slots/availability/weekly/route.ts`, `app/api/slots/availability/weekly/[id]/route.ts`, `app/api/user/consultants/[id]/route.ts`, `app/explore/experts/[consultantId]/utils/mergeSlots.ts`, `prisma/schema.prisma` (comment only), `__tests__/schedule/format-slots-for-api-throws.test.ts`.

**Docs:** `docs/booking/00-architecture-decisions.md` (ADR B4 rewritten, register row and status updated), `docs/booking/02-event-types-and-validation.md`, `docs/booking/03-slot-math-and-calculations.md` (new section "Projecting a weekly row onto real dates"), `docs/booking/19-dst-and-timezone-posture.md` (rules 1–4 corrected — the local columns are written now and the drift warning exists), `.claude/skills/booking/references/availability.md` §1/§2/§4. `docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched; PR-D writes one consolidated section.

## Verification

| Check | Command | Result |
| --- | --- | --- |
| Types | `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` | Clean, zero diagnostics (run twice: before and after the Prettier pass). `npx prisma generate` deliberately not run — a sibling worktree owns the shared client. |
| Lint | `npx eslint` over all 15 changed/new TypeScript files | **0 errors, 2 warnings**, both pre-existing `no-explicit-any` at `SlotAllocationService.ts:3445` and `:3498` (verified identical on `origin/dev` at lines 3446/3499, only shifted by edits above them). Nothing new introduced. |
| Format | `npx prettier --check` over all 21 changed files | All matched files use Prettier code style. |
| Tests | `npx jest __tests__/booking-algorithm __tests__/booking __tests__/schedule` | **84 suites passed, 1294 tests passed**, exit 0. |
| A12.1 grid ⇔ validator walk | Throwaway script, not committed: every 30-minute atom of the week 2026-09-06 → 2026-09-13 for the IST Monday 01:00–05:00 row (`{MONDAY, 1170–1410, offset 330}`), asserting `weeklyRowOccurrencesInRange` coverage ⇔ `isMinuteWithinWeeklySlot`. | `ATOMS=336 GRID=8 VALIDATOR=8 MISMATCHES=0` — the eight atoms are the four hours the row publishes, and the grid and the validator agree on every one of the 336. |

The committed pin, `__tests__/booking-algorithm/weekly-day-semantics.test.ts`, is table-driven, node-environment and Prisma-free, and covers all six required areas: the two save paths agreeing on `startDay` (including re-save idempotency), the offset resolver's table plus a source assertion that the four write paths call `resolveWeeklyUtcOffsetMinutes` and no longer call `getTimezoneOffsetMinutes` directly, grid-equals-validator for the IST pre-dawn row seen from both an `Asia/Kolkata` and an `America/New_York` viewer, the midnight block keeping its 23:30 window, display-merge-equals-booking-merge over gaps of 0/1,000/60,000 ms, and the dual-written local columns. `slotTimeUtils.test.ts`, `availability-window-merge.test.ts`, `availability-grid-conditional-get.test.ts`, `availability-window-scan.test.ts` and `slot-session-fix-pins.test.ts` were run and are green, and were not edited.

## Not done / follow-ups

- **Server-side `validateTimeIncrements`** is out of scope. It is a private helper on a separate rejection surface, so the server write paths still do not run it and a minute-level seam remains creatable through the API even though neither merge function will now advertise one.
- **#1229** (draft, `fix/explore-booking-responsive`) overlaps this area textually only. Per the guardrail, `ConsultationPricingToggle.tsx`, `ExpertPricing.tsx`, `WeeklyAvailability.tsx` and `CustomAvailability.tsx` are untouched; the only explore file changed here is `utils/mergeSlots.ts`.
- **No data repair** for rows already written by the old settings path — pre-MVP mock data, no-backfill rule, the reset wipes them.
- **#1348 item 3** still wants two tests this PR does not add: preference-scoring tie-break determinism under equal scores, and confirmation that a manual allocation is not silently overwritten by a later auto pass. Neither is a behaviour change, and both are test-budget items that belong with the allocator work rather than here.
- **#872 reader flip** remains post-MVP. This PR only makes the dual-write correct and documents the reader-flip approach in `19-dst-and-timezone-posture.md`.

Closes #1343
Closes #1342
Closes #1326
Closes #1348
Closes #1415
Closes #1416
Part of #872
Part of #1433

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…e CAS helpers, and cancelled slots are tombstoned (#1383)

## What

Wave-6 PR E (umbrella #1319). Found by the 2026-09-03 production cleanup: a user cancel succeeded and wrote no `BookingStatusHistory` row, and the cancelled slots kept `deletedAt = null`. The two most-used lifecycle routes were moving request, slot and reschedule status with raw `updateMany` calls (status re-checked in the WHERE, so a CAS, but outside the helpers), which is why `BookingStatusHistory` was empty on production.

- **Cancel route.** The four request/event writes go through `transitionConsultationRequest` / `transitionSubscriptionRequest` / `transitionWebinarEvent` / `transitionClassEvent` with the route's own from-sets as `fromIn` and the session user as actor; `IllegalTransitionError` maps to the unchanged 409 `NOT_CANCELLABLE`. The three slot writes collapse to one `transitionSlotCompletion` to CANCELLED with `deletedAt`, scoped through `fromIn` (never `completionStatus` in the `where`). The open reschedule proposal is read in the transaction and each is CAS-closed to DECLINED.
- **Reschedule route.** The four slot writes go through `transitionSlotCompletion` to RESCHEDULED with `isTentative: true` and deliberately no `deletedAt` (rule 2: a reschedule keeps its rows so a withdrawal can restore them); the four request/event writes go through the helpers; the shared catch answers the unchanged 409 `NOT_RESCHEDULABLE`. `proposedSlots` and `rescheduleRequest.create` are untouched.
- **`reschedule-withdraw.ts`.** Its one raw write (RESCHEDULED → SCHEDULED) is now the helper with `allowZero`, which is load-bearing there; its existing helper calls gain the actor.
- Slot sweeps pass no `appointmentId` on purpose: a whole-subscription cancel would otherwise file sibling appointments' slot history under the appointment the request arrived on; the request/event transitions pass it explicitly (the override #1378 documents).

Raw status writes: cancel route 8 → 0, reschedule route 8 → 0, withdraw 1 → 0. `lib/booking/transitions.ts` is untouched.

## Verification

Cold `tsc` clean; `__tests__/booking-algorithm` + `__tests__/booking` + `__tests__/payments` + `__tests__/maintenance`: 129 suites / 1,724 tests green; prettier clean; eslint per-file counts identical to `dev`. Pin extended in the existing `__tests__/payments/cancel-route-refund.test.ts`: a successful consultation cancel writes one CONSULTATION history row (actor, reason, appointment id) plus one per moved slot with `deletedAt`; a lost CAS still answers 409 with zero history rows. Both fail on pre-change code.

Rebased onto `dev` at b713e48 (#1378 included); five route assertions loosened to `objectContaining` because #1378 changed the helper's `select`. Commit 1 is format-only (`reschedule-withdraw.ts` was prettier-dirty on `dev`).

## Out of scope, found on the way

`transitionSlotCompletion` appends history rows in a sequential loop, one INSERT per slot inside the transaction; a whole-class cancel with many attendees would benefit from a `createMany`. Left for a follow-up on the helper file.

Part of #1319.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7
…l it is confirming, and accept serialises on the appointment lock (#1340) (#1515)

## Summary

Issue #1340 is titled as a double-booking race in the reschedule auto-confirm path. That race cannot open: `manualAllocate` writes every slot inside one `prisma.$transaction` under `lockAutoAllocate` plus the consultee lock, with the `slot_no_confirmed_overlap` GiST constraint as the backstop, and `openForAppointmentId` cannot dangle through this path because `DECLINED` is terminal and `transitionRescheduleRequest` clears the reservation on every terminal target. What is live at those exact lines is a different and worse defect, and this PR fixes that.

**The real defect.** `SlotAllocationService.resolveConsumedPreferenceRequests` ends every allocating transaction with a supersede sweep that `DECLINE`s each open `RescheduleRequest` whose `releasedSlotIds` intersect the slots being placed. That sweep is right for a consultant who answers a proposal by placing *different* times by hand. It was wrong for the two confirmation callers, which place the proposal's *own* times:

- `lib/booking/reschedule-auto-confirm.ts:68` and `lib/booking/reschedule-respond.ts:77` both call `SlotAllocationService.allocate({ mode: "manual", slots: <the proposed times>, wideLock: true })`, and only afterwards CAS the proposal `PENDING_REVIEW → AUTO_ACCEPTED` / `→ ACCEPTED`.
- By the time that CAS runs, the sweep has already moved the row to `DECLINED` inside the allocator's transaction, so the `updateMany` matches zero rows and `transitionRescheduleRequest` throws `IllegalTransitionError`.
- The booking has moved either way. `reschedule/route.ts:~727` swallows the error and answers `autoConfirmed: false` to a consultee whose session was in fact rescheduled; the explicit accept rethrows, so the consultee gets a **409 on a booking that moved**, and the MOVED notification in `reschedule-respond.ts` is never reached. The audit trail records a refusal for a request that was granted.

**The fix.** `AllocationRequest` gains an opt-in `excludeRescheduleRequestId`, threaded through `dispatch` into `manualAllocate` and `autoAllocate` and down to the sweep, whose supersede `WHERE` gains `...(excludeId ? { id: { not: excludeId } } : {})`. Both confirmation callers pass their own proposal id. Nothing else in the allocator moves: locks, TTLs and transaction boundaries are untouched, and an allocation that is not confirming a specific proposal still supersedes every open proposal on those slots exactly as before.

**Secondary (the same issue's real concurrency gap).** The accept path took no appointment lock at all. Accept is a lifecycle mutation that moves this appointment's slots, but the allocator's locks are keyed by consultant and by consultee, never by appointment, so an accept and a concurrent cancel of the same booking never contended for anything. `respond/route.ts` now wraps `acceptProposal` in `withAppointmentLock`, the same `appointment-lock:` atom the cancel and reschedule routes take, and maps `AppointmentBusyError` to 423 and `BookingLockUnavailableError` to 503 exactly as `reschedule/route.ts` does. Lock order is unchanged — the appointment atom is the coarsest key and is taken before the allocator acquires its own. Decline is deliberately left outside the lock: it moves nothing.

## Files touched

| File | Change |
|---|---|
| `utils/slotAllocation/types.ts` | `AllocationRequest.excludeRescheduleRequestId?: string` with the why-comment. |
| `utils/slotAllocation/SlotAllocationService.ts` | Threads the id through `dispatch` → `autoAllocate` / `manualAllocate` → `resolveConsumedPreferenceRequests`; the supersede `WHERE` gains `id: { not: … }`. |
| `lib/booking/reschedule-auto-confirm.ts` | Passes `excludeRescheduleRequestId: request.id`. |
| `lib/booking/reschedule-respond.ts` | Passes `excludeRescheduleRequestId: request.id`. |
| `app/api/appointments/[appointmentId]/reschedule/respond/route.ts` | Accept runs inside `withAppointmentLock`; 423 / 503 lock answers. |
| `__tests__/booking-algorithm/reschedule-respond.test.ts` | The pin's caller half (+ the lock case). |
| `__tests__/booking-algorithm/preference-scored-allocation.test.ts` | The pin's allocator half. |
| `docs/booking/07-rescheduling-flow.md`, `docs/booking/18-state-machines.md` | The confirmation sequence and the exclusion rule. |

## Verification

| Check | Result |
|---|---|
| `npx prettier --check` on every changed file | The five code files and `reschedule-respond.test.ts` are clean. `lib/booking/reschedule-respond.ts`, `preference-scored-allocation.test.ts` and both docs still warn, and every remaining hunk is byte-identical to `origin/dev` (pre-existing wrapping in the big `select` payloads, the two markdown tables and unrelated object literals); no added line is unformatted. |
| `npx eslint` on the changed files | 0 errors, 3 warnings — all three are the pre-existing `no-explicit-any` at `SlotAllocationService.ts` dev-lines 3446/3499 and `types.ts` dev-line 147, shifted by this diff and otherwise identical. |
| Cold `tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | exit 0, after the rebase onto `73c0d9f2c`. |
| `npx jest __tests__/booking-algorithm __tests__/booking __tests__/payments/cancel-route-refund.test.ts` | exit 0 — **72 suites, 1132 tests passed**, re-run after the rebase. |

**The pin fails without the fix.** Reverting the five production files to `origin/dev` and re-running the two suites fails 5 assertions:

```
● #1340 — resolveConsumedPreferenceRequests and the confirming proposal › declines a different stale proposal on the same slots but never the excluded one
● accept re-validates through the allocator before anything is written › sends the proposed times through manual allocation under the wide lock
● #1340 — a confirmation keeps the proposal it is confirming › accept names its own proposal to the allocator and then closes it ACCEPTED
● #1340 — a confirmation keeps the proposal it is confirming › auto-confirm names its own proposal to the allocator and then closes it AUTO_ACCEPTED
● the respond route drives the loop for the counterparty › serializes the accept on the appointment lock and answers 423 while it is held
Tests: 5 failed, 55 passed, 60 total
```

Reverting only the supersede `WHERE` (the one-line heart of the fix) fails the allocator case on its own. That case drives the real `resolveConsumedPreferenceRequests` against a transaction stub that answers `findMany` the way Postgres would, so the assertion rides on the `WHERE` the sweep actually issues rather than on a stubbed outcome: with an exclusion in flight only the *other* stale proposal is declined, and with no exclusion both still are.

## Not done

- `app/api/appointments/[appointmentId]/reschedule/route.ts` is untouched: PR #1383 owns that file. Its auto-confirm call at `:~717` therefore still runs **outside** `withAppointmentLock`, so the lock-scope half of the auto-confirm path stays as it is. Worth a follow-up once #1383 lands; the correctness fix above does not depend on it.
- The #1497 limiter rider was not taken.
- No `db push`, no schema change, no notification-workflow change.

Closes #1340
Part of #1433

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…rg tiers, and a credit-funded partial cancel restores the credit in full (#1499, #1500, #1372) (#1513)

## Summary

**The model (#1499).** Refund terms move out of the `Appointment.cancellationPolicySnapshot` Json column and into typed, versioned rows. `CancellationPolicy` is one published version of a ladder — its scope, version, status, and the percentage a consultant-initiated cancellation settles at — and `CancellationPolicyTier` holds its rungs. `Appointment.cancellationPolicyId` points at the exact row that governed the sale. A published version is **immutable**: editing a ladder archives the current `ACTIVE` row and inserts a new one at the next version, so the freeze the old snapshot provided by convention is now structural, and no code path can rewrite terms a buyer already agreed to.

**Resolution.** Checkout resolves the governing version exactly once, inside the booking transaction, through `resolveCheckoutCancellationPolicyId()`. An organisation's ladder governs the bookings the organisation **funds** — on a refund it is the organisation's money that moves — so the organisation id is passed only on the sponsored path; a personal booking merely tagged to an organisation keeps the platform ladder, as does an organisation that has never published. Sessions allocated later against a subscription **inherit** the version from the row checkout created rather than resolving afresh, which is the whole point of versioning. The platform default lives at a fixed id, is seeded, and is also provisioned idempotently by `ensurePlatformCancellationPolicy()` so a database nobody seeded cannot fail a checkout.

**The credit rule (#1500).** The credits rail cannot pay a fraction — `refundBookingPayment` refuses an `amountPaise` on a `free_` intent — so a partial tier previously had nothing it could pay and escalated to a human. That escalation is replaced by a rule with two halves:

- **Any tier above 0% restores the credit IN FULL**, via `refundBookingPayment` with no `amountPaise`. The buyer gave the notice the ladder rewards, and the rail's inability to divide should not cost them the refund.
- **A 0% tier restores NOTHING**, falling through to the existing `POLICY_ZERO` arm. A late cancel bites a credit buyer exactly as it bites a card buyer; paying a full credit back for a cancellation that earns a card buyer nothing would make free credit strictly better than money and delete the late-cancel deterrent.

The whole rule is one predicate in `quoteBookingRefund` — `isFreeCreditFunded && refundPct > 0` — surfaced as `creditRestoresInFull`. `MANUAL_REVIEW` is gone from the route, the response union, the client and the docs.

## Schema

Additive only.

- **`CancellationPolicy`** — `organizationId` (null = the platform default), `version`, `status` (`CancellationPolicyStatus`: `ACTIVE` | `ARCHIVED`), `consultantInitiatedBps`, `policyText`, `publishedByUserId`, `createdAt`, `archivedAt`. `@@unique([organizationId, version])`, `@@index([organizationId, status])`.
- **`CancellationPolicyTier`** — `policyId`, `hoursBefore`, `refundBps`. `@@unique([policyId, hoursBefore])`, `@@index([policyId])`.
- **`Appointment.cancellationPolicyId String?`** — FK at `onDelete: SetNull`, plus `@@index([cancellationPolicyId])`. `SetNull` because a booking must survive its organisation being torn down; losing the pointer degrades to the platform ladder rather than deleting money history. Null and "sold before this change" are deliberately indistinguishable.
- **`Appointment.cancellationPolicySnapshot Json?`** stays, **frozen**: never written, never read, annotated as such, dropped at the pre-MVP reset. A column a running deploy still reads must not be dropped under it, and this repo writes no backfill migrations.
- Back-relations on `Organization` and `User`; enums declared below the models that use them.
- **Staged, commented** in `prisma/sql/check-constraints.sql`: the `NULLS NOT DISTINCT` partial unique for "one ACTIVE version per scope" and for `(organizationId, version)`. Postgres treats null keys as distinct, so the platform row escapes the Prisma `@@unique` entirely. These stay commented because `check-db-sidecars` strips comments and would demand an unapplied index. Until they land, one active version per scope is enforced by the Serializable rotation in `publishOrgCancellationPolicy()`, and readers order `version desc` and take one so a slip degrades to "newest wins".

**This PR requires `npm run db:push` by the orchestrator after merge**, followed by `npm run db:assert-sidecars`. CI's `check-db-drift` skips new enums and tables, so CI is green before the push; the seed and manual QA need the push first.

## Files touched

**Schema and seed** — `prisma/schema.prisma`, `prisma/sql/check-constraints.sql`, `prisma/seed.ts`, `prisma/seedFiles/16b-create-cancellation-policy.ts` (new).

**Money core** — `lib/payments/operations/cancellation-policy.ts` (stays Prisma-free: `RefundTier`, `CancellationPolicyTerms`, `PLATFORM_DEFAULT_TERMS`, `tiersFromBps`, `validateTierLadder`, `computeRefundPct`, `quoteBookingRefund`), `lib/payments/operations/cancellation-policy-store.ts` (new: the one select shape, the platform provisioner, the resolver, the publish routine), `lib/payments/operations/checkout.ts`, `utils/slotAllocation/SlotAllocationService.ts`.

**The six readers** — `lib/booking/cancellation-scope.ts`, `lib/booking/rejection-refund.ts`, `lib/trials/cancellation.ts`, `lib/payments/operations/event-refunds.ts`, `lib/support/context.ts`, and the two cancel routes (`app/api/appointments/[appointmentId]/cancel/route.ts`, `.../cancel/preview/route.ts`). `parsePolicySnapshot` and `resolveCancellationPolicySnapshot` are deleted; no reader consults the Json column.

**Org editor** — `app/api/organizations/[orgId]/cancellation-policy/route.ts` (new; GET gated on `settings.manage`, PUT on `minimumRole: "OWNER"`, no PATCH or DELETE because a version is immutable), `app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx` (new) wired from `GeneralPanel.tsx` behind `isAtLeast("OWNER")`, `lib/enterprise/audit-actions.ts`.

**Client** — `components/appointments/consultee/useEventActions.ts`.

**Tests** — `__tests__/enterprise/multi-engagement-cap.test.ts` (#1372's three cases, in the existing file), `__tests__/booking/cancellation-policy.test.ts`, `__tests__/payments/{cancel-route-refund,refund-preview-parity,attendee-removal-refund,trial-cancellation-refund,rejection-refund,consultation-atom-parity,checkout-open-order-reuse,allocation-utilization-integrity}.test.ts`, `__tests__/booking-algorithm/{cancellation-scope,allocation-top-up,row-walk-truncation,collaborator-availability-modes,slotAllocationService}.test.ts`.

**Docs** — `docs/booking/08-cancellation-flow.md`, `docs/booking/17-org-funded-checkout.md`, `docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md` (new) + its index row in `00-README.md`, `.claude/skills/booking/references/money-boundary.md`.

## Verification

| Check | Result |
| --- | --- |
| `npx prisma generate` then cold `npx tsc --noEmit` (`tsconfig.tsbuildinfo` deleted, 8 GB heap) | **exit 0**, no errors |
| `npx eslint` on all 33 changed code files | **0 errors, 0 warnings introduced**. 1 error + 44 warnings remain, all byte-identical at `origin/dev`: `jest/no-mocks-import` and 32 `no-explicit-any` in `slotAllocationService.test.ts`, 9 `no-explicit-any` in `checkout-open-order-reuse.test.ts`, 1 `eqeqeq` in `checkout.ts`, 2 `no-explicit-any` in `SlotAllocationService.ts`. The other 29 files are clean. |
| `npx prettier --check` on every changed file | clean. The 10 files my edits drifted were verified clean at baseline first, then formatted. |
| `npx prisma format` | no change; schema already formatted |
| `npx jest __tests__/booking __tests__/payments __tests__/enterprise __tests__/booking-algorithm/cancellation-scope.test.ts` | **exit 0** — 227 suites passed, 2440 tests passed |

The five allocation suites initially failed (60 tests) because `createAppointments` now reads `tx.appointment.findFirst` to inherit the policy and their transaction mocks did not define it. Fixed by adding `findFirst: jest.fn().mockResolvedValue(null)` to those five mocks — mock plumbing, not a semantic change.

## Limitations / not done

- **Org-funded event seats fall back to the platform ladder.** One shared `Appointment` row serves every registrant of a webinar or class, across every funding organisation, so it cannot carry one buyer's terms; its FK stays null. Whole-event refunds already assumed the platform ladder, so this is consistent, but organisation tiers genuinely do not reach event seats. Reaching them means moving the terms onto the participant row — worth a follow-up issue if it is wanted.
- **A `free_` intent with a non-zero amount is out of scope.** That is a mixed payment; it takes the money arm and is refused `INVALID_AMOUNT`, exactly as today. Both halves of the `isFreeCreditFunded` predicate are load-bearing for this reason.
- **Merge order: land #1383 first.** It rewrites the status writes in the cancel transaction body (~`:39-447`). This PR touches only the import block and the post-transaction refund block, so expect at most one import-block conflict.
- **`docs/booking/05-troubleshooting-and-changelog.md` is deliberately untouched.** Its line about "the only surviving MANUAL_REVIEW path" is now stale and is amended by PR-D's consolidated changelog pass.
- No end-to-end run: that is the orchestrator-announced round, and it needs the schema pushed first.

Closes #1499
Closes #1500
Closes #1372
Part of #1503

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…nance, no-show cancels write history, and the doctrine text matches the sweeps (#1506) (#1516)

## Summary

1. **`expire-stale-requests` joins `FINANCIAL_JOB_NAMES`.** Its `expirePaymentPendingRequests`/`expireApprovedUnallocatedSubscriptions` passes call `refundPaymentsForExpired`, a refund front-door caller like every other job already in the set, so DEGRADED maintenance now holds it with the rest. `detect-consultant-no-shows` was already there via #1505.
2. **A registry pin gates every future refund-front-door caller, not just today's two.** `__tests__/maintenance/cron-lock-registry.test.ts` gains one assertion that greps `scripts/**/*.ts` for callers of `refundBookingPayment(`, `refundWholeEventPayments(`, `refundRemovedAttendeeSeat(`, and `refundPaymentsForExpired(`, and asserts each caller's `withCronLock` name is in `FINANCIAL_JOB_NAMES`.
3. **Two more money-twin routes drop their `status: () => 200` override.** `app/api/cleanup/process-payouts/route.ts` and `.../sweep-abandoned-overage-charges/route.ts` now fall through to `cleanup-route.ts`'s default `statusFor`, which reads `result.success`, mirroring `release-earnings`/`sync-payment-earnings` under #1390.
4. **The no-show cancel writes a `BookingStatusHistory` row.** `claimConsultantNoShow` in `scripts/appointments/detect-consultant-no-shows.ts` now runs the CANCELLED transition through `transitionConsultationRequest` inside `prisma.$transaction`, instead of a bare `consultation.updateMany`. The zero-row "someone else moved it" outcome is preserved via `IllegalTransitionError` catch. Candidate query, grace/handoff constants, refund call and notifications are untouched.
5. **Doctrine text corrected in `.claude/skills/booking/SKILL.md`.** Rule 2 no longer claims `expire-stale-requests.ts`/`cleanup-tentative-slots.ts` hard-delete tentative holds (fixed by #1380/#1424's soft-cancel via `transitionSlotCompletion`). Rule 5 no longer names `expirePaymentPendingRequests` as the doctrine's counter-example (fixed by #1423's CAS + money-predicate rewrite).
6. **`docs/booking/18-state-machines.md`'s reschedule section corrected.** `COUNTERED` is noted as an unreachable enum edge with no writer (the counter-round was removed per `lib/booking/reschedule-proposals.ts`), and `AUTO_ACCEPTED` is documented as the second terminal-acceptance state.
7. **Glossary linked.** `docs/booking/README.md` links `docs/enterprise/00-foundations/07-slots-sessions-glossary.md` under Core Concepts.
8. **DEGRADED gate noted in both cron references.** `docs/booking/13-cron-jobs-and-background-tasks.md`'s Safety paragraphs and `docs/maintenance/04-cron-jobs-reference.md`'s table rows for both jobs now say they are held during DEGRADED as well as OFFLINE.
9. **Consolidated train changelog.** One new `## Changelog: 2026-09-05 — booking closure train` section in `docs/booking/05-troubleshooting-and-changelog.md`, with one subsection per train PR (#1512, #1513, #1514, #1515, this PR), written from each PR's merged/open body. Also fixes the stale "only surviving `MANUAL_REVIEW` path" sentence that #1513 obsoletes.

## Files

- `lib/maintenance-cron.ts`
- `app/api/cleanup/process-payouts/route.ts`
- `app/api/cleanup/sweep-abandoned-overage-charges/route.ts`
- `scripts/appointments/detect-consultant-no-shows.ts`
- `__tests__/maintenance/cron-lock-registry.test.ts`
- `__tests__/booking/no-show-refund-front-door.test.ts`
- `__tests__/maintenance/no-show-auto-complete-handoff.test.ts`
- `.claude/skills/booking/SKILL.md`
- `docs/booking/18-state-machines.md`
- `docs/booking/README.md`
- `docs/booking/13-cron-jobs-and-background-tasks.md`
- `docs/maintenance/04-cron-jobs-reference.md`
- `docs/booking/05-troubleshooting-and-changelog.md`

## Verification

| Check | Result |
| --- | --- |
| `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` (after rebase onto `01a377342`) | exit 0, no errors |
| `npx eslint` on all 7 changed/new code files | 0 problems |
| `npx prettier --check` on all 7 changed/new code files | clean |
| `npx prettier --check` on the 6 changed docs files | 5 clean; `docs/booking/18-state-machines.md` was already Prettier-dirty on `origin/dev` (unpadded tables and a wrapped bullet outside the section I touched) and is left as-is per the existing project pattern (see #1514's note on the same posture); the lines I added are themselves Prettier-clean |
| `npx jest __tests__/maintenance __tests__/booking __tests__/appointments` (after rebase) | exit 0 — **87 suites, 1348 tests passed** |
| Two suites mocked Prisma without `$transaction` (`__tests__/booking/no-show-refund-front-door.test.ts`, `__tests__/maintenance/no-show-auto-complete-handoff.test.ts`) | extended the mocks with `$transaction`, `consultation.findUnique`, and `bookingStatusHistory.create` rather than weakening any assertion |

## Not done

- None of the six numbered spec items were skipped.

Closes #1506
Part of #1338
Part of #1493
Part of #1420

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
…uteRaw so nothing deserialises a void column (#1518) (#1519)

`guardInitialAllocationInTx` took the per-event lock with
`tx.$queryRaw`SELECT pg_advisory_xact_lock(...)``. That function returns
`void`, and under the Prisma 7 driver adapter the result row carries a
column of Postgres type `void` that the adapter cannot deserialise, so
the call threw `Failed to deserialize column of type 'void'` before the
allocation began. Every path through the guard failed: manual allocation
from the request calendar, the requested/approve path, and the reschedule
allocation. Sentry FAMILIARISE_WEB-2W caught it on the dev deploy and the
same code is on prod.

`$executeRaw` reads only the affected-row count, so the void column is
never deserialised. The key expression, the transaction scope and the
idempotency replay immediately after the lock are unchanged. A repo-wide
grep for other raw statements selecting a void-returning function
(`pg_advisory_lock`, `pg_advisory_xact_lock`, `pg_notify`, functions
declared `RETURNS void`) found no other occurrence: the only other
`RETURNS void` function, `assert_payment_legs_ok`, is invoked by triggers
rather than from application code.

The allocation suites mock the transaction client, so the six tx mocks
that stubbed `$queryRaw` for this lock now stub `$executeRaw`, and one
new pin asserts the guard issues the lock through `$executeRaw` with the
`initial-allocation:<type>:<id>` key, never touches `$queryRaw`, and
carries on to the confirmed-slot count.

Refs #1518


Claude-Session: https://claude.ai/code/session_01MgadJydWEKkdhhzY58yiL7

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…m diagnostics, Razorpay CSP, planner class dates (#1520)

## Summary
> **Update 2026-09-06:** Fix 1 (the `$executeRaw` advisory lock) landed on dev independently as #1519 while this PR was in review; this branch now carries dev's version of that change, and the PR's own contribution is fixes 2–4 (auto-confirm reason in the response, CSP frame host, planner `firstSessionAt`).


**Fix 1 — P0: initial-allocation advisory lock throws under Prisma 7.** `utils/slotAllocation/SlotAllocationService.ts` (`guardInitialAllocationInTx`) called `tx.$queryRaw` on `pg_advisory_xact_lock(...)`, which returns `void`. Prisma 7's driver-adapter client fails to deserialize a void column, so every fresh manual allocation and every reschedule auto-confirm/accept died there (Sentry FAMILIARISE_WEB-2W, seen on `POST /api/appointments/[appointmentId]/reschedule` → `manualAllocate` → `guardInitialAllocationInTx`). Switched to `$executeRaw`, which does not attempt to deserialize result columns — same SQL, same parameter, the transaction-scoped lock is still taken. Grepped `app lib utils scripts jobs` for other `$queryRaw` calls whose SELECT list is only a void-returning function (`pg_advisory_*`, `set_config`, `pg_notify`): none found. The other `$queryRaw` hits (`scripts/ci/check-db-drift.ts`, `check-db-sidecars.ts`, `assert-sidecar-applied.ts`) all select real columns, and the `$executeRawUnsafe` hits (`scripts/db/apply-*.ts`) already use the correct method for DDL.

**Fix 2 — surface the swallowed auto-confirm reason.** `app/api/appointments/[appointmentId]/reschedule/route.ts` discarded `outcome.reason` (e.g. `VALIDATION_FAILED`, `APPOINTMENT_BUSY`, an allocator `errorCode`) when `tryAutoConfirmProposal` declined, which made this incident un-diagnosable from the response. Now captures `autoConfirmReason`, includes it in the JSON response next to `autoConfirmed`, logs one `console.info({ event: "reschedule_auto_confirm", ... })` per request, and sets `autoConfirmReason` to `"LOST_RACE"`/`"ERROR"` in the catch arm so a thrown error is visible too. This is exactly the failure mode found during the E2E pass: reschedule auto-confirm returned `false` on green slots for a CUSTOM-schedule consultant, the offline allocator validator passed independently, and Sentry showed the void-deserialization error — the two fixes together make that failure both non-fatal and diagnosable.

**Fix 3 — CSP `frame-src` host.** `next.config.mjs` was missing `https://api.razorpay.com` in `frame-src`; the live Razorpay checkout iframe is served from that host, and a report-only violation was observed on the dev deploy during a test checkout. Added the host and a line to the gateway-rationale comment block. The policy is report-only until `ENABLE_CSP_ENFORCE`, so this is a no-risk correction.

**Fix 4 — planner class cards show their first session (#1346 follow-through).** The planner's `classInclude` windows `appointments.slotsOfAppointment` to ±24h of now for the Join affordance, so a class whose sessions all fall outside that window arrived with zero slots and the card fell back to `schedulingPeriodStartsAt`. Added one batched, unwindowed `slotOfAppointment.groupBy` query after the class rows load, attaching `firstSessionAt` (earliest live slot across all of a class's appointments) to each class event. `EventCard.tsx`'s `getEventStartDate` now reads `firstSessionAt` first, falling back to the existing earliest-live-slot reduction (still valid for the `/api/bookings/classes` consumer, which is unwindowed) and then `schedulingPeriodStartsAt`. The window itself is untouched — it still exists to bound the Join payload.

## Files

- `utils/slotAllocation/SlotAllocationService.ts`
- `app/api/appointments/[appointmentId]/reschedule/route.ts`
- `next.config.mjs`
- `app/api/dashboard/consultant/[consultantId]/planner/route.ts`
- `types/planner-events.ts`
- `components/planner/components/EventCard.tsx`
- `__tests__/booking-algorithm/{allocation-top-up,collaborator-availability-modes,expected-tentative-count,initial-allocation-guard,row-walk-truncation,slotAllocationService}.test.ts`
- `__tests__/dashboards/planner-class-join-payload.test.ts`

## Verification

- `rm tsconfig.tsbuildinfo && NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit` → clean, 0 errors.
- `npx eslint <changed files>` → 0 problems on every file except `__tests__/booking-algorithm/slotAllocationService.test.ts`, whose single pre-existing `jest/no-mocks-import` error was confirmed byte-identical to `origin/dev` (only the touched `$queryRaw`→`$executeRaw` line differs in that file).
- `npx prettier --check <changed files>` → clean (2 files auto-formatted before commit).
- `npx jest __tests__/booking-algorithm __tests__/booking __tests__/payments/cancel-route-refund.test.ts __tests__/dashboards/planner-class-join-payload.test.ts` → **74 suites / 1181 tests, all passed.**
- `npx jest __tests__/booking-algorithm/reschedule-preference-route.test.ts` re-run after adding `autoConfirmReason` to the response → 6/6 passed.

Fixes FAMILIARISE_WEB-2W
Part of #1346

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyngsXG829TRTBof4CSGT1
@netlify

netlify Bot commented Sep 6, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit a1fcd37
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a9d1a1927cc64000891c283
😎 Deploy Preview https://deploy-preview-1523--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 36 (🔴 down 17 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 90 (🟢 up 8 from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 67bd5d5f-5a5d-4a84-819e-f6fd7abe1f2d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@teetangh

teetangh commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

SonarCloud gate on this release PR: accepted as red. The gate fails only on new_duplicated_lines_density (3.9% against 3%), measured over the union of the 13 PRs as one "new code" set. Every constituent PR passed its own gate (0.0–2.0%). The duplicated blocks Sonar lists are the four plan-family PATCH handlers from #1507 (app/api/plans/{consultations,subscriptions,webinars,classes}/[id]/route.ts, 14 blocks each in the first two) and their mutation hooks in components/planner/hooks/usePlanner.ts (69.6%), plus pre-existing structure in checkout.ts, reschedule-respond.ts and the classes list route. None of it is new duplication introduced by the train's own diffs, and SonarCloud is not a required check on prod (required: Lint, TypeScript/Tests/Build). The structural follow-up already lives in #1375 / #1332.

@teetangh
teetangh merged commit b308264 into prod Sep 6, 2026
7 of 8 checks passed
@teetangh
teetangh deleted the release/dev-to-prod-2026-09-06 branch September 6, 2026 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant