PhoenixKitProjects.Ledger (PhoenixKitProjects v0.21.1)

Copy Markdown View Source

The work ledger (Step 10): unified effort tracking where the actor can be a human or an AI agent and the quantity minutes, tokens, or cents — one table, so "what did this task actually cost" is a single query across people-hours and AI spend.

Append-only: corrections are new entries; nothing here updates or deletes rows. Ledger writes ride the ledger feature flag at the CALLER (LV) layer — the context trusts its callers, mirroring the rest of the module.

Human time

Ledger.log_time(project, 45, assignment_uuid: a.uuid,
  note: "Design pass", actor_uuid: user.uuid)

AI usage

Ledger.record_ai(project, %{tokens: 1234, cost_cents: 12,
  model: "gpt-x", agent_uuid: agent.uuid}, assignment_uuid: a.uuid)

writes a tokens entry and (when cost is present) a cost entry sharing metadata — sums stay trivial per kind. This is the API the phoenix_kit_ai attribution seam calls when it lands; nothing here depends on that package.

Summary

Functions

Entries for a project, newest first (capped).

Logs human time in MINUTES. Options: :assignment_uuid, :note, :billable, :actor_uuid (a core user), :actor_kind (default "user"), :source ("manual"/"timer"), :started_at/:ended_at.

Records AI usage attributed to a project/task: a tokens entry plus a cost entry when cost_cents is POSITIVE, both actor_kind: "ai_agent" with shared metadata (model, endpoint, anything else passed). Returns {:ok, [entries]}.

The AI attribution sink's resolver (Phase G): maps a persisted phoenix_kit_ai request (duck-typed map/struct — this package never references that package's modules) onto record_ai/3.

Logged-time minutes per assignment for a DISPLAYED set (one query). Keyed by assignment uuid regardless of owning project, so a show page rendering a parent's tasks plus expanded sub-project child tasks gets every chip from one call (panel round: entries attribute to the project that owns the assignment, which for child rows is not the viewed one).

Effort totals for a project: %{time_minutes, tokens, cost_cents, billable_minutes} — zeros when empty; fail-safe zeros on a DB hiccup (a summary line must never take the show page down).

Functions

list_entries(project_uuid, opts \\ [])

@spec list_entries(
  binary(),
  keyword()
) :: [PhoenixKitProjects.Schemas.WorkEntry.t()]

Entries for a project, newest first (capped).

log_time(project_or_uuid, minutes, opts \\ [])

@spec log_time(map() | binary(), pos_integer() | Decimal.t(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.WorkEntry.t()} | {:error, term()}

Logs human time in MINUTES. Options: :assignment_uuid, :note, :billable, :actor_uuid (a core user), :actor_kind (default "user"), :source ("manual"/"timer"), :started_at/:ended_at.

record_ai(project_or_uuid, usage, opts \\ [])

@spec record_ai(map() | binary(), map(), keyword()) ::
  {:ok, [PhoenixKitProjects.Schemas.WorkEntry.t()]} | {:error, term()}

Records AI usage attributed to a project/task: a tokens entry plus a cost entry when cost_cents is POSITIVE, both actor_kind: "ai_agent" with shared metadata (model, endpoint, anything else passed). Returns {:ok, [entries]}.

Zero/absent quantities are SKIPPED, not errors — cost_cents: 0 is the normal shape for free/cached/local calls (panel round: 0 is truthy in Elixir, so a naive && built a zero-amount entry that failed the amount>0 validation AFTER the tokens row committed). Both inserts run in one transaction; activity/broadcast fire only after it commits.

record_ai_request(request)

@spec record_ai_request(map() | struct()) ::
  {:ok, [PhoenixKitProjects.Schemas.WorkEntry.t()]}
  | :skipped
  | {:error, term()}

The AI attribution sink's resolver (Phase G): maps a persisted phoenix_kit_ai request (duck-typed map/struct — this package never references that package's modules) onto record_ai/3.

The request's metadata["attribution"] carries resource_type + resource_uuid from the translate pipeline (or any caller passing :attribution). Resolution:

  • "assignment" → that assignment's project + the assignment;
  • "project" → the project itself;
  • "task"/"template"/anything else → not ours (library tasks and templates have no project) — :skipped.

UNIT TRAP (scout-verified): phoenix_kit_ai's cost_cents column is NANODOLLARS despite the name (1e-6 dollars); the ledger stores CENTS. Divide by 10_000 as a Decimal so sub-cent calls stay positive fractions instead of silently rounding to zero.

The AI actor identity is the ENDPOINT uuid (agent_uuid) — the stable "which AI did the work" until first-class agent records exist.

time_for_assignments(uuids)

@spec time_for_assignments([binary()]) :: %{required(binary()) => float()}

Logged-time minutes per assignment for a DISPLAYED set (one query). Keyed by assignment uuid regardless of owning project, so a show page rendering a parent's tasks plus expanded sub-project child tasks gets every chip from one call (panel round: entries attribute to the project that owns the assignment, which for child rows is not the viewed one).

totals_for_project(project_uuid)

@spec totals_for_project(binary()) :: map()

Effort totals for a project: %{time_minutes, tokens, cost_cents, billable_minutes} — zeros when empty; fail-safe zeros on a DB hiccup (a summary line must never take the show page down).