Start Date — design (#894)
Date: 2026-06-11 Issue: #894 — ratified design comment 2026-06-11 is the scope authority; this spec adds the implementation-level decisions. Related: #2411 (realign machinery — reused), #2412 (preview — must learn the formula), #2413 (history-writer pattern — copied for dates).
Scope decisions (ratified on the issue)
- Billing-only; no hard work gate. Always-HITL is the work gate. Two soft guards ship instead: work-start visibility in Expert/ops UI, and never-auto-send-before-work-start.
- Retro changes: manual + audit trail. Unbilled subs realign automatically (existing #2411 machinery); billed-or-backward moves are recorded + surfaced to the AM as "requires manual billing adjustment in Lago". No automated credit notes / back-billing.
- Out of scope: per-Specialist start dates; hard per-channel gating; automated reconciliation.
Data model (expand-only)
organizations.start_date—date, nullable. AM-owned engagement start.organizations.activated_at—timestamptz, nullable. Stamped (only when NULL — first activation wins, restores don't reset it) by all three activation paths:completeOnboarding,finishSetupWithoutInvite,updateAmOrg(clearDeactivation). Backfill for already-active orgs:COALESCE(trial_started_at, created_at).- New table
org_engagement_date_history:id uuid pk,org_id uuid,field('start_date' | 'trial_end_date'),old_value date null,new_value date null,changed_by_user_id uuid null,change_reason text null,changed_at timestamptz default now(). Index(org_id, changed_at). Append-only; written by every path that mutates either date (updateAmOrg,sendInvitation,finishSetupWithoutInvite). NULLchanged_byreserved for backfills (none needed now). - New injectable
EngagementDateHistoryWriter(pattern copy ofAssignmentRateHistoryWriter):recordChange(repo, { orgId, field, oldValue, newValue, userId, reason }), no-op whenoldValue === newValue(date-string compare).
The formula — api/src/organizations/engagement-dates.ts (pure, unit-tested)
/** MAX(activation date, start_date); null when the org has neither (not yet active, no start set). */
effectiveWorkStartIso(org: { activatedAt: Date|null; startDate: string|null }): string | null
/** MAX(work start ?? floorIso, trialEnd when > the others). floorIso = "today" at call time. */
effectiveBillingStartIso(org: { activatedAt; startDate; trialEndDate }, floorIso: string): string
All comparisons on UTC YYYY-MM-DD strings (the codebase convention). trialEndDate
stays an absolute date; stacking falls out of the MAX. Validation (DTO layer):
warn-level response field — not a rejection — when trialEndDate < startDate.
Billing semantics (AssignmentBillingInputResolver)
Current: effectiveDate = trialEnd > today ? firstAnchorOnOrAfter(trialEnd, anchorDay(createdAt)) : today.
New:
if org.startDate is set:
engagementStart = MAX(effectiveWorkStartIso(org), trialEnd-when-greater) // ORG FIELDS ONLY — no resolve-day floor
anchorDay = day-of-month(engagementStart)
effectiveDate = engagementStart > today ? engagementStart : today
// past/current engagementStart: bill from today; the provider's
// computeAnchorDate(effectiveDate, anchorDay) aligns subscription_at to the
// org anchor exactly as the legacy path does for mid-period assignments.
else:
unchanged from today (trial clamp onto createdAt anchor) // zero regression
Anchor stability (REVISED after Task-3 review): the anchor must derive from org fields
only — engagementStart, never the resolve-day floor. An earlier draft used
effectiveBillingStartIso(org, today) (which includes the floor) for the anchor; that
breaks consolidation for any Specialist assigned after the engagement start (each
assignment day would mint a different anchor → permanently split monthly invoices).
With the org-deterministic anchor, every Specialist of the org resolves the same
anchorDay in every case → consolidation holds. Date changes go through realign
(below), which re-resolves — same as trial changes today.
Change propagation
realignBillingForTrialChange→ renamedrealignBillingForDateChange(same fail-open shape). Fired on:trialEndDatechange (existing three paths),startDatechange (updateAmOrg, and the invite/finish-setup paths which now accept an optionalstartDatealongsidetrialEndDate), and org activation (completeOnboarding— new: activation is a date event because of the MAX; previously nothing realigned there).- Billed-or-backward moves: the realign machinery already returns
skipped_billedand warn-logs. v1 of the "AM-visible flag" = the history-table record of the change + the warn log + a runbook note (docs/) that date changes on billed subscriptions require a manual Lago adjustment. No new notification surface — revisit if AMs miss it in practice.
Auto-send guard (ConversationsService)
At the existing three-condition auto-respond decision (~L2140-2148): add a fourth —
workStarted: effectiveWorkStartIso(org) !== null && effectiveWorkStartIso(org) <= todayIso.
Before the effective work start (or when the org was never activated), never auto-send;
the draft queues for Expert review as if confidence were below threshold. Org fields
startDate/activatedAt added to whatever org load feeds that path.
UI
- AM setup wizard step-4 page (
frontend/src/app/ops/clients/[id]/setup/step-4/page.tsx): optional "Engagement start date" field next to the trial picker; sent to bothsendOrgInviteandfinishOrgSetup(API params extended), nullable. - Ops client detail (
frontend/src/app/ops/clients/[id]/page.tsxheader region): "Engagement starts" line when the effective work start is in the future. - Expert queue (
frontend/src/app/workspace/queue/TicketDetail.tsxTicketHeader): badge "Starts" when the org's effective work start is in the future (data via the queue item payload — extend the API response with the org's effective work start). - Client onboarding summary (
frontend/src/app/onboarding/steps/step6.tsx): "Your engagement begins" when a future start date exists (extend the completeOnboarding response payload). - #2412 preview (
ClientBillingSubscriptionsQuery):billingStartIsoderivation replaced witheffectiveBillingStartIso— also closes the documented expired-trial residual for orgs with a start date.
Testing
engagement-datespure helpers: all MAX combinations incl. nulls, stacking, past dates.- History writer: record/no-op-on-equal.
- Resolver: start-date orgs (exact billing start + anchor day), no-start-date regression suite untouched, trial stacking, future-activation (org not yet active).
- Activation stamps: all three paths set
activated_atonce; restore doesn't reset. - Realign fires on: start-date change, activation, trial change (existing).
- Auto-send guard: before work start → queued even when policy+flag+confidence allow; after → unchanged.
- Preview: start-date org previews first invoice at the right anchor.
- Migrations: CI postgres job.
- Frontend: build + lint (unit tests where the touched components already have them).