@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.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +81 -50
- package/package.json +7 -5
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +54 -12
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +155 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +160 -59
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +481 -50
- package/server/ui/routes.js +11 -1
package/server/core/job-board.js
CHANGED
|
@@ -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
|
}
|
package/server/core/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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;
|