@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -71,6 +71,34 @@
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
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "7",
99
+ "when": 1792414148360,
100
+ "tag": "0013_line_mappings",
101
+ "breakpoints": true
74
102
  }
75
103
  ]
76
104
  }
@@ -59,6 +59,9 @@ export const priceItems = pgTable('price_items', {
59
59
  organisationId: organisationId().references(() => organisations.id),
60
60
  code: text('code').notNull(),
61
61
  name: text('name').notNull(),
62
+ // The accounting system's longer text for the item, when it has one. Line
63
+ // mapping (server/core/line-mapping.js) compares a work line with it too.
64
+ description: text('description'),
62
65
  unitAmountCents: integer('unit_amount_cents').notNull(),
63
66
  taxCode: text('tax_code').notNull().default('GST'),
64
67
  archivedAt: timestamp('archived_at', { withTimezone: true }),
@@ -85,6 +88,16 @@ export const invoices = pgTable('invoices', {
85
88
  subtotalCents: integer('subtotal_cents').notNull().default(0),
86
89
  taxCents: integer('tax_cents').notNull().default(0),
87
90
  totalCents: integer('total_cents').notNull().default(0),
91
+ // When a person marked the draft ready to send, and who. Any change to the
92
+ // draft clears it (server/core/sending.js).
93
+ readyAt: timestamp('ready_at', { withTimezone: true }),
94
+ readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
95
+ // Sending to the accounting system (server/core/sending.js): queued by a
96
+ // person, then sent or failed. `sendError` is { code, message } while failed.
97
+ sendState: text('send_state'),
98
+ sendError: jsonb('send_error'),
99
+ sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
100
+ sendUpdatedAt: timestamp('send_updated_at', { withTimezone: true }),
88
101
  createdAt: createdAt(),
89
102
  updatedAt: updatedAt(),
90
103
  }, (t) => [
@@ -97,6 +110,7 @@ export const invoices = pgTable('invoices', {
97
110
  }),
98
111
  check('invoices_status_valid', sql`${t.status} in ('draft', 'issued', 'void')`),
99
112
  check('invoices_issued_has_number', sql`${t.status} = 'draft' or ${t.number} is not null`),
113
+ check('invoices_send_state_valid', sql`${t.sendState} is null or ${t.sendState} in ('queued', 'sent', 'failed')`),
100
114
  ]);
101
115
 
102
116
  export const invoiceLines = pgTable('invoice_lines', {
@@ -109,6 +123,9 @@ export const invoiceLines = pgTable('invoice_lines', {
109
123
  quantity: integer('quantity').notNull(),
110
124
  unitAmountCents: integer('unit_amount_cents').notNull(),
111
125
  taxCode: text('tax_code').notNull(),
126
+ // A line with no price item may name the accounting account it books to,
127
+ // for example a surcharge (server/core/line-mapping.js).
128
+ accountCode: text('account_code'),
112
129
  lineAmountCents: integer('line_amount_cents').notNull(),
113
130
  taxCents: integer('tax_cents').notNull(),
114
131
  }, (t) => [
@@ -209,6 +226,13 @@ export const billingObligations = pgTable('billing_obligations', {
209
226
  // The day of the work, for matching it to an accounting invoice: the work
210
227
  // system's invoice date, else its due date, else its order date.
211
228
  jobDate: date('job_date'),
229
+ // The work system's status for the job, as it reported it (for example
230
+ // "Shipped"). The draft rules read it.
231
+ sourceStatus: text('source_status'),
232
+ // Flow's own stage: the job is ready to invoice, since when and by whom (a
233
+ // person, or an automation such as one that reads the work system status).
234
+ readyAt: timestamp('ready_at', { withTimezone: true }),
235
+ readyBy: text('ready_by'),
212
236
  // The lines as the work system reported them, normalised.
213
237
  lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
214
238
  lineFingerprint: text('line_fingerprint').notNull(),
@@ -559,3 +583,137 @@ export const linkRejections = pgTable('link_rejections', {
559
583
  foreignColumns: [billingObligations.organisationId, billingObligations.id],
560
584
  }),
561
585
  ]);
586
+
587
+ // Invoicing rules (server/core/invoicing-rules.js). One row for each
588
+ // organisation: the checks a job passes before Flow drafts its invoice, and
589
+ // how a checked draft goes to the accounting system. No row means the
590
+ // defaults. When Flow drafts is an automation (`automations`).
591
+ export const SEND_MODES = ['accounting_draft', 'approve_and_email'];
592
+
593
+ export const invoicingSettings = pgTable('invoicing_settings', {
594
+ organisationId: uuid('organisation_id').primaryKey().references(() => organisations.id),
595
+ // accounting_draft: a person checks the draft in Flow, Flow sends it as an
596
+ // accounting draft, and a person approves and sends it there.
597
+ // approve_and_email: a person approves the draft in Flow, Flow sends it
598
+ // approved, and the accounting system emails it to the customer.
599
+ sendMode: text('send_mode').notNull().default('accounting_draft'),
600
+ // A job whose date is more than this many days ago waits for a person.
601
+ staleDays: integer('stale_days').notNull().default(90),
602
+ // The days from the invoice date to its due date.
603
+ paymentTermsDays: integer('payment_terms_days').notNull().default(30),
604
+ // A job needs a practitioner, its own or its customer's default one.
605
+ requirePractitioner: boolean('require_practitioner').notNull().default(false),
606
+ // The price item code of a rush charge, and its share of the other lines.
607
+ rushItemCode: text('rush_item_code'),
608
+ rushPercent: integer('rush_percent').notNull().default(20),
609
+ // The fixed checks above became invoice rules (invoice_rules). True once
610
+ // this organisation's starter rules were made from them.
611
+ rulesSeeded: boolean('rules_seeded').notNull().default(false),
612
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
613
+ updatedAt: updatedAt(),
614
+ }, (t) => [
615
+ check('invoicing_settings_send_mode_valid', inList('send_mode', SEND_MODES)),
616
+ check('invoicing_settings_stale_days_valid', sql`${t.staleDays} between 1 and 3650`),
617
+ check('invoicing_settings_terms_valid', sql`${t.paymentTermsDays} between 0 and 365`),
618
+ check('invoicing_settings_rush_valid', sql`${t.rushPercent} between 0 and 100`),
619
+ ]);
620
+
621
+ // One customer's own invoicing settings. A null value takes the
622
+ // organisation's setting.
623
+ export const customerBillingSettings = pgTable('customer_billing_settings', {
624
+ organisationId: organisationId().references(() => organisations.id),
625
+ contactId: uuid('contact_id').notNull(),
626
+ // A customer that is not billable never gets an invoice from a rule, and
627
+ // `autoDraft` false keeps the draft automation off for this customer.
628
+ billable: boolean('billable').notNull().default(true),
629
+ defaultPractitioner: text('default_practitioner'),
630
+ autoDraft: boolean('auto_draft'),
631
+ sendMode: text('send_mode'),
632
+ paymentTermsDays: integer('payment_terms_days'),
633
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
634
+ updatedAt: updatedAt(),
635
+ }, (t) => [
636
+ primaryKey({ name: 'customer_billing_settings_pk', columns: [t.organisationId, t.contactId] }),
637
+ foreignKey({
638
+ name: 'customer_billing_settings_contact_same_org',
639
+ columns: [t.organisationId, t.contactId],
640
+ foreignColumns: [contacts.organisationId, contacts.id],
641
+ }),
642
+ check('customer_billing_settings_send_mode_valid', sql`${t.sendMode} is null or ${t.sendMode} in ('accounting_draft', 'approve_and_email')`),
643
+ check('customer_billing_settings_terms_valid', sql`${t.paymentTermsDays} is null or ${t.paymentTermsDays} between 0 and 365`),
644
+ ]);
645
+
646
+ // Invoice rules (server/core/invoice-rules.js, docs/INVOICE_RULES.md): a lab's
647
+ // own checks on a job before Flow drafts its invoice. Each rule is When, If and
648
+ // Then: `conditions` is a list of { field, test, value, item? } that must all
649
+ // match, and `action` is { kind: 'wait' | 'skip' }. `starter_key` marks a rule
650
+ // Flow made from the earlier fixed settings, once per organisation.
651
+ export const invoiceRules = pgTable('invoice_rules', {
652
+ id: id(),
653
+ organisationId: organisationId().references(() => organisations.id),
654
+ name: text('name').notNull(),
655
+ position: integer('position').notNull(),
656
+ enabled: boolean('enabled').notNull().default(true),
657
+ trigger: text('trigger').notNull().default('job_ready'),
658
+ conditions: jsonb('conditions').notNull().default(sql`'[]'::jsonb`),
659
+ action: jsonb('action').notNull(),
660
+ starterKey: text('starter_key'),
661
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
662
+ createdAt: createdAt(),
663
+ updatedAt: updatedAt(),
664
+ }, (t) => [
665
+ uniqueIndex('invoice_rules_starter_key').on(t.organisationId, t.starterKey),
666
+ check('invoice_rules_trigger_valid', sql`${t.trigger} in ('job_ready')`),
667
+ check('invoice_rules_name_valid', sql`length(${t.name}) between 1 and 120`),
668
+ ]);
669
+
670
+ // Automations (server/core/automations.js): Flow's routines, each on or off,
671
+ // with its own settings. The routines are defined in code; a row holds an
672
+ // organisation's choice. No row means the routine's default.
673
+ export const automations = pgTable('automations', {
674
+ organisationId: organisationId().references(() => organisations.id),
675
+ key: text('key').notNull(),
676
+ enabled: boolean('enabled').notNull(),
677
+ config: jsonb('config').notNull().default(sql`'{}'::jsonb`),
678
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
679
+ updatedAt: updatedAt(),
680
+ }, (t) => [
681
+ primaryKey({ name: 'automations_pk', columns: [t.organisationId, t.key] }),
682
+ ]);
683
+
684
+ // Line mappings (docs/LINE_MAPPING.md): how a work system's line label finds
685
+ // the lab's price item, as rows the lab edits. `kind` says which step of the
686
+ // line mapping routine uses the row:
687
+ // alias label (exact, or the start of it) -> price item code
688
+ // price_alias label and unit price -> price item code
689
+ // saved label and unit price -> price item code, chosen by a person
690
+ // account label -> a line with no price item, booked to an account
691
+ // `label` is stored in the lookup form (lineLookupText), so it matches in any
692
+ // case, spacing or punctuation.
693
+ export const LINE_MAPPING_KINDS = ['alias', 'price_alias', 'saved', 'account'];
694
+ export const lineMappings = pgTable('line_mappings', {
695
+ id: id(),
696
+ organisationId: organisationId().references(() => organisations.id),
697
+ kind: text('kind').notNull(),
698
+ match: text('match').notNull().default('exact'),
699
+ label: text('label').notNull(),
700
+ unitAmountCents: integer('unit_amount_cents'),
701
+ itemCode: text('item_code'),
702
+ accountCode: text('account_code'),
703
+ taxCode: text('tax_code'),
704
+ description: text('description'),
705
+ enabled: boolean('enabled').notNull().default(true),
706
+ // Who made it: a person, a setup profile, or the earlier codebase's rules.
707
+ origin: text('origin').notNull().default('person'),
708
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
709
+ createdAt: createdAt(),
710
+ updatedAt: updatedAt(),
711
+ }, (t) => [
712
+ uniqueIndex('line_mappings_unique').on(t.organisationId, t.kind, t.match, t.label, sql`coalesce(${t.unitAmountCents}, -1)`),
713
+ check('line_mappings_kind_valid', sql`${t.kind} in ('alias', 'price_alias', 'saved', 'account')`),
714
+ check('line_mappings_match_valid', sql`${t.match} in ('exact', 'starts_with')`),
715
+ check('line_mappings_tax_code_valid', sql`${t.taxCode} is null or ${t.taxCode} in ('GST', 'GST_FREE')`),
716
+ check('line_mappings_target', sql`(${t.kind} = 'account' and ${t.accountCode} is not null) or (${t.kind} <> 'account' and ${t.itemCode} is not null)`),
717
+ check('line_mappings_price', sql`${t.kind} not in ('price_alias', 'saved') or ${t.unitAmountCents} is not null`),
718
+ check('line_mappings_label_valid', sql`length(${t.label}) between 1 and 200`),
719
+ ]);
@@ -1,5 +1,6 @@
1
- import { sql } from 'drizzle-orm';
2
- import { organisations, priorLinks } from '../db/schema.js';
1
+ import { and, eq, isNull, sql } from 'drizzle-orm';
2
+ import { lineMappings, organisations, priceItems, priorLinks } from '../db/schema.js';
3
+ import { lineLookupText, remapJobs } from '../core/line-mapping.js';
3
4
  import { applyImport, planImport, readEarlierFlow, summarisePlan } from './earlier-flow.js';
4
5
 
5
6
  // The import from the earlier codebase, run once by the server itself. On a
@@ -118,3 +119,64 @@ export async function importEarlierLinksAtStart({ db, pool, settings, log = cons
118
119
  log.info?.(`[import] links from schema "${settings.schema}": ${JSON.stringify(counts)}`);
119
120
  return counts;
120
121
  }
122
+
123
+ // Carries the earlier app's line mapping knowledge into Flow, at every start
124
+ // while the import settings are set, in the same read-only way:
125
+ // - each label and price a person matched to an item becomes a "saved" line
126
+ // mapping (server/core/line-mapping.js), unless the lab already has one;
127
+ // - each price item with no description takes the accounting item's own.
128
+ // Answers counts, or null when there is nothing to read.
129
+ const EARLIER_RULES = 'xero_item_match_rules';
130
+ const EARLIER_ITEMS = 'xero_catalog_items';
131
+
132
+ export async function importEarlierMappingsAtStart({ db, pool, settings, log = console }) {
133
+ if (!settings) return null;
134
+ const { rows: labs } = await db.execute(sql`select id, name from ${organisations}`);
135
+ const organisation = labs.find((lab) => lab.name === settings.organisationName) ?? (labs.length === 1 ? labs[0] : null);
136
+ if (!organisation) return null;
137
+
138
+ let rules = [];
139
+ let items = [];
140
+ const client = await pool.connect();
141
+ try {
142
+ await client.query('BEGIN TRANSACTION READ ONLY');
143
+ await client.query(`SET LOCAL search_path TO "${settings.schema}"`);
144
+ const present = async (table) => (await client.query('select to_regclass($1) is not null as present', [table])).rows[0].present;
145
+ if (await present(EARLIER_RULES)) {
146
+ ({ rows: rules } = await client.query(`select source_lookup_text, unit_amount_cents, xero_item_code from ${EARLIER_RULES}`));
147
+ }
148
+ if (await present(EARLIER_ITEMS)) {
149
+ ({ rows: items } = await client.query(`select item_code, description from ${EARLIER_ITEMS} where coalesce(description, '') <> ''`));
150
+ }
151
+ await client.query('COMMIT');
152
+ } catch (error) {
153
+ await client.query('ROLLBACK').catch(() => {});
154
+ throw new Error(`could not read mappings from schema "${settings.schema}": ${error.message}`);
155
+ } finally {
156
+ client.release();
157
+ }
158
+
159
+ const values = rules
160
+ .map((row) => ({
161
+ organisationId: organisation.id,
162
+ kind: 'saved',
163
+ match: 'exact',
164
+ label: lineLookupText(row.source_lookup_text),
165
+ unitAmountCents: Number(row.unit_amount_cents),
166
+ itemCode: String(row.xero_item_code ?? '').trim(),
167
+ origin: `earlier:${EARLIER_RULES}`,
168
+ }))
169
+ .filter((v) => v.label && v.label.length <= 200 && v.itemCode && Number.isInteger(v.unitAmountCents) && v.unitAmountCents >= 0);
170
+ const added = values.length ? (await db.insert(lineMappings).values(values).onConflictDoNothing().returning({ id: lineMappings.id })).length : 0;
171
+ let described = 0;
172
+ for (const item of items) {
173
+ const updated = await db.update(priceItems).set({ description: String(item.description).trim().slice(0, 1000) })
174
+ .where(and(eq(priceItems.organisationId, organisation.id), eq(priceItems.code, String(item.item_code).trim()), isNull(priceItems.description)))
175
+ .returning({ id: priceItems.id });
176
+ described += updated.length;
177
+ }
178
+ if (added) await remapJobs(db, organisation.id);
179
+ const counts = { rules: values.length, added, described };
180
+ log.info?.(`[import] mappings from schema "${settings.schema}": ${JSON.stringify(counts)}`);
181
+ return counts;
182
+ }
package/server/index.js CHANGED
@@ -5,8 +5,12 @@ import { seedFirstOwner } from './auth/people.js';
5
5
  import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
- import { importEarlierAtStart, importEarlierLinksAtStart, resolveImportAtStart } from './import/at-start.js';
8
+ import {
9
+ importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
10
+ } from './import/at-start.js';
11
+ import { createMonitoredLog } from './core/monitoring.js';
9
12
  import { createPipelineWorker } from './core/pipeline.js';
13
+ import { createSendWorker } from './core/sending.js';
10
14
  import { resolveSecretsKey } from './core/secrets.js';
11
15
  import { adapters as shippedAdapters } from './integrations/adapters.js';
12
16
  import { createIntegrations, resolveEnabledIntegrations } from './integrations/index.js';
@@ -64,14 +68,19 @@ try {
64
68
  // failure is logged and the start goes on.
65
69
  await importEarlierLinksAtStart({ db: database.db, pool: database.pool, settings: importAtStart })
66
70
  .catch((error) => console.error(`[import] ${error.message}`));
71
+ await importEarlierMappingsAtStart({ db: database.db, pool: database.pool, settings: importAtStart })
72
+ .catch((error) => console.error(`[import] ${error.message}`));
67
73
  // What an owner set in Settings, Integrations, with the host's own
68
74
  // environment over it (server/integrations/setup.js).
75
+ // Every part of Flow logs here: each error also goes to the installation's
76
+ // own error service while one is on (docs/SENTRY.md).
69
77
  let integrations;
78
+ const log = createMonitoredLog({ log: console, reporters: () => integrations?.with('captureError') ?? [] });
70
79
  try {
71
80
  const stored = await readStoredSettings(database.db);
72
81
  const { env: settingsEnv, unreadable } = settingsEnvironment({ env: process.env, stored, adapters: shippedAdapters, secrets });
73
82
  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 });
83
+ integrations = createIntegrations({ turnedOnInFlow: storedEnabled(stored, process.env), settingsEnv, log });
75
84
  await integrations.start(database.db);
76
85
  } catch (error) {
77
86
  console.error(`[integrations] ${error.message}`);
@@ -85,14 +94,17 @@ console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets
85
94
  // only while an integration that feeds it is on, and writes to no other
86
95
  // system. FLOW_PIPELINE=off stops it.
87
96
  if (process.env.FLOW_PIPELINE !== 'off') {
88
- createPipelineWorker({ db: database.db, integrations }).start();
97
+ createPipelineWorker({ db: database.db, integrations, log }).start();
89
98
  }
99
+ // The send worker (server/core/sending.js) sends only what a person queued
100
+ // with Send all ready, to the accounting integration that is on.
101
+ createSendWorker({ db: database.db, integrations, log }).start();
90
102
 
91
103
  let auth = null;
92
104
  if (settings.mode === 'clerk') {
93
105
  const { keys } = settings;
94
106
  const clerk = await loadClerkAdapter({ ...keys, authorizedParties: settings.authorizedParties });
95
- const personAuth = createPersonAuth({ db: database.db, clerk, ...settings });
107
+ const personAuth = createPersonAuth({ db: database.db, clerk, ...settings, log });
96
108
  let clerkProxy = null;
97
109
  const proxyPath = resolveClerkProxyPath(keys.proxyUrl);
98
110
  if (proxyPath) {
@@ -129,7 +141,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
129
141
 
130
142
  const updates = createUpdateChecker();
131
143
 
132
- createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily }).listen(port, () => {
144
+ createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
133
145
  console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
134
146
  });
135
147
 
@@ -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,18 @@ 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)
21
+ 'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
18
22
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
19
23
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
20
24
  'health', // connection state for Settings
25
+ 'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
26
+ 'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
21
27
  ]);
22
28
 
23
29
  const ID_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
@@ -1,6 +1,6 @@
1
- import { eq, sql } from 'drizzle-orm';
1
+ import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
2
2
  import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
3
- import { orderStatusLabel, parseSeazonaDate } from './map.js';
3
+ import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
4
4
  import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
5
5
 
6
6
  // The billing feed behind the port's pullWorkItems: every order that changed
@@ -104,12 +104,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
104
104
  }
105
105
 
106
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.
107
+ // with no company name is listed by its full name. A client the client list
108
+ // never gave (the list can be closed to an API client) is listed by the
109
+ // company name its orders carry, so its jobs still find their customer.
108
110
  export async function listClients(db) {
109
111
  const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
110
112
  .from(seazonaClients);
111
- return rows
113
+ const listed = rows
112
114
  .filter((r) => r.active !== false)
113
115
  .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
114
116
  .filter((r) => r.name);
117
+ const known = new Set(rows.map((r) => r.id));
118
+ const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
119
+ .from(seazonaOrders)
120
+ .where(sql`${seazonaOrders.clientId} is not null and coalesce(${seazonaOrders.company}, '') <> '' and ${seazonaOrders.deletedInSourceAt} is null`)
121
+ .orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
122
+ return [
123
+ ...listed,
124
+ ...fromOrders.filter((r) => !known.has(r.id)).map((r) => ({ id: r.id, name: r.company.trim() })),
125
+ ];
126
+ }
127
+
128
+ // A final order (shipped, delivered, cancelled; map.js isFinalStatus) stays on
129
+ // the Jobs board this long after it last changed.
130
+ export const WORK_FINISHED_DAYS = 30;
131
+ const WORK_LIMIT = 2000;
132
+ const FINAL_SQL = sql`coalesce(${seazonaOrders.status}, '') ~* '(shipped|delivered|cancelled|canceled|void|closed|archived|deleted)'`;
133
+
134
+ // The orders behind the port's listWork, for the Jobs board: every open
135
+ // order, and each final order that changed in the last WORK_FINISHED_DAYS
136
+ // days, newest change first. An order deleted in Seazona is never listed.
137
+ // The client name is the Seazona client's company (or full name), else the
138
+ // order's own company. `statuses` lists each status once, with the first time
139
+ // Flow saw an order in it, so the board can order its columns.
140
+ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINISHED_DAYS, limit = WORK_LIMIT } = {}) {
141
+ const since = new Date(now - finishedDays * 86_400_000);
142
+ const rows = await db.select({ order: seazonaOrders, company: seazonaClients.company, fullName: seazonaClients.fullName })
143
+ .from(seazonaOrders)
144
+ .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
145
+ .where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since))))
146
+ .orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
147
+ .limit(limit);
148
+ const firstSeen = new Map();
149
+ const items = rows.map(({ order, company, fullName }) => {
150
+ const status = orderStatusLabel(order) || null;
151
+ const seen = firstSeen.get(status);
152
+ if (status && (!seen || order.firstSeenAt < seen)) firstSeen.set(status, order.firstSeenAt);
153
+ return {
154
+ sourceSystem: 'seazona',
155
+ sourceJobRef: order.id,
156
+ // The Seazona order id is the case number. Never patient data.
157
+ caseRef: order.id,
158
+ status,
159
+ clientId: order.clientId,
160
+ clientName: (company || order.company || fullName || '').trim() || null,
161
+ practitioner: order.doctorName,
162
+ department: order.department,
163
+ orderedOn: parseSeazonaDate(order.orderedText),
164
+ dueOn: parseSeazonaDate(order.dueText),
165
+ lines: (order.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null })),
166
+ totalCents: order.subtotalCents,
167
+ };
168
+ });
169
+ return {
170
+ items,
171
+ statuses: [...firstSeen].map(([label, at]) => ({ label, final: isFinalStatus(label), firstSeenAt: at.toISOString() })),
172
+ };
115
173
  }
@@ -1,5 +1,5 @@
1
1
  import { createSeazonaClient } from './client.js';
2
- import { listClients, readBillableWork } from './feed.js';
2
+ import { listClients, listWork, 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';
@@ -105,6 +105,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
105
105
  async listCustomers() {
106
106
  return listClients(current().db);
107
107
  },
108
+
109
+ // Open orders and orders finished lately, with their statuses, for the
110
+ // Jobs board. Reads Flow's copy only.
111
+ async listWork() {
112
+ return listWork(current().db, { now: now() });
113
+ },
108
114
  },
109
115
  };
110
116
  }
@@ -309,8 +309,11 @@ const laneRuns = {
309
309
  async clients(ctx) {
310
310
  const { client, sync, at } = ctx;
311
311
  // The first run reads the whole list; later runs read changes only.
312
+ // Seazona answers the whole list only to an empty `?lastModified=`; a
313
+ // bare path is a 404.
312
314
  const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
313
- const items = listOrFail(await client.get('clients/', since ? { lastModified: since } : {}), 'client list', sync);
315
+ const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
316
+ const items = listOrFail(reply, 'client list', sync);
314
317
  const result = await syncListWithDetail(ctx, {
315
318
  mirror: 'clients',
316
319
  what: 'client',
@@ -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,
@@ -1,11 +1,11 @@
1
- CREATE TABLE "seazona_api_governor" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_api_governor" (
2
2
  "client_key" text PRIMARY KEY NOT NULL,
3
3
  "next_eligible_at" timestamp with time zone DEFAULT 'epoch' NOT NULL,
4
4
  "cooldown_until" timestamp with time zone DEFAULT 'epoch' NOT NULL,
5
5
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
6
6
  );
7
7
  --> statement-breakpoint
8
- CREATE TABLE "seazona_api_requests" (
8
+ CREATE TABLE IF NOT EXISTS "seazona_api_requests" (
9
9
  "id" bigserial PRIMARY KEY NOT NULL,
10
10
  "client_key" text NOT NULL,
11
11
  "workflow" text NOT NULL,
@@ -17,4 +17,4 @@ CREATE TABLE "seazona_api_requests" (
17
17
  "is_retry" boolean DEFAULT false NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE INDEX "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
20
+ CREATE INDEX IF NOT EXISTS "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
@@ -1,4 +1,4 @@
1
- CREATE TABLE "seazona_clients" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_clients" (
2
2
  "id" text PRIMARY KEY NOT NULL,
3
3
  "account_number" text,
4
4
  "company" text,
@@ -17,7 +17,7 @@ CREATE TABLE "seazona_clients" (
17
17
  "last_changed_at" timestamp with time zone NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE TABLE "seazona_invoices" (
20
+ CREATE TABLE IF NOT EXISTS "seazona_invoices" (
21
21
  "invoice_number" text PRIMARY KEY NOT NULL,
22
22
  "seazona_id" text,
23
23
  "client_id" text,
@@ -41,7 +41,7 @@ CREATE TABLE "seazona_invoices" (
41
41
  "last_changed_at" timestamp with time zone NOT NULL
42
42
  );
43
43
  --> statement-breakpoint
44
- CREATE TABLE "seazona_lane_state" (
44
+ CREATE TABLE IF NOT EXISTS "seazona_lane_state" (
45
45
  "lane" text PRIMARY KEY NOT NULL,
46
46
  "status" text DEFAULT 'idle' NOT NULL,
47
47
  "cursor_time" timestamp with time zone,
@@ -56,7 +56,7 @@ CREATE TABLE "seazona_lane_state" (
56
56
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
57
57
  );
58
58
  --> statement-breakpoint
59
- CREATE TABLE "seazona_order_read_failures" (
59
+ CREATE TABLE IF NOT EXISTS "seazona_order_read_failures" (
60
60
  "order_id" text PRIMARY KEY NOT NULL,
61
61
  "failure_count" integer NOT NULL,
62
62
  "last_attempt_at" timestamp with time zone NOT NULL,
@@ -66,7 +66,7 @@ CREATE TABLE "seazona_order_read_failures" (
66
66
  "quarantined_until" timestamp with time zone
67
67
  );
68
68
  --> statement-breakpoint
69
- CREATE TABLE "seazona_orders" (
69
+ CREATE TABLE IF NOT EXISTS "seazona_orders" (
70
70
  "id" text PRIMARY KEY NOT NULL,
71
71
  "invoice_number" text,
72
72
  "client_id" text,
@@ -88,7 +88,7 @@ CREATE TABLE "seazona_orders" (
88
88
  "last_changed_at" timestamp with time zone NOT NULL
89
89
  );
90
90
  --> statement-breakpoint
91
- CREATE TABLE "seazona_reference" (
91
+ CREATE TABLE IF NOT EXISTS "seazona_reference" (
92
92
  "resource" text NOT NULL,
93
93
  "id" text NOT NULL,
94
94
  "name" text,
@@ -101,6 +101,6 @@ CREATE TABLE "seazona_reference" (
101
101
  CONSTRAINT "seazona_reference_resource_id_pk" PRIMARY KEY("resource","id")
102
102
  );
103
103
  --> statement-breakpoint
104
- CREATE INDEX "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
- CREATE INDEX "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
- CREATE INDEX "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");
104
+ CREATE INDEX IF NOT EXISTS "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
+ CREATE INDEX IF NOT EXISTS "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
+ CREATE INDEX IF NOT EXISTS "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");