Files
picpeak/backend/src/utils/documentSequences.js
T
Luca d543949188 feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
2026-05-26 18:18:51 +02:00

94 lines
3.6 KiB
JavaScript

/**
* documentSequences — atomic gap-free sequence generator for CRM
* document numbers (invoices, quotes, contracts, future doc kinds).
*
* **Contract**: `claimNextSequence(kind, year, [trx])` returns the
* next integer in the (kind, year) series. Atomic against concurrent
* callers: two simultaneous claims for the same row return strictly
* increasing values, no collisions, no gaps.
*
* **How the atomicity works**
*
* Postgres: a single `UPDATE … SET current_value = current_value + 1
* WHERE kind = ? AND year = ? RETURNING current_value` holds a row
* lock for the duration of the statement; concurrent callers
* serialize on the lock.
*
* SQLite: knex does not expose `BEGIN IMMEDIATE` declaratively, but
* SQLite's default journal mode (or WAL) gives us per-row serialization
* via the transaction. We wrap the UPDATE + re-SELECT in a transaction
* which acquires the write lock; concurrent transactions queue.
*
* **First-claim path** (no row yet for the (kind, year))
*
* Migration 132 seeded rows for every existing year via MAX(...)
* backfill. New years need an INSERT on first use. We do an
* INSERT-OR-IGNORE then UPDATE … RETURNING. Both steps are inside
* the same transaction so the year row is guaranteed to exist when
* the UPDATE fires.
*/
const { db } = require('../database/db');
const { AppError } = require('./errors');
/**
* Claim the next sequence value for (kind, year). Returns the new
* integer. Throws AppError on DB failure; caller composes the
* formatted document number from this integer via formatNumberInTemplate.
*
* @param {string} kind 'invoice' | 'quote' | 'contract' | ...
* @param {number} year 4-digit year
* @param {object} [trx] optional knex transaction. When supplied the
* claim joins the caller's transaction so the
* sequence increment and the row INSERT can
* commit-or-roll-back together. Otherwise we
* run our own micro-transaction.
*/
async function claimNextSequence(kind, year, trx) {
if (!kind || typeof kind !== 'string') {
throw new AppError('claimNextSequence: kind required', 500);
}
const yr = parseInt(year, 10);
if (!Number.isFinite(yr)) {
throw new AppError('claimNextSequence: invalid year', 500);
}
const exec = async (q) => {
// Step 1: ensure the (kind, year) row exists. INSERT...ON CONFLICT
// DO NOTHING is the Postgres-native form; SQLite supports the same
// syntax (3.24+). knex's `onConflict('...').ignore()` paves over
// the differences.
await q('document_sequences')
.insert({
kind, year: yr, current_value: 0,
created_at: new Date(), updated_at: new Date(),
})
.onConflict(['kind', 'year']).ignore();
// Step 2: atomic claim. We do UPDATE … (no RETURNING because
// knex's returning() is uneven across drivers) then re-SELECT.
// The transaction wrapper (or the caller's trx) keeps the two
// statements on the same row lock, so concurrent claimers
// serialize.
await q('document_sequences')
.where({ kind, year: yr })
.increment('current_value', 1)
.update({ updated_at: new Date() });
const row = await q('document_sequences')
.where({ kind, year: yr })
.select('current_value')
.first();
if (!row) {
throw new AppError(`claimNextSequence: row vanished for ${kind}/${yr}`, 500);
}
return row.current_value;
};
if (trx) {
return await exec(trx);
}
return await db.transaction(async (innerTrx) => exec(innerTrx));
}
module.exports = { claimNextSequence };