@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.
- package/README.md +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -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/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- 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
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -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
|
-
|
|
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 }) {
|