@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.
Files changed (49) hide show
  1. package/SECURITY.md +12 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +119 -3
  6. package/server/app.js +3 -2
  7. package/server/auth/routes.js +20 -0
  8. package/server/auth/session.js +44 -1
  9. package/server/auth/settings.js +27 -0
  10. package/server/core/alerts.js +38 -3
  11. package/server/core/automations.js +8 -3
  12. package/server/core/invoice-rules.js +158 -8
  13. package/server/core/invoices.js +9 -1
  14. package/server/core/invoicing-rules.js +31 -6
  15. package/server/core/job-board.js +16 -1
  16. package/server/core/job-match.js +6 -4
  17. package/server/core/ledger.js +67 -8
  18. package/server/core/pipeline.js +6 -2
  19. package/server/core/reconcile.js +11 -3
  20. package/server/core/sending.js +30 -3
  21. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  22. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  23. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  24. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +21 -0
  27. package/server/integrations/seazona/feed.js +10 -3
  28. package/server/integrations/seazona/index.js +6 -1
  29. package/server/integrations/seazona/routes.js +65 -2
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/xero/drafts.js +1 -1
  32. package/server/integrations/xero/index.js +1 -1
  33. package/server/integrations/xero/ledger.js +17 -8
  34. package/server/integrations/xero/links.js +39 -1
  35. package/server/integrations/xero/lookup.js +3 -1
  36. package/server/integrations/xero/routes.js +18 -1
  37. package/server/ui/app-page.js +1 -1
  38. package/server/ui/app.js +7 -4
  39. package/server/ui/flow.css +69 -1
  40. package/server/ui/home.js +120 -0
  41. package/server/ui/invoice-rules.js +11 -3
  42. package/server/ui/invoicing.js +81 -6
  43. package/server/ui/job-board.js +8 -3
  44. package/server/ui/jobs.js +14 -2
  45. package/server/ui/nav-pins.js +5 -3
  46. package/server/ui/reconcile.js +4 -2
  47. package/server/ui/routes.js +1 -1
  48. package/skills/flow/SKILL.md +306 -0
  49. package/skills/flow/flow.sh +121 -0
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
3
+ import { billingObligations, contacts, invoiceLines, invoices, people, priceItems } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
5
+ import { listRules, ruleExceptionsFor, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
8
  import { CHANGED_REASON, diffJobLines, sourceBillingProblems, STATUS_BACK_REASON } from './ledger.js';
@@ -111,9 +111,20 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
111
111
  if (rules.length && jobs.length) {
112
112
  const settings = await readInvoicingSettings(db, organisationId);
113
113
  const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
114
+ const exceptions = await ruleExceptionsFor(db, organisationId, jobs.map((j) => j.obligation.id));
115
+ // A rule's item counts only on a charged line: an item edited to $0
116
+ // does not satisfy the rule; Skip, with a reason, is the way to say so.
117
+ const itemRows = await db.select({ invoiceId: invoiceLines.invoiceId, code: priceItems.code }).from(invoiceLines)
118
+ .innerJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
119
+ .where(and(inArray(invoiceLines.invoiceId, jobs.map((j) => j.obligation.invoiceId)), sql`${invoiceLines.unitAmountCents} * ${invoiceLines.quantity} > 0`));
120
+ const itemsOf = Map.groupBy(itemRows, (r) => r.invoiceId);
114
121
  for (const { obligation, reference, customerName } of jobs) {
115
122
  const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
116
- const job = { ...obligation, reference };
123
+ const job = {
124
+ ...obligation, reference,
125
+ ruleExceptions: exceptions.get(obligation.id) ?? [],
126
+ draftItems: (itemsOf.get(obligation.invoiceId) ?? []).map((r) => r.code),
127
+ };
117
128
  const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
118
129
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
119
130
  }
@@ -430,6 +441,11 @@ export function refusalReport(result) {
430
441
  return `[sending] the accounting system refused ${count} ${word}${detail ? `: ${detail}` : ''}`;
431
442
  }
432
443
 
444
+ async function requesterActive(db, personId) {
445
+ const [row] = await db.select({ status: people.status }).from(people).where(eq(people.id, personId));
446
+ return row?.status === 'active';
447
+ }
448
+
433
449
  // One pass of the send worker: up to `limit` queued invoices, oldest first.
434
450
  export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
435
451
  const rows = await db.select({ id: invoices.id, number: invoices.number, by: invoices.sendRequestedBy }).from(invoices)
@@ -439,6 +455,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
439
455
  const summary = { sent: 0, failed: 0, waiting: 0 };
440
456
  for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
441
457
  const known = new Map(group.map((row) => [row.id, row]));
458
+ // A send waits in the queue. The person who asked for it must still be
459
+ // active when it goes, so turning a person (or an assistant's key) off
460
+ // also stops what they queued.
461
+ if (by && !(await requesterActive(db, by))) {
462
+ for (const row of group) {
463
+ await settle(db, organisationId, row.id, { sendState: 'failed', sendError: { code: 'requester_disabled', message: 'The person who asked to send this is turned off. Send it again.' } });
464
+ summary.failed += 1;
465
+ }
466
+ summary.refused = [...new Set([...(summary.refused ?? []), 'requester_disabled'])];
467
+ continue;
468
+ }
442
469
  const { invoices: results } = await accounting.capabilities.pushInvoices({
443
470
  organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
444
471
  });
@@ -0,0 +1,24 @@
1
+ CREATE TABLE IF NOT EXISTS "invoice_rule_exceptions" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "rule_id" uuid NOT NULL,
5
+ "obligation_id" uuid NOT NULL,
6
+ "reason" text NOT NULL,
7
+ "actor" text NOT NULL,
8
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
9
+ CONSTRAINT "invoice_rule_exceptions_reason_valid" CHECK (length("invoice_rule_exceptions"."reason") between 1 and 200)
10
+ );
11
+ --> statement-breakpoint
12
+ DO $flow_guard$ BEGIN
13
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
14
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
15
+ END $flow_guard$;--> statement-breakpoint
16
+ DO $flow_guard$ BEGIN
17
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_rule_id_invoice_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "flow"."invoice_rules"("id") ON DELETE cascade ON UPDATE no action;
18
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
19
+ END $flow_guard$;--> statement-breakpoint
20
+ DO $flow_guard$ BEGIN
21
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_obligation_id_billing_obligations_id_fk" FOREIGN KEY ("obligation_id") REFERENCES "flow"."billing_obligations"("id") ON DELETE no action ON UPDATE no action;
22
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
23
+ END $flow_guard$;--> statement-breakpoint
24
+ CREATE UNIQUE INDEX IF NOT EXISTS "invoice_rule_exceptions_rule_job" ON "invoice_rule_exceptions" USING btree ("rule_id","obligation_id");
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "invoice_rule_exceptions" ADD COLUMN IF NOT EXISTS "item_code" text;--> statement-breakpoint
2
+ ALTER TABLE "invoice_rule_exceptions" ADD COLUMN IF NOT EXISTS "line_fingerprint" text;