Client Billing lets the site owner bill their clients directly from the dashboard: build a line-item invoice (or estimate), email the client a tokenized link, and get paid online through embedded Stripe checkout — or record checks, cash, and bank transfers by hand. Recurring schedules, automatic reminders, late fees, deposits, partial payments, refunds, PDF copies, a member billing portal, and CRM/Activity Log integration are all built in.
The problem
Freelancers and small agencies run their websites in one place and their billing in another — a separate invoicing SaaS with its own subscription, its own client list, and a per-payment surcharge on top of Stripe's. For a business that sends a handful of invoices a month, that's a whole extra tool (and bill) for what amounts to a document, a payment link, and a ledger.
The fix
A self-contained feature module under app/Features/ClientInvoicing/ turns the site into the billing tool: a client list, an invoice/estimate builder with line items and tax, a public pay-online page served from the install's own domain, a payment ledger, and an hourly automation tick that generates recurring invoices, sends reminders, and applies late fees — all in the install's own database, with Stripe's fees the only cut taken.
Like every addon, it's structured as a feature module gated by feature:client_invoicing middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations. Disabling preserves all data.
What the dashboard gets
Five pages under Dashboard → Invoicing (Invoices and Clients open to Manager and up and to a salesperson — see Who can do what; Recurring and Price List need Manager and up; Settings is Admin-only):
- Invoices — the ledger: Outstanding / Drafts / Estimates / Paid / All tabs plus search by number or client. Each row shows number, client, issue/due dates (due date turns red when overdue), total, open balance, and a status badge — Draft, Sent, Partially paid, Overdue, Accepted (estimates), Paid, Void. Row actions: edit, open/copy the public link, Download PDF, Send (or Resend) by email, Mark sent (no email) for offline workflows and phone-only clients (a client with no email shows a disabled No email on file item in place of Send), Record payment, Make recurring…, Mute/Resume reminders, Convert to invoice (estimates), Void, and Delete draft (only drafts are deletable — sent documents are records and can only be voided).
- Invoice editor — pick a client (or quick-add one inline), set issue date, due date, a per-invoice tax percent, and an optional required deposit percent, then build line items: description, quantity (decimals allowed), and unit price, with live-updating subtotal/tax/total — typed by hand or picked with Add from price list (see Price list). A negative unit price works as a discount line. Notes (shown to the client) and terms print on the invoice. Save & send emails it in one step; for a client with no email on file the button becomes Save & mark sent, which issues it (so its link, PDF and pay page work) without emailing anyone, with a note saying why. The tax percent keeps three decimals (8.875%). Once any payment is recorded — or the invoice is paid or void — line items lock; the payment history section lists every payment with method, reference, date, and a per-payment Refund button for Stripe payments.
- Recurring — every schedule with client, optional label, amount, frequency (weekly / monthly / quarterly / every 6 months / yearly / every 2 years / every 3 years), next run, auto-send mode, generated count, and an active switch; edit cadence, label, days-until-due, end date and the line items (typed, or Add from price list), Generate now, or delete (already-generated invoices are kept). Schedules are created from any invoice's Make recurring… action, which snapshots its line items as the template. Blank rows are ignored on save; a schedule that bills something can't be emptied (pause or delete it instead), while an old schedule made from an empty invoice still saves its dates. A paused schedule mirrored from WHMCS warns that the hourly sync replaces its lines. Schedules mirrored from WHMCS carry a WHMCS badge.
- Price List — the services and materials you sell often, with Active / Archived tabs, a category filter and search, and Import / Export CSV (see Price list).
- Clients — the billing address book: name, company, email, phone, billing address, internal notes, and a lifecycle status (Prospect → Active → Inactive), with invoice counts per client. A client needs an email or a phone — trade customers often give only a number (see Phone-only clients). Agreed to receive text messages records the office's attestation of a customer's yes to texts; it needs a number that can receive a text. When the CRM feature is enabled, an Import from CRM action pulls contacts in with one click (Select all supported) — contacts without an email or already imported are excluded, and imports stay linked to their CRM contact. Manually added clients are mirrored into the CRM (source
invoicing) when the sync setting is on. Clients with invoices can't be deleted — nor, with Jobs & Dispatch, clients with jobs or a maintenance plan still running (see Jobs & Dispatch). - Settings (admin only) — invoice + estimate number prefixes, default due days/tax, currency, default notes/terms, the business identity block (falls back to Business settings), payment options (pay online, choose-your-amount, ACH), the Stripe webhook signing secret, reminder cadence, late-fee policy, CRM sync, PDF attachments, the WHMCS migration bridge (URL, API credentials, sync/import/takeover switches, Test connection, Sync now), and email options: sender name/address, Invoice emails go to (client / client and me / only me — drafts / only me — issued, to forward), and My email for owner copies and payment notifications.
The dashboard also gets two cards: Client Billing (outstanding balance + open/overdue counts) and Invoice Aging (balances bucketed Current/1–30/31–60/61–90/90+ plus average days-to-pay).
Who can do what — the Sales grant
Manager and up reach every page here but Settings (Admin). Below Manager, a Staff login holding the Sales grant (a switch on the Users page, live while Client Billing or the CRM is on) reaches the selling pages: Invoices (the list and the editor) and Clients here, plus Proposals and Proposal Templates. They sit behind the sales route gate (EnsureSalesAccess), which Livewire re-runs on every request, so removing the grant stops an open page on its next click. A salesperson's sidebar shows a Sales group of exactly those pages — plus the CRM's contacts, companies, deals and tasks when the CRM is on, and Course Seats when Courses is on (where an invoiced course sale's seats are requested against the invoice and approved by someone else) — and they land on Proposals when they sign in.
On those pages a salesperson sells: creates, edits, sends and marks sent invoices and estimates, converts an accepted estimate, records a manual payment (the check that came in the post — a seat request review shows the approver how much of an invoice's paid amount was recorded by hand, so it is confirmed against the bank), deletes a draft, adds and edits clients, and picks lines from the price list. Card payments arrive through the invoice's pay link as usual. These stay Manager-only — refused server-side whatever the browser sends, and not offered in the UI: Void, Refund, Mute/Resume reminders, Make recurring…, Create job (and the link to a job), Delete a client, Import from CRM, and the price list's own page (the picker shows no Set up a price list / Manage link). Recurring, Price List and Settings stay closed to the grant. The rule lives in one place, SalesAccess (shared with the CRM's pages); billing components ask it through BillingAccess rather than checking a role themselves. Locked by tests/Feature/Security/SalesScopeTest.php.
Phone-only clients
invoicing_clients.email is nullable: a client is reachable by email, by phone, or both, and "no email on file" is always stored as NULL (the model turns a blank into NULL on save). Every lookup that finds a client by email goes through Client::scopeWithEmail() (Client::query()->withEmail($email)), which matches nothing for a blank or null address — a raw where('email', $x) with $x = null compiles to IS NULL and would treat every phone-only customer as the same person. The booking invoicer, WHMCS adopt, CRM import, and the member billing/agreement pages all match this way; the CRM dedupes on email alone, so a phone-only client mirrors into a contact of its own.
phone_e164— the phone in E.164 (+15551234567), derived by the model on every save from whatever wrotephone(the form, a booking, the WHMCS mirror, a CRM import) usingFormSmsNotifier::normalizePhone()— the shape the SMS opt-out tables are keyed on. A number that can't be read as a phone (no area code) storesNULL, meaning "can't be texted".- Text consent —
sms_consent,sms_consent_at,sms_consent_source(booking— the customer ticked it on their booking, copied over when the booking is invoiced;staff_attested— the office recorded a yes;form— a public form). Consent belongs to a number: whenphone_e164changes and the same save doesn't re-attest, consent clears. - What can't be emailed says so. Sending a phone-only client's invoice reports no email address on file rather than failing quietly; reminders skip them; an auto-sent recurring invoice for one stays a draft and the owner is emailed a copy saying why — even in The client mode, which otherwise sends the owner nothing; online payment opens without a prefilled email (Stripe asks the payer).
Issuing an invoice — InvoiceSender
Support/InvoiceSender is the one path from draft to sent — the invoice list's Send dialog, the editor's Save & send / Save & mark sent, and Mark sent (no email) all go through it, so the Contracts gate (require a signed contract before invoicing), the CRM timeline entry and the Activity Log line are never skipped. send($invoice, deliver: true) emails the client (owner-only mode previews it to the owner and leaves the draft); deliver: false makes it payable without an email — the public page only answers for a sent invoice — and is an atomic draft→sent claim, so a double tap issues it once. It returns an outcome (sent, made_payable, owner_preview, contract_required, no_email, delivery_failed, not_sendable for paid/void) and InvoiceSender::message() words each for the admin. The Contracts gate now also applies to Mark sent (no email).
Document numbers
Invoice, estimate and proposal numbers are the highest numeric suffix for the prefix + 1 against a unique column. Support/DocumentNumbers assigns the number at insert and, when a concurrent writer took it (two invoices created in the same instant), retries with the next one — up to five attempts, each in a savepoint when a transaction is open, and always numbering past the number that just lost (a MySQL REPEATABLE READ snapshot may not show the winning row). A number set explicitly (an import keeping its source numbering) is never reassigned; a collision on it is an error.
Estimates
New estimate creates the same document with its own number sequence (EST-0001, prefix configurable) and no payment surface. The public page shows an Accept estimate button instead of Pay; accepting stamps accepted_at (exactly once — an atomic claim), notifies the owner by email, logs to the CRM timeline and Activity Log, and shows an Accepted badge everywhere. Convert to invoice (from the row menu, the editor, or after acceptance) copies the client, lines, tax, and terms into a fresh draft invoice with a new INV- number and links the two; converting twice reuses the same invoice. The due-date field doubles as Valid until on estimates. With Jobs & Dispatch on, an accepted estimate also offers Create job (editor + row menu); once it has a job, the job bills the work — Convert to invoice returns the job's invoice or says to invoice the job (Where jobs come from).
Recurring invoices
Each schedule holds template line items, tax, notes/terms, an optional deposit percent, a cadence, and an optional end date. The hourly automation tick (invoicing:process via LazyCron — no queue worker needed) generates the invoice when a schedule comes due, then advances the next-run date from the scheduled date, not "today", so cadence never drifts; a long outage generates one catch-up invoice and fast-forwards rather than back-billing every missed period. Auto-send emails each generated invoice immediately; off leaves drafts for review. Schedules deactivate themselves once they advance past their end date.
Cadences are weekly, monthly, quarterly, every 6 months, yearly, every 2 years, and every 3 years; every non-weekly cadence re-anchors to the billing day-of-month (Jan 31 → Feb 28 → Mar 31, never drifting to the 28th). Each schedule can carry a label (shown under the client on the Recurring page — mirrored hosting schedules use the product name and domain) and a per-schedule Days until due override: blank uses the global default, 0 issues the invoice with no due date.
Price list
invoicing_catalog_items (Models/CatalogItem) holds what the business sells often: name, optional description, category (free text — the form suggests the ones in use, the page filters by them), kind (service or material), optional SKU (unique; blank is stored NULL, so any number of items can go without one), price, your cost, taxable, active, and a sort order (the list and picker sort by category, then sort order, then name). The page shows each item's margin (price − cost).
- A pick is a copy. Add from price list sits beside Add line in the invoice/estimate editor, the recurring schedule editor, the proposal editor and Proposal Templates (via the
Concerns/PicksFromPriceListtrait and thedashboard/partials/catalog-pickerpartial). Picking an item writes a new line —Name — description, quantity 1, the item's price — into the editor, filling the empty starting row rather than leaving it blank. The line stores no reference back to the price list, so repricing, renaming, archiving or deleting an item never changes a document, schedule or template that already used it. A picked line lands as a plain required line; on a proposal it can then be made an optional add-on or a set option (see Proposals → Options). - Locked documents refuse picks server-side — a paid/void invoice or an accepted/declined proposal hides the picker, and the action itself does nothing for one.
- Archive vs delete. Archiving hides an item from the picker and keeps it (restore any time from Archived); a picker left open while someone archives an item refuses that item with a notice instead of adding it at its old price. Deleting removes it for good; documents keep their lines either way.
- Cost is private and may be unknown. It is never copied onto a document. A blank cost is stored
NULL, not 0, and the margin column shows—— so a later job-costing report can say incomplete instead of reporting 100% margin. - Taxable is recorded for your books and the accounting export. A document still applies its one tax rate to every line (there is no per-line tax).
- CSV import / export (
Support/CatalogCsv) — one column layout both ways:name, description, category, kind, sku, price, cost, taxable, active, sort_order(headers are case- and space-insensitive;unit price,unit cost,type,itemare accepted aliases; Excel's byte-order mark is ignored). Import is an upsert, safe to run twice: a row updates the item with the same SKU, otherwise an item with the same name (case-insensitive) that has no SKU yet, otherwise it creates one. An empty cell leaves the existing value alone. Prices may carry$and thousands commas; a new item needs a price. A bad row is reported by line number and skipped while the rest imports; an unknown kind or yes/no value is reported and left unchanged. Up to 5,000 rows per file. Export includes archived items (active = no) and formula-quotes text cells ('-25.00for a discount line), which the import reads back as the original value — an exported list round-trips unchanged.
Send recipients
Settings → Email → Invoice emails go to decides who receives invoice emails: The client (default), The client and me (the client gets the invoice, the owner gets a [Copy] … email with the items table, an Open in dashboard button, and the public link), Only me — invoices stay drafts for review (the owner gets the copy and the invoice stays a draft; its client link does not open until it is sent), or Only me — invoices are issued and I forward them myself (forward: the invoice is marked sent — its client link opens and it counts as owed — but only the owner is emailed, with wording meant to be forwarded to the client with a personal note; it works for a client with no email on file, and the Contracts gate still applies). The setting drives recurring generation and the editor's Save & send; the Send dialog on the invoice list offers the same four choices per send. In forward mode a schedule set not to auto-send still leaves its invoice a draft and emails the owner the draft copy. Owner copies and payment notifications go to My email; when it is blank they fall back to the first admin user's address. While a WHMCS mirror is configured, every mode but The client also emails the owner each new invoice WHMCS issues (see WHMCS migration).
WHMCS migration
The WHMCS migration section of Settings turns Client Billing into a landing zone for an existing WHMCS installation so billing can move over gradually instead of in one cut-over. Enter the WHMCS URL, an API identifier + secret (created under Setup → Staff Management → API Credentials; the secret and the optional access key are write-only and encrypted at rest), and switch on Sync every hour. The hourly invoicing:whmcs-sync tick (LazyCron; Sync now runs it in-process, Test connection proves the credentials) then mirrors:
- Clients — every Active WHMCS client (add Inactive/Closed with the toggle) with name, company, email, phone, and billing address. An Active WHMCS client imports as Active; Inactive and Closed both import as Inactive, keeping WHMCS's own word for the same idea. A local client with the same email is linked, not duplicated — unless it already mirrors a different WHMCS account, in which case the second one is imported as its own client (WHMCS permits two accounts to share an address, and re-pointing the link would make them fight over one row). Local notes and CRM links are never touched.
- Invoices — every WHMCS invoice (optionally only those dated on or after a chosen date; anything older is out of scope silently, whoever it belongs to) as a mirrored invoice numbered
WHMCS-{invoicenum}(prefix configurable), with WHMCS's line items, totals, tax, and status (Unpaid/Collections/Payment Pending → Sent, Paid → Paid, Refunded → Paid + refunded, Cancelled → Void, Draft → Draft) and WHMCS's transactions as payment rows. Mirrored invoices show a WHMCS badge on the list and a warning callout in the editor; clients pay them on the normal tokenized page or member portal. Unchanged invoices cost one listing row per run — the detail call happens only when the status, totals, balance, or dates moved. - Hosting services — every recurring product (Monthly, Quarterly, Semi-Annually, Annually, Biennially, Triennially) becomes a recurring schedule labelled product — domain, with the product's recurring amount plus every Active addon on the same billing cycle as extra lines. The schedule's Days until due is set to the configured lead days so its due dates match WHMCS's own Invoice Generation setting — with a floor of 1, because a schedule reads 0 as "no due date at all" and an invoice with no due date is never reminded about, never accrues a late fee, and reads as current in the aging report forever. A lead of 0 therefore raises the invoice a day early so it still falls due on exactly WHMCS's date.
The three phases. (1) Mirror — WHMCS keeps billing; the schedules exist but stay paused and simply track WHMCS's next due date. (2) Take over — turn off Invoice Generation and overdue suspension in WHMCS (Setup → Automation Settings), then switch on Generate hosting invoices here: the next sync activates every schedule whose WHMCS product is Active, and this site generates the hosting invoices from then on. (3) Retire — switch the hourly sync off; everything imported stays. Payment push-back stops with it, so clear any still-open mirrored invoices first.
The double-billing guard. Ownership of a schedule's cadence is decided per sync:
- A schedule this site has never billed keeps mirroring WHMCS's next due date while it is paused.
- The first sync after the takeover derives the first run date from WHMCS once, skipping a period a mirrored invoice already covers — paid as well as open, since WHMCS only advances its next-due date when its own copy settles, and a client who paid the current period here (with the push not yet landed) would otherwise be billed for it twice. Only an invoice due on or after WHMCS's next due date counts, so a merely historical paid invoice can't skip a live cycle. A period billed by hand counts too: when the owner voided WHMCS's own invoice and billed the period on one they typed, the typed line is never linked to the service (and WHMCS never advances its next due date for it), so the check also accepts an open or paid mirrored invoice of the same client whose line names the service's domain and the period starting on the next due date, the way WHMCS writes it — e.g.
Level 2 - www.example.com (06/09/2026 - 12/08/2026). - An active schedule is never re-anchored from WHMCS (its next-due date stops advancing once it stops billing, so rewriting from it would re-bill periods already invoiced here) and its line items and label are left alone — after takeover, this site owns what the schedule bills.
- Once this site has billed a schedule, its next run date only ever moves forward: a pause and resume can't pull it back onto WHMCS's stale date.
- A schedule that goes live already past its next run date bills on the very next tick. That is right for a genuinely overdue service and wrong when WHMCS holds an unpaid invoice for that period which the import window left behind, so the sync warns instead of guessing: the run summary ends with “N need a look”, and each one is named in the log. Set “Import invoices dated on or after” far enough back to cover any unpaid invoices before you take over — the coverage check can only see invoices that were actually mirrored, so a narrow window quietly narrows the guard with it.
- A product that leaves Active, or turning the takeover switch off, pauses the schedule; a product deleted in WHMCS pauses it too (it stops appearing in the listing). A schedule you paused by hand is left paused once this site has billed it at least once; the sync only resumes a schedule it paused itself (a WHMCS product that left Active and came back). Before the first generated invoice there is nothing to distinguish your pause from the pre-takeover default, so switching the takeover on does activate it.
A big WHMCS is mirrored across several ticks. Installs run no cron daemon, so the hourly tick executes inside a visitor's page request, where overrunning PHP's max_execution_time kills the run outright. The pass therefore stops cleanly when its slice is spent and records where it got to — clients, then invoices, then hosting services — and the next tick carries on from there rather than starting the client walk again. A run that stops part way says so in its skip list. Sync now and the CLI (php artisan invoicing:whmcs-sync) ask for a complete pass and ignore any stored resume point, so there is always a way to force the whole mirror through in one go.
Owner copies of WHMCS invoices. While Invoice emails go to includes the owner (any mode but The client), each invoice WHMCS newly issues is emailed to My email as the sync mirrors it — a [Copy] with the items, the PDF when attachments are on, and the client link, worded to be forwarded to the client. This replaces WHMCS's own "invoice created" email for an owner who forwards invoices personally. It fires once per invoice (an email-log claim, so overlapping runs can't double it) for an invoice that is new to the mirror or a WHMCS draft just published, and only while it is open (Unpaid, Collections, Payment Pending). Three guards keep it from emailing history: nothing is sent until a first import has finished (however many hourly ticks it takes — a mirror that had already synced before this feature counts as finished), an invoice dated more than 7 days ago sends nothing (so widening Import invoices dated on or after or including inactive clients is silent), and a paid, void or draft invoice sends nothing. The client is never emailed by the mirror. A copy that fails to send is reported in the sync's "need a look" warnings, because the next run no longer sees that invoice as new.
Running both side by side. Before switching WHMCS off, an owner can leave WHMCS billing for a cycle and switch on Generate hosting invoices here, so each period gets an invoice from both systems — WHMCS's (mirrored, WHMCS-…) and this site's (INV-…) — and compare them. This site bills one invoice per schedule (one per hosting service), while WHMCS puts a client's services due the same day on one invoice — so a pair is one WHMCS invoice and every invoice this site generated for a service on it (a Hosting line naming the schedule's WHMCS service, or the schedule the sync linked it to), matched on due date within 5 days. They settle together: when WHMCS marks its invoice paid (and not refunded), the next sync marks this site's invoices for the period paid too (a payment row Paid in WHMCS (WHMCS-…), never pushed back, and a "Settled from WHMCS" count in the run summary) — but only when the amounts agree, i.e. WHMCS's total equals the sum of this site's invoices for that period. When they differ, settling would record money nobody paid, so the sync leaves this site's invoice open and warns ("settle … by hand") on every run until the owner does. When a payment is taken here on this site's invoice, it is recorded on WHMCS's still-open invoice with AddInvoicePayment, exactly like a payment on a mirrored invoice, so WHMCS stops dunning and never captures a stored card for it. Owner copies of either invoice say whether the other system billed the period the same amount (or, for one service on a WHMCS invoice that bills several, to compare line by line). If the client pays both, nothing is pushed and the sync warns once (refund one). Only this site's invoices from the last 60 days are checked, so after WHMCS is switched off the check winds down by itself. A WHMCS schedule advances by its due date and runs the lead days before it, so this site's invoice keeps landing on the same day as WHMCS's period after period. A period WHMCS billed on a hand-typed invoice (no hosting link, a different due date) does not pair — settle this site's copy for that period by hand. Send the client only one of the two. While both run, Client Billing's totals count each period twice, because both invoices are real; turn off WHMCS's Invoice Generation when the comparison is done and only this site's invoices remain.
Payment push-back. While Push payments back to WHMCS and Sync every hour are both on, a payment recorded here against a mirrored invoice (Stripe, or a manual entry) is posted to WHMCS with AddInvoicePayment (transaction id wpc-{payment id}, gateway stripe / banktransfer / mailin) so WHMCS stops dunning for it; a Duplicate Transaction ID reply counts as pushed, a transport failure is retried by the next hourly sync, and the sync never re-imports its own pushes as a second payment. Push-back rides on the hourly sync, so switching the sync off stops it: settle any open mirrored invoices before retiring the sync, or leave it on until they are paid.
What the sync overwrites while it is on. Mirrored clients (name, company, email, phone, address, status), mirrored invoices (dates, items, totals, status, payments from WHMCS — edits made here are replaced by the next sync, so change them in WHMCS), and mirrored schedules (cadence and due days always; label, items, and the next run date only while the schedule is paused and this site has not billed it). A mirrored invoice that was paid here is never downgraded to Sent or Void by WHMCS's view of it, and reminders and late fees never touch a mirrored invoice — WHMCS issued it, so WHMCS dunned it. That is a property of the invoice, not of the sync switch: gating it on the switch would make the Retire step above, and every ten-minute troubleshooting toggle, email every historical client at once and add a real late-fee line to invoices WHMCS may already have collected on. Chase a mirrored invoice by hand from its Send action instead.
Limitations. Addon line items need the GetClientsAddons permission on the API role; without it the schedules still mirror, billing the product amount alone, and the run says so. Refunds are not pushed back to WHMCS; one currency per invoice (the WHMCS invoice's currency, else the site default); addons on a different billing cycle than their parent product are skipped with a reason in the sync log (give them their own schedule, or add the line by hand once the schedule is active, at which point the sync stops overwriting its items); WHMCS credits applied to an invoice show up as paid amount rather than as a separate credit line; a WHMCS transaction whose invoice has since been DELETED there cannot be mirrored (there is nothing to attach it to), so an income report summing raw transactions can read slightly higher than the mirror's paid total; services with a non-recurring cycle (One Time, Free Account) are not mirrored; and an invoice deleted in WHMCS is not noticed (its mirror stays as it is — void it here if you delete one there, so it stops counting against the client's balance).
Reminders
Off by default; enable under Settings → Reminders. When on: a due-soon heads-up N days before the due date (0 skips it), then overdue nudges every N days after, capped at a configurable max per invoice. Every send is claimed in the email log first, so overlapping cron ticks can never double-send. Individual invoices can be muted/resumed from their row menu. Invoices mirrored from WHMCS are never reminded about at all, whatever the WHMCS sync switch is set to (see WHMCS migration).
Late fees
Off by default. When enabled, an invoice overdue by the configured days gets the fee once — as a visible "Late fee" line item (percent-of-balance, capped at 20%, or a flat amount), recomputing totals through the same path as every edit. The applied timestamp is claimed atomically, so overlapping runs can't double-fee. Invoices mirrored from WHMCS never accrue one, whatever the WHMCS sync switch is set to (see WHMCS migration).
What the client sees
The emailed invoice contains the itemized table, totals, any notes, an optional PDF attachment, and a View & pay invoice button pointing at the tokenized public page (/invoice/{token} — a 40-character random token, no login needed):
- A printable document: the business identity block, number, issue/due dates, billed-to block, line items, totals, payment history, notes, and terms — plus Download PDF and Print buttons.
- A status banner when relevant: overdue (with the missed due date), void, accepted, or a green thank-you right after paying/accepting.
- A Pay button showing the open balance whenever the invoice is payable. When a deposit is required or choose-your-amount is on, an amount chooser offers Pay the full balance, Pay the N% deposit, and (optionally) another amount — validated server-side against the deposit floor, minimum $1, and the open balance. Checkout is embedded Stripe; with ACH enabled, US bank debits appear alongside cards.
The first client open records first_viewed_at — dashboard managers previewing don't count, and drafts are invisible to the public entirely (managers can preview them while logged in, including the PDF).
Member billing portal
When member accounts exist (Memberships, Ecommerce, or Real Estate), a logged-in member visiting /members/invoices sees every non-draft invoice and estimate billed to their email address, with live status badges and links to each document. The member dashboard shows a Your invoices card whenever something has actually been billed to them. Matching is by email only — no linking step required.
Payments & refunds
- Online (Stripe) — one line item for the chosen amount, stamped with the invoice ID in the session metadata. The webhook (
invoice/stripe/webhook, verified against its own signing secretstripe.webhook_secret.invoicing; subscribe it tocheckout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed, andcharge.refunded) and the return page race to record the completed session; a unique claim on the checkout-session ID guarantees exactly one payment row lands. Credentials reuse the site-wide Stripe keys from Settings → API Keys. - ACH settlement — a bank-debit checkout completes days before the money clears, so the invoice enters a processing hold (
payment_processing_at): the public page shows a "payment is processing" notice instead of the pay button, and reminders/late fees pause.async_payment_succeededrecords the payment and releases the hold;async_payment_failedreleases it with an activity-log entry and emails the client once that the payment didn't go through (for the amount they tried to pay, with a Try again link to the invoice), so dunning resumes and the client can pay again. While the hold is on, the invoice's displayed status is Payment processing — on the dashboard list and editor, the client's page and billing portal, and the API'sdisplay_status— ahead of Overdue or Partially paid, and it is not counted as overdue (the dashboard card's overdue count, the API'sstatus=overdue).- The hold belongs to its checkout session (
payment_processing_session_id), and the last session whose bank payment bounced is remembered (payment_failed_session_id). A failure clears only its own session's hold, so a late failure for an older attempt never reopens an invoice whose newer bank payment is still clearing. A bounced session never holds the invoice again — not when its return page is reloaded, nor when itscompletedevent is redelivered or arrives after the failure (a failure delivered first is recorded, and the client emailed, all the same). The failure email goes once per failed session, and never about an invoice since paid (by cheque, say) or voided, nor while another bank payment for it is still clearing. A payment that settles releases its own session's hold; an invoice paid in full holds nothing. - If Stripe never says how it ended — the webhook isn't subscribed to the two
checkout.session.async_payment_*events, or Stripe gave up delivering them — the hold's session is checked against Stripe once the invoice has been processing for a day (php artisan payments:reconcile-processing, every six hours) and the payment recorded or released exactly as the webhook would have, with a warning in the log. A hold stamped before holds recorded their session names none, so it can't be looked up and is left as it is; any later event for the invoice, or recording the payment by hand, still clears it.
- The hold belongs to its checkout session (
- Manual — record checks, cash, bank transfers, or "other" with an amount (defaults to the open balance), received date, and reference. A caller may pass an idempotency key (
client_invoice_payments.idempotency_key, unique): the same key arriving twice lands one payment and hands the second caller the first's — Jobs & Dispatch's cash / check form on the tech's phone uses it, so a double tap on a weak signal can't record the money twice. - Partial payments — any payment below the balance keeps the invoice outstanding with a Partially paid badge; when cumulative payments reach the total, it flips to Paid automatically.
- Refunds — each Stripe payment row in the editor has a Refund button (full remaining amount, through Stripe); refunds recorded monotonically per payment (a stale
charge.refundedwebhook can't lower them) and rolled up on the invoice. The invoice keeps its Paid status — the books show the refund rather than rewriting history. The button goes throughSupport/InvoicePaymentRefunder(Stripe first, then the cumulative total, then the Activity Log line) — the same path a cancelled venue rental uses to give back the payments on its balance invoice, so the two can never disagree. - Invoices raised by other features — a venue rental's balance invoice and an appointment's "pay later" invoice feed what they collect (payments less refunds,
Invoice::netPaidCents()) back into the rental's or appointment's Paid / Balance due. Only an issued (sent or paid) invoice's total counts as owed (Invoice::isIssued()), so lines added to a draft don't show up as a debt before the client is asked for them.
Emails
All transactional mail goes through the shared Marketing transport (CampaignMailer), with an optional invoicing-specific from name/address: the invoice/estimate email (with PDF attachment when enabled), reminders, payment receipts (claimed once per payment), owner copies of generated/sent invoices (see Send recipients), an owner notification on online payment, an owner notification when an estimate is accepted, and the client notice when a bank payment bounces (one per failed payment). If the transport isn't configured, sending fails gracefully — the copy-link action, PDF download, and public page work without email entirely.
Integrations
- CRM — manually created clients mirror into the CRM as contacts (source
invoicing, toggleable); imports link to their originating contact; sent/paid/accepted events log to the contact's interaction timeline (typeinvoice). - Activity Log — sent, marked-sent, paid, voided, refunded, estimate-accepted, late-fee, and recurring-generation events all log under the
invoicingcategory.
Statuses
Stored: draft → sent → paid, plus void. Displayed: Payment processing (a bank payment clearing), Overdue, Partially paid, and Accepted are derived live from the stored status — no sweeper job when a due date passes. Voiding is allowed any time before full payment; recorded payments are kept for the books.
Limitations
- Estimates have accept-only responses — no decline button or client comments (they can simply reply to the email).
- Refunds are full-remaining per payment — no partial refund amounts from the dashboard (use the Stripe dashboard for those; the webhook records them correctly).
- The member portal matches by email only — invoices billed to a different address than the member's login won't appear.
- One currency per invoice, set from settings at creation.
- Price list prices are in the install's invoicing currency; a pick copies the number as-is onto a proposal in another currency.
- The price list's taxable flag is not applied per line — an invoice's tax rate covers every line.
- Tax rates keep three decimals (
client_invoices.tax_percentand the recurring schedule's aredecimal(6,3), matchingproposals.tax_rate), so an 8.875% proposal converts exactly.