The ledger→invoice bridge (Phase E, Option B of the panel-settled
design): generate a DRAFT invoice in phoenix_kit_billing from this
project's uninvoiced billable time.
Boundary (non-negotiable, per the consult)
- Billing never knows about projects: the write goes through
PhoenixKitBilling.create_invoice/2(guardedapply/3— billing is not a dependency), with opaquesource_*metadata for audit. - Projects owns "what's billed": the V8
phoenix_kit_project_invoiced_entriesref table (entry_uuid PK = one invoice per entry, the idempotency rule). The append-only ledger is never mutated — billed-ness DERIVES from the refs.
The honest v1
Draft only — a human reviews and issues in Billing. Bills HUMAN
billable time only (kind == "time", billable == true,
actor_kind in user/staff_person); AI cost needs a margin policy →
v2. Minutes price via the rate_cents_per_hour config on the
billing_customer extension (one money-settings home), snapshotted
onto the line items at generation. Lines group by task.
Summary
Functions
Generates the draft invoice: prices the uninvoiced entries, creates the
billing draft, then records the refs. Idempotent at the entry level
(the PK); a refs failure after the draft exists is surfaced as
{:error, :refs_failed, invoice_uuid} — the draft is real, the
entries stay re-billable, and the caller tells the human to reconcile
(the consult's compensating-action note).
Whether draft generation is POSSIBLE for a project: billing installed
and enabled, the billing_customer extension enabled with a resolvable
profile and a positive rate. Returns {:ok, %{profile:, rate_cents:}}
or a tagged error the UI can phrase.
Uninvoiced billable HUMAN time entries for a project, oldest first.
Functions
@spec generate_draft( map(), keyword() ) :: {:ok, %{ invoice_uuid: binary(), line_count: non_neg_integer(), total_cents: integer() }} | {:error, atom()} | {:error, :refs_failed, binary()}
Generates the draft invoice: prices the uninvoiced entries, creates the
billing draft, then records the refs. Idempotent at the entry level
(the PK); a refs failure after the draft exists is surfaced as
{:error, :refs_failed, invoice_uuid} — the draft is real, the
entries stay re-billable, and the caller tells the human to reconcile
(the consult's compensating-action note).
Whether draft generation is POSSIBLE for a project: billing installed
and enabled, the billing_customer extension enabled with a resolvable
profile and a positive rate. Returns {:ok, %{profile:, rate_cents:}}
or a tagged error the UI can phrase.
@spec uninvoiced_entries(binary()) :: [PhoenixKitProjects.Schemas.WorkEntry.t()]
Uninvoiced billable HUMAN time entries for a project, oldest first.