Skip to main content

Start Date (#894) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Org-level Start Date: billing (and the soft work-start guards) keyed off MAX(activation, start_date) with trial stacking on top, AM-editable with audit trail, manual reconciliation once billed.

Architecture: Spec at docs/superpowers/specs/2026-06-11-start-date-design.md (scope authority: the ratified comment on #894). Backend in api/ (NestJS, TDD, sqlite-local/PG-CI), frontend in frontend/ (Next 16 — read node_modules/next docs before nontrivial changes).

Working directory: ~/.config/superpowers/worktrees/humanwork/start-date (branch feat/894-start-date). api deps installed; frontend deps need npm ci in frontend/ before Task 5. Commits end with Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>.

Reference geography (from exploration, verify before editing):

  • Activation paths: api/src/onboarding/onboarding.service.ts ~L627 (completeOnboarding sets trialStartedAt + status), api/src/organizations/organizations.service.ts ~L1703 (finishSetupWithoutInvite), ~L1294 (clearDeactivation restore).
  • Date hooks (#2411): realignBillingForTrialChange in organizations.service.ts; call sites updateOrg / sendInvitation / finishSetupWithoutInvite.
  • Resolver: api/src/billing/application/assignment-billing-input.resolver.ts (trial clamp ~L49-63).
  • Auto-respond decision: api/src/conversations/conversations.service.ts ~L2124-2148 (resolveAutoReplyPolicy + feature flag + checkAutoRespond).
  • Preview: api/src/billing/application/queries/client-billing-subscriptions.query.ts (billingStartIso derivation, #2412).
  • UpdateOrgDto: api/src/organizations/organizations.dto.ts ~L233-322 (has trialEndDate).
  • Controller: POST /am/orgs/:orgId/invite ~L437, POST /am/orgs/:orgId/finish-setup ~L486, PATCH /am/orgs/:orgId ~L402 in organizations.controller.ts.
  • Frontend: frontend/src/app/ops/clients/[id]/setup/step-4/page.tsx (trial picker, sendOrgInvite/finishOrgSetup), frontend/src/app/ops/clients/[id]/page.tsx (org header ~L352-370), frontend/src/app/workspace/queue/TicketDetail.tsx (TicketHeader ~L135-226), frontend/src/app/onboarding/steps/step6.tsx.
  • History-writer pattern to copy: api/src/organizations/assignment-rate-history.writer.ts + its spec.
  • Migration prefix: next free is 1799000000003 (verify ls api/migrations | sort | tail -3).

Task 1: foundation — migration, entity fields, pure date helpers, history writer

Files: Create api/migrations/1799000000003-AddStartDateAndEngagementDateHistory.ts; modify api/src/auth/auth.entities.ts (Organization); create api/src/organizations/engagement-dates.ts + .spec.ts; create api/src/organizations/engagement-date-history.entity.ts (or co-locate in an existing entities file if the module convention prefers), api/src/organizations/engagement-date-history.writer.ts + .spec.ts; register entity in organizations.module.ts forFeature + data-source entity lists (check how OrgSpecialistAssignmentRateHistory is registered in api/src/config/typeorm-data-source.config.ts / data-source.ts and mirror it).

  • TDD the pure helpers first:
// engagement-dates.ts — all comparisons on UTC YYYY-MM-DD strings.
export function effectiveWorkStartIso(org: { activatedAt: Date | string | null; startDate: string | null }): string | null
// = max(activatedAt date-part, startDate); null when both null.
export function effectiveBillingStartIso(
org: { activatedAt: Date | string | null; startDate: string | null; trialEndDate: Date | string | null },
floorIso: string,
): string
// = max(effectiveWorkStartIso(org) ?? floorIso, floorIso, trialEnd when > all others)

Spec cases: both null → work=null, billing=floor; only activatedAt; only startDate (future + past); both (each direction); trial beyond work start (stacking); trial before start date (ignored); Date vs string inputs.

  • Migration (expand-only, IF NOT EXISTS):
    • organizations.start_date date NULL
    • organizations.activated_at timestamptz NULL
    • backfill: UPDATE organizations SET activated_at = COALESCE(trial_started_at, created_at) WHERE status = 'active' AND activated_at IS NULL
    • CREATE TABLE IF NOT EXISTS org_engagement_date_history (id uuid PK default gen_random_uuid(), org_id uuid NOT NULL, field varchar NOT NULL, old_value date NULL, new_value date NULL, changed_by_user_id uuid NULL, change_reason text NULL, changed_at timestamptz NOT NULL DEFAULT now()) + index (org_id, changed_at)
    • down: drop table + columns (IF EXISTS).
  • Organization entity: startDate: string | null (type: "date"), activatedAt: Date | null. Entity for the history table (sqlite-compatible types like the rate-history entity — check how it declares date).
  • EngagementDateHistoryWriter.recordChange(repo, { orgId, field, oldValue, newValue, userId, reason }) — no-op when normalized old===new; insert otherwise. Pattern + spec style copied from assignment-rate-history.writer.{ts,spec.ts}.
  • npm test -- engagement green; build clean; commit feat(orgs): start_date + activated_at + engagement date history foundation (#894).

Task 2: activation stamps, date persistence, history wiring, realign generalization

Files: api/src/onboarding/onboarding.service.ts, api/src/organizations/organizations.service.ts, organizations.dto.ts, organizations.controller.ts, specs (extend trial-realign-hook.spec.ts style with a new focused spec engagement-date-paths.spec.ts using Object.create pattern; extend onboarding/org specs only as needed).

  • activatedAt stamped (only when currently NULL) in: completeOnboarding (beside trialStartedAt), finishSetupWithoutInvite (beside status flip), clearDeactivation restore (do NOT reset when already set — first activation wins; write a test proving restore preserves the original).
  • UpdateOrgDto gains optional startDate?: string (@IsDateString(), allow explicit null to clear). updateAmOrg persists it; computes changed-flags for BOTH dates; writes history rows via the writer (one per changed field, reason from dto.reason if the DTO has one, else null); fires the realign hook when either changed.
  • sendInvitation + finishSetupWithoutInvite signatures gain optional startDateInput?: string; controller bodies pass it; persist + history + (existing) realign-on-change semantics identical to trialEndDate handling there.
  • Rename realignBillingForTrialChangerealignBillingForDateChange (update the three call sites + trial-realign-hook.spec.ts references — keep that spec file, rename its describe text).
  • NEW: completeOnboarding fires realignBillingForDateChange(orgId) after activation commit (fail-open — activation moves the MAX). Test via the focused spec (spy pattern).
  • All org/onboarding suites green; commit feat(orgs): persist start_date, stamp activated_at, audit + realign on every date event (#894).

Task 3: resolver formula + anchor

Files: api/src/billing/application/assignment-billing-input.resolver.ts + its spec.

  • Replace the trial-clamp block:
const todayIso = new Date().toISOString().slice(0, 10);
const billingStart = effectiveBillingStartIso(
{ activatedAt: org.activatedAt, startDate: org.startDate, trialEndDate: org.trialEndDate },
todayIso,
);
let anchorDay: number;
let effectiveDate: string;
if (org.startDate) {
// #894: bill exactly from the engagement start (or stacked trial end);
// the anchor day FOLLOWS the billing start so there is no unbilled
// forward-alignment gap. Deterministic from org fields → every Specialist
// of the org resolves the same anchor → consolidation holds.
effectiveDate = billingStart;
anchorDay = parseInt(billingStart.slice(8, 10), 10);
} else {
// Legacy orgs (no start date): unchanged — createdAt anchor + trial clamp.
anchorDay = (org.createdAt ?? new Date()).getUTCDate();
effectiveDate =
billingStart > todayIso ? firstAnchorOnOrAfter(billingStart, anchorDay) : todayIso;
}

(Note for the legacy branch: effectiveBillingStartIso with null startDate/activatedAt-irrelevant reduces to the old trialEnd > today ? trialEnd : today before anchoring — verify with the existing resolver spec, which must pass UNCHANGED except construction.) Careful: legacy branch must preserve exact current behavior INCLUDING when activatedAt is now set on legacy orgs — effectiveBillingStartIso includes activation in the MAX, but for an ACTIVE org activatedAt ≤ today so it never exceeds floor; write a test pinning that.

  • New spec cases: start-date org (future start → effectiveDate = start, anchor = its day); start + trial stacking (effectiveDate = trialEnd, anchor = trialEnd's day); start in past + active org (effectiveDate = today's floor → max(workStart, floor) = floor → today; anchor = day of billingStart... CAREFUL: when billingStart = floor(today), anchor = today's day — pin this explicitly as intended: a retro start on a new assignment bills from today, day-of-today anchor); legacy regression suite untouched.
  • Billing suites green; commit feat(billing): resolver bills from effective start; anchor follows it for start-date orgs (#894).

Task 4: auto-send guard + preview formula

Files: api/src/conversations/conversations.service.ts + spec; api/src/billing/application/queries/client-billing-subscriptions.query.ts + spec.

  • Conversations: at the auto-respond conjunction (~L2140-2148), add the fourth condition — load startDate/activatedAt with the org fields already fetched there:
// #894 soft work gate: before the effective work start (or before first
// activation) NEVER auto-send — drafts queue for Expert review regardless of
// threshold/auto_reply_mode. Always-HITL is the platform's work gate; this
// guard only covers orgs explicitly configured to auto-send.
const workStart = effectiveWorkStartIso(org);
const workStarted = workStart !== null && workStart <= new Date().toISOString().slice(0, 10);

AND it into the existing condition. Test: org with future startDate + policy agent_reply + flag on + confidence 100 → queued, not auto-sent; same org with past startDate → auto-sends (existing behavior).

  • Preview query: replace the trial-only billingStartIso derivation with effectiveBillingStartIso(org, todayIso) when computing the future-start branch (org repo already injected since #2412; select the two new fields). Keep all #2412 tests green; add one: start-date org → nextBillingDate = first anchor strictly after the start (factor 1 when anchor-aligned — which it is by construction since anchor follows start).
  • Suites green; commit feat(api): work-start auto-send guard + preview learns effective billing start (#894).

Task 5: API payload extensions + frontend

Files (api): queue item payload (find where expert-queue items serialize org/conversation info), onboarding completeOnboarding response, ops org GET (/am/orgs/:id detail response), public org payloads as needed. Files (frontend): step-4/page.tsx (+ its API client functions sendOrgInvite/finishOrgSetup/PATCH), ops clients/[id]/page.tsx, workspace/queue/TicketDetail.tsx, onboarding/steps/step6.tsx.

  • Run npm ci in frontend/ first.
  • API: expose start_date + effective_work_start where the UI needs them: ops org detail response; expert-queue item payload (effective work start only, computed server-side); completeOnboarding response (engagement_starts: string | null when in the future).
  • Step-4 wizard: optional "Engagement start date" date input beside the trial picker (default empty = starts at onboarding); thread through sendOrgInvite(orgId, trialEndDate, startDate?) + finishOrgSetup(...); show the computed "billing starts" hint (max(start, trialEnd)).
  • Ops client header: "Engagement starts " line, rendered only when effective work start is in the future.
  • TicketHeader: small badge Starts <date> under the same condition.
  • step6: "Your engagement begins ." line when the response carries a future date.
  • npm run build + npm run lint in frontend/ green; api suites green; commit feat(ui): Start Date in AM wizard, ops header, expert queue, onboarding summary (#894).

Task 6: runbook + full verification + PR

  • Runbook note (find the billing ops doc — docs/ grep for the #2411 runbook or USER_OPERATIONS_MANUAL): date changes on billed subscriptions are recorded in org_engagement_date_history and require manual Lago adjustment (credit note / one-off invoice); unbilled subs realign automatically.
  • api: full npm test + npm run lint (exit-code checked, no pipe masking). frontend: build + lint.
  • Push, PR against dev: feat(orgs+billing): engagement Start Date — billing from MAX(onboarding, start date), trial stacking, audited date changes (#894). Body: link the ratified design comment, the formula, scope decisions (no hard work gate / manual reconciliation), zero-regression claim for orgs without start_date, test counts. Closes #894. Attribution line.