@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
package/server/core/sending.js
CHANGED
|
@@ -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 = {
|
|
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");
|