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 work or reply gate. The date controls billing and appears in Expert/ops UI. It never withholds a successful managed Hermes reply.
  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_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). 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 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 โ†’ renamed 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.

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. Managed reply invariant: a future start date does not alter direct exactly-once delivery.
  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).