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.
billable-time connects time tracking to
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.
better-supabase add billable-timeimport { 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),
and the choices behind locking billed time are in
ADR 0074.
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
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
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 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
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
The billable time section shows a record's unbilled
time on its page, with setRate and invoice writes.
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
time.invoiced when time is billed. The
events page lists its payload.
Last updated on
Passes
Issue QR and NFC admission passes signed with your signing keys, check holders in at the door, count drop-ins against a resource's capacity with its bookings, and pay for classes with credits.
Dispatch
Schedule field service work orders into technicians' slots on a board, move them from scheduled to done, plan each technician's day as a route, and record the parts each job used.