@arcforgelabs/flow 2026.10.38 → 2026.10.39
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 +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/ui/app.js +35 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
package/server/db/schema.js
CHANGED
|
@@ -784,6 +784,8 @@ export const jobFields = pgTable('job_fields', {
|
|
|
784
784
|
name: text('name').notNull(),
|
|
785
785
|
type: text('type').notNull(),
|
|
786
786
|
options: jsonb('options').notNull().default(sql`'[]'::jsonb`),
|
|
787
|
+
// The field's colour, one of the option colours; null is its type's colour.
|
|
788
|
+
color: text('color'),
|
|
787
789
|
position: integer('position').notNull(),
|
|
788
790
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
789
791
|
createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
|
|
@@ -794,8 +796,20 @@ export const jobFields = pgTable('job_fields', {
|
|
|
794
796
|
unique('job_fields_org_id').on(t.organisationId, t.id),
|
|
795
797
|
check('job_fields_type_valid', sql`${t.type} in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')`),
|
|
796
798
|
check('job_fields_name_valid', sql`length(${t.name}) between 1 and 60`),
|
|
799
|
+
check('job_fields_color_valid', sql`${t.color} is null or ${t.color} in ('gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink')`),
|
|
797
800
|
]);
|
|
798
801
|
|
|
802
|
+
// One row per organisation for Settings, Custom fields: each type group's
|
|
803
|
+
// colour ({ dropdown: 'red' }; a type not named keeps its own colour), and
|
|
804
|
+
// when Flow made the Assignee, so a lab that deletes it does not get it back.
|
|
805
|
+
export const jobFieldSettings = pgTable('job_field_settings', {
|
|
806
|
+
organisationId: uuid('organisation_id').primaryKey().references(() => organisations.id),
|
|
807
|
+
groupColors: jsonb('group_colors').notNull().default(sql`'{}'::jsonb`),
|
|
808
|
+
assigneeMadeAt: timestamp('assignee_made_at', { withTimezone: true }),
|
|
809
|
+
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
810
|
+
updatedAt: updatedAt(),
|
|
811
|
+
});
|
|
812
|
+
|
|
799
813
|
// A job's value for one custom field. A job is the work system's job
|
|
800
814
|
// (source_system, source_job_ref), the same key the Jobs board uses, so a
|
|
801
815
|
// value needs no Flow copy of the job. `value` is JSON: an option id, a person
|
|
@@ -360,3 +360,151 @@ export function createCallGuard({
|
|
|
360
360
|
|
|
361
361
|
return { integration, policy, check, stoppedNow, record, halt, resume, status };
|
|
362
362
|
}
|
|
363
|
+
|
|
364
|
+
// ── Button presses ─────────────────────────────────────────────────────────
|
|
365
|
+
//
|
|
366
|
+
// The action guard: a person's button press that calls an outside system (a
|
|
367
|
+
// copy, a recheck, a send) is one action. Pressed again and again, it spends
|
|
368
|
+
// the provider's rate limit and can trip the call guard above. So each
|
|
369
|
+
// guarded route:
|
|
370
|
+
//
|
|
371
|
+
// - runs once at a time per key: a second press while it runs waits
|
|
372
|
+
// (429, `action_running`);
|
|
373
|
+
// - after a run, waits `cooldownMs` before it runs again for that key
|
|
374
|
+
// (429, `action_cooldown`);
|
|
375
|
+
// - after a failed run (a 5xx, a 429 or a stopped call), waits twice as
|
|
376
|
+
// long each time, to at most `maxCooldownMs`; a good run resets it;
|
|
377
|
+
// - shares one budget per integration: at most `budget` runs in `budgetMs`
|
|
378
|
+
// for all its actions together (429, `action_budget`). An action with
|
|
379
|
+
// `budget: false` (a send a person makes one invoice at a time) is
|
|
380
|
+
// outside it; the provider's rate limit still paces its calls.
|
|
381
|
+
//
|
|
382
|
+
// A 429 carries Retry-After and `retryAfterMs`, so the page shows the wait.
|
|
383
|
+
// A good run says its cooldown in the `Flow-Cooldown-Ms` header. A refused
|
|
384
|
+
// request (another 4xx) costs nothing: no call went. The state is in memory:
|
|
385
|
+
// one Flow process serves one lab (server/rate-limit.js), and the call guard
|
|
386
|
+
// above still protects every process.
|
|
387
|
+
|
|
388
|
+
export const ACTION_GUARD_POLICY = Object.freeze({
|
|
389
|
+
cooldownMs: 30_000,
|
|
390
|
+
maxCooldownMs: 15 * 60_000,
|
|
391
|
+
budget: 30,
|
|
392
|
+
budgetMs: 10 * 60_000,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const ACTION_TEXT = {
|
|
396
|
+
action_running: () => 'This is already running. Wait for it to finish.',
|
|
397
|
+
action_cooldown: (seconds) => `Flow just did this. Try again in ${seconds} seconds.`,
|
|
398
|
+
action_budget: (seconds) => `Too many requests to this system. Try again in ${seconds} seconds.`,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// The wait after `failures` failed runs in a row.
|
|
402
|
+
export function actionCooldownMs(failures, { cooldownMs, maxCooldownMs }) {
|
|
403
|
+
if (failures <= 0) return cooldownMs;
|
|
404
|
+
return Math.min(maxCooldownMs, Math.max(cooldownMs, 5_000) * 2 ** Math.min(failures, 10));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// How a response ends a run: 'ok', 'failed' (the system or the call guard
|
|
408
|
+
// said no) or 'refused' (Flow refused the request; nothing was called).
|
|
409
|
+
export function actionOutcome(statusCode) {
|
|
410
|
+
if (statusCode >= 500 || statusCode === 429) return 'failed';
|
|
411
|
+
if (statusCode >= 400) return 'refused';
|
|
412
|
+
return 'ok';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const cleanPart = (value) => String(value ?? '').replace(/[^A-Za-z0-9_.:-]+/g, '').slice(0, 80);
|
|
416
|
+
|
|
417
|
+
// `integration` names the budget; `actions` maps each action name to its
|
|
418
|
+
// policy ({ cooldownMs, maxCooldownMs, budget }). `route(name, { key })` answers the
|
|
419
|
+
// Express middleware for one route; `key(req)` names what the cooldown
|
|
420
|
+
// covers inside the organisation (an invoice id, say), or null for all of it.
|
|
421
|
+
// `off: true` lets every press through (tests that press one route often).
|
|
422
|
+
export function createActionGuard({ integration, actions = {}, policy: overrides = {}, off = false, now = Date.now } = {}) {
|
|
423
|
+
if (!/^[a-z][a-z0-9-]{1,31}$/.test(integration ?? '')) throw new TypeError('createActionGuard: integration must be an adapter id');
|
|
424
|
+
const policy = { ...ACTION_GUARD_POLICY, ...overrides };
|
|
425
|
+
const runs = new Map(); // key -> { running, failures, readyAt }
|
|
426
|
+
const started = []; // start times inside the budget window
|
|
427
|
+
const policyOf = (name) => ({ cooldownMs: policy.cooldownMs, maxCooldownMs: policy.maxCooldownMs, ...(actions[name] ?? {}) });
|
|
428
|
+
|
|
429
|
+
const runOf = (key) => {
|
|
430
|
+
let run = runs.get(key);
|
|
431
|
+
if (!run) runs.set(key, (run = { running: false, failures: 0, readyAt: 0 }));
|
|
432
|
+
return run;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Why `name` may not run for `key` now, or null.
|
|
436
|
+
function waitFor(name, key) {
|
|
437
|
+
const nowMs = Number(now());
|
|
438
|
+
const run = runs.get(key);
|
|
439
|
+
if (run?.running) return { code: 'action_running', retryAfterMs: 5_000 };
|
|
440
|
+
if (run && run.readyAt > nowMs) return { code: 'action_cooldown', retryAfterMs: run.readyAt - nowMs };
|
|
441
|
+
if (policyOf(name).budget === false) return null;
|
|
442
|
+
while (started.length && started[0] <= nowMs - policy.budgetMs) started.shift();
|
|
443
|
+
if (started.length >= policy.budget) return { code: 'action_budget', retryAfterMs: started[0] + policy.budgetMs - nowMs };
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function begin(name, key) {
|
|
448
|
+
const wait = waitFor(name, key);
|
|
449
|
+
if (wait) return wait;
|
|
450
|
+
runOf(key).running = true;
|
|
451
|
+
if (policyOf(name).budget !== false) started.push(Number(now()));
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// `retryAfterMs`: a wait the system or the call guard asked for.
|
|
456
|
+
function end(name, key, outcome, { retryAfterMs = 0 } = {}) {
|
|
457
|
+
const run = runOf(key);
|
|
458
|
+
const rule = policyOf(name);
|
|
459
|
+
run.running = false;
|
|
460
|
+
if (outcome === 'refused') {
|
|
461
|
+
if (rule.budget !== false) started.pop();
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
run.failures = outcome === 'failed' ? run.failures + 1 : 0;
|
|
465
|
+
const waitMs = Math.max(actionCooldownMs(run.failures, rule), retryAfterMs || 0);
|
|
466
|
+
run.readyAt = Number(now()) + waitMs;
|
|
467
|
+
return waitMs;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function route(name, { key = () => null } = {}) {
|
|
471
|
+
if (!actions[name]) throw new TypeError(`createActionGuard: ${name} is not a declared action`);
|
|
472
|
+
if (off) return (_req, _res, next) => next();
|
|
473
|
+
return (req, res, next) => {
|
|
474
|
+
const organisation = cleanPart(req.body?.organisationId ?? req.query?.organisationId) || 'sole';
|
|
475
|
+
const sub = key(req);
|
|
476
|
+
const runKey = [name, organisation, sub === null || sub === undefined ? '' : cleanPart(sub)].join('|');
|
|
477
|
+
const wait = begin(name, runKey);
|
|
478
|
+
if (wait) {
|
|
479
|
+
const seconds = Math.max(1, Math.ceil(wait.retryAfterMs / 1000));
|
|
480
|
+
res.set('Retry-After', String(seconds));
|
|
481
|
+
return res.status(429).json({ ok: false, code: wait.code, error: ACTION_TEXT[wait.code](seconds), retryAfterMs: wait.retryAfterMs });
|
|
482
|
+
}
|
|
483
|
+
let ended = false;
|
|
484
|
+
const finish = (statusCode) => {
|
|
485
|
+
if (ended) return 0;
|
|
486
|
+
ended = true;
|
|
487
|
+
const asked = Number(res.getHeader('Retry-After')) * 1000;
|
|
488
|
+
return end(name, runKey, actionOutcome(statusCode), { retryAfterMs: Number.isFinite(asked) ? asked : 0 });
|
|
489
|
+
};
|
|
490
|
+
// The cooldown is set as the answer is written, so a press right after
|
|
491
|
+
// it already waits, and the page learns the wait from the header.
|
|
492
|
+
const writeHead = res.writeHead;
|
|
493
|
+
res.writeHead = function writeHeadWithCooldown(statusCode, ...rest) {
|
|
494
|
+
const waitMs = finish(statusCode);
|
|
495
|
+
if (waitMs && actionOutcome(statusCode) === 'ok' && !res.headersSent) res.setHeader('Flow-Cooldown-Ms', String(waitMs));
|
|
496
|
+
return writeHead.call(this, statusCode, ...rest);
|
|
497
|
+
};
|
|
498
|
+
res.on('close', () => finish(res.headersSent ? res.statusCode : 500));
|
|
499
|
+
return next();
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Forget every run: for tests, and when a person connects again.
|
|
504
|
+
const reset = () => {
|
|
505
|
+
runs.clear();
|
|
506
|
+
started.length = 0;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
return { integration, policy, actions, route, waitFor, reset };
|
|
510
|
+
}
|
|
@@ -16,6 +16,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
16
16
|
'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
|
|
17
17
|
'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
|
|
18
18
|
'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
|
|
19
|
+
'invoiceStages', // { organisationId, invoiceIds? }: Map(invoiceId -> draft | approved | sent | void | pending) for issued invoices, from its copy
|
|
19
20
|
'documentFacts', // what the customer's copy of a sent invoice says (number, reference, dates), from the system's copy in Flow; never a live call
|
|
20
21
|
'sendStatus', // { connected, writesAllowed }: may Flow send invoices now?
|
|
21
22
|
'pushBill', // send an approved bill; the Flow record id is the idempotency key
|
|
@@ -150,7 +151,7 @@ export function defineAdapter(adapter) {
|
|
|
150
151
|
// integration id, its two tables, and its policy. An adapter that calls
|
|
151
152
|
// an outside system declares it (docs/INTEGRATIONS.md, "Outbound calls").
|
|
152
153
|
callGuard: callGuard ? Object.freeze({ ...callGuard }) : null,
|
|
153
|
-
// Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
|
|
154
|
+
// Optional HTTP routes: routes({ db, personAuth, log, messagePage, buttonCooldowns }) returns a router
|
|
154
155
|
// mounted at /api/<id> (registry.routes).
|
|
155
156
|
routes: routes ?? null,
|
|
156
157
|
// Optional: the settings an owner may enter in Settings, Integrations
|
|
@@ -317,7 +318,7 @@ export function createRegistry({ log = console, env = process.env } = {}) {
|
|
|
317
318
|
.filter((a) => ready(a) && typeof a.capabilities[capability] === 'function');
|
|
318
319
|
},
|
|
319
320
|
// The adapters' own HTTP routes, each under /api/<id>. `context` is
|
|
320
|
-
// { db, personAuth, log, messagePage }. An enabled, ready adapter's router is built
|
|
321
|
+
// { db, personAuth, log, messagePage, buttonCooldowns }. An enabled, ready adapter's router is built
|
|
321
322
|
// once, here; a request to an adapter that is off gets its turned-off
|
|
322
323
|
// answer (503), so a turned-off adapter never reads its settings or
|
|
323
324
|
// tables.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
|
3
|
+
import { withMigrationLock } from '../db/index.js';
|
|
3
4
|
|
|
4
5
|
// An adapter's tables: created from its own migrations while it is enabled,
|
|
5
6
|
// never dropped by turning it off. Each adapter keeps its own migration
|
|
@@ -24,12 +25,15 @@ export async function missingAdapterTables(db, adapter) {
|
|
|
24
25
|
|
|
25
26
|
// Runs the adapter's pending migrations, then checks they created every table
|
|
26
27
|
// it declares, so the declaration stays true.
|
|
28
|
+
// It holds Flow's migration lock (server/db/index.js) while it does.
|
|
27
29
|
export async function migrateAdapterSchema(db, adapter) {
|
|
28
|
-
await
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
await withMigrationLock(db, async (locked) => {
|
|
31
|
+
await migrate(locked, { migrationsFolder: adapter.schema.migrationsFolder, migrationsTable: migrationsTable(adapter) });
|
|
32
|
+
const missing = await missingAdapterTables(locked, adapter);
|
|
33
|
+
if (missing.length) {
|
|
34
|
+
throw new Error(`Adapter ${adapter.id} migrations did not create ${missing.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export function isMissingTable(error) {
|
|
@@ -77,15 +77,19 @@ export function orderUrlTemplate(value) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// The link to one order for "Open in Seazona": the order's own page when the
|
|
80
|
-
// installation set SEAZONA_ORDER_URL
|
|
81
|
-
//
|
|
82
|
-
//
|
|
80
|
+
// installation set SEAZONA_ORDER_URL; else, when the API address names the
|
|
81
|
+
// lab (https://{lab}.seazonaapi.net), its order page
|
|
82
|
+
// (https://{lab}.seazona.net/OrderEdit.aspx?id={order id}), or its home page
|
|
83
|
+
// with no order id; else null. `exact` is true only for the order's own page.
|
|
83
84
|
export function orderLink(settings, { sourceJobRef } = {}) {
|
|
84
85
|
const template = settings?.orderUrlTemplate ?? null;
|
|
85
86
|
if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
|
|
86
87
|
const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
|
|
87
88
|
const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
|
|
88
|
-
|
|
89
|
+
if (!lab) return null;
|
|
90
|
+
// Seazona's own order screen takes the order id (a GUID) Flow syncs.
|
|
91
|
+
if (new RegExp(`^${ORDER_ID.source}$`, 'i').test(sourceJobRef ?? '')) return { url: `https://${lab}.seazona.net/OrderEdit.aspx?id=${encodeURIComponent(sourceJobRef)}`, exact: true };
|
|
92
|
+
return { url: `https://${lab}.seazona.net/`, exact: false };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
// Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
|
|
@@ -131,7 +131,7 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
|
|
|
131
131
|
};
|
|
132
132
|
if (line.itemCode) {
|
|
133
133
|
if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
|
|
134
|
-
else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
|
|
134
|
+
else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} not in Xero` }));
|
|
135
135
|
} else if (line.accountCode) {
|
|
136
136
|
// A line with no item books to its own account, such as a surcharge.
|
|
137
137
|
out.AccountCode = line.accountCode;
|
|
@@ -289,8 +289,8 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
289
289
|
if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
|
|
290
290
|
if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
|
|
291
291
|
const match = await matchContact(db, organisationId, document.contact.name);
|
|
292
|
-
if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync
|
|
293
|
-
if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync
|
|
292
|
+
if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`, { short: 'Client not in Xero' }));
|
|
293
|
+
if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`, { short: 'Client twice in Xero' }));
|
|
294
294
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
295
295
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
296
296
|
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
@@ -389,7 +389,7 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
|
|
|
389
389
|
for (const preview of invoices) {
|
|
390
390
|
found.set(preview.invoiceId, preview.problems
|
|
391
391
|
.filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
|
|
392
|
-
.map(({ code, message }) => ({ code, message })));
|
|
392
|
+
.map(({ code, message, short }) => ({ code, message, ...(short ? { short } : {}) })));
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
return found;
|
|
@@ -6,6 +6,7 @@ import { readConnection } from './connections.js';
|
|
|
6
6
|
import { createImportWorker } from './import.js';
|
|
7
7
|
import { readPayments, xeroLiveCheck } from './ledger.js';
|
|
8
8
|
import { findInvoices, listCustomers, listPriceItems, mirrorState } from './lookup.js';
|
|
9
|
+
import { invoiceStages } from './reconcile.js';
|
|
9
10
|
import { createXeroRouter } from './routes.js';
|
|
10
11
|
import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
|
|
11
12
|
import { writesAllowed } from './settings.js';
|
|
@@ -116,6 +117,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
116
117
|
pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
|
|
117
118
|
// Several issued invoices, in batches of MAX_BATCH (drafts.js).
|
|
118
119
|
pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
|
|
120
|
+
// The Invoices page tab of each issued invoice (reconcile.js), from
|
|
121
|
+
// Flow's copy of Xero: draft, approved, sent, void or pending.
|
|
122
|
+
invoiceStages: ({ organisationId, invoiceIds = null }) => invoiceStages(database, current(), organisationId, invoiceIds),
|
|
119
123
|
// What would stop each Flow draft in Xero, from Flow's copy (no calls).
|
|
120
124
|
checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
|
|
121
125
|
sendStatus: async ({ organisationId }) => {
|
|
@@ -142,7 +146,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
142
146
|
// Xero items, from Flow's copy, for the price list (lookup.js).
|
|
143
147
|
listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
|
|
144
148
|
},
|
|
145
|
-
routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
|
|
149
|
+
routes: ({ db, personAuth, log, messagePage, buttonCooldowns }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage, buttonCooldowns }),
|
|
146
150
|
};
|
|
147
151
|
}
|
|
148
152
|
|
|
@@ -77,6 +77,7 @@ export function mapInvoice(invoice, { keepReference = false } = {}) {
|
|
|
77
77
|
amountDueCents: toCents(invoice.AmountDue),
|
|
78
78
|
amountPaidCents: toCents(invoice.AmountPaid),
|
|
79
79
|
amountCreditedCents: toCents(invoice.AmountCredited),
|
|
80
|
+
sentToContact: typeof invoice.SentToContact === 'boolean' ? invoice.SentToContact : null,
|
|
80
81
|
lines: mapLines(invoice.LineItems, keepReference),
|
|
81
82
|
xeroUpdatedAt: parseXeroDate(invoice.UpdatedDateUTC),
|
|
82
83
|
};
|
|
@@ -135,17 +136,17 @@ const UPSERT = {
|
|
|
135
136
|
invoices: (org, r, hash) => sql`
|
|
136
137
|
insert into xero_invoices (organisation_id, xero_invoice_id, type, invoice_number, reference, xero_contact_id, status,
|
|
137
138
|
issued_on, due_on, fully_paid_on, currency, subtotal_cents, tax_cents, total_cents, amount_due_cents,
|
|
138
|
-
amount_paid_cents, amount_credited_cents, lines, xero_updated_at, record_hash)
|
|
139
|
+
amount_paid_cents, amount_credited_cents, sent_to_contact, lines, xero_updated_at, record_hash)
|
|
139
140
|
values (${org}, ${r.xeroInvoiceId}, ${r.type}, ${r.invoiceNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
|
|
140
141
|
${r.issuedOn}, ${r.dueOn}, ${r.fullyPaidOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents},
|
|
141
|
-
${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
142
|
+
${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${r.sentToContact ?? null}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
142
143
|
on conflict (organisation_id, xero_invoice_id) do update set
|
|
143
144
|
type = excluded.type, invoice_number = excluded.invoice_number, reference = excluded.reference,
|
|
144
145
|
xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
|
|
145
146
|
due_on = excluded.due_on, fully_paid_on = excluded.fully_paid_on, currency = excluded.currency,
|
|
146
147
|
subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents, total_cents = excluded.total_cents,
|
|
147
148
|
amount_due_cents = excluded.amount_due_cents, amount_paid_cents = excluded.amount_paid_cents,
|
|
148
|
-
amount_credited_cents = excluded.amount_credited_cents, lines = excluded.lines,
|
|
149
|
+
amount_credited_cents = excluded.amount_credited_cents, sent_to_contact = excluded.sent_to_contact, lines = excluded.lines,
|
|
149
150
|
xero_updated_at = excluded.xero_updated_at, record_hash = excluded.record_hash, last_changed_at = now()
|
|
150
151
|
where xero_invoices.record_hash is distinct from excluded.record_hash
|
|
151
152
|
returning (xmax = 0) as inserted`,
|
|
@@ -96,7 +96,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
96
96
|
p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
|
|
97
97
|
v.document, l.xero_invoice_id as linked_xero_id, l.matched_by as linked_by,
|
|
98
98
|
x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
|
|
99
|
-
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
|
|
99
|
+
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.sent_to_contact, x.lines, x.issued_on
|
|
100
100
|
from invoices i
|
|
101
101
|
left join lateral (
|
|
102
102
|
select * from xero_invoice_pushes
|
|
@@ -131,6 +131,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
131
131
|
status: r.x_status,
|
|
132
132
|
totalCents: num(r.x_total_cents),
|
|
133
133
|
amountDueCents: num(r.amount_due_cents),
|
|
134
|
+
sentToContact: r.sent_to_contact ?? null,
|
|
134
135
|
url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(xeroInvoiceId)}`,
|
|
135
136
|
} : null;
|
|
136
137
|
const status = {
|
|
@@ -153,6 +154,29 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
153
154
|
return out;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// The Invoices page tab of each issued Flow invoice, from Flow's copy of
|
|
158
|
+
// Xero (no calls): draft (on its way, or a Xero DRAFT), approved (AUTHORISED,
|
|
159
|
+
// not sent to the customer yet), sent (Xero sent it, or it is part or fully
|
|
160
|
+
// paid), void (voided or deleted in Xero), or pending (out of sync: a person
|
|
161
|
+
// looks). Answers Map(invoiceId -> stage).
|
|
162
|
+
export async function invoiceStages(db, runtime, organisationId, invoiceIds = null) {
|
|
163
|
+
const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
|
|
164
|
+
const out = new Map();
|
|
165
|
+
for (const status of statuses.values()) {
|
|
166
|
+
if (status.flowStatus !== 'issued') continue;
|
|
167
|
+
out.set(status.invoiceId, stageOf(status));
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function stageOf(status) {
|
|
173
|
+
if (status.finding) return 'pending';
|
|
174
|
+
if (['part_paid', 'paid'].includes(status.state)) return 'sent';
|
|
175
|
+
if (status.state === 'approved') return status.xero?.sentToContact ? 'sent' : 'approved';
|
|
176
|
+
if (['voided', 'deleted'].includes(status.state)) return 'void';
|
|
177
|
+
return 'draft';
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
// Opens or refreshes one review item for each finding, and closes the item of
|
|
157
181
|
// an invoice whose finding went away. Answers counts.
|
|
158
182
|
export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, eq } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../../core/errors.js';
|
|
4
|
+
import { createActionGuard } from '../call-guard.js';
|
|
4
5
|
import { billingObligations, organisations } from '../../db/schema.js';
|
|
5
6
|
import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
|
|
6
7
|
import { createXeroClient } from './client.js';
|
|
@@ -43,10 +44,25 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
43
44
|
// POST /webhooks Xero: change events, signed with the
|
|
44
45
|
// webhook key (XERO_WEBHOOK_KEY); no session
|
|
45
46
|
//
|
|
47
|
+
// Each POST that calls Xero waits between presses (XERO_ACTIONS, the action
|
|
48
|
+
// guard): a press too soon answers 429 with the wait.
|
|
49
|
+
//
|
|
46
50
|
// Only an owner, with a verified owner sign-in, can connect or disconnect.
|
|
47
51
|
// The callback carries no Flow session: the one-use state from /connect,
|
|
48
52
|
// issued to that owner for that organisation, is what admits it.
|
|
49
53
|
|
|
54
|
+
// The buttons that call Xero, and how long each waits before it runs again
|
|
55
|
+
// (the action guard, call-guard.js). A send is one invoice at a time, so it
|
|
56
|
+
// only runs once at a time and backs off after a failure.
|
|
57
|
+
export const XERO_ACTIONS = Object.freeze({
|
|
58
|
+
sync: { cooldownMs: 5 * 60_000 },
|
|
59
|
+
recheck: { cooldownMs: 30_000 },
|
|
60
|
+
push: { cooldownMs: 0, budget: false },
|
|
61
|
+
obligationCheck: { cooldownMs: 30_000 },
|
|
62
|
+
paymentsSync: { cooldownMs: 5 * 60_000 },
|
|
63
|
+
mirror: { cooldownMs: 2 * 60_000 },
|
|
64
|
+
});
|
|
65
|
+
|
|
50
66
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
51
67
|
|
|
52
68
|
|
|
@@ -93,10 +109,11 @@ export function callsState(status) {
|
|
|
93
109
|
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
112
|
+
export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
97
113
|
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
98
114
|
const { settings } = runtime;
|
|
99
115
|
const router = express.Router();
|
|
116
|
+
const act = (name, key) => actions.route(name, { key });
|
|
100
117
|
|
|
101
118
|
// Xero's webhooks (webhooks.js), before the JSON parser: the signature
|
|
102
119
|
// covers the raw body. Xero wants 200 with no body within 5 seconds, and
|
|
@@ -220,7 +237,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
220
237
|
return res.json({ ok: true, disconnected: true, revoked });
|
|
221
238
|
}));
|
|
222
239
|
|
|
223
|
-
router.post('/sync', ...manager, handle(log, async (req, res) => {
|
|
240
|
+
router.post('/sync', ...manager, act('sync'), handle(log, async (req, res) => {
|
|
224
241
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
225
242
|
const run = await syncCatalogue(db, runtime, organisationId, { startedBy: req.person.id });
|
|
226
243
|
res.json({ ok: true, sync: run });
|
|
@@ -252,7 +269,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
252
269
|
// Mark resolved: read Xero's changes (a few calls, through the call guard),
|
|
253
270
|
// then check this one invoice again. The item closes only when the
|
|
254
271
|
// difference is gone; otherwise it stays open with the new evidence.
|
|
255
|
-
router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
|
|
272
|
+
router.post('/invoices/:invoiceId/recheck', ...manager, act('recheck', (req) => req.params.invoiceId), handle(log, async (req, res) => {
|
|
256
273
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
257
274
|
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
258
275
|
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
@@ -261,7 +278,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
261
278
|
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
262
279
|
}));
|
|
263
280
|
|
|
264
|
-
router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
|
|
281
|
+
router.post('/invoices/push', ...manager, act('push'), handle(log, async (req, res) => {
|
|
265
282
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
266
283
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
|
267
284
|
const allowExistingReference = req.body?.allowExistingReference === undefined ? [] : requireInvoiceIds(req.body.allowExistingReference, 'allowExistingReference');
|
|
@@ -276,7 +293,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
276
293
|
res.json({ ok: true, pushes: (await listPushes(db, organisationId, invoiceId)).map(describePush) });
|
|
277
294
|
}));
|
|
278
295
|
|
|
279
|
-
router.post('/obligations/:obligationId/check', ...manager, handle(log, async (req, res) => {
|
|
296
|
+
router.post('/obligations/:obligationId/check', ...manager, act('obligationCheck', (req) => req.params.obligationId), handle(log, async (req, res) => {
|
|
280
297
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
281
298
|
const [obligationId] = requireInvoiceIds([req.params.obligationId], 'obligationId');
|
|
282
299
|
const [obligation] = await db.select().from(billingObligations)
|
|
@@ -286,13 +303,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
286
303
|
res.json({ ok: true, obligation: row });
|
|
287
304
|
}));
|
|
288
305
|
|
|
289
|
-
router.post('/payments/sync', ...manager, handle(log, async (req, res) => {
|
|
306
|
+
router.post('/payments/sync', ...manager, act('paymentsSync'), handle(log, async (req, res) => {
|
|
290
307
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
291
308
|
const result = await recordXeroPayments(db, runtime, organisationId, { actor: `person:${req.person.id}` });
|
|
292
309
|
res.json({ ok: true, ...result });
|
|
293
310
|
}));
|
|
294
311
|
|
|
295
|
-
router.post('/mirror', ...manager, handle(log, async (req, res) => {
|
|
312
|
+
router.post('/mirror', ...manager, act('mirror'), handle(log, async (req, res) => {
|
|
296
313
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
297
314
|
const resources = req.body?.resources ?? MIRROR_RESOURCES;
|
|
298
315
|
if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
|
package/server/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
+
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
16
17
|
import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
17
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
18
19
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
@@ -126,10 +127,41 @@ async function flow(path, options = {}) {
|
|
|
126
127
|
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
127
128
|
});
|
|
128
129
|
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
129
|
-
|
|
130
|
+
// A button that calls Xero or Seazona waits between presses (the action
|
|
131
|
+
// guard): a press too soon says how long (retryAfterMs), and a good run
|
|
132
|
+
// says its own wait (Flow-Cooldown-Ms). holdButton shows the wait.
|
|
133
|
+
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details, retryAfterMs: body.retryAfterMs });
|
|
134
|
+
const cooldownMs = Number(res.headers.get('Flow-Cooldown-Ms'));
|
|
135
|
+
if (cooldownMs > 0 && body && typeof body === 'object') body.cooldownMs = cooldownMs;
|
|
130
136
|
return body;
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
140
|
+
function holdButton(button, ms) {
|
|
141
|
+
if (!button || !(ms > 0)) return;
|
|
142
|
+
clearInterval(button.fxHold);
|
|
143
|
+
const label = button.dataset.label ?? button.textContent;
|
|
144
|
+
button.dataset.label = label;
|
|
145
|
+
const until = Date.now() + ms;
|
|
146
|
+
const draw = () => {
|
|
147
|
+
const left = Math.ceil((until - Date.now()) / 1000);
|
|
148
|
+
if (left <= 0) {
|
|
149
|
+
clearInterval(button.fxHold);
|
|
150
|
+
button.textContent = label;
|
|
151
|
+
delete button.dataset.label;
|
|
152
|
+
button.removeAttribute('title');
|
|
153
|
+
button.disabled = false;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
157
|
+
button.disabled = true;
|
|
158
|
+
button.textContent = `${label} · ${time}`;
|
|
159
|
+
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
160
|
+
};
|
|
161
|
+
draw();
|
|
162
|
+
button.fxHold = setInterval(draw, 1000);
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
134
166
|
|
|
135
167
|
function pageHeader(title, { subtitle, actions = [] } = {}) {
|
|
@@ -204,7 +236,7 @@ const VIEWS = {
|
|
|
204
236
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
205
237
|
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
206
238
|
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
207
|
-
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
239
|
+
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
208
240
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
209
241
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
210
242
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
@@ -371,7 +403,7 @@ function navigate(event) {
|
|
|
371
403
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
372
404
|
// takes a path, or a link's click event.
|
|
373
405
|
const ui = () => ({
|
|
374
|
-
h, flow, toast, notice, pageHeader, dialog, me,
|
|
406
|
+
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
375
407
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
376
408
|
});
|
|
377
409
|
|