Skip to main content

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:

  1. Staleness guard (pre-RPC, P0). Each requested DRAFT is recomputed from CURRENT logged/approved hours via buildInvoicePreview (with allowInactive so completed work at a deactivated facility can still ship). A draft is held back as stale when its frozen total_amount_cents no 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.
  2. Send RPC + email. Remaining ids go to the DB-atomic send_invoices RPC, which applies the hard/soft gates, flips status to 'sent', takes the entry-lock, and audits. Soft gate: acknowledgeUnapproved lets a bill with unapproved hours through; without it such drafts come back as held: '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 into outbound_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.