@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.
- package/README.md +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- 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
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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");
|