# How-to: generate an invoice from events

**Goal:** hold billable events, turn them into invoice line items
automatically, issue the invoice, and record the payment.

Assumes the [Quickstart](/docs/quickstart.html) (`$LYDIAN`, `$TENANT`, `$KEY`
exported) plus one person to bill — `$STUDENT_PERSON` below is a person id in
your tenant.

The bridge between scheduling and billing is an ordinary **number attribute**:
events carry a fee value, and the from-events generator turns each fee-bearing
event into a line item. No special "billable event" kind exists — it's the
shared attribute pool doing the work.

---

## 1. Give events a fee to bill

Create the number attribute and an event type that carries it:

```bash
FEE=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/attributes" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"name": "Session Fee", "description": "Flat fee billed for this session, in invoice currency",
       "type": {"kind": "number", "required": false, "minValue": 0}}' | jq -r .id)

LESSON=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/event-types" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"name\": \"Lesson\", \"description\": \"A one-on-one music lesson\",
       \"attributeIds\": [\"$FEE\"]}" | jq -r .id)
```

Now create a couple of held lessons, with the student as a participant and a
fee value:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/events" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{
        \"name\": \"Cello lesson — Ada\",
        \"startAt\": \"2026-08-03T15:00:00Z\",
        \"endAt\":   \"2026-08-03T16:00:00Z\",
        \"eventTypeId\": \"$LESSON\",
        \"attributeValues\": {\"$FEE\": 60},
        \"participants\": [{\"personId\": \"$STUDENT_PERSON\"}]
      }" | jq '{id, name, startAt, attributeValues, participants}'
```

```json
{
  "id": "0198c2b0-11aa-7e40-8c55-0d9e6f3a2b71",
  "name": "Cello lesson — Ada",
  "startAt": "2026-08-03T15:00:00Z",
  "attributeValues": {"0198c2ae-90cd-7b12-a6e3-4f8b1c5d7e29": 60},
  "participants": [{"personId": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42", "role": "ATTENDEE", "status": "PENDING"}]
}
```

Repeat for a second lesson (say August 10, same shape). Two details worth
knowing now:

- **The fee is a flat amount per event, not a rate.** Event duration is never
  read. To bill `hours × rate`, store *two* number attributes on the event and
  pass the second as `quantityAttributeId` in step 3.
- Adding a participant fires a system notification to that person — that's
  the platform talking, see [the inbox guide](/docs/work-with-the-inbox.html).

## 2. Create a draft invoice

```bash
INVOICE=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/invoices" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"recipientPersonId\": \"$STUDENT_PERSON\", \"dueDate\": \"2026-08-31\"}" | jq)
echo "$INVOICE"
INVOICE_ID=$(echo "$INVOICE" | jq -r .id)
```

```json
{
  "id": "0198c2c4-55ef-7a09-b3d1-8e2f0a6c4d93",
  "tenantId": "019fb5a2-9626-7ba6-ad59-b5f98cf04c8e",
  "invoiceNumber": "INV-000001",
  "recipientPersonId": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42",
  "issueDate": "2026-08-05",
  "dueDate": "2026-08-31",
  "currency": "USD",
  "status": "DRAFT",
  "invoiceTypeId": "0198c09c-3e44-7c21-9a07-b5d2e8f1a604",
  "invoiceTypeVersion": {"major": 1, "minor": 0},
  "total": 0.00
}
```

Note what the server decided for you: the `invoiceNumber` (atomic per-tenant
sequence — drafts included, deleted drafts leave gaps), `issueDate` defaulting
to today, `currency` defaulting to `USD`, `status: DRAFT`. And note what's
absent: an empty `lineItems` is omitted from the response entirely — read
`invoice.lineItems ?? []`.

## 3. Generate line items from the events

One endpoint, two modes: `apply: false` previews, `apply: true` appends to the
draft. Preview first:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/invoices/$INVOICE_ID/line-items/from-events" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{
        \"attributeId\": \"$FEE\",
        \"personId\": \"$STUDENT_PERSON\",
        \"from\": \"2026-08-01T00:00:00Z\",
        \"to\":   \"2026-09-01T00:00:00Z\",
        \"apply\": false
      }" | jq
```

```json
{
  "lineItems": [
    {"description": "Cello lesson — Ada (2026-08-03)", "quantity": 1, "unitPrice": 60,
     "sourceEventId": "0198c2b0-11aa-7e40-8c55-0d9e6f3a2b71"},
    {"description": "Cello lesson — Ada (2026-08-10)", "quantity": 1, "unitPrice": 60,
     "sourceEventId": "0198c2b2-4c01-7d88-be12-7a5f9d0e3c46"}
  ]
}
```

What the generator did: found the tenant's events carrying a value for
`attributeId`, windowed on `startAt` (`from` inclusive, `to` exclusive),
filtered to events involving `personId` — as a participant **or** as the
value of any person-kind attribute on the event — and made each one a
candidate line: the fee as `unitPrice`, quantity `1`, stamped with its
`sourceEventId`. An optional `eventIds` list narrows the result further to
just those events; it's an extra filter on top of the attribute/window/person
matching (`attributeId` stays required), not a replacement for it.

Events already billed on a non-void invoice are skipped and reported in a
separate `alreadyBilled` array — each entry carries the `eventId`, its
would-be line `description`, and the billing invoice's `invoiceId` and
`invoiceNumber` — so "nothing matched" and "everything already billed" are
distinguishable. (On a preview that skipped nothing, `alreadyBilled` is
omitted, not `[]` — same for `invoice`, which only appears when applying.)

Happy with the preview? Apply it — same body, `"apply": true`. The response
gains `invoice`: the updated draft with the lines appended and `total: 120.00`.

## 4. Issue, then record the payment

Issuing freezes content and opens the invoice for payments:

```bash
curl -s -X POST -H "X-API-Key: $KEY" \
  "$LYDIAN/tenants/$TENANT/invoices/$INVOICE_ID/issue" | jq '{invoiceNumber, status, total}'
```

```json
{"invoiceNumber": "INV-000001", "status": "ISSUED", "total": 120.00}
```

From here the rules tighten: no more content edits, no deletion — an issued
invoice can only be voided, and only while it has no payments. Issuing also
notifies the recipient (system notification). Billing is record-keeping: when
the money actually arrives (outside the platform), record it:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/invoices/$INVOICE_ID/payments" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"amount": 120.00, "method": "BANK_TRANSFER", "reference": "wire 8841"}' | jq
```

```json
{
  "id": "0198c2d9-72b3-7f55-a0c8-1e4d6b9f2a07",
  "tenantId": "019fb5a2-9626-7ba6-ad59-b5f98cf04c8e",
  "invoiceId": "0198c2c4-55ef-7a09-b3d1-8e2f0a6c4d93",
  "amount": 120.00,
  "paidAt": "2026-08-05T16:02:47.310Z",
  "method": "BANK_TRANSFER",
  "reference": "wire 8841"
}
```

`PAID` is **derived, never set**: the moment recorded payments cover the
total, the invoice flips to `PAID`; delete a payment (a bookkeeping
correction) and it reverts to `ISSUED`. Partial payments are fine;
overpayment is rejected (`400`). Omitting `method` stores `OTHER`.

```bash
curl -s -H "X-API-Key: $KEY" \
  "$LYDIAN/tenants/$TENANT/invoices/$INVOICE_ID" | jq .status
# "PAID"
```

## The guards you'll meet

- **Double billing:** a `sourceEventId` on any non-void invoice excludes that
  event from future generations (it shows up in `alreadyBilled` instead).
  Voiding the invoice frees its events.
- **Event deletion:** an event billed on a non-void invoice can't be deleted —
  `409 REFERENCE_BLOCKED` (the message names the event; find the invoice via
  a from-events preview, whose `alreadyBilled` entry identifies it).
- **Lifecycle:** editing a non-draft, issuing a draft whose total isn't
  positive (no line items, or a zero total), voiding a paid invoice, deleting
  an issued one — all `409 STATE_CONFLICT`.

---

## Next

- [Work with the inbox](/docs/work-with-the-inbox.html) — the notifications
  this flow just emitted, and messaging in general.
- [Model your domain](/docs/model-your-domain.html) — the attribute mechanics
  behind "Session Fee".
