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):
realignBillingForTrialChangein 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(verifyls 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 NULLorganizations.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 declaresdate). -
EngagementDateHistoryWriter.recordChange(repo, { orgId, field, oldValue, newValue, userId, reason })— no-op when normalized old===new; insert otherwise. Pattern + spec style copied fromassignment-rate-history.writer.{ts,spec.ts}. -
npm test -- engagementgreen; build clean; commitfeat(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).
-
activatedAtstamped (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). -
UpdateOrgDtogains optionalstartDate?: string(@IsDateString(), allow explicit null to clear).updateAmOrgpersists it; computes changed-flags for BOTH dates; writes history rows via the writer (one per changed field, reason fromdto.reasonif the DTO has one, else null); fires the realign hook when either changed. -
sendInvitation+finishSetupWithoutInvitesignatures gain optionalstartDateInput?: string; controller bodies pass it; persist + history + (existing) realign-on-change semantics identical to trialEndDate handling there. - Rename
realignBillingForTrialChange→realignBillingForDateChange(update the three call sites + trial-realign-hook.spec.ts references — keep that spec file, rename its describe text). - NEW:
completeOnboardingfiresrealignBillingForDateChange(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/activatedAtwith 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
billingStartIsoderivation witheffectiveBillingStartIso(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 ciinfrontend/first. - API: expose
start_date+effective_work_startwhere the UI needs them: ops org detail response; expert-queue item payload (effective work start only, computed server-side); completeOnboarding response (engagement_starts: string | nullwhen 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 lintin frontend/ green; api suites green; commitfeat(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 inorg_engagement_date_historyand 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.