@arcforgelabs/flow 2026.10.26 → 2026.10.27

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 (42) hide show
  1. package/README.md +5 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +231 -20
  5. package/server/app.js +28 -2
  6. package/server/core/automations.js +140 -0
  7. package/server/core/invoice-rules.js +224 -0
  8. package/server/core/invoices.js +4 -1
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/ledger.js +45 -12
  11. package/server/core/monitoring.js +34 -0
  12. package/server/core/pipeline.js +8 -1
  13. package/server/core/sending.js +282 -0
  14. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  15. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  16. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  17. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  18. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  19. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  20. package/server/db/migrations/meta/_journal.json +21 -0
  21. package/server/db/schema.js +115 -0
  22. package/server/index.js +12 -4
  23. package/server/integrations/adapters.js +2 -1
  24. package/server/integrations/port.js +5 -0
  25. package/server/integrations/seazona/ledger.js +2 -0
  26. package/server/integrations/sentry/index.js +105 -0
  27. package/server/integrations/xero/client.js +5 -0
  28. package/server/integrations/xero/drafts.js +123 -16
  29. package/server/integrations/xero/index.js +11 -1
  30. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  31. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  32. package/server/ui/app-page.js +2 -1
  33. package/server/ui/app.js +14 -1
  34. package/server/ui/automations.js +96 -0
  35. package/server/ui/flow.css +88 -0
  36. package/server/ui/invoice-rules.js +237 -0
  37. package/server/ui/invoicing-settings.js +108 -0
  38. package/server/ui/invoicing.js +166 -20
  39. package/server/ui/jobs.js +19 -0
  40. package/server/ui/logos/sentry.svg +1 -0
  41. package/server/ui/monitoring.js +53 -0
  42. package/server/ui/routes.js +2 -2
@@ -71,6 +71,27 @@
71
71
  "when": 1792404377371,
72
72
  "tag": "0009_pipeline_matcher",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "7",
78
+ "when": 1792407554981,
79
+ "tag": "0010_invoicing_rules",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1792411008479,
86
+ "tag": "0011_invoice_rules",
87
+ "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "7",
92
+ "when": 1792414436676,
93
+ "tag": "0012_invoice_sending",
94
+ "breakpoints": true
74
95
  }
75
96
  ]
76
97
  }
@@ -85,6 +85,16 @@ export const invoices = pgTable('invoices', {
85
85
  subtotalCents: integer('subtotal_cents').notNull().default(0),
86
86
  taxCents: integer('tax_cents').notNull().default(0),
87
87
  totalCents: integer('total_cents').notNull().default(0),
88
+ // When a person marked the draft ready to send, and who. Any change to the
89
+ // draft clears it (server/core/sending.js).
90
+ readyAt: timestamp('ready_at', { withTimezone: true }),
91
+ readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
92
+ // Sending to the accounting system (server/core/sending.js): queued by a
93
+ // person, then sent or failed. `sendError` is { code, message } while failed.
94
+ sendState: text('send_state'),
95
+ sendError: jsonb('send_error'),
96
+ sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
97
+ sendUpdatedAt: timestamp('send_updated_at', { withTimezone: true }),
88
98
  createdAt: createdAt(),
89
99
  updatedAt: updatedAt(),
90
100
  }, (t) => [
@@ -97,6 +107,7 @@ export const invoices = pgTable('invoices', {
97
107
  }),
98
108
  check('invoices_status_valid', sql`${t.status} in ('draft', 'issued', 'void')`),
99
109
  check('invoices_issued_has_number', sql`${t.status} = 'draft' or ${t.number} is not null`),
110
+ check('invoices_send_state_valid', sql`${t.sendState} is null or ${t.sendState} in ('queued', 'sent', 'failed')`),
100
111
  ]);
101
112
 
102
113
  export const invoiceLines = pgTable('invoice_lines', {
@@ -209,6 +220,13 @@ export const billingObligations = pgTable('billing_obligations', {
209
220
  // The day of the work, for matching it to an accounting invoice: the work
210
221
  // system's invoice date, else its due date, else its order date.
211
222
  jobDate: date('job_date'),
223
+ // The work system's status for the job, as it reported it (for example
224
+ // "Shipped"). The draft rules read it.
225
+ sourceStatus: text('source_status'),
226
+ // Flow's own stage: the job is ready to invoice, since when and by whom (a
227
+ // person, or an automation such as one that reads the work system status).
228
+ readyAt: timestamp('ready_at', { withTimezone: true }),
229
+ readyBy: text('ready_by'),
212
230
  // The lines as the work system reported them, normalised.
213
231
  lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
214
232
  lineFingerprint: text('line_fingerprint').notNull(),
@@ -559,3 +577,100 @@ export const linkRejections = pgTable('link_rejections', {
559
577
  foreignColumns: [billingObligations.organisationId, billingObligations.id],
560
578
  }),
561
579
  ]);
580
+
581
+ // Invoicing rules (server/core/invoicing-rules.js). One row for each
582
+ // organisation: the checks a job passes before Flow drafts its invoice, and
583
+ // how a checked draft goes to the accounting system. No row means the
584
+ // defaults. When Flow drafts is an automation (`automations`).
585
+ export const SEND_MODES = ['accounting_draft', 'approve_and_email'];
586
+
587
+ export const invoicingSettings = pgTable('invoicing_settings', {
588
+ organisationId: uuid('organisation_id').primaryKey().references(() => organisations.id),
589
+ // accounting_draft: a person checks the draft in Flow, Flow sends it as an
590
+ // accounting draft, and a person approves and sends it there.
591
+ // approve_and_email: a person approves the draft in Flow, Flow sends it
592
+ // approved, and the accounting system emails it to the customer.
593
+ sendMode: text('send_mode').notNull().default('accounting_draft'),
594
+ // A job whose date is more than this many days ago waits for a person.
595
+ staleDays: integer('stale_days').notNull().default(90),
596
+ // The days from the invoice date to its due date.
597
+ paymentTermsDays: integer('payment_terms_days').notNull().default(30),
598
+ // A job needs a practitioner, its own or its customer's default one.
599
+ requirePractitioner: boolean('require_practitioner').notNull().default(false),
600
+ // The price item code of a rush charge, and its share of the other lines.
601
+ rushItemCode: text('rush_item_code'),
602
+ rushPercent: integer('rush_percent').notNull().default(20),
603
+ // The fixed checks above became invoice rules (invoice_rules). True once
604
+ // this organisation's starter rules were made from them.
605
+ rulesSeeded: boolean('rules_seeded').notNull().default(false),
606
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
607
+ updatedAt: updatedAt(),
608
+ }, (t) => [
609
+ check('invoicing_settings_send_mode_valid', inList('send_mode', SEND_MODES)),
610
+ check('invoicing_settings_stale_days_valid', sql`${t.staleDays} between 1 and 3650`),
611
+ check('invoicing_settings_terms_valid', sql`${t.paymentTermsDays} between 0 and 365`),
612
+ check('invoicing_settings_rush_valid', sql`${t.rushPercent} between 0 and 100`),
613
+ ]);
614
+
615
+ // One customer's own invoicing settings. A null value takes the
616
+ // organisation's setting.
617
+ export const customerBillingSettings = pgTable('customer_billing_settings', {
618
+ organisationId: organisationId().references(() => organisations.id),
619
+ contactId: uuid('contact_id').notNull(),
620
+ // A customer that is not billable never gets an invoice from a rule, and
621
+ // `autoDraft` false keeps the draft automation off for this customer.
622
+ billable: boolean('billable').notNull().default(true),
623
+ defaultPractitioner: text('default_practitioner'),
624
+ autoDraft: boolean('auto_draft'),
625
+ sendMode: text('send_mode'),
626
+ paymentTermsDays: integer('payment_terms_days'),
627
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
628
+ updatedAt: updatedAt(),
629
+ }, (t) => [
630
+ primaryKey({ name: 'customer_billing_settings_pk', columns: [t.organisationId, t.contactId] }),
631
+ foreignKey({
632
+ name: 'customer_billing_settings_contact_same_org',
633
+ columns: [t.organisationId, t.contactId],
634
+ foreignColumns: [contacts.organisationId, contacts.id],
635
+ }),
636
+ check('customer_billing_settings_send_mode_valid', sql`${t.sendMode} is null or ${t.sendMode} in ('accounting_draft', 'approve_and_email')`),
637
+ check('customer_billing_settings_terms_valid', sql`${t.paymentTermsDays} is null or ${t.paymentTermsDays} between 0 and 365`),
638
+ ]);
639
+
640
+ // Invoice rules (server/core/invoice-rules.js, docs/INVOICE_RULES.md): a lab's
641
+ // own checks on a job before Flow drafts its invoice. Each rule is When, If and
642
+ // Then: `conditions` is a list of { field, test, value, item? } that must all
643
+ // match, and `action` is { kind: 'wait' | 'skip' }. `starter_key` marks a rule
644
+ // Flow made from the earlier fixed settings, once per organisation.
645
+ export const invoiceRules = pgTable('invoice_rules', {
646
+ id: id(),
647
+ organisationId: organisationId().references(() => organisations.id),
648
+ name: text('name').notNull(),
649
+ position: integer('position').notNull(),
650
+ enabled: boolean('enabled').notNull().default(true),
651
+ trigger: text('trigger').notNull().default('job_ready'),
652
+ conditions: jsonb('conditions').notNull().default(sql`'[]'::jsonb`),
653
+ action: jsonb('action').notNull(),
654
+ starterKey: text('starter_key'),
655
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
656
+ createdAt: createdAt(),
657
+ updatedAt: updatedAt(),
658
+ }, (t) => [
659
+ uniqueIndex('invoice_rules_starter_key').on(t.organisationId, t.starterKey),
660
+ check('invoice_rules_trigger_valid', sql`${t.trigger} in ('job_ready')`),
661
+ check('invoice_rules_name_valid', sql`length(${t.name}) between 1 and 120`),
662
+ ]);
663
+
664
+ // Automations (server/core/automations.js): Flow's routines, each on or off,
665
+ // with its own settings. The routines are defined in code; a row holds an
666
+ // organisation's choice. No row means the routine's default.
667
+ export const automations = pgTable('automations', {
668
+ organisationId: organisationId().references(() => organisations.id),
669
+ key: text('key').notNull(),
670
+ enabled: boolean('enabled').notNull(),
671
+ config: jsonb('config').notNull().default(sql`'{}'::jsonb`),
672
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
673
+ updatedAt: updatedAt(),
674
+ }, (t) => [
675
+ primaryKey({ name: 'automations_pk', columns: [t.organisationId, t.key] }),
676
+ ]);
package/server/index.js CHANGED
@@ -6,7 +6,9 @@ import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
8
  import { importEarlierAtStart, importEarlierLinksAtStart, resolveImportAtStart } from './import/at-start.js';
9
+ import { createMonitoredLog } from './core/monitoring.js';
9
10
  import { createPipelineWorker } from './core/pipeline.js';
11
+ import { createSendWorker } from './core/sending.js';
10
12
  import { resolveSecretsKey } from './core/secrets.js';
11
13
  import { adapters as shippedAdapters } from './integrations/adapters.js';
12
14
  import { createIntegrations, resolveEnabledIntegrations } from './integrations/index.js';
@@ -66,12 +68,15 @@ await importEarlierLinksAtStart({ db: database.db, pool: database.pool, settings
66
68
  .catch((error) => console.error(`[import] ${error.message}`));
67
69
  // What an owner set in Settings, Integrations, with the host's own
68
70
  // environment over it (server/integrations/setup.js).
71
+ // Every part of Flow logs here: each error also goes to the installation's
72
+ // own error service while one is on (docs/SENTRY.md).
69
73
  let integrations;
74
+ const log = createMonitoredLog({ log: console, reporters: () => integrations?.with('captureError') ?? [] });
70
75
  try {
71
76
  const stored = await readStoredSettings(database.db);
72
77
  const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
73
78
  if (unreadable.length) console.warn(`[integrations] stored credentials Flow cannot read with this secrets key: ${unreadable.join(', ')}. An owner enters them again in Settings.`);
74
- integrations = createIntegrations({ turnedOnInFlow: storedEnabled(stored, process.env), settingsEnv });
79
+ integrations = createIntegrations({ turnedOnInFlow: storedEnabled(stored, process.env), settingsEnv, log });
75
80
  await integrations.start(database.db);
76
81
  } catch (error) {
77
82
  console.error(`[integrations] ${error.message}`);
@@ -85,14 +90,17 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
85
90
  // only while an integration that feeds it is on, and writes to no other
86
91
  // system. FLOW_PIPELINE=off stops it.
87
92
  if (process.env.FLOW_PIPELINE !== 'off') {
88
- createPipelineWorker({ db: database.db, integrations }).start();
93
+ createPipelineWorker({ db: database.db, integrations, log }).start();
89
94
  }
95
+ // The send worker (server/core/sending.js) sends only what a person queued
96
+ // with Send all ready, to the accounting integration that is on.
97
+ createSendWorker({ db: database.db, integrations, log }).start();
90
98
 
91
99
  let auth = null;
92
100
  if (settings.mode === 'clerk') {
93
101
  const { keys } = settings;
94
102
  const clerk = await loadClerkAdapter({ ...keys, authorizedParties: settings.authorizedParties });
95
- const personAuth = createPersonAuth({ db: database.db, clerk, ...settings });
103
+ const personAuth = createPersonAuth({ db: database.db, clerk, ...settings, log });
96
104
  let clerkProxy = null;
97
105
  const proxyPath = resolveClerkProxyPath(keys.proxyUrl);
98
106
  if (proxyPath) {
@@ -129,7 +137,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
129
137
 
130
138
  const updates = createUpdateChecker();
131
139
 
132
- createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily }).listen(port, () => {
140
+ createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
133
141
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
134
142
  });
135
143
 
@@ -1,6 +1,7 @@
1
1
  // The adapters this build of Flow ships. server/index.js registers each one;
2
2
  // FLOW_INTEGRATIONS chooses which are on (server/integrations/index.js).
3
3
  import { seazonaAdapter } from './seazona/index.js';
4
+ import { sentryAdapter } from './sentry/index.js';
4
5
  import { xeroAdapter } from './xero/index.js';
5
6
 
6
- export const adapters = [seazonaAdapter, xeroAdapter];
7
+ export const adapters = [seazonaAdapter, sentryAdapter, xeroAdapter];
@@ -12,12 +12,17 @@ import { isMissingTable, migrateAdapterSchema } from './schema.js';
12
12
  export const CAPABILITIES = Object.freeze([
13
13
  'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
14
14
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
15
+ 'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
16
+ 'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
17
+ 'sendStatus', // { connected, writesAllowed }: may Flow send invoices now?
15
18
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
16
19
  'pullPayments', // payments since a cursor, matched to invoices and bills
17
20
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
18
21
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
19
22
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
20
23
  'health', // connection state for Settings
24
+ 'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
25
+ 'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
21
26
  ]);
22
27
 
23
28
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -48,6 +48,8 @@ export function toLedgerJob(item) {
48
48
  shippedOn: null,
49
49
  // The day of the work, for matching it to its accounting invoice.
50
50
  jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
51
+ // The draft rules bill only the statuses the lab chose (for example Shipped).
52
+ sourceStatus: item.status ?? null,
51
53
  lines: ledgerLinesFor(item).lines,
52
54
  observedAt: item.syncedAt,
53
55
  actor: SEAZONA_SOURCE_SYSTEM,
@@ -0,0 +1,105 @@
1
+ import * as SentryNode from '@sentry/node';
2
+
3
+ // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, and the
4
+ // browser's errors and problem reports, to the installation's own Sentry
5
+ // project. Flow ships no DSN: each installation enters its own, here or as
6
+ // the host setting SENTRY_DSN. It has no tables.
7
+
8
+ // The browser SDK, loaded from Sentry's CDN only while this adapter is on
9
+ // (server/ui/monitoring.js). Pinned, with its integrity hash.
10
+ export const SENTRY_BROWSER = Object.freeze({
11
+ src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
12
+ integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
13
+ });
14
+
15
+ // A DSN is https://<public key>@<host>/<project id>.
16
+ const DSN_PATTERN = /^https:\/\/[0-9a-f]{16,64}@[a-z0-9.-]+(:\d+)?\/(\d+)$/i;
17
+
18
+ export const SENTRY_SETUP = {
19
+ guide: {
20
+ title: 'Connect your own Sentry project',
21
+ link: 'https://sentry.io/signup/',
22
+ steps: [
23
+ 'Sign in to your organisation\'s own Sentry account, or create one.',
24
+ 'Create a project. Choose Express as the platform.',
25
+ 'Open the project\'s Settings, Client Keys (DSN), and copy the DSN.',
26
+ 'Paste it below, or set SENTRY_DSN where Flow is hosted. Then turn Sentry on.',
27
+ 'Optional: in Sentry, Settings, Integrations, add your issue tracker to turn a report into a task.',
28
+ ],
29
+ },
30
+ fields: [
31
+ { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
32
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
33
+ { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
34
+ ],
35
+ };
36
+
37
+ export function readSentrySettings(env) {
38
+ const dsn = String(env.SENTRY_DSN ?? '').trim();
39
+ if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
40
+ if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
41
+ const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
42
+ const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
43
+ return { dsn, environment, feedback };
44
+ }
45
+
46
+ // The release Sentry groups events by: Flow's release tag, else its commit.
47
+ export function sentryRelease(env) {
48
+ const read = (value) => String(value ?? '').trim() || null;
49
+ const release = read(env.FLOW_DEPLOYED_RELEASE) ?? read(env.FLOW_RELEASE);
50
+ const revision = read(env.FLOW_REVISION);
51
+ return release ? `flow@${release.replace(/^v/, '')}` : revision ? `flow@${revision.slice(0, 12)}` : null;
52
+ }
53
+
54
+ // `sdk` is for tests: anything with init, captureMessage, captureException
55
+ // and close, as @sentry/node has.
56
+ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.env } = {}) {
57
+ let settings = null;
58
+ let release = null;
59
+
60
+ return {
61
+ id: 'sentry',
62
+ name: 'Sentry',
63
+ bundled: false,
64
+ setup: SENTRY_SETUP,
65
+ async start({ log, env }) {
66
+ settings = readSentrySettings(env ?? hostEnv);
67
+ release = sentryRelease(hostEnv);
68
+ sdk.init({
69
+ dsn: settings.dsn,
70
+ environment: settings.environment,
71
+ release: release ?? undefined,
72
+ // Flow sends what it logs; no request bodies, cookies or IP addresses.
73
+ sendDefaultPii: false,
74
+ tracesSampleRate: 0,
75
+ skipOpenTelemetrySetup: true,
76
+ });
77
+ log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}`);
78
+ },
79
+ async stop() {
80
+ if (settings) await sdk.close(2000);
81
+ settings = null;
82
+ },
83
+ capabilities: {
84
+ async health() {
85
+ return { ok: Boolean(settings), environment: settings?.environment ?? null, feedback: settings?.feedback ?? false };
86
+ },
87
+ // One error Flow logged: `source` is its "[tag]", `error` the Error when
88
+ // there is one.
89
+ captureError({ message, source = 'server', error = null }) {
90
+ if (!settings) return;
91
+ const scope = { level: 'error', tags: { source } };
92
+ if (error instanceof Error) sdk.captureException(error, { ...scope, extra: { log: message } });
93
+ else sdk.captureMessage(message, scope);
94
+ },
95
+ // What the browser needs to report to the same project. The DSN is
96
+ // meant for browsers.
97
+ browserMonitoring() {
98
+ if (!settings) return null;
99
+ return { dsn: settings.dsn, environment: settings.environment, release, feedback: settings.feedback, script: SENTRY_BROWSER };
100
+ },
101
+ },
102
+ };
103
+ }
104
+
105
+ export const sentryAdapter = createSentryAdapter();
@@ -243,6 +243,11 @@ export function createXeroClient({
243
243
  });
244
244
  return payload[path] ?? [];
245
245
  },
246
+ // Xero emails one approved invoice to its contact, with the organisation's
247
+ // own template, and marks it sent. Not idempotent: a repeat sends again.
248
+ async emailInvoice(invoiceId) {
249
+ await request('POST', `Invoices/${encodeURIComponent(invoiceId)}/Email`, { body: {} });
250
+ },
246
251
  // One invoice. `idempotencyKey` makes a retry within Xero's window
247
252
  // return the first result instead of a second invoice.
248
253
  async createInvoice(invoice, { idempotencyKey }) {
@@ -1,13 +1,22 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
4
- import { contacts as flowContacts, priceItems } from '../../db/schema.js';
4
+ import { sendModeFor } from '../../core/invoicing-rules.js';
5
+ import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
6
+ import { readConnection } from './connections.js';
5
7
  import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
6
8
  import { xeroError } from './client.js';
7
9
  import { withXeroLane } from './governor.js';
8
-
9
- // Issued Flow invoices become Xero draft invoices (ACCREC, DRAFT). The lab
10
- // checks and approves them in Xero.
10
+ import { mirrorState } from './lookup.js';
11
+
12
+ // Issued Flow invoices go to Xero. How depends on the customer's send mode
13
+ // (server/core/invoicing-rules.js):
14
+ // - accounting_draft (the default): a Xero draft (ACCREC, DRAFT). The lab
15
+ // approves and sends it in Xero.
16
+ // - approve_and_email: issuing in Flow was the approval. Flow sends it
17
+ // approved (AUTHORISED), then asks Xero to email it to the contact with the
18
+ // organisation's own template.
19
+ // Either way the mirror (mirror.js) reads Xero's status back to Flow.
11
20
  //
12
21
  // The rules, as in the earlier codebase:
13
22
  // - What Flow sends is the issued version's document, exactly. A sent
@@ -19,7 +28,12 @@ import { withXeroLane } from './governor.js';
19
28
  // - Before it creates, Flow asks Xero for invoices with the same reference.
20
29
  // One for the same contact and total is the same work: Flow records it and
21
30
  // creates nothing. One for the same contact at another total within 90
22
- // days is held for a person, unless they chose to send it anyway.
31
+ // days is held for a person, unless they chose to send it anyway. Flow skips
32
+ // this call for an invoice it drafted from a job when its complete, fresh
33
+ // copy of Xero showed no invoice for that job (docs/JOB_INVOICE_LINKS.md).
34
+ // - Xero's email action is not idempotent. Flow records `sending` before it
35
+ // calls, and calls again only after Xero refused (`failed`), never after an
36
+ // unknown outcome: a person checks Xero then.
23
37
  // - The request, with its Idempotency-Key, is stored before it leaves. An
24
38
  // uncertain outcome (timeout, 5xx) is resolved later: within Xero's
25
39
  // six-minute idempotency window Flow repeats the same request with the same
@@ -103,7 +117,7 @@ function trackingFor(settings, contact) {
103
117
  }
104
118
 
105
119
  // The Xero draft for a document, and what stops it.
106
- export function buildDraft(document, { settings, contact, items }) {
120
+ export function buildDraft(document, { settings, contact, items, sendMode = 'accounting_draft' }) {
107
121
  const problems = [];
108
122
  const tracking = trackingFor(settings, contact);
109
123
  const lines = document.lines.map((line) => {
@@ -124,7 +138,7 @@ export function buildDraft(document, { settings, contact, items }) {
124
138
  const issueDate = document.issueDate;
125
139
  const draft = {
126
140
  Type: 'ACCREC',
127
- Status: 'DRAFT',
141
+ Status: sendMode === 'approve_and_email' ? 'AUTHORISED' : 'DRAFT',
128
142
  Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
129
143
  ...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
130
144
  Reference: xeroReference(document),
@@ -168,6 +182,8 @@ function mapPush(row) {
168
182
  verified: row.verified,
169
183
  error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
170
184
  attempts: row.attempts,
185
+ sendMode: row.send_mode ?? 'accounting_draft',
186
+ email: row.email_state ? { state: row.email_state, error: row.email_error ?? null, at: row.emailed_at ?? null } : null,
171
187
  pushedBy: row.pushed_by,
172
188
  firstDispatchedAt: row.first_dispatched_at,
173
189
  lastDispatchedAt: row.last_dispatched_at,
@@ -240,8 +256,12 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
240
256
  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.`));
241
257
  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.`));
242
258
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
243
- const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
259
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
260
+ const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
244
261
  problems.push(...lineProblems);
262
+ if (sendMode === 'approve_and_email' && match.state === 'matched' && !match.contact.email) {
263
+ problems.push(problem('xero_contact_no_email', `The Xero contact "${match.contact.name}" has no email address, so Xero cannot email the invoice. Add one in Xero, then sync.`, { blocking: false }));
264
+ }
245
265
 
246
266
  const pushes = await listPushes(db, organisationId, invoiceId);
247
267
  const current = version ? pushes.find((p) => p.version === version) : null;
@@ -259,6 +279,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
259
279
  invoiceId,
260
280
  number: document.number,
261
281
  version,
282
+ sendMode,
262
283
  state,
263
284
  problems,
264
285
  contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
@@ -298,10 +319,46 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
298
319
  results.push(failed(invoiceId, error));
299
320
  }
300
321
  }
322
+ await keepSendState(db, organisationId, results);
301
323
  return summarise(results);
302
324
  }));
303
325
  }
304
326
 
327
+ // Flow's Problem view (server/core/sending.js) shows an issued invoice that
328
+ // failed to send. Once Xero holds it, by any path, that problem is gone.
329
+ async function keepSendState(db, organisationId, results) {
330
+ const held = results.filter((r) => ['created', 'reconciled', 'existing', 'already_in_xero'].includes(r.outcome)).map((r) => r.invoiceId);
331
+ if (!held.length) return;
332
+ await db.update(flowInvoices).set({ sendState: 'sent', sendError: null, sendUpdatedAt: sql`now()` })
333
+ .where(and(eq(flowInvoices.organisationId, organisationId), inArray(flowInvoices.id, held), sql`${flowInvoices.sendState} is not null`));
334
+ }
335
+
336
+ // Send all ready (server/core/sending.js): issued invoices in batches.
337
+ export async function pushInvoices(db, runtime, { organisationId, invoiceIds, pushedBy = null }) {
338
+ const invoices = [];
339
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
340
+ invoices.push(...(await pushDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH), { pushedBy })).invoices);
341
+ }
342
+ return { invoices };
343
+ }
344
+
345
+ // What would stop each Flow draft in Xero, from Flow's copy of Xero (no
346
+ // calls). Null while Xero is not connected: then Flow's own checks decide.
347
+ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
348
+ const connection = await readConnection(db, organisationId);
349
+ if (!connection || connection.mode !== runtime.settings.mode) return null;
350
+ const found = new Map();
351
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
352
+ const { invoices } = await previewDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH));
353
+ for (const preview of invoices) {
354
+ found.set(preview.invoiceId, preview.problems
355
+ .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
356
+ .map(({ code, message }) => ({ code, message })));
357
+ }
358
+ }
359
+ return found;
360
+ }
361
+
305
362
  function failed(invoiceId, error, outcome = 'failed') {
306
363
  return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
307
364
  }
@@ -316,6 +373,8 @@ function summarise(results) {
316
373
  mismatched: count('mismatch'),
317
374
  ambiguous: count('ambiguous'),
318
375
  failed: count('failed') + count('not_tried'),
376
+ emailed: results.filter((r) => r.email?.state === 'sent').length,
377
+ emailProblems: results.filter((r) => r.email && ['failed', 'unknown'].includes(r.email.state)).length,
319
378
  },
320
379
  invoices: results,
321
380
  };
@@ -347,7 +406,8 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
347
406
  const pushes = await listPushes(db, organisationId, invoiceId);
348
407
  const current = pushes.find((p) => p.version === version);
349
408
  if (current && SETTLED.has(current.state)) {
350
- return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero };
409
+ const email = await emailIfDue(db, client, current);
410
+ return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero, ...(email ? { email } : {}) };
351
411
  }
352
412
  if (current?.state === 'mismatch') {
353
413
  return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
@@ -377,14 +437,18 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
377
437
  { status: 409 });
378
438
  }
379
439
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
380
- const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
440
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
441
+ const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
381
442
  if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
382
443
 
383
444
  // Live duplicate check: invoices in Xero with this reference, made by Flow
384
- // or by hand.
445
+ // or by hand. Not needed when Flow's own copy of Xero already answered it
446
+ // for the job this invoice bills.
385
447
  const earlierIds = new Set(pushes.map((p) => p.xero?.invoiceId).filter(Boolean));
386
- const sameReference = (await client.findInvoicesByReference(draft.Reference))
387
- .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
448
+ const sameReference = (await copyShowsNoInvoice(db, runtime, organisationId, invoiceId))
449
+ ? []
450
+ : (await client.findInvoicesByReference(draft.Reference))
451
+ .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
388
452
  const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
389
453
  if (!allowExisting) {
390
454
  const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
@@ -406,9 +470,9 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
406
470
  const preExisting = sameReference.map((inv) => inv.InvoiceID);
407
471
  const { rows } = await db.execute(sql`
408
472
  insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pre_existing_ids, pushed_by,
409
- first_dispatched_at, last_dispatched_at)
473
+ first_dispatched_at, last_dispatched_at, send_mode, email_state)
410
474
  values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
411
- ${new Date(runtime.now())}, ${new Date(runtime.now())})
475
+ ${new Date(runtime.now())}, ${new Date(runtime.now())}, ${sendMode}, ${sendMode === 'approve_and_email' ? 'pending' : null})
412
476
  on conflict (invoice_id, version) do nothing returning *`);
413
477
  if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
414
478
  return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
@@ -481,7 +545,50 @@ async function verify(db, client, push, draft, document, created, outcome) {
481
545
  return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
482
546
  }
483
547
  const settled = await settle(db, push.id, { state: 'created', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status, verified: true });
484
- return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero };
548
+ const email = await emailIfDue(db, client, settled);
549
+ return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero, ...(email ? { email } : {}) };
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Email
554
+
555
+ // Xero errors that prove it sent no email: it refused the request.
556
+ const emailRefused = (error) => error.code === 'xero_validation_failed'
557
+ || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
558
+
559
+ // Asks Xero to email an approved invoice that Flow sent and checked, once.
560
+ // Answers the email's state, or null when the push sends no email.
561
+ async function emailIfDue(db, client, push) {
562
+ if (push.sendMode !== 'approve_and_email' || !push.email) return null;
563
+ if (!push.verified || !push.xero?.invoiceId || !['pending', 'failed'].includes(push.email.state)) return push.email;
564
+ // Claim the email, so two pushes never both send it.
565
+ const { rows } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sending', updated_at = now()
566
+ where id = ${push.id} and email_state in ('pending', 'failed') returning id`);
567
+ if (!rows.length) return { state: 'sending', error: null, at: null };
568
+ try {
569
+ await client.emailInvoice(push.xero.invoiceId);
570
+ } catch (error) {
571
+ const state = emailRefused(error) ? 'failed' : 'unknown';
572
+ const message = state === 'failed'
573
+ ? `Xero did not email the invoice: ${error.message}`
574
+ : 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
575
+ await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
576
+ if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
577
+ return { state, error: message, at: null };
578
+ }
579
+ const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
580
+ where id = ${push.id} returning emailed_at`);
581
+ return { state: 'sent', error: null, at: done.emailed_at };
582
+ }
583
+
584
+ // Flow drafted this invoice from a job, and its complete, fresh copy of Xero
585
+ // showed no invoice for that job: Xero needs no live duplicate check.
586
+ async function copyShowsNoInvoice(db, runtime, organisationId, invoiceId) {
587
+ const { rows: [job] } = await db.execute(sql`select accounting_state from billing_obligations
588
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
589
+ if (job?.accounting_state !== 'not_found') return false;
590
+ const mirror = await mirrorState(db, organisationId, { now: new Date(runtime.now()) });
591
+ return mirror.complete && mirror.fresh;
485
592
  }
486
593
 
487
594
  // An earlier attempt whose outcome is unknown.
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
- import { pushInvoice } from './drafts.js';
3
+ import { checkDrafts, pushInvoice, pushInvoices } from './drafts.js';
4
+ import { readConnection } from './connections.js';
4
5
  import { createImportWorker } from './import.js';
5
6
  import { readPayments, xeroLiveCheck } from './ledger.js';
6
7
  import { findInvoices, listCustomers } from './lookup.js';
@@ -86,6 +87,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
86
87
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
87
88
  // and version are the idempotency key.
88
89
  pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
90
+ // Several issued invoices, in batches of MAX_BATCH (drafts.js).
91
+ pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
92
+ // What would stop each Flow draft in Xero, from Flow's copy (no calls).
93
+ checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
94
+ sendStatus: async ({ organisationId }) => {
95
+ const { settings } = current();
96
+ const connection = await readConnection(database, organisationId);
97
+ return { connected: Boolean(connection && connection.mode === settings.mode), writesAllowed: writesAllowed(settings) };
98
+ },
89
99
  // Sales invoice payments changed since `since` (ledger.js). The answer's
90
100
  // cursor is the next `since`.
91
101
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),