@arcforgelabs/flow 2026.10.34 → 2026.10.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,6 +138,8 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
138
138
  : (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
139
139
  // A stage move the work system has not made yet (moveWork).
140
140
  pendingMove: item.pendingMove ?? null,
141
+ // The link to the job in the work system's own web app ({ url, exact }).
142
+ link: item.link ?? null,
141
143
  obligation: o,
142
144
  });
143
145
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import {
4
- billingObligations, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
4
+ billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
@@ -325,9 +325,16 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
325
325
  ));
326
326
  } else changes[field] = obligation[field];
327
327
  }
328
- const invoice = Object.keys(changes).length
328
+ let invoice = Object.keys(changes).length
329
329
  ? await updateDraftInvoice(tx, organisationId, obligation.invoiceId, changes)
330
330
  : await getInvoice(tx, organisationId, obligation.invoiceId);
331
+ // The draft now matches a job that is ready to invoice, so it goes to
332
+ // Ready on its own; Flow's checks still hold it in Problem if one fails.
333
+ if (obligation.readyAt && !invoice.readyAt) {
334
+ await tx.update(invoices).set({ readyAt: sql`now()`, readyBy: null })
335
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoice.id), eq(invoices.status, 'draft')));
336
+ invoice = await getInvoice(tx, organisationId, invoice.id);
337
+ }
331
338
  const fields = { draftBasis: basisOf(obligation) };
332
339
  if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
333
340
  const row = await update(tx, obligation, fields);
@@ -337,6 +344,79 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
337
344
  });
338
345
  }
339
346
 
347
+ // What changed in a job after its invoice was made, for the invoice page:
348
+ // each line as it was (the draft basis) and as the work system has it now.
349
+ // Lines pair by price item, else item code, account or description; an
350
+ // unpaired old line and an unpaired new line at the same place pair as a
351
+ // replaced item. Answers [{ change, position, was, now, fields }], where
352
+ // `change` is changed, replaced, added or removed and `fields` names what
353
+ // differs on a changed or replaced line (item, description, quantity, price).
354
+ export function diffJobLines(wasLines = [], nowLines = []) {
355
+ const keyOf = (l) => l.priceItemId ?? l.itemCode ?? l.accountCode ?? String(l.description ?? '').trim().toLowerCase();
356
+ const show = (l) => ({
357
+ code: l.itemCode ?? l.accountCode ?? null, description: l.description ?? '', quantity: Number(l.quantity), unitAmountCents: l.unitAmountCents ?? null,
358
+ });
359
+ const differs = (a, b) => [
360
+ keyOf(a) !== keyOf(b) ? 'item' : null,
361
+ String(a.description ?? '').trim() !== String(b.description ?? '').trim() && keyOf(a) === keyOf(b) ? 'description' : null,
362
+ Number(a.quantity) !== Number(b.quantity) ? 'quantity' : null,
363
+ (a.unitAmountCents ?? null) !== (b.unitAmountCents ?? null) ? 'price' : null,
364
+ ].filter(Boolean);
365
+ const wasLeft = wasLines.map((line, index) => ({ line, index }));
366
+ const pairs = [];
367
+ const added = [];
368
+ nowLines.forEach((line, index) => {
369
+ const at = wasLeft.findIndex((w) => keyOf(w.line) === keyOf(line));
370
+ if (at === -1) { added.push({ line, index }); return; }
371
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now: { line, index } });
372
+ });
373
+ // What is left over: an old line and a new line in the same place are one
374
+ // replaced item; the rest were added or removed.
375
+ for (const now of [...added]) {
376
+ const at = wasLeft.findIndex((w) => w.index === now.index);
377
+ if (at === -1) continue;
378
+ pairs.push({ was: wasLeft.splice(at, 1)[0], now });
379
+ added.splice(added.indexOf(now), 1);
380
+ }
381
+ const changes = [];
382
+ for (const { was, now } of pairs) {
383
+ const fields = differs(was.line, now.line);
384
+ if (!fields.length) continue;
385
+ changes.push({ change: fields.includes('item') ? 'replaced' : 'changed', position: now.index + 1, was: show(was.line), now: show(now.line), fields });
386
+ }
387
+ for (const { line, index } of added) changes.push({ change: 'added', position: index + 1, was: null, now: show(line), fields: [] });
388
+ for (const { line, index } of wasLeft) changes.push({ change: 'removed', position: index + 1, was: show(line), now: null, fields: [] });
389
+ return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
390
+ }
391
+
392
+ // The open "job changed" problem of an invoice, for the invoice page, or
393
+ // null: the job as it was when the invoice was made, as the work system has
394
+ // it now, and whether a person can adopt the change into Flow's own draft
395
+ // ("Adopt changes", useSourceLines). An invoice drafted before the basis was
396
+ // kept compares with the invoice's own lines.
397
+ export async function jobChangeFor(db, organisationId, invoiceId) {
398
+ const [obligation] = await db.select().from(billingObligations)
399
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId),
400
+ eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
401
+ if (!obligation) return null;
402
+ const compare = await compareWithDraft(db, organisationId, obligation, obligation);
403
+ const was = obligation.draftBasis
404
+ ?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
405
+ return {
406
+ jobId: obligation.id,
407
+ sourceSystem: obligation.sourceSystem,
408
+ sourceJobRef: obligation.sourceJobRef,
409
+ sourceInvoiceRef: obligation.sourceInvoiceRef,
410
+ seenAt: obligation.sourceSeenAt,
411
+ outcome: compare.outcome,
412
+ sent: compare.sent,
413
+ canAdopt: compare.outcome === 'offer',
414
+ lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
415
+ contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
416
+ total: { was: was.totalCents ?? null, now: obligation.totalCents },
417
+ };
418
+ }
419
+
340
420
  // Records that a job the ledger knows is no longer billable in the work
341
421
  // system: deleted there, or its status moved back (for example from Shipped
342
422
  // to In production). Flow writes nothing to another system:
@@ -0,0 +1 @@
1
+ ALTER TABLE "people" ADD COLUMN IF NOT EXISTS "views" jsonb;