SlimFact Invoice Lifecycle
A complete reference for how bills, receipts, invoices, payments, refunds, reminders, exhortations, and subscriptions work in SlimFact.
Statuses
InvoiceStatus
| Status | Meaning |
|---|---|
CONCEPT | Draft — still being edited, not yet sent |
OPEN | Sent to customer, awaiting payment (immutable) |
PAID | Fully paid |
BILL | A payment request — not an invoice yet |
RECEIPT | Proof of payment — created from a paid bill |
CANCELED | Void |
PaymentStatus (checkout.payments)
OPEN, CANCELED, PENDING, AUTHORIZED, EXPIRED, FAILED, PAID
RefundStatus (checkout.refunds)
QUEUED, PENDING, PROCESSING, REFUNDED, FAILED, CANCELED
Invoice Lifecycle
┌──────────┐
│ CANCELED │ ← terminal
└──────────┘
^
│ cancelInvoice (any status)
┌───────────────────────────────┼───────────────────┐
│ │ │
v │ v
┌─────────┐ │ ┌─────────┐
│ CONCEPT │─────────────────────────┤ │ BILL │
│ (draft) │ │ │ │
└─────────┘ │ └─────────┘
│ │ │
│ sendInvoice │ │ sendReceipt
│ (assigns number, date, │ │ (amountDue must be 0)
│ due date) │ │
│ │ v
v │ ┌─────────┐
┌─────────┐ │ │ RECEIPT │ ← terminal
│ OPEN │─────────────────────────┤ │ (proof) │ for bills
│ (sent) │ │ └─────────┘
└─────────┘ │
│ │
│ payment covers │
│ amountDue │
v │
┌─────────┐ (can cancel
│ PAID │ any status)
└─────────┘Valid Transitions (setInvoiceStatus)
| From | To | Condition |
|---|---|---|
| CONCEPT | OPEN | Direct — sendInvoice assigns number, date, due date |
| CONCEPT | CANCELED | Direct |
| BILL | RECEIPT | amountDue must be 0 (fully paid). Only from BILL |
| BILL | CANCELED | Direct (unpaid bills only) |
| OPEN | PAID | amountDue must be 0 |
| RECEIPT | PAID | Convert receipt to invoice |
Blocked transitions:
| Attempt | Reason |
|---|---|
| Any → CONCEPT | "Cannot convert an invoice back to concept" |
| Any → RECEIPT (except BILL) | "Can only create receipts for bills" |
Any → RECEIPT when amountDue > 0 | "Cannot create a receipt when amount due is not zero" |
Any → PAID when amountDue > 0 | "Cannot set paid status when amount due is not zero" |
| BILL → OPEN | "Can only open concept invoices" |
| RECEIPT → OPEN | "Can only open concept invoices" |
| OPEN/PAID/RECEIPT → CANCELED | "Only concepts and unpaid bills can be canceled" |
Document Types & Semantics
Invoice
- Starts as CONCEPT (draft)
sendInvoice→ OPEN: assigns invoice number, date, and due date. Becomes immutable.- Payment received → PAID
- Can be canceled at any stage
Bill
- Created with status BILL
- Behaves like an invoice but represents a payment request
- Must be paid first before it can become a receipt
sendReceipt→ RECEIPT (requiresamountDue === 0)- Subscription cron jobs can create bills by setting
type: 'bill'
Receipt
- Created from a paid bill via
sendReceipt - Proof of payment document
- Can be converted to an invoice via
sendInvoice(status → OPEN) - PDF rendering shows tax-inclusive amounts (like bills)
Numbering
Invoice numbers are assigned when status transitions to OPEN (via sendInvoice). The number is assigned atomically using a serializable transaction:
- Read
initialNumberForPrefixbynumberPrefixTemplate - Count existing OPEN invoices with same prefix
- Compute next number:
initialNumber + count - Fill in
{num},{year},{month}placeholders in the template
CONCEPT and BILL invoices have no number yet. CONCEPT and BILL invoices have no number yet. Receipts converted to invoices get PAID status (already numbered).
Payment Flow
Adding a Payment
- Admin or customer calls
addPaymentToInvoice - Payment method routing:
ideal→IDEAL_PAYMENT_HANDLERenv var (default:mollie)creditcard→CREDITCARD_PAYMENT_HANDLERenv var (default:stripe)cash→ always availablebankTransfer→ always availablepin→ available ifpinEnabled
- PSP handler's
createPaymentcreates an external payment record and a localcheckout.paymentsrow - If online payment (iDEAL/creditcard): returns
checkoutUrlfor redirect - If offline (cash/bank transfer): records payment immediately
Webhook Settlement
Mollie (POST /mollie/webhook):
- Receives
{ id: molliePaymentId } - Looks up local payment by
externalId - Calls
mollieHandler.settlePayment({ externalId })→ syncs status from Mollie - If
amountPaid >= totalIncludingTaxand status isOPEN→setInvoiceStatus(PAID) - Calls
fetchWebhookUrl(invoice)if external webhook URL configured
Stripe (POST /stripe/webhook):
- Verifies webhook signature using
STRIPE_WEBHOOK_SECRET - Filters for
checkout.session.completedorpayment_intent.succeeded - Idempotency guard: checks if invoice was already OPEN before transitioning
- Calls
stripeHandler.settlePayment({ externalId })→ syncs from Stripe - If
wasOpen === trueand fully paid →setInvoiceStatus(PAID) - Calls
fetchWebhookUrl(invoice)
Refunds
- Admin calls
refundInvoice({ id }) - Finds the highest paid payment from a supported PSP where
amountDue < 0 - Calls PSP's
refundPayment({ id, amount: -amountDue }) - PSP creates external refund, inserts into
checkout.refunds - A daily cron job (
checkRefunds, runs at midnight) syncs pending refund statuses
Reminders & Exhortations
Flow
Invoice due date passes
│
├─ 1st reminder (send immediately after due)
│ │
│ └─ Must wait 7 days after last reminder
│
├─ 2nd reminder (7 days after 1st)
│ │
│ └─ Must wait 7 days after last reminder
│
└─ Exhortation (7 days after 2nd)
│
└─ Requires at least 2 reminders sentRules
- Max 2 reminders — after 2, you must send an exhortation instead
- 7-day waiting period between each reminder/exhortation
- Invoice must be past its due date
- Email templates for
remindInvoiceandexhortInvoiceare customizable per locale reminderSentDatesis tracked on the invoice
Subscriptions
Creating a Subscription
- Admin creates a subscription with:
companyId,clientId,numberPrefixTemplatecronSchedule— standard cron expressionstartDate, optionalendDatetype—'invoice'or'bill'lines[]— same as invoice lines
How It Runs
startSubscriptionis called when a subscription is created or activated- Fetches company/client details from DB
- Builds a
RawNewInvoice:- If
type === 'bill':status = InvoiceStatus.BILL - If
type === 'invoice':status = undefined(defaults to CONCEPT)
- If
- Creates a pg-boss job queue named
subscription.{uuid} - Schedules the cron with the subscription's cron expression
- Each cron tick:
invoiceHandler.createInvoice(job.data)creates a new invoice - The new invoice gets its own number when sent (via
sendInvoice)
Managing Subscriptions
- Active/inactive toggle — calls
stopSubscriptionorstartSubscription - Edit — updates subscription and restarts the cron job
- Delete — stops the cron job and removes the record
Key Environment Variables
| Variable | Purpose |
|---|---|
MOLLIE_API_KEY | Mollie API key (test/live) |
MOLLIE_API_KEY_<PREFIX> | Company-specific Mollie key (multi-company) |
STRIPE_API_KEY | Stripe secret key |
STRIPE_API_KEY_<PREFIX> | Company-specific Stripe key (multi-company) |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret for signature verification |
IDEAL_PAYMENT_HANDLER | mollie or stripe (default: mollie) |
CREDITCARD_PAYMENT_HANDLER | mollie or stripe (default: stripe) |
MODULARAPI_ADMIN_PASSWORD | Password for the seed-created admin account |
Email System
Event Types
| Event | Template Name | Trigger |
|---|---|---|
sendInvoice | sendInvoice | Admin/public sends an invoice |
sendReceipt | sendReceipt | Admin converts bill to receipt |
remindInvoice | remindInvoice | Admin sends a payment reminder |
exhortInvoice | exhortInvoice | Admin sends a formal exhortation |
replyInvoice | replyInvoice | Admin replies to invoice thread |
Features
- Handlebars templates —
,,, etc. - Per-locale variants —
en-US,nl-NL,de-DE - Open tracking —
?eventType=emailOpenedquery parameter in the invoice link - Per-company settings — custom
MAIL_FROM,EMAIL_BCCper company - PDF attachment — invoice PDF attached automatically on every send/remind/exhort
- UBL XML attachment — attached automatically when sending OPEN/PAID invoices