@arcforgelabs/flow 2026.11.2 → 2026.11.4
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/SECURITY.md +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +119 -3
- package/server/app.js +3 -2
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +44 -1
- package/server/auth/settings.js +27 -0
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +67 -8
- package/server/core/pipeline.js +6 -2
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +30 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/seazona/index.js +6 -1
- package/server/integrations/seazona/routes.js +65 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +17 -8
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +7 -4
- package/server/ui/flow.css +69 -1
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +81 -6
- package/server/ui/job-board.js +8 -3
- package/server/ui/jobs.js +14 -2
- package/server/ui/nav-pins.js +5 -3
- package/server/ui/reconcile.js +4 -2
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +306 -0
- package/skills/flow/flow.sh +121 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
5
|
-
import { invoiceRules, invoicingSettings } from '../db/schema.js';
|
|
4
|
+
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
5
|
+
import { billingObligations, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
|
|
6
6
|
import { FlowError, notFound } from './errors.js';
|
|
7
|
+
import { getInvoice, updateDraftInvoice } from './invoices.js';
|
|
7
8
|
|
|
8
9
|
// Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
|
|
9
10
|
// Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
|
|
@@ -62,7 +63,7 @@ export const FIELDS = Object.freeze({
|
|
|
62
63
|
// one marker, so a new branch needs no new rule.
|
|
63
64
|
customer: {
|
|
64
65
|
label: 'Customer', kind: 'choice', options: 'customers',
|
|
65
|
-
tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
|
|
66
|
+
tests: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'is_empty'],
|
|
66
67
|
read: (j) => j.contactId,
|
|
67
68
|
},
|
|
68
69
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
@@ -73,7 +74,7 @@ export const FIELDS = Object.freeze({
|
|
|
73
74
|
export const TESTS = Object.freeze({
|
|
74
75
|
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
75
76
|
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
76
|
-
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
77
|
+
choice: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'is_empty'],
|
|
77
78
|
text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'number_in_words', 'one_word', 'is_empty', 'is_not_empty'],
|
|
78
79
|
boolean: ['is'],
|
|
79
80
|
items: ['has', 'has_not'],
|
|
@@ -134,6 +135,13 @@ export function readRule(raw) {
|
|
|
134
135
|
const note = typeof raw.action.note === 'string' ? raw.action.note.trim() : '';
|
|
135
136
|
if (!note || note.length > MAX_NOTE) throw invalid(`a pending rule needs a note of ${MAX_NOTE} characters at most: what a person must fix`);
|
|
136
137
|
action.note = note;
|
|
138
|
+
// The fix a person can take with one press: add this price list item to
|
|
139
|
+
// the draft (for example a shipping line the work system left off).
|
|
140
|
+
if (raw.action.addItem !== undefined && raw.action.addItem !== null) {
|
|
141
|
+
const item = typeof raw.action.addItem === 'string' ? raw.action.addItem.trim() : '';
|
|
142
|
+
if (!item || item.length > 40) throw invalid('addItem must be a price list code of 40 characters at most');
|
|
143
|
+
action.addItem = item;
|
|
144
|
+
}
|
|
137
145
|
} else {
|
|
138
146
|
const early = conditions.find((c) => FIELDS[c.field].draftOnly);
|
|
139
147
|
if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
|
|
@@ -189,6 +197,7 @@ function matches(condition, job, context) {
|
|
|
189
197
|
default: {
|
|
190
198
|
const left = norm(have);
|
|
191
199
|
if (test === 'is_one_of') return value.some((v) => norm(v) === left);
|
|
200
|
+
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
192
201
|
return test === 'is' ? left === norm(value) : left !== norm(value);
|
|
193
202
|
}
|
|
194
203
|
}
|
|
@@ -227,12 +236,21 @@ const ruleResolution = (rule) => {
|
|
|
227
236
|
return { kind: 'rule_review' };
|
|
228
237
|
};
|
|
229
238
|
|
|
239
|
+
// A rule leaves a job alone when a person made an exception for it
|
|
240
|
+
// (`job.ruleExceptions`, rule ids), or, for a rule with `addItem`, when the
|
|
241
|
+
// draft already has that item (`job.draftItems`, codes): the fix was taken.
|
|
230
242
|
export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
231
243
|
const context = { customer, now };
|
|
244
|
+
const excepted = new Set(job.ruleExceptions ?? []);
|
|
245
|
+
const onDraft = new Set((job.draftItems ?? []).map(norm));
|
|
232
246
|
return rules
|
|
233
247
|
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
|
|
248
|
+
.filter((r) => !excepted.has(r.id) && !(r.action.addItem && onDraft.has(norm(r.action.addItem))))
|
|
234
249
|
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
235
|
-
.map((r) => ({
|
|
250
|
+
.map((r) => ({
|
|
251
|
+
code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))],
|
|
252
|
+
resolution: ruleResolution(r), ...(r.action.addItem ? { addItem: r.action.addItem } : {}),
|
|
253
|
+
}));
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
// Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
|
|
@@ -295,9 +313,9 @@ async function seed(db, organisationId) {
|
|
|
295
313
|
});
|
|
296
314
|
}
|
|
297
315
|
|
|
298
|
-
const shape = (r) => ({
|
|
316
|
+
const shape = (r, exceptions = 0) => ({
|
|
299
317
|
id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
300
|
-
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
|
|
318
|
+
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt, exceptions,
|
|
301
319
|
});
|
|
302
320
|
|
|
303
321
|
// The organisation's rules, in order. The first read makes the starter rules.
|
|
@@ -307,11 +325,101 @@ export async function listRules(db, organisationId) {
|
|
|
307
325
|
if (!settings?.seeded) await seed(db, organisationId);
|
|
308
326
|
const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
|
|
309
327
|
.orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
|
|
310
|
-
|
|
328
|
+
const counts = await db.select({ ruleId: invoiceRuleExceptions.ruleId, count: sql`count(*)::int` }).from(invoiceRuleExceptions)
|
|
329
|
+
.where(eq(invoiceRuleExceptions.organisationId, organisationId)).groupBy(invoiceRuleExceptions.ruleId);
|
|
330
|
+
const byRule = new Map(counts.map((c) => [c.ruleId, Number(c.count)]));
|
|
331
|
+
return rows.map((r) => shape(r, byRule.get(r.id) ?? 0));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Exceptions ─────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
// A person says one job is fine although a rule matches it. The rule then
|
|
337
|
+
// leaves the job alone. Only a rule with an item to add takes one: its Skip.
|
|
338
|
+
// Each exception keeps who, when and why, stays listed on the rule, and is
|
|
339
|
+
// in the job's history. It holds while the rule's item and the job's lines
|
|
340
|
+
// stay as they were; a change to either ends it, so a person looks again.
|
|
341
|
+
export async function addRuleException(db, organisationId, ruleId, obligationId, { reason, actor }) {
|
|
342
|
+
const why = typeof reason === 'string' ? reason.trim() : '';
|
|
343
|
+
if (!why || why.length > 200) throw invalid('an exception needs a reason of 1 to 200 characters');
|
|
344
|
+
if (!actor) throw invalid('actor is required');
|
|
345
|
+
const rule = await loadRule(db, organisationId, ruleId);
|
|
346
|
+
if (rule.action?.kind !== 'pending' || !rule.action.addItem) throw invalid('only a pending rule with an item to add takes an exception; change or turn off the rule');
|
|
347
|
+
return db.transaction(async (tx) => {
|
|
348
|
+
const [job] = await tx.select({ id: billingObligations.id, lineFingerprint: billingObligations.lineFingerprint }).from(billingObligations)
|
|
349
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId))).for('update');
|
|
350
|
+
if (!job) throw notFound('job');
|
|
351
|
+
const values = { reason: why, actor, itemCode: rule.action.addItem, lineFingerprint: job.lineFingerprint, createdAt: sql`now()` };
|
|
352
|
+
const [row] = await tx.insert(invoiceRuleExceptions).values({ organisationId, ruleId, obligationId, ...values })
|
|
353
|
+
.onConflictDoUpdate({ target: [invoiceRuleExceptions.ruleId, invoiceRuleExceptions.obligationId], set: values })
|
|
354
|
+
.returning();
|
|
355
|
+
await tx.insert(obligationEvents).values({ organisationId, obligationId, kind: 'rule_exception_added', actor,
|
|
356
|
+
evidence: { ruleId, rule: rule.name, item: rule.action.addItem, reason: why } });
|
|
357
|
+
return row;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The rule ids each job has a current exception for, by job.
|
|
362
|
+
export async function ruleExceptionsFor(db, organisationId, obligationIds) {
|
|
363
|
+
if (!obligationIds.length) return new Map();
|
|
364
|
+
const rows = await db.select({ ruleId: invoiceRuleExceptions.ruleId, obligationId: invoiceRuleExceptions.obligationId }).from(invoiceRuleExceptions)
|
|
365
|
+
.innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
|
|
366
|
+
.innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
|
|
367
|
+
.where(and(eq(invoiceRuleExceptions.organisationId, organisationId), inArray(invoiceRuleExceptions.obligationId, obligationIds), CURRENT));
|
|
368
|
+
const out = new Map();
|
|
369
|
+
for (const r of rows) out.set(r.obligationId, [...(out.get(r.obligationId) ?? []), r.ruleId]);
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
const CURRENT = sql`(${invoiceRuleExceptions.itemCode} is not distinct from (${invoiceRules.action} ->> 'addItem')
|
|
373
|
+
and ${invoiceRuleExceptions.lineFingerprint} is not distinct from ${billingObligations.lineFingerprint})`;
|
|
374
|
+
|
|
375
|
+
// One rule's exceptions, newest first. `current` is false when the rule's
|
|
376
|
+
// item or the job's lines changed since: the rule applies to that job again.
|
|
377
|
+
export async function listRuleExceptions(db, organisationId, ruleId) {
|
|
378
|
+
await loadRule(db, organisationId, ruleId);
|
|
379
|
+
return db.select({ id: invoiceRuleExceptions.id, obligationId: invoiceRuleExceptions.obligationId, reason: invoiceRuleExceptions.reason,
|
|
380
|
+
actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
|
|
381
|
+
.from(invoiceRuleExceptions)
|
|
382
|
+
.innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
|
|
383
|
+
.innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
|
|
384
|
+
.where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.ruleId, ruleId)))
|
|
385
|
+
.orderBy(desc(invoiceRuleExceptions.createdAt));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// One job's exceptions, for its invoice page.
|
|
389
|
+
export async function jobRuleExceptions(db, organisationId, obligationId) {
|
|
390
|
+
return db.select({ id: invoiceRuleExceptions.id, ruleId: invoiceRuleExceptions.ruleId, rule: invoiceRules.name, reason: invoiceRuleExceptions.reason,
|
|
391
|
+
actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
|
|
392
|
+
.from(invoiceRuleExceptions)
|
|
393
|
+
.innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
|
|
394
|
+
.innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
|
|
395
|
+
.where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.obligationId, obligationId)))
|
|
396
|
+
.orderBy(desc(invoiceRuleExceptions.createdAt));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Takes an exception back: the rule applies to the job again.
|
|
400
|
+
export async function removeRuleException(db, organisationId, ruleId, exceptionId, { actor = 'system' } = {}) {
|
|
401
|
+
return db.transaction(async (tx) => {
|
|
402
|
+
const [row] = await tx.delete(invoiceRuleExceptions).where(and(eq(invoiceRuleExceptions.organisationId, organisationId),
|
|
403
|
+
eq(invoiceRuleExceptions.ruleId, ruleId), eq(invoiceRuleExceptions.id, exceptionId))).returning();
|
|
404
|
+
if (!row) throw notFound('exception');
|
|
405
|
+
await tx.insert(obligationEvents).values({ organisationId, obligationId: row.obligationId, kind: 'rule_exception_removed', actor,
|
|
406
|
+
evidence: { ruleId, item: row.itemCode, reason: row.reason } });
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A rule's item must be on the price list, and not archived.
|
|
411
|
+
async function checkRuleItem(db, organisationId, rule) {
|
|
412
|
+
const code = rule.action.addItem;
|
|
413
|
+
if (!code) return;
|
|
414
|
+
const [item] = await db.select({ archivedAt: priceItems.archivedAt }).from(priceItems)
|
|
415
|
+
.where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
|
|
416
|
+
if (!item) throw invalid(`the price list has no item ${code}`);
|
|
417
|
+
if (item.archivedAt) throw invalid(`price item ${code} is archived`);
|
|
311
418
|
}
|
|
312
419
|
|
|
313
420
|
export async function createRule(db, organisationId, raw, { personId = null } = {}) {
|
|
314
421
|
const rule = readRule(raw);
|
|
422
|
+
await checkRuleItem(db, organisationId, rule);
|
|
315
423
|
const existing = await listRules(db, organisationId);
|
|
316
424
|
if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
|
|
317
425
|
const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
|
|
@@ -331,6 +439,7 @@ export async function updateRule(db, organisationId, ruleId, changes, { personId
|
|
|
331
439
|
const row = await loadRule(db, organisationId, ruleId);
|
|
332
440
|
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
333
441
|
const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
442
|
+
if (rule.action.addItem && rule.action.addItem !== row.action?.addItem) await checkRuleItem(db, organisationId, rule);
|
|
334
443
|
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
335
444
|
.where(eq(invoiceRules.id, row.id)).returning();
|
|
336
445
|
return shape(saved);
|
|
@@ -340,3 +449,44 @@ export async function deleteRule(db, organisationId, ruleId) {
|
|
|
340
449
|
const row = await loadRule(db, organisationId, ruleId);
|
|
341
450
|
await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
|
|
342
451
|
}
|
|
452
|
+
|
|
453
|
+
// ── A rule's one-press fix ─────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
// "Add <item>" on a Pending draft: adds one of the rule's price list item to
|
|
456
|
+
// the draft, at the price list's price. The rule then leaves the draft alone.
|
|
457
|
+
// Flow never writes the line to the work system: the invoice page says the
|
|
458
|
+
// job and the work system differ until the work system has it too.
|
|
459
|
+
export async function addRuleItem(db, organisationId, invoiceId, ruleId) {
|
|
460
|
+
const rule = await loadRule(db, organisationId, ruleId);
|
|
461
|
+
const code = rule.action?.addItem;
|
|
462
|
+
if (!code || rule.action.kind !== 'pending') throw invalid('this rule has no item to add');
|
|
463
|
+
if (!rule.enabled) throw invalid('this rule is off');
|
|
464
|
+
const [item] = await db.select().from(priceItems)
|
|
465
|
+
.where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
|
|
466
|
+
if (!item) throw invalid(`the price list has no item ${code}`);
|
|
467
|
+
if (item.archivedAt) throw invalid(`price item ${code} is archived`);
|
|
468
|
+
// The lines are read under the draft's lock: a second press, or an edit at
|
|
469
|
+
// the same time, adds nothing twice and loses nothing.
|
|
470
|
+
await updateDraftInvoice(db, organisationId, invoiceId, {
|
|
471
|
+
lines: (current) => (current.some((l) => l.priceItemId === item.id) ? null : [
|
|
472
|
+
...current.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
473
|
+
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode })),
|
|
474
|
+
{ priceItemId: item.id, quantity: 1 },
|
|
475
|
+
]),
|
|
476
|
+
});
|
|
477
|
+
return getInvoice(db, organisationId, invoiceId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// "Skip" on a Pending draft: an exception for the draft's job, with a reason.
|
|
481
|
+
// The same, before the job has a draft: the job board shows the job needs an
|
|
482
|
+
// item, and the person knows it does not (picked up, for example).
|
|
483
|
+
export async function exceptJob(db, organisationId, obligationId, ruleId, { reason, actor }) {
|
|
484
|
+
return addRuleException(db, organisationId, ruleId, obligationId, { reason, actor });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export async function exceptDraft(db, organisationId, invoiceId, ruleId, { reason, actor }) {
|
|
488
|
+
const [job] = await db.select({ id: billingObligations.id }).from(billingObligations)
|
|
489
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
|
|
490
|
+
if (!job) throw invalid('this invoice has no job; edit the invoice instead');
|
|
491
|
+
return addRuleException(db, organisationId, ruleId, job.id, { reason, actor });
|
|
492
|
+
}
|
package/server/core/invoices.js
CHANGED
|
@@ -111,7 +111,15 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
|
|
|
111
111
|
.where(eq(invoices.id, invoice.id)).returning();
|
|
112
112
|
}
|
|
113
113
|
if ('lines' in changes) {
|
|
114
|
-
|
|
114
|
+
// `lines` may be a function of the draft's current lines, read under
|
|
115
|
+
// the lock, so a change made at the same time is not lost.
|
|
116
|
+
let lines = changes.lines;
|
|
117
|
+
if (typeof lines === 'function') {
|
|
118
|
+
const current = await tx.select().from(invoiceLines).where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
|
|
119
|
+
lines = lines(current);
|
|
120
|
+
if (lines === null) return row;
|
|
121
|
+
}
|
|
122
|
+
row = await writeLines(tx, row, await resolveLines(tx, organisationId, lines));
|
|
115
123
|
}
|
|
116
124
|
return row;
|
|
117
125
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
2
2
|
import {
|
|
3
|
-
billingObligations, contacts, customerBillingSettings, invoicingSettings, SEND_MODES,
|
|
3
|
+
billingObligations, contacts, customerBillingSettings, externalLinks, invoicingSettings, SEND_MODES,
|
|
4
4
|
} from '../db/schema.js';
|
|
5
5
|
import { FlowError, notFound } from './errors.js';
|
|
6
6
|
import { listRules, ruleReasons } from './invoice-rules.js';
|
|
@@ -214,20 +214,45 @@ const isoDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart
|
|
|
214
214
|
// The ledger's live check, answered from the accounting system's copy in Flow
|
|
215
215
|
// instead of a call for each job: the copy is complete and was read lately,
|
|
216
216
|
// the matcher checked the job after the copy last changed, and the copy holds
|
|
217
|
-
// no invoice with the job's own invoice number.
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
// no invoice with the job's own invoice number. Another customer's invoice
|
|
218
|
+
// with the same number is not this job's: when the job's customer is linked to
|
|
219
|
+
// an accounting contact (`db` given), only that contact's invoices count.
|
|
220
|
+
// Send's window for the same client and Reference (xero/drafts.js).
|
|
221
|
+
const SAME_REFERENCE_DAYS = 90;
|
|
222
|
+
|
|
223
|
+
export function copyCheck(accounting, organisationId, { db = null } = {}) {
|
|
224
|
+
const contactOf = async (contactId) => {
|
|
225
|
+
if (!db || !contactId) return null;
|
|
226
|
+
const [link] = await db.select({ externalId: externalLinks.externalId }).from(externalLinks).where(and(
|
|
227
|
+
eq(externalLinks.organisationId, organisationId), eq(externalLinks.system, accounting.id),
|
|
228
|
+
eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId, contactId),
|
|
229
|
+
));
|
|
230
|
+
return link?.externalId ?? null;
|
|
231
|
+
};
|
|
232
|
+
return async (obligation, { reference = null } = {}) => {
|
|
220
233
|
const { complete, fresh, changedAt, invoices } = await accounting.capabilities.findInvoices({
|
|
221
234
|
organisationId,
|
|
222
235
|
numbers: obligation.sourceInvoiceRef ? [obligation.sourceInvoiceRef] : [],
|
|
236
|
+
references: reference ? [reference] : [],
|
|
223
237
|
});
|
|
224
238
|
if (!complete || !fresh) throw new Error('the accounting copy is not complete or not fresh');
|
|
225
239
|
if (obligation.accountingState !== 'not_found') return { state: obligation.accountingState, matchedBy: 'copy' };
|
|
226
240
|
if (changedAt && (!obligation.accountingCheckedAt || new Date(obligation.accountingCheckedAt) < changedAt)) {
|
|
227
241
|
throw new Error('the accounting copy changed after the job was last matched');
|
|
228
242
|
}
|
|
229
|
-
const
|
|
243
|
+
const xeroContactId = await contactOf(obligation.contactId);
|
|
244
|
+
const live = invoices.filter((i) => !['voided', 'deleted'].includes(i.state));
|
|
245
|
+
const sameNumber = (i) => obligation.sourceInvoiceRef && String(i.number ?? '').toUpperCase() === obligation.sourceInvoiceRef.toUpperCase();
|
|
246
|
+
const sales = live.filter((i) => sameNumber(i) && (!xeroContactId || i.contactId === xeroContactId));
|
|
230
247
|
if (sales.length) return { state: sales[0].state, accountingRef: sales[0].id, matchedBy: 'copy' };
|
|
248
|
+
// The same client and Reference as this draft, within Send's window: Send
|
|
249
|
+
// would stop it (xero/drafts.js), so the draft is not made. A person
|
|
250
|
+
// links the job to that invoice, or says it is another job.
|
|
251
|
+
const day = obligation.jobDate ? Date.parse(`${String(obligation.jobDate).slice(0, 10)}T00:00:00Z`) : Date.now();
|
|
252
|
+
const sameReference = reference && xeroContactId ? live.filter((i) => i.contactId === xeroContactId
|
|
253
|
+
&& String(i.reference ?? '').toUpperCase() === reference.toUpperCase()
|
|
254
|
+
&& (!i.issuedOn || Math.abs(Date.parse(`${String(i.issuedOn).slice(0, 10)}T00:00:00Z`) - day) <= SAME_REFERENCE_DAYS * 86_400_000)) : [];
|
|
255
|
+
if (sameReference.length) return { state: 'ambiguous', accountingRef: sameReference[0].id, matchedBy: 'reference' };
|
|
231
256
|
return { state: 'not_found', matchedBy: 'copy' };
|
|
232
257
|
};
|
|
233
258
|
}
|
|
@@ -269,7 +294,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
269
294
|
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
270
295
|
const names = await customerNames(db, organisationId, contactIds);
|
|
271
296
|
const named = (contactId) => ({ ...effectiveSettings(settings, customers.get(contactId)), name: names.get(contactId) ?? null });
|
|
272
|
-
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
297
|
+
const liveCheck = accounting ? copyCheck(accounting, organisationId, { db }) : null;
|
|
273
298
|
const invoiceDate = isoDay(now);
|
|
274
299
|
let accountingRead = null;
|
|
275
300
|
|
package/server/core/job-board.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
2
|
import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
|
|
3
|
-
import { listRules, ruleWait } from './invoice-rules.js';
|
|
3
|
+
import { listRules, ruleExceptionsFor, rulePending, ruleWait } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
5
|
import { REVIEW_CHANGED, listObligations } from './ledger.js';
|
|
6
6
|
import { isHistoryHeld } from './history.js';
|
|
@@ -47,6 +47,20 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
47
47
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
48
48
|
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
49
49
|
const rules = await listRules(db, organisationId);
|
|
50
|
+
// A rule with an item to add also checks the job as the work system has it,
|
|
51
|
+
// from the first read: the lab prints from the work system, so it must be
|
|
52
|
+
// right there, not only on Flow's invoice. The card says what to add.
|
|
53
|
+
const addRules = rules.filter((r) => r.action.kind === 'pending' && r.action.addItem);
|
|
54
|
+
const excepted = addRules.length ? await ruleExceptionsFor(db, organisationId, listed.map((e) => e.obligation.id)) : new Map();
|
|
55
|
+
const sourceNeeds = (o) => {
|
|
56
|
+
if (!addRules.length) return [];
|
|
57
|
+
const customer = { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null };
|
|
58
|
+
// The work system's own lines count as having the item, whatever the
|
|
59
|
+
// rule's conditions say, so the badge shows only while the item is missing there.
|
|
60
|
+
const inSource = (o.lines ?? []).flatMap((l) => [l.itemCode, l.label].filter(Boolean));
|
|
61
|
+
return rulePending(addRules, { ...o, ruleExceptions: excepted.get(o.id) ?? [], draftItems: inSource }, { customer })
|
|
62
|
+
.map((p) => ({ rule: p.rule, item: p.addItem, note: p.message, short: p.short }));
|
|
63
|
+
};
|
|
50
64
|
const ready = new Set(views?.ready ?? []);
|
|
51
65
|
const toCheck = new Set(views?.toCheck ?? []);
|
|
52
66
|
const invoiceView = (id) => (!id || !views ? null
|
|
@@ -74,6 +88,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
74
88
|
invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
|
|
75
89
|
view,
|
|
76
90
|
reasons,
|
|
91
|
+
sourceNeeds: sourceNeeds(o),
|
|
77
92
|
...waiting(o, view),
|
|
78
93
|
}));
|
|
79
94
|
}
|
package/server/core/job-match.js
CHANGED
|
@@ -101,8 +101,9 @@ export async function listReconcile(db, organisationId) {
|
|
|
101
101
|
status: billingObligations.sourceStatus,
|
|
102
102
|
sourceSystem: billingObligations.sourceSystem,
|
|
103
103
|
sourceJobRef: billingObligations.sourceJobRef,
|
|
104
|
-
// An unclear accounting match
|
|
105
|
-
|
|
104
|
+
// An unclear accounting match, or a matched accounting invoice that was
|
|
105
|
+
// voided or deleted there: "Make invoice" settles it first.
|
|
106
|
+
unclear: sql`${billingObligations.accountingState} in ('ambiguous', 'voided', 'deleted') and ${billingObligations.invoiceId} is null`.mapWith(Boolean),
|
|
106
107
|
possible: sql`(select count(*) from invoices i
|
|
107
108
|
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
108
109
|
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
|
@@ -152,8 +153,9 @@ export async function jobMatchDetail(db, organisationId, obligationId, { referen
|
|
|
152
153
|
lines: jobLines(o, codes),
|
|
153
154
|
totalCents: o.totalCents,
|
|
154
155
|
open: !o.invoiceId && !o.accountingRef,
|
|
155
|
-
// An unclear accounting match
|
|
156
|
-
|
|
156
|
+
// An unclear accounting match, or a matched accounting invoice that was
|
|
157
|
+
// voided or deleted there: "Make invoice" settles it first.
|
|
158
|
+
unclear: !o.invoiceId && ['ambiguous', 'voided', 'deleted'].includes(o.accountingState),
|
|
157
159
|
};
|
|
158
160
|
|
|
159
161
|
const search = String(q ?? '').trim();
|
package/server/core/ledger.js
CHANGED
|
@@ -769,7 +769,51 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
769
769
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
770
770
|
const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
|
|
771
771
|
await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
|
|
772
|
-
from: obligation.holdState, reason: row.holdReason,
|
|
772
|
+
from: obligation.holdState, reason: holdState === 'none' ? (reason?.trim() || null) : row.holdReason,
|
|
773
|
+
});
|
|
774
|
+
return row;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const GONE_STATES = ['voided', 'deleted'];
|
|
779
|
+
|
|
780
|
+
// A person (or the assistant, after the lab says yes) frees a job that is
|
|
781
|
+
// stuck on an invoice that bills nothing now: a Flow invoice that was voided,
|
|
782
|
+
// or an accounting invoice that was voided or deleted there. The job lets go
|
|
783
|
+
// of that invoice and goes on hold, so nothing drafts until a person releases
|
|
784
|
+
// it (setHold to 'none'). A Flow invoice that is still live must be voided
|
|
785
|
+
// first: this never voids or changes an invoice.
|
|
786
|
+
export async function freeVoidedJob(db, organisationId, obligationId, { actor, reason, system = null }) {
|
|
787
|
+
requireActor(actor);
|
|
788
|
+
if (!reason?.trim()) throw new FlowError('invalid', 'say why the job is freed');
|
|
789
|
+
return db.transaction(async (tx) => {
|
|
790
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
791
|
+
let flowInvoice = null;
|
|
792
|
+
if (obligation.invoiceId) {
|
|
793
|
+
[flowInvoice] = await tx.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
|
|
794
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, obligation.invoiceId)));
|
|
795
|
+
if (flowInvoice && flowInvoice.status !== 'void') {
|
|
796
|
+
throw new FlowError('invalid', `Flow invoice ${flowInvoice.number ?? flowInvoice.id} is ${flowInvoice.status}: void it first, then free the job`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
|
|
800
|
+
if (!flowInvoice && !gone) throw new FlowError('invalid', 'the job is not linked to a void or deleted invoice');
|
|
801
|
+
const row = await update(tx, obligation, {
|
|
802
|
+
...(flowInvoice ? { invoiceId: null, draftBasis: null } : {}),
|
|
803
|
+
...(gone || GONE_STATES.includes(obligation.accountingState)
|
|
804
|
+
? { accountingState: 'not_found', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: sql`now()` }
|
|
805
|
+
: {}),
|
|
806
|
+
holdState: 'held',
|
|
807
|
+
holdReason: reason.trim(),
|
|
808
|
+
});
|
|
809
|
+
if (gone && system) {
|
|
810
|
+
await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
|
|
811
|
+
}
|
|
812
|
+
await addEvent(tx, row, 'freed', actor, {
|
|
813
|
+
reason: reason.trim(),
|
|
814
|
+
...(flowInvoice ? { invoiceId: flowInvoice.id } : {}),
|
|
815
|
+
...(gone ? { released: gone } : {}),
|
|
816
|
+
from: obligation.holdState,
|
|
773
817
|
});
|
|
774
818
|
return row;
|
|
775
819
|
});
|
|
@@ -777,9 +821,10 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
777
821
|
|
|
778
822
|
// A job with no invoice of its own whose accounting evidence is ambiguous:
|
|
779
823
|
// the matcher found accounting invoices that may be this job's, but none
|
|
780
|
-
// exactly.
|
|
781
|
-
//
|
|
782
|
-
|
|
824
|
+
// exactly. Or the accounting invoice the matcher linked was voided or deleted
|
|
825
|
+
// there: it bills nothing now. A person settles it on the Reconcile page or
|
|
826
|
+
// the job: "Make invoice", or a link to a Flow invoice (linkJobToInvoice).
|
|
827
|
+
const unlinked = (o) => !o.invoiceId && (GONE_STATES.includes(o.accountingState) || (o.accountingState === 'ambiguous' && !o.accountingRef));
|
|
783
828
|
|
|
784
829
|
// The matcher's open suggestions for one job, best first.
|
|
785
830
|
export async function linkSuggestionsFor(db, organisationId, obligationId) {
|
|
@@ -814,14 +859,26 @@ async function rejectSuggestions(tx, obligation, { actor }) {
|
|
|
814
859
|
// "Make invoice": a person says the job has no invoice yet. Every suggestion
|
|
815
860
|
// is rejected and the job is recorded as not found, so it can draft. The
|
|
816
861
|
// live check before the draft still runs (draftInvoiceForObligation).
|
|
817
|
-
|
|
862
|
+
// `system` is the accounting system's id: a released voided or deleted link
|
|
863
|
+
// becomes that person's "not this one" too, so the matcher never offers it.
|
|
864
|
+
export async function confirmNoInvoice(db, organisationId, obligationId, { actor, system = null }) {
|
|
818
865
|
requireActor(actor);
|
|
819
866
|
return db.transaction(async (tx) => {
|
|
820
867
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
821
868
|
if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
|
|
822
869
|
const rejected = await rejectSuggestions(tx, obligation, { actor });
|
|
823
|
-
|
|
824
|
-
|
|
870
|
+
// A voided or deleted link is let go, so the matcher stops following it.
|
|
871
|
+
const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
|
|
872
|
+
const row = await update(tx, obligation, {
|
|
873
|
+
accountingState: 'not_found', accountingCheckedAt: sql`now()`,
|
|
874
|
+
...(gone ? { accountingRef: null, accountingUrl: null, accountingMatchedBy: null } : {}),
|
|
875
|
+
});
|
|
876
|
+
if (gone && system) {
|
|
877
|
+
await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
|
|
878
|
+
}
|
|
879
|
+
await addEvent(tx, row, 'accounting_checked', actor, {
|
|
880
|
+
reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected, ...(gone ? { released: gone } : {}),
|
|
881
|
+
});
|
|
825
882
|
return row;
|
|
826
883
|
});
|
|
827
884
|
}
|
|
@@ -913,7 +970,9 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
913
970
|
let live = null;
|
|
914
971
|
if (!reasons.length && accounting) {
|
|
915
972
|
try {
|
|
916
|
-
|
|
973
|
+
// The draft's own Reference too: Send stops the same client and
|
|
974
|
+
// Reference in Xero, so the draft must not be made either.
|
|
975
|
+
live = await liveCheck(obligation, { reference });
|
|
917
976
|
} catch (error) {
|
|
918
977
|
reasons.push(`the live accounting check failed: ${error.message}`);
|
|
919
978
|
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -342,6 +342,10 @@ export function rankCandidates(obligation, invoices, { prior = null, rivals = []
|
|
|
342
342
|
|
|
343
343
|
const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
|
|
344
344
|
const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
|
|
345
|
+
// A voided or deleted accounting invoice bills nothing: it never links a job
|
|
346
|
+
// by itself. A lab that reissued work voids the first invoice, and that one
|
|
347
|
+
// keeps the work system's number.
|
|
348
|
+
const live = (invoice) => !['voided', 'deleted'].includes(invoice.state);
|
|
345
349
|
|
|
346
350
|
async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
|
|
347
351
|
const keep = ranked.slice(0, 5);
|
|
@@ -392,14 +396,14 @@ export async function matchJob(db, organisationId, obligation, {
|
|
|
392
396
|
});
|
|
393
397
|
if (prior) {
|
|
394
398
|
const [invoice] = await find({ invoiceIds: [prior.externalId] });
|
|
395
|
-
if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
399
|
+
if (invoice && live(invoice) && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
396
400
|
return exact(invoice, 'prior_link');
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
if (obligation.sourceInvoiceRef) {
|
|
400
404
|
const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
|
|
401
405
|
const same = (await find({ numbers }))
|
|
402
|
-
.filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
406
|
+
.filter((i) => live(i) && !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
403
407
|
if (same.length === 1) return exact(same[0], 'source_invoice_ref');
|
|
404
408
|
}
|
|
405
409
|
// Without the customer's accounting contact there is nothing to compare.
|
package/server/core/reconcile.js
CHANGED
|
@@ -30,6 +30,9 @@ export const RECONCILE_REVIEW_KIND = 'reconcile.day';
|
|
|
30
30
|
export const ACTOR = 'system:reconcile';
|
|
31
31
|
export const RECONCILE_DAYS = 60;
|
|
32
32
|
export const EVIDENCE_IDS = 20;
|
|
33
|
+
// A job ready this long with no invoice in Flow or the accounting system,
|
|
34
|
+
// and no hold, is a difference: something stops it that nobody sees.
|
|
35
|
+
export const READY_GRACE_DAYS = 3;
|
|
33
36
|
const RUN_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
34
37
|
const RECHECK_MS = 60 * 60 * 1000;
|
|
35
38
|
const TICK_MS = 10 * 60 * 1000;
|
|
@@ -55,7 +58,7 @@ function emptyDay(day) {
|
|
|
55
58
|
flowInvoices: { count: 0, totalCents: 0 },
|
|
56
59
|
accounting: { linked: 0, found: 0, totalCents: 0 },
|
|
57
60
|
differences: {
|
|
58
|
-
missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [],
|
|
61
|
+
missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [], readyNotInvoiced: [],
|
|
59
62
|
},
|
|
60
63
|
};
|
|
61
64
|
}
|
|
@@ -76,7 +79,7 @@ const moneyAtRisk = (entry) => Object.values(entry.differences).flat()
|
|
|
76
79
|
.reduce((cents, d) => cents + Math.abs(d.cents ?? 0), 0);
|
|
77
80
|
|
|
78
81
|
// One pass over Flow's own tables. Answers one entry per day, oldest first.
|
|
79
|
-
export async function reconcileDays(db, organisationId, { integrations, from, to }) {
|
|
82
|
+
export async function reconcileDays(db, organisationId, { integrations, from, to, now = Date.now() }) {
|
|
80
83
|
const byDay = new Map();
|
|
81
84
|
const dayOf = (day) => {
|
|
82
85
|
if (!byDay.has(day)) byDay.set(day, emptyDay(day));
|
|
@@ -110,6 +113,8 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
110
113
|
accountingState: billingObligations.accountingState,
|
|
111
114
|
accountingRef: billingObligations.accountingRef,
|
|
112
115
|
invoiceId: billingObligations.invoiceId,
|
|
116
|
+
readyAt: billingObligations.readyAt,
|
|
117
|
+
holdState: billingObligations.holdState,
|
|
113
118
|
invoiceStatus: invoices.status,
|
|
114
119
|
invoiceTotalCents: invoices.totalCents,
|
|
115
120
|
})
|
|
@@ -148,6 +153,9 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
148
153
|
if (o.invoiceId) {
|
|
149
154
|
entry.flowInvoices.count += 1;
|
|
150
155
|
entry.flowInvoices.totalCents += o.invoiceTotalCents ?? 0;
|
|
156
|
+
} else if (!o.accountingRef && o.holdState === 'none' && o.readyAt
|
|
157
|
+
&& new Date(o.readyAt).getTime() < now - READY_GRACE_DAYS * 86_400_000) {
|
|
158
|
+
entry.differences.readyNotInvoiced.push({ id: o.id, cents: o.totalCents });
|
|
151
159
|
}
|
|
152
160
|
// Only a work system that is on can say a job is gone from its copy.
|
|
153
161
|
if (reported.has(o.sourceSystem)) {
|
|
@@ -217,7 +225,7 @@ export async function runReconcile(db, organisationId, {
|
|
|
217
225
|
const open = await openDayItems(db, organisationId);
|
|
218
226
|
// An open item older than the window is checked too, so it can close.
|
|
219
227
|
const oldest = [...open.keys()].reduce((min, day) => (day < min ? day : min), isoDay(now - (days - 1) * 86_400_000));
|
|
220
|
-
const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to });
|
|
228
|
+
const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to, now });
|
|
221
229
|
const differing = entries.filter((e) => differenceCount(e) > 0);
|
|
222
230
|
const recheck = new Set(unconfirmed);
|
|
223
231
|
const firstSeen = [];
|