Function: performSend()
performSend(
companyId,actorId,invoiceIds,acknowledgeUnapproved,perSend?):Promise<SendResult| {error:string; }>
Defined in: src/server/services/invoice/send.ts:398
Issue a batch of invoices and email each one — the shared send path behind both the cockpit's batch "Review & send" and the single-invoice "Mark sent". Runs in two phases:
- Staleness guard (pre-RPC, P0). Each requested DRAFT is recomputed from CURRENT logged/approved
hours via buildInvoicePreview (with
allowInactiveso completed work at a deactivated facility can still ship). A draft is held back asstalewhen its frozentotal_amount_centsno longer equals the live recompute (kind: 'out_of_date'— operator must Correct/recompute first) or when it can't be recomputed at all (kind: 'blocked'— missing billing week, no billable lines, facility gone). This guarantees a committed send always bills a figure that reflects current hours, never a number the operator never reviewed. Non-draft ids bypass this phase. - Send RPC + email. Remaining ids go to the DB-atomic
send_invoicesRPC, which applies the hard/soft gates, flips status to'sent', takes the entry-lock, and audits. Soft gate:acknowledgeUnapprovedlets a bill with unapproved hours through; without it such drafts come back asheld: 'unapproved_hours'. After the RPC commits, each sent invoice's email is rendered and delivered best-effort — a skipped/failed email never undoes a committed send; the delivery outcome is persisted on the invoice and intooutbound_emails, and an exception in the email step is caught per-invoice (recorded'failed') so the rest of the batch still sends.
Billing weeks are Sunday-anchored (distinct from the pay engine's Monday ISO weeks); the lesser-of logged-vs-contracted (with approved-overage) policy is enforced upstream in invoice generation and is what the recompute reproduces here. All money is integer USD cents. Company-scoped throughout.
Parameters
companyId
string
Tenant scope; every query, the RPC, and email config are filtered to this company.
actorId
string
User performing the send; passed to the RPC as actor and recorded as created_by
on each outbound_emails row.
invoiceIds
string[]
Invoice ids to attempt; each lands in exactly one return bucket.
acknowledgeUnapproved
boolean
Soft-gate override: when true, the RPC sends bills containing
unapproved hours instead of holding them as 'unapproved_hours'.
perSend?
Optional batch-wide and per-invoice email overrides. cc/bcc/replyTo apply to
every email in the batch; perInvoice[id] adds/overrides bodyText, replyTo, and extra cc
for a single invoice. CC is merged with the facility's flagged contacts and de-duped against the
To list by buildCc.
bcc?
string[]
cc?
string[]
perInvoice?
Record<string, { bodyText?: string; cc?: string[]; replyTo?: string | null; }>
One-off per-invoice tweaks keyed by invoice id; overrides the batch body/reply-to and adds CC.
replyTo?
string | null
Returns
Promise<SendResult | { error: string; }>
On success, a SendResult partitioning the ids into sent / held / skipped /
stale. On RPC failure (or no data returned), { error: 'Could not send the invoices.' } — no
invoices were sent. If every requested id is held back as stale, returns an all-empty
SendResult (with stale populated) without invoking the RPC.