Просмотр исходного кода

Number new jobs after the highest number in the workshop's format (#575)

* Number new jobs after the highest number in the workshop's format

A new job continued from the newest job alone. When that job had no number
(an online booking) the sequence restarted at 1001, and when it was
numbered in another format (an import, a format changed and changed back)
the next job got a number the workshop had already issued.

Every place that numbers a job now asks one function, src/lib/job-number.ts:
new work orders and counter sales, the full create, a quote turned into a
job, both recurring invoice paths and the LubeLogger and Invoice Ninja
imports. It takes the highest running number already issued in the
workshop's format, any year in place of {year}, and adds one. With nothing
in the format yet, as right after the format changes, it continues from the
newest numbered job as before. The running number still carries across a
new year.

It also follows the rest of the numbering settings everywhere:
- {year} and {month} are the workshop's own calendar, not the server's,
  so a job opened just after midnight on New Year's Day is numbered in the
  new year. {month} was ignored outside new work orders.
- "Next invoice number" is used once and cleared on every path. Quote
  conversion ignored it, and a start below 1001 jumped to 1001 on the
  second job.

A database test runs the format query on PostgreSQL, and the restore
workflow now runs when the numbering changes.

* Count job numbers of any length, and spend "Next invoice number" only with the job

Running numbers were read to 15 digits and counted as JavaScript numbers,
so 999999999999999 led to a 16-digit number the format query could not
read back, and every job after it got that same number. They are now read
as NUMERIC and counted as BigInt, with no limit on either side.

"Next invoice number" was cleared before the job was written on several
paths, so a failed write lost it. The numbering now only says the setting
was used; each caller clears it right after writing the job, in the same
transaction, and a failed write leaves it for the retry.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
fd0870719a

+ 2 - 0
.github/workflows/restore-tenancy.yml

@@ -12,6 +12,8 @@ on:
       - 'src/lib/backup/**'
       - 'src/app/api/protected/backup/**'
       - 'src/__tests__/db/**'
+      # Job numbering reads the database with a raw pattern query.
+      - 'src/lib/job-number.ts'
       - 'prisma/**'
       - 'vitest.db.config.ts'
       - '.github/workflows/restore-tenancy.yml'

+ 126 - 0
src/__tests__/db/job-number.db.test.ts

@@ -0,0 +1,126 @@
+/**
+ * The next job number on a real PostgreSQL (`npm run test:db` with
+ * E2E_DATABASE_URL set).
+ *
+ * The unit tests mock the query that finds the highest number already issued
+ * in the workshop's format. This runs it: a pattern PostgreSQL reads
+ * differently from JavaScript, or a parameter it takes for a number, fails
+ * here instead of on a workshop's next job.
+ *
+ * It makes one person and one workshop of its own and removes them again; it
+ * empties nothing.
+ */
+import { randomUUID } from 'node:crypto'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+
+const url = process.env.E2E_DATABASE_URL ?? ''
+const databaseName = url.split('/').pop()?.split('?')[0]?.toLowerCase() ?? ''
+if (url && !/test|e2e/.test(databaseName)) {
+  throw new Error(
+    `Refusing to use database "${databaseName}": its name must say test or e2e, like the rest of this suite.`
+  )
+}
+
+// Set before the app's modules load, so the client connects to the test database.
+process.env.DATABASE_URL = url
+
+const stamp = randomUUID().slice(0, 8)
+const OSLO = 'Europe/Oslo'
+/** 13 November 2026 in Oslo. */
+const NOW = new Date('2026-11-13T09:00:00Z')
+
+let db: typeof import('@/lib/db')['db']
+let nextJobNumber: typeof import('@/lib/job-number')['nextJobNumber']
+let clearNextNumberSetting: typeof import('@/lib/job-number')['clearNextNumberSetting']
+let organizationId = ''
+let userId = ''
+
+/** Jobs in the order they were opened, the last one the newest. */
+const NUMBERS: (string | null)[] = [
+  '2025-1050',
+  '2026-1071',
+  '2026-1060',
+  'INV/2026.11-7',
+  // Looks like the INV format, but the dot is not a dot.
+  'INV/2026x11-9',
+  // Longer than any integer column or JavaScript number holds exactly.
+  'BIG-99999999999999999999',
+  'XT-1018',
+  // An online booking: no number at all.
+  null,
+]
+
+beforeAll(async () => {
+  ;({ db } = await import('@/lib/db'))
+  ;({ nextJobNumber, clearNextNumberSetting } = await import('@/lib/job-number'))
+  const user = await db.user.create({
+    data: { name: `Numbering ${stamp}`, email: `numbering-${stamp}@example.com` },
+  })
+  userId = user.id
+  const organization = await db.organization.create({ data: { name: `Numbering ${stamp}` } })
+  organizationId = organization.id
+  for (const [i, invoiceNumber] of NUMBERS.entries()) {
+    await db.serviceRecord.create({
+      data: {
+        title: `Job ${i}`,
+        organizationId,
+        invoiceNumber,
+        createdAt: new Date(Date.UTC(2026, 10, 1, 8, i)),
+      },
+    })
+  }
+})
+
+afterAll(async () => {
+  // The jobs and settings go with the workshop.
+  if (organizationId) await db.organization.deleteMany({ where: { id: organizationId } })
+  if (userId) await db.user.deleteMany({ where: { id: userId } })
+})
+
+function numbered(settings: Record<string, string>) {
+  return nextJobNumber({ organizationId, settings, timeZone: OSLO, now: NOW })
+}
+async function next(settings: Record<string, string>) {
+  return (await numbered(settings)).invoiceNumber
+}
+
+describe('the next job number on PostgreSQL', () => {
+  it('continues past the highest number in the format, whatever the newest job says', async () => {
+    expect(await next({ 'workshop.invoicePrefix': '{year}-' })).toBe('2026-1072')
+  })
+
+  it('reads the rest of the format literally', async () => {
+    expect(await next({ 'workshop.invoicePrefix': 'INV/{year}.{month}-' })).toBe('INV/2026.11-8')
+  })
+
+  it('reads and counts a running number of any length', async () => {
+    expect(await next({ 'workshop.invoicePrefix': 'BIG-' })).toBe('BIG-100000000000000000000')
+  })
+
+  it('continues from the newest numbered job in a format with no numbers yet', async () => {
+    expect(await next({ 'workshop.invoicePrefix': 'WO-' })).toBe('WO-1019')
+  })
+
+  it('uses "Next invoice number", which the job write then clears in its transaction', async () => {
+    await db.appSetting.create({
+      data: { organizationId, userId, key: 'workshop.invoiceStartNumber', value: '5000' },
+    })
+    const where = { organizationId_key: { organizationId, key: 'workshop.invoiceStartNumber' } }
+
+    expect(
+      await numbered({ 'workshop.invoicePrefix': '{year}-', 'workshop.invoiceStartNumber': '5000' })
+    ).toEqual({ invoiceNumber: '2026-5000', usesNextNumberSetting: true })
+    // Not spent until the job is written.
+    expect((await db.appSetting.findUnique({ where }))?.value).toBe('5000')
+
+    await db.$transaction(async (tx) => {
+      await tx.serviceRecord.create({
+        data: { title: 'The 5000 job', organizationId, invoiceNumber: '2026-5000' },
+      })
+      await clearNextNumberSetting(organizationId, tx)
+    })
+
+    expect((await db.appSetting.findUnique({ where }))?.value).toBe('')
+    expect(await next({ 'workshop.invoicePrefix': '{year}-' })).toBe('2026-5001')
+  })
+})

+ 3 - 0
src/__tests__/features/counter-sales.test.ts

@@ -24,6 +24,9 @@ vi.mock('@/features/inventory/Lib/onInventoryChanged', () => ({
 }))
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so resetAllMocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     vehicle: { findFirst: vi.fn(), update: vi.fn() },
     customer: { findFirst: vi.fn() },

+ 3 - 0
src/__tests__/features/invoices/recurring-invoice-timezone.test.ts

@@ -15,6 +15,9 @@ vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'Europ
 
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so resetAllMocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     vehicle: { findFirst: vi.fn() },
     appSetting: { findMany: vi.fn() },

+ 3 - 0
src/__tests__/features/quotes/convert-quote-inventory.test.ts

@@ -28,6 +28,9 @@ vi.mock('@/lib/invoice-utils', async (importOriginal) => ({
 
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so a reset of the mocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
     quote: { findFirst: vi.fn() },

+ 3 - 0
src/__tests__/features/tax/taxable-lines-backfill.test.ts

@@ -19,6 +19,9 @@ vi.mock('@/lib/document-lock.server', () => ({
 
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so resetAllMocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
     recurringInvoice: { findMany: vi.fn() },

+ 3 - 0
src/__tests__/features/tax/taxable-lines-save.test.ts

@@ -45,6 +45,9 @@ vi.mock('@/lib/files/manager', () => ({ releaseFiles: vi.fn().mockResolvedValue(
 
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so resetAllMocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn(), updateMany: vi.fn() },
     organization: { findUnique: vi.fn() },

+ 3 - 0
src/__tests__/features/tire-hotel/tire-lines-respect-lock.test.ts

@@ -32,6 +32,9 @@ vi.mock('@/features/inventory/Lib/onInventoryChanged', () => ({ onInventoryChang
 
 vi.mock('@/lib/db', () => ({
   db: {
+    // Nothing yet in the workshop's number format (src/lib/job-number.ts). A plain
+    // function, so resetAllMocks cannot blank it.
+    $queryRaw: async () => [],
     user: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
     serviceRecord: { findFirst: vi.fn(), update: vi.fn() },

+ 59 - 4
src/__tests__/features/workorders/create-workorder.test.ts

@@ -51,6 +51,8 @@ vi.mock('@/lib/db', () => ({
     // The workshop's closed days and time off, read with what is booked.
     workshopClosure: { findMany: vi.fn() },
     technicianAbsence: { findMany: vi.fn() },
+    // The highest number already issued in the workshop's format.
+    $queryRaw: vi.fn(),
     $transaction: vi.fn(),
   },
 }))
@@ -86,6 +88,7 @@ function setupCommonMocks() {
   vi.mocked(db.appSetting.findMany).mockResolvedValue([])
   vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Test Shop' } as any)
   vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null) // no previous records
+  vi.mocked(db.$queryRaw).mockResolvedValue([{ highest: null }] as any)
   vi.mocked(db.vehicle.update).mockResolvedValue(VEHICLE as any)
 }
 
@@ -97,6 +100,8 @@ beforeEach(() => {
   vi.mocked(db.inspection.findMany).mockResolvedValue([])
   vi.mocked(db.workshopClosure.findMany).mockResolvedValue([])
   vi.mocked(db.technicianAbsence.findMany).mockResolvedValue([])
+  // Nothing yet in the workshop's number format.
+  vi.mocked(db.$queryRaw).mockResolvedValue([{ highest: null }] as any)
 })
 
 // ---------------------------------------------------------------------------
@@ -164,13 +169,13 @@ describe('createServiceRecord — invoice number', () => {
     )
   })
 
-  it('increments from last invoice number', async () => {
+  it('continues from the highest number in the format, not from the newest job', async () => {
     setupAuth()
     setupCommonMocks()
     const year = new Date().getFullYear()
-    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({
-      invoiceNumber: `${year}-1005`,
-    } as any)
+    vi.mocked(db.$queryRaw).mockResolvedValue([{ highest: '1005' }] as any)
+    // The newest job is numbered in another format; it used to decide the number.
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue({ invoiceNumber: 'XT-1018' } as any)
 
     const mockCreate = vi.fn().mockResolvedValue({ id: 'sr-2' })
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
@@ -251,6 +256,56 @@ describe('createServiceRecord — invoice number', () => {
       })
     )
   })
+
+  /** "Next invoice number" set to 5000, and the transaction the job is written in. */
+  function withNextNumber(create: ReturnType<typeof vi.fn>) {
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([
+      { key: 'workshop.invoiceStartNumber', value: '5000' } as any,
+    ])
+    const txUpdateMany = vi.fn()
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        serviceRecord: { create },
+        servicePart: { createMany: vi.fn() },
+        serviceLabor: { createMany: vi.fn() },
+        serviceAttachment: { createMany: vi.fn() },
+        inventoryPart: { findFirst: vi.fn(), update: vi.fn() },
+        appSetting: { updateMany: txUpdateMany },
+      })
+    )
+    return txUpdateMany
+  }
+
+  it('spends "Next invoice number" in the transaction that writes the job', async () => {
+    setupAuth()
+    setupCommonMocks()
+    const year = new Date().getFullYear()
+    const mockCreate = vi.fn().mockResolvedValue({ id: 'sr-5' })
+    const txUpdateMany = withNextNumber(mockCreate)
+
+    await createServiceRecord({ vehicleId: VEHICLE_ID, title: 'Test' })
+
+    expect(mockCreate).toHaveBeenCalledWith(
+      expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: `${year}-5000` }) })
+    )
+    expect(txUpdateMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, key: 'workshop.invoiceStartNumber' },
+      data: { value: '' },
+    })
+    expect(db.appSetting.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('keeps "Next invoice number" when the job cannot be written', async () => {
+    setupAuth()
+    setupCommonMocks()
+    const txUpdateMany = withNextNumber(vi.fn().mockRejectedValue(new Error('insert failed')))
+
+    const result = await createServiceRecord({ vehicleId: VEHICLE_ID, title: 'Test' })
+
+    expect(result.success).toBe(false)
+    expect(txUpdateMany).not.toHaveBeenCalled()
+    expect(db.appSetting.updateMany).not.toHaveBeenCalled()
+  })
 })
 
 // ---------------------------------------------------------------------------

+ 190 - 0
src/__tests__/lib/job-number.test.ts

@@ -0,0 +1,190 @@
+/**
+ * A new job's number follows the format the workshop chose under Settings,
+ * Invoice, Numbering, and is never one already in use.
+ *
+ * It used to continue from the newest job alone. When that job had no number
+ * (an online booking) the sequence restarted at 1001, and when it was numbered
+ * in another format (XT-1018 after an import) the next job got 2026-1019,
+ * which the workshop had issued long before.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    $queryRaw: vi.fn(),
+    serviceRecord: { findFirst: vi.fn() },
+    appSetting: { updateMany: vi.fn() },
+  },
+}))
+
+import { db } from '@/lib/db'
+import { jobNumberPattern, jobNumberPrefix, nextJobNumber } from '@/lib/job-number'
+
+const ORG = 'org-1'
+const OSLO = 'Europe/Oslo'
+/** 13 November 2026 in Oslo. */
+const NOW = new Date('2026-11-13T09:00:00Z')
+
+/** The highest running number already issued in the workshop's format, as PostgreSQL returns it. */
+function highestInFormat(n: number | string | null) {
+  vi.mocked(db.$queryRaw).mockResolvedValue([{ highest: n === null ? null : String(n) }] as never)
+}
+function newestNumbered(invoiceNumber: string | null) {
+  vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(
+    invoiceNumber === null ? null : ({ invoiceNumber } as never)
+  )
+}
+function numbered(settings: Record<string, string> = {}, now = NOW) {
+  return nextJobNumber({
+    organizationId: ORG,
+    settings: { 'workshop.invoicePrefix': '{year}-', ...settings },
+    timeZone: OSLO,
+    now,
+  })
+}
+async function next(settings: Record<string, string> = {}, now = NOW) {
+  return (await numbered(settings, now)).invoiceNumber
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  highestInFormat(null)
+  newestNumbered(null)
+})
+
+describe('the next job number', () => {
+  it('continues from the highest number in the format, not from the newest job', async () => {
+    // The newest job is XT-1018 or an online booking with no number; 2026-1071 exists.
+    highestInFormat(1071)
+    newestNumbered('XT-1018')
+
+    expect(await next()).toBe('2026-1072')
+    expect(db.serviceRecord.findFirst).not.toHaveBeenCalled()
+  })
+
+  it('asks only for numbers in the workshop format, in its own workshop', async () => {
+    highestInFormat(1071)
+    await next({ 'workshop.invoicePrefix': 'WO-{year}/' })
+
+    const [sql, ...values] = vi.mocked(db.$queryRaw).mock.calls[0] as unknown as [
+      TemplateStringsArray,
+      ...unknown[],
+    ]
+    expect(sql.join('?')).toContain('"organizationId" = ?')
+    expect(values).toContain(ORG)
+    expect(values).toContain(jobNumberPattern('WO-{year}/'))
+  })
+
+  it('carries the running number across a new year', async () => {
+    highestInFormat(1050)
+
+    expect(await next({}, new Date('2027-01-04T09:00:00Z'))).toBe('2027-1051')
+  })
+
+  it("writes the year on the workshop's calendar, not the server's", async () => {
+    highestInFormat(1050)
+    // 00:30 on 1 January in Oslo is still 31 December in UTC.
+    expect(await next({}, new Date('2026-12-31T23:30:00Z'))).toBe('2027-1051')
+  })
+
+  it('writes the month when the format asks for it', async () => {
+    highestInFormat(7)
+
+    expect(await next({ 'workshop.invoicePrefix': 'INV-{year}{month}-' })).toBe('INV-202611-8')
+  })
+
+  it('continues from the newest numbered job after the format has changed', async () => {
+    newestNumbered('2026-1071')
+
+    expect(await next({ 'workshop.invoicePrefix': 'WO-' })).toBe('WO-1072')
+    expect(db.serviceRecord.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { organizationId: ORG, invoiceNumber: { not: null } } })
+    )
+  })
+
+  it('starts at 1001 in a workshop with no numbers yet', async () => {
+    expect(await next()).toBe('2026-1001')
+  })
+
+  it('uses "Next invoice number", and leaves clearing it to whoever writes the job', async () => {
+    highestInFormat(1071)
+
+    expect(await numbered({ 'workshop.invoiceStartNumber': '2000' })).toEqual({
+      invoiceNumber: '2026-2000',
+      usesNextNumberSetting: true,
+    })
+    // Cleared here, a job that then failed to save would lose it.
+    expect(db.appSetting.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('keeps counting from a "Next invoice number" below 1001 once it has been used', async () => {
+    // The settings page suggests 94; the job after 2026-94 is 2026-95, not 2026-1001.
+    highestInFormat(94)
+
+    expect(await next()).toBe('2026-95')
+  })
+
+  it('never goes back to a "Next invoice number" already passed', async () => {
+    highestInFormat(1071)
+
+    expect(await numbered({ 'workshop.invoiceStartNumber': '96' })).toEqual({
+      invoiceNumber: '2026-1072',
+      usesNextNumberSetting: false,
+    })
+  })
+
+  it('counts past numbers of any length, so a long one is read back and never issued twice', async () => {
+    highestInFormat('999999999999999')
+    expect(await next()).toBe('2026-1000000000000000')
+
+    highestInFormat('99999999999999999999')
+    expect(await next()).toBe('2026-100000000000000000000')
+  })
+
+  it('reads a long running number off the newest job whole', async () => {
+    newestNumbered('XT-12345678901234567890')
+
+    expect(await next({ 'workshop.invoicePrefix': 'WO-' })).toBe('WO-12345678901234567891')
+  })
+
+  it('reads "{year}-" as the format when the workshop never set one', async () => {
+    highestInFormat(1071)
+
+    const number = await nextJobNumber({
+      organizationId: ORG,
+      settings: {},
+      timeZone: OSLO,
+      now: NOW,
+    })
+
+    expect(number.invoiceNumber).toBe('2026-1072')
+  })
+})
+
+describe('which numbers count as the same format', () => {
+  const matches = (format: string, number: string) =>
+    new RegExp(jobNumberPattern(format)).exec(number)?.[1] ?? null
+
+  it('any length of running number', () => {
+    expect(matches('{year}-', '2026-100000000000000000000')).toBe('100000000000000000000')
+  })
+
+  it('any year, the same shape', () => {
+    expect(matches('{year}-', '2026-1071')).toBe('1071')
+    expect(matches('{year}-', '2019-5')).toBe('5')
+    expect(matches('{year}-', 'XT-1018')).toBeNull()
+    expect(matches('{year}-', '26-1018')).toBeNull()
+  })
+
+  it('reads the format literally around the year and month', () => {
+    expect(matches('INV/{year}.{month}-', 'INV/2026.11-12')).toBe('12')
+    expect(matches('INV/{year}.{month}-', 'INV/2026x11-12')).toBeNull()
+    expect(matches('', '1043')).toBe('1043')
+    expect(matches('', '2026-1043')).toBeNull()
+  })
+
+  it('writes the year and month of the day it is in the workshop', () => {
+    expect(jobNumberPrefix('{year}/{month}/', NOW, OSLO)).toBe('2026/11/')
+  })
+})

+ 19 - 16
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -5,7 +5,11 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import {
+  clearNextNumberSetting,
+  JOB_NUMBER_SETTING_KEYS,
+  jobNumberSequence,
+} from '@/lib/job-number'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { mkdir, rm } from 'fs/promises'
@@ -296,23 +300,18 @@ export async function POST(request: NextRequest) {
       docsByInvoice.set(doc.documentable_id, existing)
     }
 
-    // Get invoice prefix settings
-    const prefixSetting = await db.appSetting.findFirst({
-      where: { organizationId, key: 'workshop.invoicePrefix' },
+    // Imported jobs carry on the workshop's own numbering, in its format and
+    // past every number already issued in it; see src/lib/job-number.ts.
+    const numberingSettings = await db.appSetting.findMany({
+      where: { organizationId, key: { in: [...JOB_NUMBER_SETTING_KEYS] } },
     })
-    const invoicePrefix = resolveInvoicePrefix(prefixSetting?.value ?? '{year}-')
-
-    // Get the current highest invoice number
-    const lastRecord = await db.serviceRecord.findFirst({
-      where: { organizationId },
-      orderBy: { createdAt: 'desc' },
-      select: { invoiceNumber: true },
+    const sequence = await jobNumberSequence({
+      organizationId,
+      settings: Object.fromEntries(numberingSettings.map((s) => [s.key, s.value])),
+      timeZone,
     })
-    let nextInvoiceNum = 1001
-    if (lastRecord?.invoiceNumber) {
-      const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-      if (match) nextInvoiceNum = Number.parseInt(match[1], 10) + 1
-    }
+    const invoicePrefix = sequence.prefix
+    let nextInvoiceNum = sequence.first
 
     // Uploads directory for this org
     const uploadsBase = path.join(uploadsRoot(), organizationId)
@@ -549,6 +548,10 @@ export async function POST(request: NextRequest) {
             counts.payments++
           }
         }
+        // "Next invoice number" was used up by the first imported job.
+        if (sequence.usesNextNumberSetting && nextInvoiceNum > sequence.first) {
+          await clearNextNumberSetting(organizationId, tx)
+        }
       },
       { timeout: maxDuration * 1000 }
     )

+ 19 - 16
src/app/api/protected/backup/import-lubelog/route.ts

@@ -5,7 +5,11 @@ import { getAuthContext } from '@/lib/get-auth-context'
 import { isRestoring, RESTORE_IN_PROGRESS_MESSAGE } from '@/lib/backup/freeze'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import {
+  clearNextNumberSetting,
+  JOB_NUMBER_SETTING_KEYS,
+  jobNumberSequence,
+} from '@/lib/job-number'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { mkdir, stat, readdir, rm } from 'fs/promises'
@@ -261,23 +265,18 @@ export async function POST(request: NextRequest) {
       )
     }
 
-    // Get invoice prefix settings
-    const prefixSetting = await db.appSetting.findFirst({
-      where: { organizationId, key: 'workshop.invoicePrefix' },
+    // Imported jobs carry on the workshop's own numbering, in its format and
+    // past every number already issued in it; see src/lib/job-number.ts.
+    const numberingSettings = await db.appSetting.findMany({
+      where: { organizationId, key: { in: [...JOB_NUMBER_SETTING_KEYS] } },
     })
-    const invoicePrefix = resolveInvoicePrefix(prefixSetting?.value ?? '{year}-')
-
-    // Get the current highest invoice number to continue the sequence
-    const lastRecord = await db.serviceRecord.findFirst({
-      where: { organizationId },
-      orderBy: { createdAt: 'desc' },
-      select: { invoiceNumber: true },
+    const sequence = await jobNumberSequence({
+      organizationId,
+      settings: Object.fromEntries(numberingSettings.map((s) => [s.key, s.value])),
+      timeZone,
     })
-    let nextInvoiceNum = 1001
-    if (lastRecord?.invoiceNumber) {
-      const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-      if (match) nextInvoiceNum = Number.parseInt(match[1], 10) + 1
-    }
+    const invoicePrefix = sequence.prefix
+    let nextInvoiceNum = sequence.first
 
     // Map LubeLog vehicle IDs → Torqvoice vehicle IDs
     const vehicleIdMap = new Map<number, string>()
@@ -442,6 +441,10 @@ export async function POST(request: NextRequest) {
             }
           }
         }
+        // "Next invoice number" was used up by the first imported job.
+        if (sequence.usesNextNumberSetting && nextInvoiceNum > sequence.first) {
+          await clearNextNumberSetting(organizationId, tx)
+        }
       },
       { timeout: maxDuration * 1000 }
     )

+ 17 - 28
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -11,7 +11,12 @@ import {
   type CreateRecurringInvoiceInput,
   type UpdateRecurringInvoiceInput,
 } from '../Schema/recurringInvoiceSchema'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import {
+  clearNextNumberSetting,
+  type JobNumber,
+  JOB_NUMBER_SETTING_KEYS,
+  nextJobNumber,
+} from '@/lib/job-number'
 import {
   documentTotals,
   readWorkshopTax,
@@ -276,36 +281,14 @@ export async function toggleRecurringInvoice(id: string) {
   )
 }
 
-async function generateInvoiceNumber(organizationId: string): Promise<string> {
+/** The next job number in the workshop's format; see src/lib/job-number.ts. */
+async function generateInvoiceNumber(organizationId: string, timeZone: string): Promise<JobNumber> {
   const settings = await db.appSetting.findMany({
-    where: {
-      organizationId,
-      key: { in: ['workshop.invoicePrefix', 'workshop.invoiceStartNumber'] },
-    },
+    where: { organizationId, key: { in: [...JOB_NUMBER_SETTING_KEYS] } },
   })
-
   const settingsMap: Record<string, string> = {}
   for (const s of settings) settingsMap[s.key] = s.value
-
-  const prefix = resolveInvoicePrefix(settingsMap['workshop.invoicePrefix'] ?? '{year}-')
-  const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
-
-  const lastRecord = await db.serviceRecord.findFirst({
-    where: { organizationId },
-    orderBy: { createdAt: 'desc' },
-    select: { invoiceNumber: true },
-  })
-
-  let nextNum = startNumber || 1001
-  if (lastRecord?.invoiceNumber) {
-    const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-    if (match) {
-      const lastNum = parseInt(match[1], 10) + 1
-      nextNum = Math.max(nextNum, lastNum)
-    }
-  }
-
-  return `${prefix}${nextNum}`
+  return nextJobNumber({ organizationId, settings: settingsMap, timeZone })
 }
 
 export async function processRecurringInvoices() {
@@ -331,7 +314,10 @@ export async function processRecurringInvoices() {
       const taxApplies = await loadTaxApplies(organizationId)
 
       for (const ri of dueInvoices) {
-        const invoiceNumber = await generateInvoiceNumber(organizationId)
+        const { invoiceNumber, usesNextNumberSetting } = await generateInvoiceNumber(
+          organizationId,
+          timeZone
+        )
 
         // Calculate totals
         const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
@@ -394,6 +380,9 @@ export async function processRecurringInvoices() {
             },
           })
 
+          // Spent only once the job is written, in the same transaction.
+          if (usesNextNumberSetting) await clearNextNumberSetting(organizationId, tx)
+
           // Update recurring invoice
           const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency, timeZone)
           const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate

+ 11 - 15
src/features/quotes/Actions/quoteActions.ts

@@ -48,7 +48,7 @@ import {
 } from '../Lib/conversionConflicts'
 import { revalidatePath } from 'next/cache'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import { clearNextNumberSetting, JOB_NUMBER_SETTING_KEYS, nextJobNumber } from '@/lib/job-number'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -509,7 +509,7 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
           where: {
             organizationId,
             key: {
-              in: ['workshop.invoicePrefix', ...WARRANTY_SETTING_KEYS, ...SHOP_FEE_SETTING_KEYS],
+              in: [...JOB_NUMBER_SETTING_KEYS, ...WARRANTY_SETTING_KEYS, ...SHOP_FEE_SETTING_KEYS],
             },
           },
         }),
@@ -520,25 +520,19 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
       ])
       const settingsMap: Record<string, string> = {}
       for (const s of settings) settingsMap[s.key] = s.value
-      const prefix = resolveInvoicePrefix(settingsMap['workshop.invoicePrefix'] ?? '{year}-')
-
-      const lastRecord = await db.serviceRecord.findFirst({
-        where: { organizationId },
-        orderBy: { createdAt: 'desc' },
-        select: { invoiceNumber: true },
+      const timeZone = await workshopTimeZone(organizationId)
+      // The next number in the workshop's format, like any new job; see
+      // src/lib/job-number.ts. It ignored "Next invoice number" before.
+      const { invoiceNumber, usesNextNumberSetting } = await nextJobNumber({
+        organizationId,
+        settings: settingsMap,
+        timeZone,
       })
-      let nextNum = 1001
-      if (lastRecord?.invoiceNumber) {
-        const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-        if (match) nextNum = parseInt(match[1], 10) + 1
-      }
-      const invoiceNumber = `${prefix}${nextNum}`
 
       // What the customer was told on the quote is what the job carries, "not
       // included" as much as twelve months: they accepted on those words. A
       // quote that never mentioned warranty leaves the job to start like any
       // other, from the workshop's standing answer.
-      const timeZone = await workshopTimeZone(organizationId)
       const serviceDate = new Date()
       const warranty = quote.warrantyStatus
         ? normalizeWarranty(quote)
@@ -578,6 +572,8 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
             startDateTime: serviceDate,
           },
         })
+        // Spent only once the job is written, in the same transaction.
+        if (usesNextNumberSetting) await clearNextNumberSetting(organizationId, tx)
 
         const includedParts = await copyQuotePartsToJob(tx, {
           organizationId,

+ 9 - 27
src/features/vehicles/Actions/serviceActions.ts

@@ -28,7 +28,7 @@ import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
 import { randomUUID } from 'crypto'
 import { releaseFiles } from '@/lib/files/manager'
 import { serviceRecordFileUrls } from '@/lib/files/collect'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import { clearNextNumberSetting, JOB_NUMBER_SETTING_KEYS, nextJobNumber } from '@/lib/job-number'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { endOfWorkshopDay, toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { zonedDayKey } from '@/lib/timezone'
@@ -391,8 +391,7 @@ export async function createServiceRecord(input: unknown) {
             organizationId,
             key: {
               in: [
-                'workshop.invoicePrefix',
-                'workshop.invoiceStartNumber',
+                ...JOB_NUMBER_SETTING_KEYS,
                 ...WORKSHOP_TAX_SETTING_KEYS,
                 ...WARRANTY_SETTING_KEYS,
               ],
@@ -408,7 +407,6 @@ export async function createServiceRecord(input: unknown) {
       for (const s of settings) settingsMap[s.key] = s.value
 
       const shopName = data.shopName || org?.name || undefined
-      const prefix = resolveInvoicePrefix(settingsMap['workshop.invoicePrefix'] ?? '{year}-')
 
       // If the caller didn't pass taxInclusive, inherit the org's current setting.
       // Existing callers (vehicle-detail-client, ServiceForm) don't send the field,
@@ -447,30 +445,12 @@ export async function createServiceRecord(input: unknown) {
         labor: taxedLabor,
       })
 
-      // Generate sequential invoice number
-      const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
-      const lastRecord = await db.serviceRecord.findFirst({
-        where: { organizationId },
-        orderBy: { createdAt: 'desc' },
-        select: { invoiceNumber: true },
+      // The next number in the workshop's format; see src/lib/job-number.ts.
+      const { invoiceNumber, usesNextNumberSetting } = await nextJobNumber({
+        organizationId,
+        settings: settingsMap,
+        timeZone,
       })
-      let nextNum = startNumber || 1001
-      if (lastRecord?.invoiceNumber) {
-        const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-        if (match) {
-          const lastNum = parseInt(match[1], 10) + 1
-          nextNum = Math.max(nextNum, lastNum)
-        }
-      }
-      const invoiceNumber = `${prefix}${nextNum}`
-
-      // Clear start number after first use so it doesn't override future increments
-      if (startNumber && nextNum === startNumber) {
-        await db.appSetting.updateMany({
-          where: { organizationId, key: 'workshop.invoiceStartNumber' },
-          data: { value: '' },
-        })
-      }
 
       const {
         concerns,
@@ -536,6 +516,8 @@ export async function createServiceRecord(input: unknown) {
             warrantyExpiresAt: warrantyExpiryFor(warranty, warrantyStartsOn, timeZone),
           },
         })
+        // Spent only once the job is written, in the same transaction.
+        if (usesNextNumberSetting) await clearNextNumberSetting(organizationId, tx)
 
         if (partItems && partItems.length > 0) {
           // Enrich parts with unitCost from inventory (stock movement is handled

+ 11 - 29
src/features/vehicles/Lib/createDraftRecord.ts

@@ -23,6 +23,7 @@ import { loadBookingContext } from '@/features/workboard/Lib/bookings'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { atZonedTime, startOfZonedDay, zonedParts } from '@/lib/timezone'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { clearNextNumberSetting, JOB_NUMBER_SETTING_KEYS, nextJobNumber } from '@/lib/job-number'
 import {
   resolveWorkOrderTitle,
   workOrderTitleTemplateFrom,
@@ -75,8 +76,7 @@ export async function createDraftRecord(
         organizationId,
         key: {
           in: [
-            'workshop.invoicePrefix',
-            'workshop.invoiceStartNumber',
+            ...JOB_NUMBER_SETTING_KEYS,
             'workshop.defaultTechnician',
             'workshop.defaultTechnicianId',
             SETTING_KEYS.WORK_ORDER_TITLE_TEMPLATE,
@@ -150,35 +150,14 @@ export async function createDraftRecord(
     }
   }
 
-  const rawPrefix = settingsMap['workshop.invoicePrefix'] ?? '{year}-'
   const now = new Date()
   const today = zonedParts(now, timeZone)
-  const prefix = rawPrefix
-    .replace('{year}', String(today.year))
-    .replace('{month}', String(today.month).padStart(2, '0'))
-
-  const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
-  const lastRecord = await db.serviceRecord.findFirst({
-    where: { organizationId },
-    orderBy: { createdAt: 'desc' },
-    select: { invoiceNumber: true },
+  const { invoiceNumber, usesNextNumberSetting } = await nextJobNumber({
+    organizationId,
+    settings: settingsMap,
+    timeZone,
+    now,
   })
-  let nextNum = startNumber || 1001
-  if (lastRecord?.invoiceNumber) {
-    const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-    if (match) {
-      const lastNum = parseInt(match[1], 10) + 1
-      nextNum = Math.max(nextNum, lastNum)
-    }
-  }
-  const invoiceNumber = `${prefix}${nextNum}`
-
-  if (startNumber && nextNum === startNumber) {
-    await writer.appSetting.updateMany({
-      where: { organizationId, key: 'workshop.invoiceStartNumber' },
-      data: { value: '' },
-    })
-  }
 
   // The workshop's default tax, components included when it splits its tax.
   // Tax-exempt customers always get a 0% rate regardless of org default.
@@ -276,7 +255,7 @@ export async function createDraftRecord(
         }
       : null
 
-  return writer.serviceRecord.create({
+  const created = await writer.serviceRecord.create({
     data: {
       organizationId,
       createdById: userId,
@@ -301,6 +280,9 @@ export async function createDraftRecord(
       endDateTime: defaultEnd ?? new Date(defaultStart.getTime() + 3600000),
     },
   })
+  // Spent only once the job is written, and in its transaction when it has one.
+  if (usesNextNumberSetting) await clearNextNumberSetting(organizationId, opts.tx)
+  return created
 }
 
 /** The car and the customer a job is for, as far as the title template cares. */

+ 17 - 28
src/lib/cron/recurring-invoices.ts

@@ -1,6 +1,11 @@
 import { CronJob } from 'cron'
 import { db } from '@/lib/db'
-import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import {
+  clearNextNumberSetting,
+  type JobNumber,
+  JOB_NUMBER_SETTING_KEYS,
+  nextJobNumber,
+} from '@/lib/job-number'
 import { documentTotals } from '@/features/settings/Lib/workshopTax'
 import { loadTaxApplies } from '@/features/settings/Lib/loadTaxApplies'
 import { laborTaxable, recurringTaxableSubtotal, type TaxApplies } from '@/lib/taxable-lines'
@@ -32,36 +37,14 @@ export function calculateNextRunDate(current: Date, frequency: string, timeZone:
   }
 }
 
-async function generateInvoiceNumber(organizationId: string): Promise<string> {
+/** The next job number in the workshop's format; see src/lib/job-number.ts. */
+async function generateInvoiceNumber(organizationId: string, timeZone: string): Promise<JobNumber> {
   const settings = await db.appSetting.findMany({
-    where: {
-      organizationId,
-      key: { in: ['workshop.invoicePrefix', 'workshop.invoiceStartNumber'] },
-    },
+    where: { organizationId, key: { in: [...JOB_NUMBER_SETTING_KEYS] } },
   })
-
   const settingsMap: Record<string, string> = {}
   for (const s of settings) settingsMap[s.key] = s.value
-
-  const prefix = resolveInvoicePrefix(settingsMap['workshop.invoicePrefix'] ?? '{year}-')
-  const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
-
-  const lastRecord = await db.serviceRecord.findFirst({
-    where: { organizationId },
-    orderBy: { createdAt: 'desc' },
-    select: { invoiceNumber: true },
-  })
-
-  let nextNum = startNumber || 1001
-  if (lastRecord?.invoiceNumber) {
-    const match = lastRecord.invoiceNumber.match(/(\d+)$/)
-    if (match) {
-      const lastNum = parseInt(match[1], 10) + 1
-      nextNum = Math.max(nextNum, lastNum)
-    }
-  }
-
-  return `${prefix}${nextNum}`
+  return nextJobNumber({ organizationId, settings: settingsMap, timeZone })
 }
 
 /** Generates invoices from recurring templates every hour */
@@ -96,7 +79,10 @@ export function processRecurringInvoices() {
             timeZone = await workshopTimeZone(organizationId)
             zones.set(organizationId, timeZone)
           }
-          const invoiceNumber = await generateInvoiceNumber(organizationId)
+          const { invoiceNumber, usesNextNumberSetting } = await generateInvoiceNumber(
+            organizationId,
+            timeZone
+          )
 
           const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
           const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0)
@@ -162,6 +148,9 @@ export function processRecurringInvoices() {
               },
             })
 
+            // Spent only once the job is written, in the same transaction.
+            if (usesNextNumberSetting) await clearNextNumberSetting(organizationId, tx)
+
             const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency, timeZone)
             const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate
 

+ 160 - 0
src/lib/job-number.ts

@@ -0,0 +1,160 @@
+import { db, type TxClient } from '@/lib/db'
+import { zonedParts } from '@/lib/timezone'
+
+/**
+ * The number a new job (work order, counter sale, recurring invoice) gets, in
+ * the format the workshop chose under Settings, Invoice, Numbering.
+ *
+ * A number is the format with today's year (and month) written in, followed by
+ * a running number: `{year}-` gives 2026-1043. The running number carries on
+ * across years, so 2026-1050 is followed by 2027-1051. "Next invoice number"
+ * in the same settings moves it forward once and is then cleared.
+ *
+ * The running number continues from the highest one already issued in this
+ * format, whatever its year. It used to continue from the newest job alone,
+ * which handed out numbers already in use when that job had none (an online
+ * booking) or was numbered in another format (an import, a format changed and
+ * changed back). When nothing in the current format exists yet, as right after
+ * the format changes, it continues from the newest numbered job, as before.
+ *
+ * Running numbers are whole numbers of any length, read and counted as
+ * BigInt: a cap on either side would let a long number be issued that could
+ * not be read back, and then be issued again.
+ */
+
+export const JOB_NUMBER_SETTING_KEYS = [
+  'workshop.invoicePrefix',
+  'workshop.invoiceStartNumber',
+] as const
+
+/** What the format is when the workshop has never set one. */
+export const DEFAULT_JOB_NUMBER_FORMAT = '{year}-'
+
+/** Where the running number starts when there is nothing to continue from. */
+const FIRST_RUNNING_NUMBER = BigInt(1001)
+
+/** The format with the year and month of `now` on the workshop's own calendar. */
+export function jobNumberPrefix(format: string, now: Date, timeZone: string): string {
+  const today = zonedParts(now, timeZone)
+  return format
+    .replace(/\{year\}/g, String(today.year))
+    .replace(/\{month\}/g, String(today.month).padStart(2, '0'))
+}
+
+/**
+ * A PostgreSQL regular expression matching any number written in this format,
+ * whatever its year or month, with the running number as its one group.
+ */
+export function jobNumberPattern(format: string): string {
+  const body = format
+    .split(/(\{year\}|\{month\})/)
+    .map((part) => {
+      if (part === '{year}') return '[0-9]{4}'
+      if (part === '{month}') return '[0-9]{2}'
+      return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    })
+    .join('')
+  return `^${body}([0-9]+)$`
+}
+
+/** The highest running number among this workshop's numbers in `format`. */
+async function highestInFormat(organizationId: string, format: string): Promise<bigint | null> {
+  const pattern = jobNumberPattern(format)
+  // NUMERIC has no length limit, and the answer comes back as text so no
+  // driver turns it into a float on the way.
+  const rows = await db.$queryRaw<{ highest: string | null }[]>`
+    SELECT MAX(CAST(substring("invoiceNumber", ${pattern}::text) AS NUMERIC))::text AS highest
+    FROM "public"."service_records"
+    WHERE "organizationId" = ${organizationId} AND "invoiceNumber" ~ ${pattern}::text
+  `
+  const highest = rows[0]?.highest
+  return highest ? BigInt(highest) : null
+}
+
+/** The running number of the newest job that has a number at all. */
+async function newestRunningNumber(organizationId: string): Promise<bigint | null> {
+  const newest = await db.serviceRecord.findFirst({
+    where: { organizationId, invoiceNumber: { not: null } },
+    orderBy: { createdAt: 'desc' },
+    select: { invoiceNumber: true },
+  })
+  const digits = newest?.invoiceNumber?.match(/(\d+)$/)
+  return digits ? BigInt(digits[1]) : null
+}
+
+/** "Next invoice number" as a running number, or null when it is empty or not a number. */
+function nextNumberSetting(value: string | undefined): bigint | null {
+  const digits = value?.match(/^\s*(\d+)/)
+  if (!digits) return null
+  const number = BigInt(digits[1])
+  return number > BigInt(0) ? number : null
+}
+
+interface NumberingInput {
+  organizationId: string
+  /** The workshop's numbering settings, keys in JOB_NUMBER_SETTING_KEYS. */
+  settings: Record<string, string | undefined>
+  timeZone: string
+  now?: Date
+}
+
+/**
+ * Where the workshop's numbering stands: the prefix for today and the next
+ * running number. For a caller that numbers many jobs at once, such as an
+ * import, which counts up from `first` itself.
+ *
+ * `usesNextNumberSetting` says the first number is "Next invoice number".
+ * The caller clears it with `clearNextNumberSetting` after writing the job,
+ * in the same transaction: cleared before, a failed write would lose it.
+ */
+export async function jobNumberSequence(
+  input: NumberingInput
+): Promise<{ prefix: string; first: bigint; usesNextNumberSetting: boolean }> {
+  const { organizationId, settings, timeZone } = input
+  const format = settings['workshop.invoicePrefix'] ?? DEFAULT_JOB_NUMBER_FORMAT
+  const startNumber = nextNumberSetting(settings['workshop.invoiceStartNumber'])
+
+  const highest =
+    (await highestInFormat(organizationId, format)) ?? (await newestRunningNumber(organizationId))
+  // "Next invoice number" can move the sequence forward, never back onto
+  // numbers already issued. 1001 is only where a workshop with nothing to
+  // continue from starts: as a floor it sent a sequence started at 94 to
+  // 1001 on its second job.
+  let first: bigint
+  if (highest === null) first = startNumber ?? FIRST_RUNNING_NUMBER
+  else if (startNumber !== null && startNumber > highest) first = startNumber
+  else first = highest + BigInt(1)
+
+  return {
+    prefix: jobNumberPrefix(format, input.now ?? new Date(), timeZone),
+    first,
+    usesNextNumberSetting: startNumber !== null && first === startNumber,
+  }
+}
+
+/** "Next invoice number" is used once; after that the sequence carries on by itself. */
+export async function clearNextNumberSetting(organizationId: string, writer?: TxClient) {
+  await (writer ?? db).appSetting.updateMany({
+    where: { organizationId, key: 'workshop.invoiceStartNumber' },
+    data: { value: '' },
+  })
+}
+
+/** One job's number, and whether it used up "Next invoice number". */
+export interface JobNumber {
+  invoiceNumber: string
+  /**
+   * Clear the setting with `clearNextNumberSetting` once the job is written,
+   * in the transaction that writes it, so a failed write keeps it for the retry.
+   */
+  usesNextNumberSetting: boolean
+}
+
+/** The next job number. */
+export async function nextJobNumber(input: NumberingInput): Promise<JobNumber> {
+  const sequence = await jobNumberSequence(input)
+  return {
+    invoiceNumber: `${sequence.prefix}${sequence.first}`,
+    usesNextNumberSetting: sequence.usesNextNumberSetting,
+  }
+}