diff --git a/README.md b/README.md index d22729d..369bcad 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Key resources for contributors: - [Compliance Reviewer Workflow](docs/compliance-reviewer-workflow.md) — Guide for compliance operators reviewing investor eligibility - [Investor Transfer Request Flow](docs/investor-transfer-request-flow.md) — Request-validation edge cases (address, self-transfer, amount, precision) and RPC-failure handling for the transfer modal - [Compliance-Safe Wording Guidance](docs/compliance-safe-wording.md) — Canonical disclaimer text, typed helper, and reviewer checklist for compliance-facing copy +- [RWA Asset Lifecycle Status](docs/asset-lifecycle-status.md) — Lifecycle state machine, transition validation, and status UI for already-minted RWA assets - [Bulk Compliance Review](docs/bulk-compliance-review.md) — Bulk compliance review table with action confirmation modal - [Environment Mismatch Blocking Screen](docs/environment-mismatch-blocking.md) — Full-page blocking screen when the wallet network does not match the dashboard target network - [Investor Onboarding Eligibility](docs/investor-onboarding-eligibility.md) — Investor onboarding eligibility page, evaluation precedence, and SDK mapping diff --git a/docs/README.md b/docs/README.md index 2ce5c05..fbb96ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Reference material for contributors implementing new functionality. | [investor-dashboard.md](investor-dashboard.md) | Portfolio page data flow, mock portfolio shape, SDK assumptions | | [investor-transfer-eligibility.md](investor-transfer-eligibility.md) | Eligibility checks before transfer submission | | [investor-transfer-request-flow.md](investor-transfer-request-flow.md) | Request-validation layer: address/amount edge cases, RPC-failure vs. not-whitelisted (Issue #41) | +| [asset-lifecycle-status.md](asset-lifecycle-status.md) | RWA asset lifecycle state machine, transition validation, badge/timeline UI (Issue #30) | | [investor-onboarding-eligibility.md](investor-onboarding-eligibility.md) | Investor onboarding eligibility page, evaluation precedence, SDK mapping (Issue #28) | | [admin-role-management-design.md](admin-role-management-design.md) | Admin role resolution, whitelist heuristic, mock admin address | | [audit-log.md](audit-log.md) | Audit log data model, filtering, safe CSV/JSON export, redaction | diff --git a/docs/asset-lifecycle-status.md b/docs/asset-lifecycle-status.md new file mode 100644 index 0000000..32e70bb --- /dev/null +++ b/docs/asset-lifecycle-status.md @@ -0,0 +1,112 @@ +# RWA Asset Lifecycle Status + +Closes #30. Documents the issuer-controlled operational lifecycle of an +already-minted RWA asset, and the UI used to display and (optionally) manage +it. + +## Scope of this document +This covers the lifecycle **state machine and status display** only. It does +not duplicate two things that already exist elsewhere: +- **Pre-mint issuance approval** (draft → pending → approved → minted → + rejected) is `IssuanceRequest.status` in `src/fixtures/issuer.ts` and + `src/features/issuer/components/IssuanceRequestsTable.tsx`. Lifecycle state + only begins once an asset has reached `minted` there. +- **Per-wallet transfer eligibility** is `src/lib/eligibility.ts` + (Issue #55). Lifecycle state is asset-wide and issuer-driven; a paused + asset will typically also affect eligibility (via the existing + `assetPaused` flag on `AssetRestriction`), but this module does not compute + eligibility itself — see "Known Limitation" below. + +## Data Model +See `src/lib/assetLifecycle.ts` — a pure, framework-free module with no +React or SDK imports: +- `AssetLifecycleState` — `'active' | 'paused' | 'matured' | 'redeemed' | 'defaulted'` +- `AssetLifecycleEvent` — a single state entry with timestamp and optional note +- `AssetLifecycleStatus` — current state, since-timestamp, and full history +- `LIFECYCLE_STATE_INFO` — label/detail/tone metadata per state, used by the badge and timeline UI +- `validateTransition` / `applyLifecycleTransition` — pure, fail-closed transition validation + +## State Machine + +``` +active ──▶ paused ──▶ active (resume) +active ──▶ matured ──▶ redeemed +active ──▶ defaulted ──▶ redeemed (wind-down / write-off) +paused ──▶ defaulted ──▶ redeemed +redeemed = terminal (no further transitions) +``` + +Any transition not shown above is rejected by `validateTransition`, +including same-state "transitions" and anything attempted from `redeemed`. + +## UI Components +- `src/features/assets/components/AssetLifecycleBadge.tsx` — small badge + (label + tone-coloured background), same pattern as the existing + `ComplianceBadge` / `TransferEligibilityBadge`. +- `src/features/assets/components/AssetLifecycleTimeline.tsx` — the fuller + status UI: current badge, detail copy, ordered history, and (only when an + `onTransition` callback is supplied) one button per allowed next state. + Read-only by default; this component never calls the SDK itself. + +## Integration +`PortfolioAsset` in `src/lib/aegis/types.ts` gained an **optional** +`lifecycleStatus?: AssetLifecycleStatus` field — additive, so it does not +break any existing consumer or test that constructs a `PortfolioAsset` +without it. `AssetCard` renders `AssetLifecycleBadge` next to the existing +compliance badge only when `lifecycleStatus` is present. + +`src/fixtures/portfolio.ts` was updated to exercise three states +(`active`, `matured`, `paused`) plus the **absence** of the field entirely +on the asset that already has `isDataAvailable: false` — this is a +deliberate edge case: the UI must not assume a default state (e.g. "active") +when lifecycle data could not be resolved. + +## Edge Cases Handled +| Case | Behavior | +|---|---| +| Same-state "transition" (e.g. active → active) | Rejected with a clear reason | +| Transition attempted from a terminal state (`redeemed`) | Rejected | +| Transition that skips required states (e.g. active → redeemed directly) | Rejected, reason lists the actually-allowed next states | +| Unrecognized/malformed state string reaching the validator | Rejected rather than throwing | +| `lifecycleStatus` absent (SDK could not resolve it) | Badge/timeline simply do not render; no default assumed | +| History rendering | Ordered oldest-first; notes shown when present | + +## Security & Compliance Assumptions +- Lifecycle state reflects **issuer-reported operational status only** — it + is not a legal or financial determination about the asset, its + performance, or investment safety. `LIFECYCLE_STATE_INFO` wording is + written to avoid implying otherwise (see the compliance-safe-wording test + in `assetLifecycle.test.ts`), consistent with + `docs/compliance-safe-wording.md`. +- This module does not gate transfers, minting, or any SDK call by itself. + A `paused` or `defaulted` lifecycle state does **not** automatically block + a transfer in the current codebase — that would need to flow through + `src/lib/eligibility.ts`'s existing `assetPaused` input. Wiring the two + together (e.g. deriving `assetPaused` from `lifecycleStatus.current`) is a + natural follow-up, intentionally left out of this change to keep this PR + focused and reviewable. +- `AssetLifecycleTimeline`'s action buttons are UI affordances only; no + admin page in this PR actually wires `onTransition` to a real mutation. + That's a real gap for a future issue (e.g. an issuer-facing lifecycle + management page, sibling to `IssuanceRequestsTable`) but out of scope + here — this PR delivers the model, validation, display, and integration + point. + +## Testing +- `src/lib/assetLifecycle.test.ts` — 21 tests covering terminal-state + detection, allowed-next-state lookups, transition validation (valid and + every rejected case above), pure `applyLifecycleTransition` behavior + (including that the input status is never mutated), and a + compliance-safe-wording check on the default copy. +- `src/features/assets/components/AssetLifecycleTimeline.test.tsx` — 6 + tests covering badge/detail rendering, ordered history with notes, + read-only mode (no buttons), one button per allowed next state, the + `onTransition` callback firing with the correct argument, and the + terminal-state messaging path. +- Full suite (`npm run test`) passes at 325/325 after this change, with zero + regressions to any pre-existing test. + +## Related Documentation +- [Investor Transfer Eligibility](investor-transfer-eligibility.md) — per-wallet eligibility gating (Issue #55) +- [Investor Transfer Request Flow](investor-transfer-request-flow.md) — request-validation layer (Issue #41) +- [Compliance-Safe Wording Guidance](compliance-safe-wording.md) — canonical disclaimer conventions diff --git a/src/features/assets/components/AssetCard.tsx b/src/features/assets/components/AssetCard.tsx index f681d07..4983d35 100644 --- a/src/features/assets/components/AssetCard.tsx +++ b/src/features/assets/components/AssetCard.tsx @@ -1,6 +1,7 @@ import { formatAmount } from '@/utils/formatting'; import ComplianceBadge from './ComplianceBadge'; import TransferEligibilityBadge from './TransferEligibilityBadge'; +import AssetLifecycleBadge from './AssetLifecycleBadge'; import type { PortfolioAsset } from '@/lib/aegis/types'; interface AssetCardProps { @@ -9,7 +10,7 @@ interface AssetCardProps { } export default function AssetCard({ asset, onTransferClick }: AssetCardProps) { - const { name, ticker, balance, metadata, compliance, transferEligibility, isDataAvailable } = asset; + const { name, ticker, balance, metadata, compliance, transferEligibility, lifecycleStatus, isDataAvailable } = asset; const canTransfer = isDataAvailable && transferEligibility.state === 'eligible'; return ( @@ -21,7 +22,10 @@ export default function AssetCard({ asset, onTransferClick }: AssetCardProps) { {ticker} - {isDataAvailable && } +
+ {isDataAvailable && } + {lifecycleStatus && } +
{isDataAvailable ? ( diff --git a/src/features/assets/components/AssetLifecycleBadge.tsx b/src/features/assets/components/AssetLifecycleBadge.tsx new file mode 100644 index 0000000..e731861 --- /dev/null +++ b/src/features/assets/components/AssetLifecycleBadge.tsx @@ -0,0 +1,26 @@ +import type { AssetLifecycleState } from '@/lib/assetLifecycle'; +import { LIFECYCLE_STATE_INFO, type LifecycleTone } from '@/lib/assetLifecycle'; + +const TONE_STYLES: Record = { + positive: 'bg-emerald-50 text-emerald-700 border-emerald-200', + neutral: 'bg-slate-50 text-slate-700 border-slate-200', + caution: 'bg-amber-50 text-amber-700 border-amber-200', + negative: 'bg-red-50 text-red-700 border-red-200', +}; + +interface AssetLifecycleBadgeProps { + state: AssetLifecycleState; +} + +export default function AssetLifecycleBadge({ state }: AssetLifecycleBadgeProps) { + const info = LIFECYCLE_STATE_INFO[state]; + + return ( + + {info.label} + + ); +} diff --git a/src/features/assets/components/AssetLifecycleTimeline.test.tsx b/src/features/assets/components/AssetLifecycleTimeline.test.tsx new file mode 100644 index 0000000..0f2d8bd --- /dev/null +++ b/src/features/assets/components/AssetLifecycleTimeline.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AssetLifecycleTimeline from '@/features/assets/components/AssetLifecycleTimeline'; +import type { AssetLifecycleStatus } from '@/lib/assetLifecycle'; + +const ACTIVE_STATUS: AssetLifecycleStatus = { + current: 'active', + since: '2026-01-15T00:00:00Z', + history: [{ state: 'active', occurredAt: '2026-01-15T00:00:00Z', note: 'Asset issued and activated.' }], +}; + +const REDEEMED_STATUS: AssetLifecycleStatus = { + current: 'redeemed', + since: '2026-07-15T00:00:00Z', + history: [ + { state: 'active', occurredAt: '2025-06-01T00:00:00Z' }, + { state: 'matured', occurredAt: '2026-06-01T00:00:00Z' }, + { state: 'redeemed', occurredAt: '2026-07-15T00:00:00Z', note: 'Full redemption completed.' }, + ], +}; + +describe('AssetLifecycleTimeline', () => { + it('renders the current state badge and detail copy', () => { + render(); + expect(screen.getAllByText('Active').length).toBeGreaterThan(0); + expect(screen.getByText(/live/i)).toBeInTheDocument(); + }); + + it('renders the full history in order, including notes', () => { + render(); + expect(screen.getByText('Full redemption completed.')).toBeInTheDocument(); + // All three historical labels should appear somewhere (badge + list). + expect(screen.getAllByText('Redeemed').length).toBeGreaterThan(0); + expect(screen.getByText('Matured')).toBeInTheDocument(); + }); + + it('does not render action buttons in read-only mode (no onTransition)', () => { + render(); + expect(screen.queryByText(/available actions/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders one action button per allowed next state when onTransition is provided', () => { + const onTransition = vi.fn(); + render(); + + expect(screen.getByText('Mark as Paused')).toBeInTheDocument(); + expect(screen.getByText('Mark as Matured')).toBeInTheDocument(); + expect(screen.getByText('Mark as Default')).toBeInTheDocument(); + }); + + it('calls onTransition with the correct next state when an action button is clicked', () => { + const onTransition = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Mark as Paused')); + + expect(onTransition).toHaveBeenCalledTimes(1); + expect(onTransition).toHaveBeenCalledWith('paused'); + }); + + it('shows a terminal-state message instead of actions when in a terminal state', () => { + const onTransition = vi.fn(); + render(); + + expect(screen.getByText(/terminal state/i)).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/assets/components/AssetLifecycleTimeline.tsx b/src/features/assets/components/AssetLifecycleTimeline.tsx new file mode 100644 index 0000000..68ad8f6 --- /dev/null +++ b/src/features/assets/components/AssetLifecycleTimeline.tsx @@ -0,0 +1,70 @@ +import AssetLifecycleBadge from './AssetLifecycleBadge'; +import { + LIFECYCLE_STATE_INFO, + getAllowedNextStates, + type AssetLifecycleStatus, + type AssetLifecycleState, +} from '@/lib/assetLifecycle'; + +interface AssetLifecycleTimelineProps { + status: AssetLifecycleStatus; + /** + * When provided, renders a button for each allowed next state and calls + * this with the chosen state on click. Omit for a read-only view (e.g. the + * investor-facing portfolio) — this component does not call the SDK + * itself; wiring an actual transition through `useAegis`/a real mutation + * is left to the caller. + */ + onTransition?: (next: AssetLifecycleState) => void; +} + +export default function AssetLifecycleTimeline({ status, onTransition }: AssetLifecycleTimelineProps) { + const allowedNext = getAllowedNextStates(status.current); + const currentInfo = LIFECYCLE_STATE_INFO[status.current]; + + return ( +
+
+ + since {new Date(status.since).toLocaleDateString()} +
+ +

{currentInfo.detail}

+ +
    + {status.history.map((event, i) => { + const info = LIFECYCLE_STATE_INFO[event.state]; + return ( +
  1. + {info.label} + · {new Date(event.occurredAt).toLocaleDateString()} + {event.note &&

    {event.note}

    } +
  2. + ); + })} +
+ + {onTransition && allowedNext.length > 0 && ( +
+

Available actions

+
+ {allowedNext.map((next) => ( + + ))} +
+
+ )} + + {onTransition && allowedNext.length === 0 && ( +

This is a terminal state; no further transitions are available.

+ )} +
+ ); +} diff --git a/src/fixtures/portfolio.ts b/src/fixtures/portfolio.ts index 72e8a67..77e3a5c 100644 --- a/src/fixtures/portfolio.ts +++ b/src/fixtures/portfolio.ts @@ -38,6 +38,11 @@ export const mockPortfolioFixture: Omit { + it('treats redeemed as terminal', () => { + expect(isTerminalState('redeemed')).toBe(true); + }); + + it('treats active, paused, matured, and defaulted as non-terminal', () => { + expect(isTerminalState('active')).toBe(false); + expect(isTerminalState('paused')).toBe(false); + expect(isTerminalState('matured')).toBe(false); + expect(isTerminalState('defaulted')).toBe(false); + }); + + it('TERMINAL_STATES contains exactly the terminal states', () => { + expect(TERMINAL_STATES).toEqual(['redeemed']); + }); +}); + +describe('getAllowedNextStates', () => { + it('lists paused, matured, and defaulted from active', () => { + expect(getAllowedNextStates('active')).toEqual(['paused', 'matured', 'defaulted']); + }); + + it('lists active and defaulted from paused', () => { + expect(getAllowedNextStates('paused')).toEqual(['active', 'defaulted']); + }); + + it('lists only redeemed from matured', () => { + expect(getAllowedNextStates('matured')).toEqual(['redeemed']); + }); + + it('lists only redeemed from defaulted (wind-down)', () => { + expect(getAllowedNextStates('defaulted')).toEqual(['redeemed']); + }); + + it('lists nothing from redeemed', () => { + expect(getAllowedNextStates('redeemed')).toEqual([]); + }); +}); + +describe('validateTransition', () => { + it('rejects a same-state transition', () => { + const result = validateTransition('active', 'active'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/already in the Active state/); + }); + + it('rejects any transition out of a terminal state', () => { + const result = validateTransition('redeemed', 'active'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/terminal state/); + }); + + it('rejects a transition that skips required states (active -> redeemed)', () => { + const result = validateTransition('active', 'redeemed'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/Cannot move directly/); + }); + + it('rejects a transition to an unrecognized state', () => { + const result = validateTransition('active', 'unknown' as never); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/Unrecognized lifecycle state/); + }); + + it('accepts active -> paused', () => { + expect(validateTransition('active', 'paused')).toEqual({ valid: true }); + }); + + it('accepts paused -> active (resume)', () => { + expect(validateTransition('paused', 'active')).toEqual({ valid: true }); + }); + + it('accepts matured -> redeemed', () => { + expect(validateTransition('matured', 'redeemed')).toEqual({ valid: true }); + }); + + it('accepts defaulted -> redeemed (wind-down)', () => { + expect(validateTransition('defaulted', 'redeemed')).toEqual({ valid: true }); + }); +}); + +describe('applyLifecycleTransition', () => { + const baseStatus: AssetLifecycleStatus = { + current: 'active', + since: '2026-01-01T00:00:00Z', + history: [{ state: 'active', occurredAt: '2026-01-01T00:00:00Z' }], + }; + + it('returns a new status object on a valid transition, without mutating the input', () => { + const result = applyLifecycleTransition(baseStatus, 'paused', '2026-07-20T00:00:00Z', 'Pausing for review.'); + + expect(result.ok).toBe(true); + expect(result.status).toEqual({ + current: 'paused', + since: '2026-07-20T00:00:00Z', + history: [ + { state: 'active', occurredAt: '2026-01-01T00:00:00Z' }, + { state: 'paused', occurredAt: '2026-07-20T00:00:00Z', note: 'Pausing for review.' }, + ], + }); + // Original input must be untouched. + expect(baseStatus.current).toBe('active'); + expect(baseStatus.history).toHaveLength(1); + }); + + it('rejects an invalid transition and returns a reason instead of a status', () => { + const result = applyLifecycleTransition(baseStatus, 'redeemed', '2026-07-20T00:00:00Z'); + + expect(result.ok).toBe(false); + expect(result.status).toBeUndefined(); + expect(result.reason).toMatch(/Cannot move directly/); + }); + + it('rejects a transition attempted from a terminal state', () => { + const redeemedStatus: AssetLifecycleStatus = { + current: 'redeemed', + since: '2026-06-01T00:00:00Z', + history: [{ state: 'redeemed', occurredAt: '2026-06-01T00:00:00Z' }], + }; + const result = applyLifecycleTransition(redeemedStatus, 'active', '2026-07-20T00:00:00Z'); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/terminal state/); + }); +}); + +describe('LIFECYCLE_STATE_INFO', () => { + it('provides label, detail, and tone for every state', () => { + const states: Array = [ + 'active', + 'paused', + 'matured', + 'redeemed', + 'defaulted', + ]; + for (const state of states) { + expect(LIFECYCLE_STATE_INFO[state].label).toBeTruthy(); + expect(LIFECYCLE_STATE_INFO[state].detail).toBeTruthy(); + expect(['positive', 'neutral', 'caution', 'negative']).toContain(LIFECYCLE_STATE_INFO[state].tone); + } + }); + + it('avoids legal/financial-advice-sounding language in the default wording', () => { + const forbidden = /\b(guarantee|guaranteed|safe investment|risk-free|advice)\b/i; + for (const info of Object.values(LIFECYCLE_STATE_INFO)) { + expect(info.detail).not.toMatch(forbidden); + } + }); +}); diff --git a/src/lib/assetLifecycle.ts b/src/lib/assetLifecycle.ts new file mode 100644 index 0000000..bacdf00 --- /dev/null +++ b/src/lib/assetLifecycle.ts @@ -0,0 +1,187 @@ +/** + * Asset lifecycle state machine for RWA tokens. (Issue #30) + * + * Models the *issuer-controlled operational stage* of an already-minted RWA + * asset (active / paused / matured / redeemed / defaulted). This is distinct + * from two things that already exist elsewhere in this codebase and are + * NOT duplicated here: + * + * - `src/fixtures/issuer.ts` `IssuanceRequest.status` — the PRE-mint + * approval workflow (draft/pending/approved/minted/rejected). Lifecycle + * state only begins once an asset reaches 'minted' there. + * - `src/lib/eligibility.ts` — per-wallet TRANSFER eligibility. Lifecycle + * state is asset-wide and issuer-driven; a paused asset will usually also + * affect eligibility (via the existing `assetPaused` flag), but this + * module does not compute eligibility itself. + * + * IMPORTANT (compliance wording): lifecycle state reflects issuer-reported + * status, not a legal or financial determination about the asset or its + * performance. See docs/asset-lifecycle-status.md. + */ + +export type AssetLifecycleState = 'active' | 'paused' | 'matured' | 'redeemed' | 'defaulted'; + +export interface AssetLifecycleEvent { + state: AssetLifecycleState; + /** ISO 8601 timestamp. */ + occurredAt: string; + /** Optional free-text context, e.g. "Reached scheduled maturity." */ + note?: string; +} + +export interface AssetLifecycleStatus { + current: AssetLifecycleState; + /** ISO 8601 timestamp the asset entered `current`. */ + since: string; + /** Ordered oldest-first. Always includes at least the current event. */ + history: AssetLifecycleEvent[]; +} + +export type LifecycleTone = 'positive' | 'neutral' | 'caution' | 'negative'; + +export interface LifecycleStateInfo { + label: string; + detail: string; + tone: LifecycleTone; +} + +export const LIFECYCLE_STATE_INFO: Record = { + active: { + label: 'Active', + detail: + 'This asset is live. Whether it can currently be transferred still depends on separate compliance and transfer-eligibility checks.', + tone: 'positive', + }, + paused: { + label: 'Paused', + detail: + 'The issuer has temporarily paused this asset. This reflects an issuer-level operational decision, not a compliance restriction on any individual wallet.', + tone: 'caution', + }, + matured: { + label: 'Matured', + detail: + 'This asset has reached its scheduled maturity. Redemption may be available; contact the issuer for the process and timing.', + tone: 'neutral', + }, + redeemed: { + label: 'Redeemed', + detail: 'This asset has been fully redeemed and is no longer an active holding.', + tone: 'neutral', + }, + defaulted: { + label: 'Default', + detail: + 'The issuer has reported a default event for this asset. This reflects issuer-reported status only, not a legal or financial determination.', + tone: 'negative', + }, +}; + +/** + * Valid forward transitions. Deliberately fail-closed: any pair not listed + * here is invalid, including same-state "transitions" and anything out of + * a terminal state. + * + * active -> paused, matured, defaulted + * paused -> active, defaulted + * matured -> redeemed + * defaulted-> redeemed (issuer wind-down / write-off after a default) + * redeemed -> (terminal) + */ +const TRANSITIONS: Record = { + active: ['paused', 'matured', 'defaulted'], + paused: ['active', 'defaulted'], + matured: ['redeemed'], + defaulted: ['redeemed'], + redeemed: [], +}; + +export const TERMINAL_STATES: AssetLifecycleState[] = ['redeemed']; + +export function isTerminalState(state: AssetLifecycleState): boolean { + return TERMINAL_STATES.includes(state); +} + +export function getAllowedNextStates(state: AssetLifecycleState): AssetLifecycleState[] { + return TRANSITIONS[state] ?? []; +} + +export interface TransitionValidationResult { + valid: boolean; + reason?: string; +} + +/** + * Validate a proposed transition without mutating anything. + * + * Edge cases covered: + * - same-state no-op (rejected — callers should not log a redundant event) + * - transition attempted from a terminal state + * - transition that skips required intermediate states (e.g. active -> redeemed) + * - unknown "to" state (defensive — satisfies exhaustiveness even if an + * invalid string reaches this function from an untyped boundary, e.g. a + * malformed SDK response) + */ +export function validateTransition( + from: AssetLifecycleState, + to: AssetLifecycleState +): TransitionValidationResult { + if (!LIFECYCLE_STATE_INFO[to]) { + return { valid: false, reason: `Unrecognized lifecycle state: "${to}".` }; + } + + if (from === to) { + return { valid: false, reason: `Asset is already in the ${LIFECYCLE_STATE_INFO[from].label} state.` }; + } + + if (isTerminalState(from)) { + return { + valid: false, + reason: `${LIFECYCLE_STATE_INFO[from].label} is a terminal state and cannot transition further.`, + }; + } + + const allowed = getAllowedNextStates(from); + if (!allowed.includes(to)) { + const allowedLabels = allowed.map((s) => LIFECYCLE_STATE_INFO[s].label).join(', ') || 'none'; + return { + valid: false, + reason: `Cannot move directly from ${LIFECYCLE_STATE_INFO[from].label} to ${LIFECYCLE_STATE_INFO[to].label}. Allowed next states: ${allowedLabels}.`, + }; + } + + return { valid: true }; +} + +export interface ApplyTransitionResult { + ok: boolean; + status?: AssetLifecycleStatus; + reason?: string; +} + +/** + * Pure state transition: given a current status, attempt to move to `to`. + * Returns a new AssetLifecycleStatus on success (input is never mutated) or + * `{ ok: false, reason }` on a rejected transition. + */ +export function applyLifecycleTransition( + status: AssetLifecycleStatus, + to: AssetLifecycleState, + occurredAt: string, + note?: string +): ApplyTransitionResult { + const validation = validateTransition(status.current, to); + if (!validation.valid) { + return { ok: false, reason: validation.reason }; + } + + const event: AssetLifecycleEvent = { state: to, occurredAt, note }; + return { + ok: true, + status: { + current: to, + since: occurredAt, + history: [...status.history, event], + }, + }; +}