Headlessly
Billing

Invoice

Bills generated from subscriptions -- track amounts, line items, and payment status.

Schema

import { Noun } from 'digital-objects'

export const Invoice = Noun('Invoice', {
  number: 'string!##',
  organization: '-> Organization',
  customer: '-> Customer.invoices',
  subscription: '-> Subscription',
  subtotal: 'number!',
  tax: 'number',
  discount: 'number',
  total: 'number!',
  amountPaid: 'number',
  amountDue: 'number!',
  currency: 'string',
  status: 'Draft | Open | Paid | Voided | Uncollectible',
  periodStart: 'datetime',
  periodEnd: 'datetime',
  issuedAt: 'datetime',
  dueAt: 'datetime',
  paidAt: 'datetime',
  voidedAt: 'datetime',
  lineItems: 'json',
  receiptUrl: 'string',
  pdfUrl: 'string',
  hostedUrl: 'string',
  stripeInvoiceId: 'string##',
  pay: 'Paid',
  void: 'Voided',
  finalize: 'Finalized',
})

Fields

FieldTypeRequiredDescription
numberstringYesInvoice number (unique, indexed) -- human-readable sequential identifier
subtotalnumberYesAmount before tax and discounts (in smallest currency unit)
taxnumberNoTax amount applied
discountnumberNoDiscount amount applied
totalnumberYesFinal amount after tax and discounts
amountPaidnumberNoAmount already paid toward this invoice
amountDuenumberYesRemaining amount to be collected
currencystringNoISO 4217 currency code (e.g. usd)
statusenumNoInvoice state: Draft, Open, Paid, Voided, or Uncollectible
periodStartdatetimeNoStart of the billing period this invoice covers
periodEnddatetimeNoEnd of the billing period this invoice covers
issuedAtdatetimeNoWhen the invoice was finalized and sent
dueAtdatetimeNoPayment due date
paidAtdatetimeNoWhen payment was received
voidedAtdatetimeNoWhen the invoice was voided
lineItemsjsonNoItemized charges (serialized array of line item objects)
receiptUrlstringNoURL to the Stripe-hosted receipt
pdfUrlstringNoURL to the PDF version of the invoice
hostedUrlstringNoURL to the Stripe-hosted invoice page
stripeInvoiceIdstringNoStripe Invoice ID (unique, indexed)

Relationships

FieldDirectionTargetDescription
organization->OrganizationThe organization billed
customer->Customer.invoicesThe billing customer
subscription->SubscriptionThe subscription that generated this invoice

Verbs

VerbEventDescription
createCreatedCreate a new invoice
updateUpdatedUpdate invoice fields
deleteDeletedSoft-delete the invoice
payPaidMark the invoice as paid
voidVoidedVoid the invoice (no longer collectible)
finalizeFinalizedFinalize a draft invoice and send it

Verb Lifecycle

Every verb follows the full conjugation pattern -- execute, before hook, after hook:

import { Invoice } from '@headlessly/billing'

// Execute
await Invoice.pay('invoice_mR4nVkTw')

// Before hook -- runs before payment is processed
Invoice.paying(invoice => {
  console.log(`About to mark invoice ${invoice.number} as paid`)
})

// After hook -- runs after payment is confirmed
Invoice.paid(invoice => {
  console.log(`Invoice ${invoice.number} paid at ${invoice.paidAt}`)
})

Status State Machine

  create()           finalize()            pay()
(none) ──────→ Draft ──────────→ Open ──────────→ Paid

                         void()    │   (collection fails)

                         Voided  Uncollectible

Valid transitions:

FromVerbTo
--createDraft
DraftfinalizeOpen
OpenpayPaid
OpenvoidVoided
Open(system)Uncollectible
  • Draft: Invoice is being prepared. Line items can still be added or modified.
  • Open: Invoice has been finalized and sent to the customer. Awaiting payment.
  • Paid: Payment has been received in full.
  • Voided: Invoice has been cancelled. No payment is expected.
  • Uncollectible: Payment attempts have been exhausted. The invoice is written off.

Invoice Amounts

All amounts are in the smallest currency unit (cents for USD). The relationship between amount fields:

subtotal                    = sum of line items
tax                         = calculated tax
discount                    = applied discounts
total                       = subtotal + tax - discount
amountPaid                  = payments received so far
amountDue                   = total - amountPaid

Cross-Domain Patterns

Invoices connect billing to analytics and support:

import { Invoice } from '@headlessly/billing'

// When an invoice is paid, update revenue metrics
Invoice.paid((invoice, $) => {
  $.Metric.record('revenue', {
    amount: invoice.total,
    currency: invoice.currency,
    customer: invoice.customer,
  })
})

// When an invoice becomes uncollectible, create a support ticket
Invoice.updated((invoice, $) => {
  if (invoice.status === 'Uncollectible') {
    const customer = await $.Customer.get(invoice.customer)
    await $.Ticket.create({
      subject: `Uncollectible invoice ${invoice.number}`,
      description: `Invoice ${invoice.number} for ${invoice.total} ${invoice.currency} is uncollectible`,
      priority: 'High',
      contact: customer.contact,
    })
  }
})

Query Examples

SDK

import { Invoice } from '@headlessly/billing'

// Find all open invoices
const open = await Invoice.find({ status: 'Open' })

// Get a specific invoice
const invoice = await Invoice.get('invoice_mR4nVkTw')

// Find overdue invoices
const overdue = await Invoice.find({
  status: 'Open',
  dueAt: { $lt: new Date() },
})

// Find invoices for a customer
const customerInvoices = await Invoice.find({
  customer: 'customer_fX9bL5nRd',
})

// Sum paid invoices for revenue reporting
const paid = await Invoice.find({
  status: 'Paid',
  paidAt: { $gte: new Date('2025-01-01') },
})

MCP

headless.ly/mcp#search
{ "type": "Invoice", "filter": { "status": "Open" } }
headless.ly/mcp#fetch
{ "type": "Invoice", "id": "invoice_mR4nVkTw" }
headless.ly/mcp#do
const overdue = await $.Invoice.find({
  status: 'Open',
  dueAt: { $lt: new Date() },
})
for (const invoice of overdue) {
  await $.Message.create({
    type: 'Email',
    to: invoice.customer,
    subject: `Payment reminder: Invoice ${invoice.number}`,
  })
}

REST

GET https://headless.ly/~acme/invoices?status=Open
GET https://headless.ly/~acme/invoices/invoice_mR4nVkTw
POST https://headless.ly/~acme/invoices/invoice_mR4nVkTw/pay
POST https://headless.ly/~acme/invoices/invoice_mR4nVkTw/void

Event-Driven Patterns

React to invoice lifecycle events:

import { Invoice } from '@headlessly/billing'

// Track revenue when invoices are paid
Invoice.paid((invoice, $) => {
  $.Event.create({
    type: 'billing.invoice_paid',
    data: {
      number: invoice.number,
      total: invoice.total,
      currency: invoice.currency,
      customer: invoice.customer,
      subscription: invoice.subscription,
    },
  })
})

// Alert on voided invoices
Invoice.voided((invoice, $) => {
  $.Event.create({
    type: 'billing.invoice_voided',
    data: {
      number: invoice.number,
      total: invoice.total,
      reason: 'voided',
    },
  })
})

// Send payment reminders for upcoming due dates
Invoice.finalized((invoice, $) => {
  $.Event.create({
    type: 'billing.invoice_sent',
    data: {
      number: invoice.number,
      amountDue: invoice.amountDue,
      dueAt: invoice.dueAt,
      hostedUrl: invoice.hostedUrl,
    },
  })
})

Stripe Sync

Invoices are created by Stripe when a subscription renews. The stripeInvoiceId field links back to the Stripe Invoice. Stripe webhooks (invoice.created, invoice.finalized, invoice.paid, invoice.payment_failed, invoice.voided, invoice.marked_uncollectible) flow into the headless.ly event log, keeping the status machine in sync.

On this page