Skip to main content

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)

  1. 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.
  2. 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.
  3. Out of scope: per-Specialist start dates; hard per-channel gating; automated reconciliation.

Data model (expand-only)

  • organizations.start_datedate, nullable. AM-owned engagement start.
  • organizations.activated_attimestamptz, 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). NULL changed_by reserved for backfills (none needed now).
  • New injectable EngagementDateHistoryWriter (pattern copy of AssignmentRateHistoryWriter): recordChange(repo, { orgId, field, oldValue, newValue, userId, reason }), no-op when oldValue === 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 onlyengagementStart, 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

  • realignBillingForTrialChangerenamed realignBillingForDateChange (same fail-open shape). Fired on: trialEndDate change (existing three paths), startDate change (updateAmOrg, and the invite/finish-setup paths which now accept an optional startDate alongside trialEndDate), 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_billed and 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 both sendOrgInvite and finishOrgSetup (API params extended), nullable.
  • Ops client detail (frontend/src/app/ops/clients/[id]/page.tsx header region): "Engagement starts " line when the effective work start is in the future.
  • Expert queue (frontend/src/app/workspace/queue/TicketDetail.tsx TicketHeader): 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): billingStartIso derivation replaced with effectiveBillingStartIso — also closes the documented expired-trial residual for orgs with a start date.

Testing

  1. engagement-dates pure helpers: all MAX combinations incl. nulls, stacking, past dates.
  2. History writer: record/no-op-on-equal.
  3. Resolver: start-date orgs (exact billing start + anchor day), no-start-date regression suite untouched, trial stacking, future-activation (org not yet active).
  4. Activation stamps: all three paths set activated_at once; restore doesn't reset.
  5. Realign fires on: start-date change, activation, trial change (existing).
  6. Auto-send guard: before work start → queued even when policy+flag+confidence allow; after → unchanged.
  7. Preview: start-date org previews first invoice at the right anchor.
  8. Migrations: CI postgres job.
  9. Frontend: build + lint (unit tests where the touched components already have them).