Files
picpeak/backend/src/services/invoice/sending.js
T
Paul Nothaft fdf62dafef refactor(backend): decompose invoiceService, contractService, adminEvents
Move-code split, public entry points unchanged:
- services/invoiceService.js (3,623 -> 99) + services/invoice/* (10 modules)
- services/contractService.js (2,363 -> 83) + services/contract/* (6 modules)
- routes/adminEvents.js -> routes/adminEvents/* (crud, slideshow, resets,
  archive/bulk, logo); route registration order verified identical
Lazy cross-service requires preserved to keep the module graph acyclic.
2026-07-03 08:10:25 +02:00

655 lines
28 KiB
JavaScript

// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { AppError } = require('../../utils/errors');
const { formatShortDate } = require('../../utils/dateFormatter');
const { resolveBillingRecipients } = require('../_billingRecipients');
const pdfService = require('../pdfService');
const emailProcessor = require('../emailProcessor');
const { ensureInt, ensureNumber } = require('../../utils/numericHelpers');
const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers, nextInvoiceNumber, resolveNetDaysForRow } = require('./helpers');
const { getInvoiceById } = require('./queries');
const { createInvoice } = require('./create');
const { buildInvoiceRenderContext } = require('./render');
/**
* Send an invoice email + PDF. Flips status scheduled → sent.
*/
async function sendInvoice(id, adminId) {
const data = await getInvoiceById(id);
if (!data) throw new AppError('Invoice not found', 404);
const { invoice, lineItems } = data;
// Stornorechnungen go through their own send path — different
// email template, different variables, different PDF render
// branch. The scheduler's flush loop hits this entry point for
// every row in status='scheduled', so the dispatch lives here.
if (invoice.kind === 'storno') {
return await sendStorno(id, adminId);
}
if (!['scheduled', 'sent', 'overdue'].includes(invoice.status)) {
throw new AppError(`Cannot send invoice with status '${invoice.status}'`, 409);
}
// Monthly-draft guard (migration 128). Rows flagged
// is_monthly_draft=true accumulate line items across the period
// and must ONLY be issued via triggerMonthlyBillNow / the scheduled
// monthly flush — both clear the flag before re-entering this
// function. Without this guard, admin clicks on a draft's Send
// button would ship the running accumulator early AND leave the
// flag set, so subsequent createInvoice calls would silently
// append onto the same already-sent row.
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) {
throw new AppError(
'This invoice is a monthly draft — use "Trigger invoice now" on the customer detail page, or wait for the scheduled cycle day.',
409, 'MONTHLY_DRAFT_NOT_SENDABLE',
);
}
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
ensureCustomerCanBill(customer);
// Re-sync the invoice's language from the customer's current
// preferred_language at send time when the invoice has never been
// sent. Picks up admin language changes made between create and
// send (notable for monthly drafts that accumulate for ~30 days,
// and for any standalone scheduled invoice where admin updated the
// customer record after authoring). Sent / overdue invoices keep
// their existing language because they're legal records — the
// rendered PDF is the source of truth from the moment it ships.
if (invoice.status === 'scheduled' && customer.preferred_language
&& customer.preferred_language !== invoice.language) {
await db('invoices').where({ id }).update({
language: customer.preferred_language,
updated_at: new Date(),
});
invoice.language = customer.preferred_language;
}
// Stamp the issue date at the moment the invoice actually goes out.
// A scheduled invoice's issue_date is provisional — set to the
// authoring day at creation — but the legal issue date is when it
// ships. Anchoring it here keeps the printed invoice date, the Skonto
// window (a relative "pay within N working days" counted from that
// date) and the net-days due date all consistent with the send date.
// Only on the first send (status 'scheduled'); 'sent' / 'overdue'
// rows are immutable legal records and keep their stamped date.
if (invoice.status === 'scheduled') {
const sendDateIso = new Date().toISOString().slice(0, 10);
const netDays = await resolveNetDaysForRow(invoice);
// Re-anchor the due date too, but only when it was machine-set: if
// the stored due_date still equals the auto formula off the OLD
// base (scheduled_send_at, else the old issue_date), the admin never
// hand-edited it and we slide it to the new issue date. A divergent
// value means a manual override (the editor's "Override due date"
// toggle) — leave it untouched.
const oldBase = invoice.scheduled_send_at
? new Date(invoice.scheduled_send_at)
: new Date(invoice.issue_date);
const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10);
const storedDue = invoice.due_date
? new Date(invoice.due_date).toISOString().slice(0, 10)
: null;
const updates = { issue_date: sendDateIso, updated_at: new Date() };
if (storedDue && storedDue === oldAutoDue) {
updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10);
}
await db('invoices').where({ id }).update(updates);
invoice.issue_date = updates.issue_date;
if (updates.due_date) invoice.due_date = updates.due_date;
}
const ctx = await buildInvoiceRenderContext(invoice, lineItems);
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
// Persist PDF snapshot.
const fs = require('fs');
const path = require('path');
const year = new Date(invoice.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
const newStatus = invoice.status === 'overdue' ? 'overdue' : 'sent';
await db('invoices').where({ id }).update({
status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(),
});
const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', {
invoice_number: invoice.invoice_number,
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
event_name: invoice.event_name || '',
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
due_date: formatShortDate(invoice.due_date),
installment_label: invoice.installment_label || '',
installment_index: invoice.installment_index + 1,
installment_total: invoice.installment_total,
cc: invoiceCc,
attachments: [{
filename: `${invoice.invoice_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}],
});
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
// Fire the workflow engine's invoice.sent trigger (after the row is updated +
// the email queued). Idempotent per invoice id; no-op when the workflows flag
// is off. Never throws into the send path.
try {
await require('../workflows').emitWorkflowEvent('invoice.sent', {
entityType: 'invoice',
entityId: id,
payload: {
invoiceId: id,
invoiceNumber: invoice.invoice_number,
eventId: invoice.event_id || null,
customerAccountId: invoice.customer_account_id,
customerEmail: invoiceTo,
dueDate: invoice.due_date,
issueDate: invoice.issue_date,
totalMinor: invoice.total_amount_minor,
currency: invoice.currency,
},
});
} catch (_) {}
return { sent: true, pdfPath };
}
/**
* Materialise a Stornorechnung (cancellation invoice) for an already-
* issued original. Atomic:
* 1. Insert a new `invoices` row with `kind='storno'`, totals
* negated, no due_date / payment terms / bank account / QR,
* and `cancels_invoice_id` pointing at the original.
* 2. Snapshot the original's line items at full positive amounts
* (the sign is carried by the row-level totals; the renderer
* flips line totals visually for `kind='storno'`). Preserves
* the migration-119 sub-item hierarchy via parent_position →
* parent_line_item_id resolution in `insertLineItemsHierarchical`.
* 3. Flip the original to `status='cancelled'` and pin its
* `cancellation_storno_id` so the admin detail view can render
* a "Cancelled by Storno S-XXXX" banner.
*
* Returns the Storno's id. The caller is responsible for actually
* sending it (sendStorno) — splitting the create/send seam means
* a failed PDF render or email queue doesn't roll back the
* cancellation itself; the storno sits in `status='scheduled'`
* and the cron picks it up.
*/
async function createStorno(originalId, adminId, trx = db) {
const original = await trx('invoices').where({ id: originalId }).first();
if (!original) throw new AppError('Invoice not found', 404);
if (original.kind === 'storno') {
throw new AppError('Cannot Storno a Storno', 409, 'IS_STORNO');
}
if (original.status === 'scheduled') {
throw new AppError(
'This invoice has not been sent yet — Storno only applies to issued documents.',
409,
'USE_EDIT_INSTEAD',
);
}
if (original.status === 'cancelled') {
throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED');
}
// Generate the Storno's sequence number from the same gap-free
// series as regular invoices (single sequence — decision locked
// with the maintainer; satisfies §14 (4) Nr. 4 UStG).
// Pass trx so the sequence claim joins the caller's transaction —
// SQLite deadlocks otherwise (1-connection default).
const stornoNumber = await nextInvoiceNumber(trx);
const now = new Date();
const issueDate = now.toISOString().slice(0, 10);
// Insert the Storno row. Totals negated for accounting integrity
// (tax report aggregates by row-level totals, so a Storno
// contributes correctly without the renderer needing to flip
// signs at report time). Line items below stay positive — the
// renderer applies the sign at presentation time.
const insertedRow = await trx('invoices').insert({
kind: 'storno',
invoice_number: stornoNumber,
customer_account_id: original.customer_account_id,
event_id: original.event_id,
// Inline event snapshot — copy so the Storno carries the same
// event label as the invoice it reverses (migration 123). The
// bookkeeper expects to see both documents under the same event.
event_name: original.event_name || null,
event_date: original.event_date || null,
event_time_start: original.event_time_start || null,
event_time_end: original.event_time_end || null,
source_quote_id: null,
// Migration 124 — carry the split FKs through onto the Storno row
// so the lineage stays consistent if anyone audits the
// cancellation document and checks the picker state.
payment_net_days_template_id: original.payment_net_days_template_id || null,
payment_timing_template_id: original.payment_timing_template_id || null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
// Migration 130 — carry the original's VAT-code snapshot onto the Storno so
// both documents export the same code. Conditional spread = safe on pre-130
// DBs (undefined → omitted).
...(original.vat_code ? { vat_code: original.vat_code } : {}),
shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0),
net_amount_minor: -ensureInt(original.net_amount_minor),
vat_amount_minor: -ensureInt(original.vat_amount_minor),
total_amount_minor: -ensureInt(original.total_amount_minor),
late_fee_amount_minor: 0,
paid_amount_minor: 0,
status: 'scheduled',
scheduled_send_at: now,
issue_date: issueDate,
// Storni have no payment due — mirror issue_date to satisfy the
// schema's NOT NULL constraint on due_date. The field is dead data
// for kind='storno' rows: the PDF renderer suppresses the due-date
// line, and the dunning scheduler filters kind='invoice'.
due_date: issueDate,
reminder_level: 0,
cc_pdf_email: original.cc_pdf_email,
// No payment block on a Storno — it's not a payment instrument.
business_bank_account_id: null,
qr_format: null,
payment_term_template_id: null,
// Lineage.
cancels_invoice_id: original.id,
replaces_invoice_id: null,
cancellation_storno_id: null,
// Migration 140 — Storno belongs to the same deal as the invoice
// it cancels; both render together in the lineage view.
deal_uuid: original.deal_uuid || crypto.randomUUID(),
created_at: now,
updated_at: now,
}).returning('id');
const stornoId = Array.isArray(insertedRow)
? (insertedRow[0]?.id ?? insertedRow[0])
: insertedRow;
// Snapshot the original's line items (positive amounts — the
// Storno's sign convention lives on the row-level totals + the
// renderer flip).
const lineItems = await trx('invoice_line_items as li')
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
.where('li.invoice_id', originalId)
.orderBy('li.position', 'asc')
.select('li.*', 'parent.position as parent_position');
if (lineItems.length > 0) {
const cloned = lineItems.map((li) => ({
position: ensureInt(li.position),
quantity: li.quantity,
description: li.description,
unit_price_minor: ensureInt(li.unit_price_minor),
discount_percent: ensureNumber(li.discount_percent, 0),
line_total_minor: ensureInt(li.line_total_minor),
parent_position: li.parent_position == null ? null : ensureInt(li.parent_position),
details_text: li.details_text || null,
}));
const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers();
validateLineItemHierarchy(cloned);
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', stornoId, cloned);
}
// Flip the original to cancelled + link the Storno.
await trx('invoices').where({ id: originalId }).update({
status: 'cancelled',
cancellation_storno_id: stornoId,
updated_at: now,
});
try {
await logActivity('invoice_cancelled_via_storno',
{ invoiceId: originalId, stornoId, stornoNumber },
original.event_id || null, `admin:${adminId}`);
} catch (_) {}
return stornoId;
}
/**
* Send a Stornorechnung — renders the PDF, persists it on disk,
* flips the row to `status='sent'`, and queues the `storno_issued`
* email to the customer with the PDF attached.
*
* Mirrors sendInvoice's shape so the scheduler's flush loop can
* delegate uniformly. The email template ships in Phase 3
* (renames the dormant `invoice_cancelled` seed); if the worker
* picks up the job before the template lands it logs the missing
* template — the row stays in `sent` either way.
*/
async function sendStorno(stornoId, adminId) {
const data = await getInvoiceById(stornoId);
if (!data) throw new AppError('Storno not found', 404);
const { invoice: storno, lineItems } = data;
if (storno.kind !== 'storno') {
throw new AppError(`Expected kind='storno', got '${storno.kind}'`, 409);
}
if (storno.status === 'sent') return { status: 'sent' };
const customer = await db('customer_accounts').where({ id: storno.customer_account_id }).first();
ensureCustomerCanBill(customer);
const ctx = await buildInvoiceRenderContext(storno, lineItems);
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
// Persist PDF snapshot alongside regular invoices.
const fs = require('fs');
const path = require('path');
const year = new Date(storno.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${storno.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
await db('invoices').where({ id: stornoId }).update({
status: 'sent',
sent_at: new Date(),
pdf_path: pdfPath,
updated_at: new Date(),
});
// Look up the original so we can include both numbers in the
// email body — customers' bookkeepers expect to see the pair.
const originalRow = storno.cancels_invoice_id
? await db('invoices').where({ id: storno.cancels_invoice_id })
.select('invoice_number', 'issue_date').first()
: null;
const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email);
await emailProcessor.queueEmail(storno.event_id || null, stornoTo, 'storno_issued', {
storno_number: storno.invoice_number,
original_invoice_number: originalRow?.invoice_number || '',
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
cc: stornoCc,
attachments: [{
filename: `${storno.invoice_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}],
});
try {
await logActivity('storno_sent',
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
storno.event_id || null, `admin:${adminId || 'system'}`);
} catch (_) {}
return { status: 'sent', stornoId };
}
/**
* Reissue an invoice — the legally-correct alternative to post-send
* editing.
* 1. If the original is still live (sent / overdue / paid),
* generate a Stornorechnung for it via `createStorno` and
* immediately send it to the customer (sendStorno). The
* original flips to `status='cancelled'` and its
* `cancellation_storno_id` is pinned.
* 2. Create a fresh `scheduled` invoice with a new sequence
* number, line items snapshotted from the original, and
* `replaces_invoice_id` pointing at the original so the
* renderer can stamp "Bezug: Ersetzt Rechnung R-XXXX".
*
* If the original is ALREADY cancelled (admin previously cancelled
* it via Storno on its own), the cancel step is skipped — only the
* replacement is created. `scheduled` originals are rejected
* (USE_EDIT_INSTEAD) since drafts don't need legal cancellation.
*/
async function reissueInvoice(id, adminId) {
const original = await db('invoices').where({ id }).first();
if (!original) throw new AppError('Invoice not found', 404);
if (original.kind === 'storno') {
throw new AppError('Cannot reissue a Storno document', 409, 'IS_STORNO');
}
if (original.status === 'scheduled') {
throw new AppError(
'This invoice has not been sent yet — use Edit instead of Cancel & reissue.',
409,
'USE_EDIT_INSTEAD',
);
}
// Cancel via Storno first if still live. We deliberately commit
// the Storno BEFORE creating the replacement so a failed sendStorno
// doesn't roll back the cancellation; the storno sits in
// status='scheduled' and the cron picks it up. Same resiliency
// contract as cancelInvoice.
let stornoId = null;
if (original.status !== 'cancelled') {
stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx));
try { await sendStorno(stornoId, adminId); } catch (err) {
logger.warn('sendStorno during reissue failed — scheduler will retry', { stornoId, err: err.message });
}
}
// Build the replacement. Same shape as the original — re-uses
// createInvoice so totals are recomputed authoritatively from
// line items (any rounding drift gets normalised). Self-join
// carries parent_position so migration-119 sub-items survive.
return await db.transaction(async (trx) => {
const lineItems = await trx('invoice_line_items as li')
.leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id')
.where('li.invoice_id', id)
.orderBy('li.position', 'asc')
.select('li.*', 'parent.position as parent_position');
const liPayload = lineItems.map((li) => ({
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unit_price_minor: Number(li.unit_price_minor),
discount_percent: Number(li.discount_percent || 0),
parent_position: li.parent_position == null ? null : Number(li.parent_position),
details_text: li.details_text || null,
}));
const { invoiceIds: reissuedIds } = await createInvoice({
customerAccountId: original.customer_account_id,
sourceQuoteId: original.source_quote_id || null,
eventId: original.event_id || null,
language: original.language,
currency: original.currency,
vatRate: original.vat_rate,
shippingAmountMinor: original.shipping_amount_minor,
ccPdfEmail: original.cc_pdf_email,
businessBankAccountId: original.business_bank_account_id,
qrFormat: original.qr_format,
paymentTermTemplateId: original.payment_term_template_id,
// Reissue always produces a standalone invoice even when the
// customer is on monthly billing — folding the reissued items
// into the current period's running draft would conflate two
// unrelated billing periods. The escape hatch keeps the
// standard createInvoice flow.
_skipMonthlyRouting: true,
// Carry the split picker (migration 124) + event snapshot
// (migration 123) onto the reissued draft so the admin doesn't
// have to re-set them after a Cancel & reissue. createInvoice
// already accepts these on both code paths.
paymentNetDaysTemplateId: original.payment_net_days_template_id || null,
paymentTimingTemplateId: original.payment_timing_template_id || null,
eventName: original.event_name || null,
eventDate: original.event_date || null,
eventTimeStart: original.event_time_start || null,
eventTimeEnd: original.event_time_end || null,
// No installment metadata — reissue defaults to a single
// standalone invoice. If the admin needs the same split they
// can run the original conversion again from the quote.
lineItems: liPayload,
// Migration 140 — reissue inherits the cancelled original's
// deal_uuid so Storno + replacement + cancelled all group
// under one deal lineage view.
dealUuid: original.deal_uuid || null,
}, adminId, trx);
// Reissue always produces a single invoice (no installments
// forced), so the array length is 1.
const newId = reissuedIds[0];
await trx('invoices').where({ id: newId }).update({
replaces_invoice_id: id,
updated_at: new Date(),
});
try {
await logActivity('invoice_reissued',
{ originalInvoiceId: id, newInvoiceId: newId, stornoId },
original.event_id || null, `admin:${adminId}`);
} catch (_) {}
return { id: newId, replaces: id, stornoId };
});
}
/**
* Release a `pending_delivery` invoice for sending. Used when the
* photographer has actually delivered the photos and is ready to
* collect the final installment — flips the status to `scheduled`
* with `scheduled_send_at = now`, then immediately calls sendInvoice
* so the email goes out without waiting for the next scheduler tick.
*
* Refuses to act on rows that aren't pending — admins should use
* sendInvoice / sendReminder for the normal `scheduled`/`sent` flow.
*/
async function releaseForDelivery(id, adminId) {
const invoice = await db('invoices').where({ id }).first();
if (!invoice) throw new AppError('Invoice not found', 404);
if (invoice.status !== 'pending_delivery') {
throw new AppError(
`Invoice is not awaiting delivery (status: '${invoice.status}')`,
409,
'NOT_PENDING_DELIVERY',
);
}
const now = new Date();
await db('invoices').where({ id }).update({
status: 'scheduled',
scheduled_send_at: now,
updated_at: now,
});
try {
await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`);
} catch (_) {}
// Fire immediately rather than waiting for the next scheduler
// tick — admin clicked the button because they want it out now.
return await sendInvoice(id, adminId);
}
/**
* Cancel an invoice. The behaviour depends on whether the document
* was ever issued:
*
* - `scheduled` (draft, no PDF emitted): soft cancel — status
* flips to 'cancelled', nothing leaves the system. No Storno is
* generated because no document exists for the customer to
* reverse.
*
* - `sent` / `overdue` / `paid` (issued): generate a
* Stornorechnung (cancellation invoice) with its own sequence
* number, attach a signed PDF, and email it to the customer.
* Original flips to 'cancelled' and pins its
* `cancellation_storno_id` for the admin lineage view. This is
* the only §14c-defensible cancellation path under DACH tax law
* once an invoice has been delivered to the recipient.
*
* Note we allow `paid` here on purpose — bookkeepers cancel
* paid invoices when issuing refunds. The actual money
* movement (refund, carry-forward as Anzahlung) is handled
* separately; the Storno is the document leg.
*
* - `cancelled` (already): 409, `ALREADY_CANCELLED`.
*
* Returns `{ cancelled: true, stornoId? }` so the caller can
* surface "Storno S-XXXX wurde erzeugt" feedback when applicable.
*/
async function cancelInvoice(id, adminId) {
const invoice = await db('invoices').where({ id }).first();
if (!invoice) throw new AppError('Invoice not found', 404);
if (invoice.kind === 'storno') {
throw new AppError('Cannot cancel a Storno document', 409, 'IS_STORNO');
}
if (invoice.status === 'cancelled') {
throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED');
}
// Draft path: nothing was issued, soft cancel and we're done.
if (invoice.status === 'scheduled') {
await db('invoices').where({ id }).update({
status: 'cancelled', updated_at: new Date(),
});
try {
await logActivity('invoice_cancelled',
{ invoiceId: id, viaStorno: false },
invoice.event_id || null, `admin:${adminId}`);
} catch (_) {}
return { cancelled: true, stornoId: null };
}
// Issued path: Storno required. Commit createStorno in its own
// transaction so a failed sendStorno doesn't roll back the
// cancellation; the scheduler picks up an unsent Storno on the
// next tick.
const stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx));
try { await sendStorno(stornoId, adminId); } catch (err) {
logger.warn('sendStorno after cancelInvoice failed — scheduler will retry', { stornoId, err: err.message });
}
return { cancelled: true, stornoId };
}
async function triggerMonthlyBillNow(customerId, adminId) {
const draft = await db('invoices')
.where({ customer_account_id: customerId, is_monthly_draft: true })
.orderBy('id', 'desc')
.first();
if (!draft) {
throw new AppError('No pending monthly bill for this customer', 409, 'NO_MONTHLY_DRAFT');
}
const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1);
if (items.length === 0) {
throw new AppError('Monthly draft is empty — nothing to bill', 409, 'EMPTY_DRAFT');
}
// Arm the draft: clear the discriminator, pin issue_date to today,
// and set scheduled_send_at to now so the flush pass + sendInvoice
// path treats it like any other ready-to-send invoice. Logged as a
// distinct activity so the audit trail shows admin override vs the
// scheduler's automatic fire.
const issueDate = new Date().toISOString().slice(0, 10);
await db('invoices').where({ id: draft.id }).update({
is_monthly_draft: false,
issue_date: issueDate,
scheduled_send_at: new Date(),
updated_at: new Date(),
});
try {
await logActivity('monthly_bill_triggered_manually',
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
null, `admin:${adminId}`);
} catch (_) {}
// Inline send so admin gets immediate feedback (PDF stored, status
// flipped to 'sent', email queued). A failure here doesn't roll
// back the arming — the scheduler will pick it up on the next tick.
try {
await sendInvoice(draft.id, adminId);
} catch (err) {
logger.warn('triggerMonthlyBillNow: inline send failed — scheduler will retry',
{ invoiceId: draft.id, err: err.message });
}
return { invoiceId: draft.id, invoiceNumber: draft.invoice_number };
}
module.exports = {
sendInvoice,
createStorno,
sendStorno,
reissueInvoice,
releaseForDelivery,
cancelInvoice,
triggerMonthlyBillNow,
};