@arcforgelabs/flow 2026.10.25 → 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 (60) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -1
  4. package/server/api/invoicing.js +586 -0
  5. package/server/app.js +38 -1
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/automations.js +140 -0
  9. package/server/core/invoice-rules.js +224 -0
  10. package/server/core/invoices.js +11 -1
  11. package/server/core/invoicing-rules.js +255 -0
  12. package/server/core/ledger.js +50 -14
  13. package/server/core/links.js +3 -3
  14. package/server/core/monitoring.js +34 -0
  15. package/server/core/pipeline.js +495 -0
  16. package/server/core/sending.js +282 -0
  17. package/server/db/migrations/0008_people_appearance.sql +1 -0
  18. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  19. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  20. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  21. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  22. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  23. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  24. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  25. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  26. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  27. package/server/db/migrations/meta/_journal.json +35 -0
  28. package/server/db/schema.js +186 -2
  29. package/server/index.js +20 -3
  30. package/server/integrations/adapters.js +2 -1
  31. package/server/integrations/port.js +8 -0
  32. package/server/integrations/seazona/feed.js +11 -0
  33. package/server/integrations/seazona/index.js +7 -1
  34. package/server/integrations/seazona/ledger.js +4 -0
  35. package/server/integrations/sentry/index.js +105 -0
  36. package/server/integrations/xero/client.js +5 -0
  37. package/server/integrations/xero/drafts.js +123 -16
  38. package/server/integrations/xero/import.js +22 -0
  39. package/server/integrations/xero/index.js +21 -3
  40. package/server/integrations/xero/lookup.js +94 -0
  41. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  42. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  43. package/server/theme/color-contrast.js +39 -2
  44. package/server/theme/contrast-audit.js +63 -0
  45. package/server/theme/themes.js +10 -2
  46. package/server/ui/app-page.js +6 -10
  47. package/server/ui/app.js +364 -105
  48. package/server/ui/appearance.js +3 -2
  49. package/server/ui/automations.js +96 -0
  50. package/server/ui/clerk-appearance.js +18 -2
  51. package/server/ui/flow.css +334 -22
  52. package/server/ui/invoice-rules.js +237 -0
  53. package/server/ui/invoicing-settings.js +108 -0
  54. package/server/ui/invoicing.js +597 -0
  55. package/server/ui/jobs.js +172 -0
  56. package/server/ui/logos/seazona.svg +1 -0
  57. package/server/ui/logos/sentry.svg +1 -0
  58. package/server/ui/logos/xero.svg +1 -0
  59. package/server/ui/monitoring.js +53 -0
  60. package/server/ui/routes.js +5 -1
@@ -57,6 +57,41 @@
57
57
  "when": 1792391146736,
58
58
  "tag": "0007_integration_settings",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1792396463612,
65
+ "tag": "0008_people_appearance",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1792404377371,
72
+ "tag": "0009_pipeline_matcher",
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
60
95
  }
61
96
  ]
62
97
  }
@@ -9,6 +9,7 @@ import {
9
9
  integer,
10
10
  jsonb,
11
11
  pgTable,
12
+ primaryKey,
12
13
  text,
13
14
  timestamp,
14
15
  unique,
@@ -84,6 +85,16 @@ export const invoices = pgTable('invoices', {
84
85
  subtotalCents: integer('subtotal_cents').notNull().default(0),
85
86
  taxCents: integer('tax_cents').notNull().default(0),
86
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 }),
87
98
  createdAt: createdAt(),
88
99
  updatedAt: updatedAt(),
89
100
  }, (t) => [
@@ -96,6 +107,7 @@ export const invoices = pgTable('invoices', {
96
107
  }),
97
108
  check('invoices_status_valid', sql`${t.status} in ('draft', 'issued', 'void')`),
98
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')`),
99
111
  ]);
100
112
 
101
113
  export const invoiceLines = pgTable('invoice_lines', {
@@ -153,7 +165,9 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
153
165
  // someone is; this table decides what they may do. One installation serves one
154
166
  // lab, so people are installation-wide and carry no organisation_id. Emails
155
167
  // are stored lower-case. `clerk_user_id` is set on the person's first verified
156
- // sign-in; until then the row is an invitation.
168
+ // sign-in; until then the row is an invitation. `appearance` is the person's
169
+ // own design system, light or dark and accent (server/auth/people.js,
170
+ // normaliseAppearance); null until they first choose.
157
171
  export const people = pgTable('people', {
158
172
  id: id(),
159
173
  email: text('email').notNull(),
@@ -163,6 +177,7 @@ export const people = pgTable('people', {
163
177
  clerkUserId: text('clerk_user_id'),
164
178
  invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
165
179
  linkedAt: timestamp('linked_at', { withTimezone: true }),
180
+ appearance: jsonb('appearance'),
166
181
  createdAt: createdAt(),
167
182
  updatedAt: updatedAt(),
168
183
  }, (t) => [
@@ -202,6 +217,16 @@ export const billingObligations = pgTable('billing_obligations', {
202
217
  contactId: uuid('contact_id'),
203
218
  practitioner: text('practitioner'),
204
219
  shippedOn: date('shipped_on'),
220
+ // The day of the work, for matching it to an accounting invoice: the work
221
+ // system's invoice date, else its due date, else its order date.
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'),
205
230
  // The lines as the work system reported them, normalised.
206
231
  lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
207
232
  lineFingerprint: text('line_fingerprint').notNull(),
@@ -316,7 +341,9 @@ export const obligationWritebacks = pgTable('obligation_writebacks', {
316
341
  // one active link per external record and per Flow record, in each system. A
317
342
  // link that is not exact stays `proposed` until a person confirms it.
318
343
  export const LINK_ENTITIES = ['contact', 'price_item', 'obligation', 'invoice', 'payment', 'credit_note'];
319
- export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'imported', 'name', 'person'];
344
+ // `exact_name`: the same name, after spacing and case are evened out, held by
345
+ // exactly one record on each side.
346
+ export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'exact_name', 'imported', 'name', 'person'];
320
347
  export const LINK_STATES = ['active', 'proposed', 'superseded'];
321
348
 
322
349
  export const externalLinks = pgTable('external_links', {
@@ -490,3 +517,160 @@ export const integrationSettingEvents = pgTable('integration_setting_events', {
490
517
  index('integration_setting_events_adapter').on(t.adapterId, t.createdAt),
491
518
  check('integration_setting_events_kind_valid', inList('kind', INTEGRATION_SETTING_EVENTS)),
492
519
  ]);
520
+
521
+ // The pipeline (server/core/pipeline.js, issue 58). Where each step that reads
522
+ // another system's records stands, by name, so a restart goes on from there.
523
+ export const pipelineCursors = pgTable('pipeline_cursors', {
524
+ organisationId: organisationId().references(() => organisations.id),
525
+ name: text('name').notNull(),
526
+ cursor: text('cursor'),
527
+ updatedAt: updatedAt(),
528
+ }, (t) => [
529
+ primaryKey({ name: 'pipeline_cursors_pk', columns: [t.organisationId, t.name] }),
530
+ ]);
531
+
532
+ // The matcher's suggestions: accounting invoices that could bill a job, with
533
+ // the signals that agreed. Only exact evidence links a job by itself; a
534
+ // suggestion waits for a person. A newer run marks the ones it no longer
535
+ // makes `stale`.
536
+ export const LINK_SUGGESTION_STATES = ['open', 'accepted', 'rejected', 'stale'];
537
+
538
+ export const linkSuggestions = pgTable('link_suggestions', {
539
+ id: id(),
540
+ organisationId: organisationId().references(() => organisations.id),
541
+ obligationId: uuid('obligation_id').notNull(),
542
+ system: text('system').notNull(),
543
+ externalId: text('external_id').notNull(),
544
+ externalNumber: text('external_number'),
545
+ score: integer('score').notNull(),
546
+ rank: integer('rank').notNull(),
547
+ signals: jsonb('signals').notNull().default(sql`'[]'::jsonb`),
548
+ state: text('state').notNull().default('open'),
549
+ decidedBy: text('decided_by'),
550
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
551
+ createdAt: createdAt(),
552
+ updatedAt: updatedAt(),
553
+ }, (t) => [
554
+ unique('link_suggestions_pair').on(t.organisationId, t.obligationId, t.system, t.externalId),
555
+ index('link_suggestions_open').on(t.organisationId, t.state, t.obligationId),
556
+ foreignKey({
557
+ name: 'link_suggestions_obligation_same_org',
558
+ columns: [t.organisationId, t.obligationId],
559
+ foreignColumns: [billingObligations.organisationId, billingObligations.id],
560
+ }),
561
+ check('link_suggestions_state_valid', inList('state', LINK_SUGGESTION_STATES)),
562
+ ]);
563
+
564
+ // A person's "not this one": the matcher never suggests the pair again.
565
+ export const linkRejections = pgTable('link_rejections', {
566
+ organisationId: organisationId().references(() => organisations.id),
567
+ obligationId: uuid('obligation_id').notNull(),
568
+ system: text('system').notNull(),
569
+ externalId: text('external_id').notNull(),
570
+ actor: text('actor').notNull(),
571
+ createdAt: createdAt(),
572
+ }, (t) => [
573
+ primaryKey({ name: 'link_rejections_pk', columns: [t.organisationId, t.obligationId, t.system, t.externalId] }),
574
+ foreignKey({
575
+ name: 'link_rejections_obligation_same_org',
576
+ columns: [t.organisationId, t.obligationId],
577
+ foreignColumns: [billingObligations.organisationId, billingObligations.id],
578
+ }),
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,6 +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';
10
+ import { createPipelineWorker } from './core/pipeline.js';
11
+ import { createSendWorker } from './core/sending.js';
9
12
  import { resolveSecretsKey } from './core/secrets.js';
10
13
  import { adapters as shippedAdapters } from './integrations/adapters.js';
11
14
  import { createIntegrations, resolveEnabledIntegrations } from './integrations/index.js';
@@ -65,12 +68,15 @@ await importEarlierLinksAtStart({ db: database.db, pool: database.pool, settings
65
68
  .catch((error) => console.error(`[import] ${error.message}`));
66
69
  // What an owner set in Settings, Integrations, with the host's own
67
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).
68
73
  let integrations;
74
+ const log = createMonitoredLog({ log: console, reporters: () => integrations?.with('captureError') ?? [] });
69
75
  try {
70
76
  const stored = await readStoredSettings(database.db);
71
77
  const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
72
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.`);
73
- integrations = createIntegrations({ turnedOnInFlow: storedEnabled(stored, process.env), settingsEnv });
79
+ integrations = createIntegrations({ turnedOnInFlow: storedEnabled(stored, process.env), settingsEnv, log });
74
80
  await integrations.start(database.db);
75
81
  } catch (error) {
76
82
  console.error(`[integrations] ${error.message}`);
@@ -79,11 +85,22 @@ try {
79
85
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
80
86
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
81
87
 
88
+ // The pipeline (server/core/pipeline.js) joins what the integrations copy into
89
+ // Flow: jobs into the ledger, and each job to its accounting invoice. It runs
90
+ // only while an integration that feeds it is on, and writes to no other
91
+ // system. FLOW_PIPELINE=off stops it.
92
+ if (process.env.FLOW_PIPELINE !== 'off') {
93
+ createPipelineWorker({ db: database.db, integrations, log }).start();
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();
98
+
82
99
  let auth = null;
83
100
  if (settings.mode === 'clerk') {
84
101
  const { keys } = settings;
85
102
  const clerk = await loadClerkAdapter({ ...keys, authorizedParties: settings.authorizedParties });
86
- const personAuth = createPersonAuth({ db: database.db, clerk, ...settings });
103
+ const personAuth = createPersonAuth({ db: database.db, clerk, ...settings, log });
87
104
  let clerkProxy = null;
88
105
  const proxyPath = resolveClerkProxyPath(keys.proxyUrl);
89
106
  if (proxyPath) {
@@ -120,7 +137,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
120
137
 
121
138
  const updates = createUpdateChecker();
122
139
 
123
- createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily }).listen(port, () => {
140
+ createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
124
141
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
125
142
  });
126
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,9 +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
20
+ 'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
21
+ 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
22
+ 'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
17
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
18
26
  ]);
19
27
 
20
28
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -102,3 +102,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
102
102
  more: rows.length === pageSize,
103
103
  };
104
104
  }
105
+
106
+ // Seazona clients, by id and the company name an invoice bills. A client
107
+ // with no company name is listed by its full name.
108
+ export async function listClients(db) {
109
+ const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
110
+ .from(seazonaClients);
111
+ return rows
112
+ .filter((r) => r.active !== false)
113
+ .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
114
+ .filter((r) => r.name);
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { createSeazonaClient } from './client.js';
2
- import { readBillableWork } from './feed.js';
2
+ import { listClients, readBillableWork } from './feed.js';
3
3
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
4
4
  import { createSeazonaScheduler } from './scheduler.js';
5
5
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
@@ -99,6 +99,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
99
99
  async pullWorkItems(cursor = null, options = {}) {
100
100
  return readBillableWork(current().db, { cursor, ...options });
101
101
  },
102
+
103
+ // Seazona clients by id and company name, for the identity map. Reads
104
+ // Flow's copy only.
105
+ async listCustomers() {
106
+ return listClients(current().db);
107
+ },
102
108
  },
103
109
  };
104
110
  }
@@ -46,6 +46,10 @@ export function toLedgerJob(item) {
46
46
  practitioner: item.doctorName ?? null,
47
47
  // The Seazona API gives a shipped status but no shipped date.
48
48
  shippedOn: null,
49
+ // The day of the work, for matching it to its accounting invoice.
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,
49
53
  lines: ledgerLinesFor(item).lines,
50
54
  observedAt: item.syncedAt,
51
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 }) {