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