# Billable time

> Price tracked time with an hourly rate card per organization, member and record, preview what it would bill, and turn it into draft invoice lines in bigint minor units.

Source: https://bettersupabase.com/docs/blocks/billable-time

`billable-time` connects [time tracking](/docs/blocks/time-tracking) to
[invoices](/docs/blocks/invoices). You keep a rate card of hourly rates, see
what the unbilled time on a project or customer would bill, and put it on a
draft invoice in one call. Each billed entry remembers the invoice and the
rate it was billed at, so it can't be billed twice.

```bash
better-supabase add billable-time
```

```ts
import { createBillableTime } from "better-supabase/blocks/billable-time";
import { money } from "better-supabase/commerce";
```

The module requires `tenant`, `access`, `subjects`, `time-tracking` and
`invoices`. With `outbox` it records a `time.invoiced` event, and with
`audit` rate changes and invoicing are audited. Amounts are bigint minor
units ([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)),
and the choices behind locking billed time are in
[ADR 0074](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0074-billable-time-rate-card.md).

## Tables [#tables]

| Table                  | Holds                                                                                                                                                 |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `time_rates`           | The rate card: an hourly `rate` and `currency`, an optional member (`user_id`) and record (`subject_type`, `subject_id`), and the day it takes effect |
| `time_invoice_entries` | One row per billed time entry: the invoice, the rate and currency it was billed at, and the seconds billed                                            |

Both tables are read through the functions only. Members with
`billable_time.read` read the rate card and the billed entries.

## Set rates [#set-rates]

```ts
const billing = createBillableTime({ transport, tenant: organizationId });

await billing.setRate({ rate: money(9000n, "EUR") }).orThrow();
await billing
  .setRate({ rate: money(12000n, "EUR"), subject: { type: "projects", id } })
  .orThrow();
await billing
  .setRate({
    rate: money(15000n, "EUR"),
    userId,
    subject: { type: "projects", id },
  })
  .orThrow();
```

A rate without `userId` and `subject` is the organization's rate. A rate can
name a member, a record in `config.subjects`, or both. For each entry the most
specific rate wins: the member on the record, then the record, then the
member, then the organization. A rate takes effect on `effectiveFrom`
(default today) and stays until a newer rate on the same key takes over, so a
raise doesn't change what older time bills. The day is read in the
time-tracking block's `timeZone`. Setting a rate on the same key and day
replaces it. An entry that carries its own `rate` keeps that rate. Setting
and deleting rates needs `billable_time.manage`.

| Method                             | Returns                                                    |
| ---------------------------------- | ---------------------------------------------------------- |
| `setRate(input)`                   | the rate                                                   |
| `deleteRate(rateId)`               | the deleted rate                                           |
| `listRates({ userId, subject })`   | the rate card                                              |
| `rateFor({ userId, subject, on })` | the rate that applies, or `null`                           |
| `unbilled(query)`                  | the lines, one total per currency, and the unrated entries |
| `invoice(input)`                   | the draft invoice's id, its total and the billed entry ids |
| `invoicedEntries(invoiceId)`       | the entries an invoice billed, with their rates            |

## Preview and invoice [#preview-and-invoice]

```ts
const preview = await billing
  .unbilled({ subject: { type: "projects", id } })
  .orThrow();

const { invoiceId, total } = await billing
  .invoice({
    subject: { type: "projects", id },
    company: { type: "companies", id: companyId },
    title: "October",
    termsDays: 30,
  })
  .orThrow();
```

`unbilled` shows what `invoice` would bill without billing it. Only finished
entries marked billable that aren't on an invoice count. A line's `quantity`
is hours to four decimals, as text; its `amount` is the quantity times the
hourly rate, rounded to a minor unit. `unrated` lists the entries no rate
covers.

`invoice` creates a draft with the [invoices](/docs/blocks/invoices) block
and adds a line per group, all in one transaction. It fails with
`TIME_RATE_MISSING` while an entry has no rate, and with
`BILLABLE_TIME_CURRENCIES` when the entries bill in more than one currency
and `currency` doesn't pick one. The title defaults to `Time`. Invoicing
needs `billable_time.invoice` and `invoices.write`.

The scope narrows the entries: `subject`, `userId`, `starts` and `ends` (by
start time), and `entryIds`. `groupBy` decides the lines:

| `groupBy`           | One line per                 |
| ------------------- | ---------------------------- |
| `subject` (default) | record and rate              |
| `member`            | record, member and rate      |
| `entry`             | entry, described by its note |

## Billed time is locked [#billed-time-is-locked]

A billed entry can't be deleted, and its start, end, billable flag and rate
can't change: the database raises `TIME_ENTRY_INVOICED`. Its record can still
change, so merging two records keeps working. Voiding the invoice releases
its entries, and they show up in `unbilled` again.

An organization purge deletes the billed rows with the invoices. Erasing a
member doesn't delete their billed time; it stays as an accounting record
until the invoice is voided.

## Section [#section]

The [billable time section](/docs/blocks/records) shows a record's unbilled
time on its page, with `setRate` and `invoice` writes.

## Errors [#errors]

| Hint                             | Cause                                                                                                      |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `BILLABLE_TIME_FORBIDDEN`        | the caller lacks `billable_time.read`, `.manage` or `.invoice`                                             |
| `TIME_RATE_INVALID`              | the rate is negative, the currency isn't a currency code, or only one of the record type and id was passed |
| `TIME_RATE_SUBJECT_INVALID`      | the record is unknown or not in the organization                                                           |
| `TIME_RATE_MEMBER_INVALID`       | `userId` isn't a member of the organization                                                                |
| `TIME_RATE_NOT_FOUND`            | no rate with this id in the organization                                                                   |
| `BILLABLE_TIME_GROUPING_INVALID` | `groupBy` isn't `subject`, `member` or `entry`                                                             |
| `TIME_RATE_MISSING`              | a billable entry has no rate                                                                               |
| `BILLABLE_TIME_CURRENCIES`       | the entries bill in more than one currency and none was picked                                             |
| `BILLABLE_TIME_EMPTY`            | the scope selects no unbilled time                                                                         |
| `INVOICE_NOT_FOUND`              | `invoicedEntries` named an invoice outside the organization                                                |
| `TIME_ENTRY_INVOICED`            | the entry is on an invoice; void the invoice to change it                                                  |

## Events [#events]

`time.invoiced` when time is billed. The
[events page](/docs/extending/events) lists its payload.