@arcforgelabs/flow 2026.10.30 → 2026.10.32
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 +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -99,6 +99,27 @@
|
|
|
99
99
|
"when": 1792414148360,
|
|
100
100
|
"tag": "0013_line_mappings",
|
|
101
101
|
"breakpoints": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"idx": 14,
|
|
105
|
+
"version": "7",
|
|
106
|
+
"when": 1792448941014,
|
|
107
|
+
"tag": "0014_price_list",
|
|
108
|
+
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "7",
|
|
113
|
+
"when": 1792452497948,
|
|
114
|
+
"tag": "0015_alert_dismissals",
|
|
115
|
+
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1792456270558,
|
|
121
|
+
"tag": "0016_invoice_reference",
|
|
122
|
+
"breakpoints": true
|
|
102
123
|
}
|
|
103
124
|
]
|
|
104
125
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -64,8 +64,12 @@ export const priceItems = pgTable('price_items', {
|
|
|
64
64
|
description: text('description'),
|
|
65
65
|
unitAmountCents: integer('unit_amount_cents').notNull(),
|
|
66
66
|
taxCode: text('tax_code').notNull().default('GST'),
|
|
67
|
+
// The sales account a line with this code books to, when the lab sets one.
|
|
68
|
+
accountCode: text('account_code'),
|
|
67
69
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
70
|
+
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
68
71
|
createdAt: createdAt(),
|
|
72
|
+
updatedAt: updatedAt(),
|
|
69
73
|
}, (t) => [
|
|
70
74
|
unique('price_items_org_id').on(t.organisationId, t.id),
|
|
71
75
|
unique('price_items_org_code').on(t.organisationId, t.code),
|
|
@@ -494,6 +498,20 @@ export const reviewItems = pgTable('review_items', {
|
|
|
494
498
|
check('review_items_closed_has_resolution', sql`${t.state} = 'open' or (${t.resolution} is not null and ${t.resolvedBy} is not null and ${t.resolvedAt} is not null)`),
|
|
495
499
|
]);
|
|
496
500
|
|
|
501
|
+
// Quiet alerts (issue 117, docs/ALERTS.md). An alert is worked out each time
|
|
502
|
+
// from Flow's own records (server/core/alerts.js); only a person's dismissal
|
|
503
|
+
// is stored. `members` are the things the alert was about when it was
|
|
504
|
+
// dismissed: the alert stays hidden for that person until something new joins.
|
|
505
|
+
export const alertDismissals = pgTable('alert_dismissals', {
|
|
506
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
507
|
+
personId: uuid('person_id').notNull().references(() => people.id, { onDelete: 'cascade' }),
|
|
508
|
+
alertKey: text('alert_key').notNull(),
|
|
509
|
+
members: text('members').array().notNull().default(sql`'{}'::text[]`),
|
|
510
|
+
dismissedAt: timestamp('dismissed_at', { withTimezone: true }).notNull().defaultNow(),
|
|
511
|
+
}, (t) => [
|
|
512
|
+
primaryKey({ columns: [t.organisationId, t.personId, t.alertKey] }),
|
|
513
|
+
]);
|
|
514
|
+
|
|
497
515
|
// Integration settings an owner enters in Settings (server/integrations/setup.js).
|
|
498
516
|
// One row per adapter for the installation. `config` holds plain settings by
|
|
499
517
|
// their environment name; `secrets` holds credentials sealed with Flow's
|
|
@@ -606,6 +624,8 @@ export const invoicingSettings = pgTable('invoicing_settings', {
|
|
|
606
624
|
// The price item code of a rush charge, and its share of the other lines.
|
|
607
625
|
rushItemCode: text('rush_item_code'),
|
|
608
626
|
rushPercent: integer('rush_percent').notNull().default(20),
|
|
627
|
+
// The invoice Reference, from tokens (server/core/references.js).
|
|
628
|
+
referenceTemplate: text('reference_template').notNull().default('{practitioner} - {patient}'),
|
|
609
629
|
// The fixed checks above became invoice rules (invoice_rules). True once
|
|
610
630
|
// this organisation's starter rules were made from them.
|
|
611
631
|
rulesSeeded: boolean('rules_seeded').notNull().default(false),
|
|
@@ -717,3 +737,29 @@ export const lineMappings = pgTable('line_mappings', {
|
|
|
717
737
|
check('line_mappings_price', sql`${t.kind} not in ('price_alias', 'saved') or ${t.unitAmountCents} is not null`),
|
|
718
738
|
check('line_mappings_label_valid', sql`length(${t.label}) between 1 and 200`),
|
|
719
739
|
]);
|
|
740
|
+
|
|
741
|
+
// The price list's links (server/core/price-list.js, docs/PRICE_LIST.md): one
|
|
742
|
+
// row joins one record in a connected system's price list (a Xero item, a
|
|
743
|
+
// Seazona product) to one Flow price item. Flow's price list stays the source
|
|
744
|
+
// of truth; a link only says which record to compare and map. Many Seazona
|
|
745
|
+
// products can share one price item; a price item has one accounting item at
|
|
746
|
+
// most (the code checks it: the adapter says which kind it is).
|
|
747
|
+
// matched_by code the same code on both sides, linked by Flow
|
|
748
|
+
// person a person chose it
|
|
749
|
+
export const PRICE_LINK_MATCHES = ['code', 'person'];
|
|
750
|
+
export const priceItemLinks = pgTable('price_item_links', {
|
|
751
|
+
id: id(),
|
|
752
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
753
|
+
priceItemId: uuid('price_item_id').notNull(),
|
|
754
|
+
system: text('system').notNull(),
|
|
755
|
+
externalId: text('external_id').notNull(),
|
|
756
|
+
externalCode: text('external_code'),
|
|
757
|
+
matchedBy: text('matched_by').notNull(),
|
|
758
|
+
createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
|
|
759
|
+
createdAt: createdAt(),
|
|
760
|
+
}, (t) => [
|
|
761
|
+
foreignKey({ name: 'price_item_links_item_same_org', columns: [t.organisationId, t.priceItemId], foreignColumns: [priceItems.organisationId, priceItems.id] }).onDelete('cascade'),
|
|
762
|
+
uniqueIndex('price_item_links_external').on(t.organisationId, t.system, t.externalId),
|
|
763
|
+
check('price_item_links_matched_by_valid', sql`${t.matchedBy} in ('code', 'person')`),
|
|
764
|
+
check('price_item_links_system_valid', sql`${t.system} ~ '^[a-z][a-z0-9-]{1,31}$'`),
|
|
765
|
+
]);
|
package/server/flow-server.js
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
// 2026.10.18-beta.2), so GET /health shows it as build.release. A checkout
|
|
5
5
|
// starts server/index.js (`npm start`) instead: its package.json holds the
|
|
6
6
|
// upcoming version, which is not a release yet.
|
|
7
|
+
//
|
|
8
|
+
// flow-server start Flow
|
|
9
|
+
// flow-server migrate bring DATABASE_URL up to this build
|
|
10
|
+
// (Flow's tables and every shipped
|
|
11
|
+
// adapter's), then stop
|
|
12
|
+
// flow-server doctor [--live <origin>] [--json]
|
|
13
|
+
// check DATABASE_URL against this build,
|
|
14
|
+
// and with --live, against the live
|
|
15
|
+
// app's GET /health (server/db/doctor.js);
|
|
16
|
+
// exit 1 when it is not at this build
|
|
7
17
|
import { readFileSync } from 'node:fs';
|
|
8
18
|
import { parseReleaseTag } from './release-channel.js';
|
|
9
19
|
|
|
@@ -12,4 +22,13 @@ if (!process.env.FLOW_RELEASE) {
|
|
|
12
22
|
const release = parseReleaseTag(version);
|
|
13
23
|
if (release) process.env.FLOW_RELEASE = release.tag;
|
|
14
24
|
}
|
|
15
|
-
|
|
25
|
+
const [command, ...args] = process.argv.slice(2);
|
|
26
|
+
if (command === 'migrate' || command === 'doctor') {
|
|
27
|
+
const { runCommand } = await import('./db/commands.js');
|
|
28
|
+
process.exitCode = await runCommand(command, args);
|
|
29
|
+
} else if (command) {
|
|
30
|
+
console.error(`flow-server: unknown command '${command}'. Use: flow-server, flow-server migrate, flow-server doctor`);
|
|
31
|
+
process.exitCode = 2;
|
|
32
|
+
} else {
|
|
33
|
+
await import('./index.js');
|
|
34
|
+
}
|
package/server/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createClerkProxy, ensureClerkAccount, loadClerkAdapter, resolveClerkPro
|
|
|
4
4
|
import { seedFirstOwner } from './auth/people.js';
|
|
5
5
|
import { createPersonAuth } from './auth/session.js';
|
|
6
6
|
import { resolveSignInSettings } from './auth/settings.js';
|
|
7
|
+
import { expectedSchema } from './db/doctor.js';
|
|
7
8
|
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
8
9
|
import {
|
|
9
10
|
importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
|
|
@@ -141,7 +142,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
141
142
|
|
|
142
143
|
const updates = createUpdateChecker();
|
|
143
144
|
|
|
144
|
-
createApp({ database, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
|
|
145
|
+
createApp({ database, schemaJournals: expectedSchema({ adapters: shippedAdapters }).journals, auth, trustProxy, updates, integrations, secrets, defaultFamily, log }).listen(port, () => {
|
|
145
146
|
console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
|
|
146
147
|
});
|
|
147
148
|
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
// The call guard: one pattern every adapter that calls an outside system
|
|
4
|
+
// follows (docs/INTEGRATIONS.md, "Outbound calls"). A provider can cut off an
|
|
5
|
+
// installation's API access after many failed calls, even inside its rate
|
|
6
|
+
// limit (issues #101 and #111). So, before each call, the guard says whether
|
|
7
|
+
// a call may go, and after it, the guard records how it ended:
|
|
8
|
+
//
|
|
9
|
+
// - a refused credential halts every call until a person acts (saves the
|
|
10
|
+
// settings or connects again, which calls resume());
|
|
11
|
+
// - a 401 or 403 on one resource, when the adapter's auth failures stop a
|
|
12
|
+
// resource only, stops that resource until a person acts;
|
|
13
|
+
// - `circuitFailures` failed calls inside `circuitWindowMs` open the
|
|
14
|
+
// circuit: no call for `circuitPauseMs`, then a probe; a failed probe opens
|
|
15
|
+
// it again for twice as long, to at most `maxCircuitPauseMs`, and a
|
|
16
|
+
// success closes it;
|
|
17
|
+
// - `dailyFailureBudget` failed calls in one UTC day stop calls until the
|
|
18
|
+
// next day.
|
|
19
|
+
//
|
|
20
|
+
// A stopped call fails at once with CallsStopped; it never waits. Each stop
|
|
21
|
+
// is reported once with log.error, so the connected error service gets one
|
|
22
|
+
// report per problem; a repeat is a warning.
|
|
23
|
+
//
|
|
24
|
+
// State lives in PostgreSQL, in two tables the adapter owns and creates in its
|
|
25
|
+
// own migrations (CALL_GUARD_COLUMNS), so every process on one database
|
|
26
|
+
// shares it and a restart does not reset it. Nothing here names a vendor.
|
|
27
|
+
|
|
28
|
+
export const CALL_GUARD_POLICY = Object.freeze({
|
|
29
|
+
circuitFailures: 5,
|
|
30
|
+
circuitWindowMs: 10 * 60_000,
|
|
31
|
+
circuitPauseMs: 5 * 60_000,
|
|
32
|
+
maxCircuitPauseMs: 6 * 60 * 60_000,
|
|
33
|
+
dailyFailureBudget: 50,
|
|
34
|
+
// 'all': a 401 or 403 halts every call. 'resource': it stops only the
|
|
35
|
+
// resource it came from; halt() still stops everything.
|
|
36
|
+
authStops: 'all',
|
|
37
|
+
telemetryRetentionMs: 35 * 24 * 60 * 60_000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The columns each guard table must have. `key` and `at` may have other names
|
|
41
|
+
// (the `columns` option), so an adapter that kept telemetry before the guard
|
|
42
|
+
// existed can keep its tables.
|
|
43
|
+
export const CALL_GUARD_COLUMNS = Object.freeze({
|
|
44
|
+
state: ['key', 'circuit_open_until', 'circuit_opens', 'halted_reason', 'halted_at', 'stopped_resources', 'updated_at'],
|
|
45
|
+
calls: ['key', 'at', 'resource', 'request_path', 'status_code', 'failure_kind', 'duration_ms'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
49
|
+
const EPOCH = new Date(0);
|
|
50
|
+
|
|
51
|
+
// A call that failed in a way a provider sees as a fault: no answer, a 5xx, a
|
|
52
|
+
// 429, or a 4xx other than 404 (a 404 on one record means it was deleted).
|
|
53
|
+
export function countsAsFailure({ statusCode = null, failureKind = null } = {}) {
|
|
54
|
+
if (failureKind) return true;
|
|
55
|
+
if (!Number.isInteger(statusCode)) return false;
|
|
56
|
+
return statusCode >= 500 || (statusCode >= 400 && statusCode !== 404);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const isAuthRefusal = (statusCode) => statusCode === 401 || statusCode === 403;
|
|
60
|
+
|
|
61
|
+
export function startOfUtcDay(ms) {
|
|
62
|
+
const date = new Date(ms);
|
|
63
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The pause after the circuit has opened `opens` times before.
|
|
67
|
+
export function circuitPauseMs(opens, policy = CALL_GUARD_POLICY) {
|
|
68
|
+
return Math.min(policy.maxCircuitPauseMs, policy.circuitPauseMs * 2 ** Math.min(Math.max(0, opens), 10));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Why no call may go now, or null. `state` is the guard row
|
|
72
|
+
// ({ haltedReason, circuitOpenUntil, stoppedResources }); `failedToday`
|
|
73
|
+
// counts failed calls since the start of the UTC day.
|
|
74
|
+
export function stoppedReason(state, { nowMs, failedToday, resource = null }, policy = CALL_GUARD_POLICY) {
|
|
75
|
+
if (state?.haltedReason) return { reason: 'halted', retryAfterMs: null };
|
|
76
|
+
if (resource && state?.stoppedResources?.[resource]) return { reason: 'resource_stopped', retryAfterMs: null, resource };
|
|
77
|
+
const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
|
|
78
|
+
if (openUntil > nowMs) return { reason: 'circuit_open', retryAfterMs: openUntil - nowMs };
|
|
79
|
+
const dayStart = startOfUtcDay(nowMs);
|
|
80
|
+
if (failedToday >= policy.dailyFailureBudget) return { reason: 'budget_spent', retryAfterMs: dayStart + DAY_MS - nowMs };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Whether a failed call opens the circuit. `recentFailures` counts failed
|
|
85
|
+
// calls since max(now - circuitWindowMs, the end of the last pause), this one
|
|
86
|
+
// included. Answers { pauseMs } or null.
|
|
87
|
+
export function circuitAfterFailure(state, { nowMs, recentFailures }, policy = CALL_GUARD_POLICY) {
|
|
88
|
+
const opens = state?.circuitOpens ?? 0;
|
|
89
|
+
const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
|
|
90
|
+
// After a pause the next call is a probe: if it fails, open again at once.
|
|
91
|
+
const probeFailed = opens > 0 && openUntil <= nowMs;
|
|
92
|
+
if (!probeFailed && (recentFailures < policy.circuitFailures || openUntil > nowMs)) return null;
|
|
93
|
+
return { pauseMs: circuitPauseMs(opens, policy) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const STOP_TEXT = {
|
|
97
|
+
halted: (name) => `${name} calls are stopped until a person checks the connection (it was refused)`,
|
|
98
|
+
resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped until a person checks the connection (they were refused)`,
|
|
99
|
+
circuit_open: (name) => `${name} calls are paused after repeated failures`,
|
|
100
|
+
budget_spent: (name) => `${name} calls are stopped for today: the day's failed calls are spent`,
|
|
101
|
+
};
|
|
102
|
+
const STOP_CODE = {
|
|
103
|
+
halted: 'api_halted',
|
|
104
|
+
resource_stopped: 'api_resource_stopped',
|
|
105
|
+
circuit_open: 'api_circuit_open',
|
|
106
|
+
budget_spent: 'api_failure_budget_spent',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Calls are stopped. `retryAfterMs` is null for a stop that only a person
|
|
110
|
+
// clears. `code` is `<integration>_<what>`, for example api_circuit_open.
|
|
111
|
+
export class CallsStopped extends Error {
|
|
112
|
+
constructor({ integration, name = integration, reason, retryAfterMs = null, resource = null }) {
|
|
113
|
+
super(STOP_TEXT[reason](name, resource));
|
|
114
|
+
this.name = 'CallsStopped';
|
|
115
|
+
this.code = `${integration}_${STOP_CODE[reason]}`;
|
|
116
|
+
this.kind = 'calls_stopped';
|
|
117
|
+
this.integration = integration;
|
|
118
|
+
this.reason = reason;
|
|
119
|
+
this.resource = resource;
|
|
120
|
+
this.retryAfterMs = retryAfterMs;
|
|
121
|
+
this.status = 503;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const isCallsStopped = (error) => error?.kind === 'calls_stopped';
|
|
126
|
+
|
|
127
|
+
// The quiet alerts a guard's status() makes (server/core/alerts.js): only the
|
|
128
|
+
// stops a person must clear. A paused circuit or a spent day ends by itself,
|
|
129
|
+
// so it is no alert. `name` is what a person reads; `fix` says how a person
|
|
130
|
+
// clears the stop.
|
|
131
|
+
export function guardAlerts(name, status, { fix = 'Check the connection, then connect again.' } = {}) {
|
|
132
|
+
const alerts = [];
|
|
133
|
+
if (status?.haltedReason) {
|
|
134
|
+
alerts.push({
|
|
135
|
+
id: 'calls-stopped',
|
|
136
|
+
tone: 'danger',
|
|
137
|
+
title: `${name} refused Flow. Flow stopped all ${name} calls. ${fix}`,
|
|
138
|
+
members: [`halted:${status.haltedAt ?? ''}`],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const resources = status?.stoppedResources ?? [];
|
|
142
|
+
if (resources.length) {
|
|
143
|
+
alerts.push({
|
|
144
|
+
id: 'resources-stopped',
|
|
145
|
+
tone: 'danger',
|
|
146
|
+
title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow stopped those calls. ${fix}`,
|
|
147
|
+
members: resources.map((r) => `${r.resource}:${r.at ?? ''}`),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return alerts;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Path only: drop any host and query, and keep a safe character set.
|
|
154
|
+
export function normaliseRequestPath(value) {
|
|
155
|
+
const path = String(value || '').split(/[?#]/)[0].replace(/^[a-z]+:\/\/[^/]+/i, '');
|
|
156
|
+
return path.replace(/[^A-Za-z0-9/_.:-]+/g, '').slice(0, 160) || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cleanKey = (value) => String(value ?? '').trim().slice(0, 120);
|
|
160
|
+
const cleanResource = (value) => (value ? String(value).replace(/[^A-Za-z0-9/_.:-]+/g, '').slice(0, 80) || null : null);
|
|
161
|
+
const iso = (value) => (value ? new Date(value).toISOString() : null);
|
|
162
|
+
|
|
163
|
+
// `integration` is the adapter id (codes, the advisory lock), `name` what a
|
|
164
|
+
// person reads. `tables` names the adapter's two tables; `columns` renames
|
|
165
|
+
// the `key` and `at` columns when an adapter's tables use other names, and
|
|
166
|
+
// `callDefaults` fills other required columns of a new call row. An adapter
|
|
167
|
+
// that admits its calls in its own transaction passes `lockName`, so the
|
|
168
|
+
// guard takes the same lock. `report: false` leaves the reports to the
|
|
169
|
+
// adapter, for one that already reports each stop once at a higher level.
|
|
170
|
+
export function createCallGuard({
|
|
171
|
+
db,
|
|
172
|
+
integration,
|
|
173
|
+
name = integration,
|
|
174
|
+
tables,
|
|
175
|
+
columns = {},
|
|
176
|
+
callDefaults = {},
|
|
177
|
+
lockName = `${integration}-call-guard`,
|
|
178
|
+
report: reports = true,
|
|
179
|
+
policy: overrides = {},
|
|
180
|
+
now = Date.now,
|
|
181
|
+
log = console,
|
|
182
|
+
} = {}) {
|
|
183
|
+
if (!db) throw new TypeError('createCallGuard: db is required');
|
|
184
|
+
if (!/^[a-z][a-z0-9-]{1,31}$/.test(integration ?? '')) throw new TypeError('createCallGuard: integration must be an adapter id');
|
|
185
|
+
if (!tables?.state || !tables?.calls) throw new TypeError('createCallGuard: tables.state and tables.calls are required');
|
|
186
|
+
const policy = { ...CALL_GUARD_POLICY, ...overrides };
|
|
187
|
+
if (!['all', 'resource'].includes(policy.authStops)) throw new TypeError('createCallGuard: authStops must be "all" or "resource"');
|
|
188
|
+
const T = { state: sql.identifier(tables.state), calls: sql.identifier(tables.calls) };
|
|
189
|
+
const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
|
|
190
|
+
const tag = `[${integration}]`;
|
|
191
|
+
const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
|
|
192
|
+
|
|
193
|
+
const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
|
|
194
|
+
const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
|
|
195
|
+
const extraValues = Object.values(callDefaults).map((value) => sql`, ${value}`);
|
|
196
|
+
|
|
197
|
+
async function readState(runner, key) {
|
|
198
|
+
const { rows } = await runner.execute(sql`select circuit_open_until, circuit_opens, halted_reason, halted_at, stopped_resources
|
|
199
|
+
from ${T.state} where ${C.key} = ${key}`);
|
|
200
|
+
const row = rows[0];
|
|
201
|
+
if (!row) return { circuitOpenUntil: EPOCH, circuitOpens: 0, haltedReason: null, haltedAt: null, stoppedResources: {} };
|
|
202
|
+
return {
|
|
203
|
+
circuitOpenUntil: new Date(row.circuit_open_until),
|
|
204
|
+
circuitOpens: Number(row.circuit_opens),
|
|
205
|
+
haltedReason: row.halted_reason,
|
|
206
|
+
haltedAt: row.halted_at ? new Date(row.halted_at) : null,
|
|
207
|
+
stoppedResources: row.stopped_resources ?? {},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function failuresSince(runner, key, fromMs) {
|
|
212
|
+
const { rows } = await runner.execute(sql`select count(*)::int as n from ${T.calls}
|
|
213
|
+
where ${C.key} = ${key} and ${C.at} >= ${new Date(fromMs)} and ${failed}`);
|
|
214
|
+
return Number(rows[0].n);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ensureRow = (runner, key) => runner.execute(sql`insert into ${T.state} (${C.key}) values (${key}) on conflict do nothing`);
|
|
218
|
+
|
|
219
|
+
// Why no call may go to `resource` now, or null. `runner` is the database
|
|
220
|
+
// or a transaction the adapter holds (with the guard's lock).
|
|
221
|
+
async function stoppedNow(runner, rawKey, { resource = null } = {}) {
|
|
222
|
+
const key = cleanKey(rawKey);
|
|
223
|
+
const nowMs = Number(now());
|
|
224
|
+
const state = await readState(runner, key);
|
|
225
|
+
const failedToday = await failuresSince(runner, key, startOfUtcDay(nowMs));
|
|
226
|
+
return stoppedReason(state, { nowMs, failedToday, resource: cleanResource(resource) }, policy);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Throws CallsStopped when no call may go to `resource` now.
|
|
230
|
+
async function check(rawKey, options = {}) {
|
|
231
|
+
const stopped = await stoppedNow(db, rawKey, options);
|
|
232
|
+
if (stopped) throw new CallsStopped({ integration, name, ...stopped });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Records how one call ended, and what that does to the guard. Answers
|
|
236
|
+
// what this call stopped ({ halted, resourceStopped, opened, budgetSpent }),
|
|
237
|
+
// each of which was just reported once. `callId` names a call row the
|
|
238
|
+
// adapter wrote when it admitted the call: that row is updated, not added.
|
|
239
|
+
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
|
|
240
|
+
const key = cleanKey(rawKey);
|
|
241
|
+
const where = cleanResource(resource);
|
|
242
|
+
const nowMs = Number(now());
|
|
243
|
+
const nowAt = new Date(nowMs);
|
|
244
|
+
const status = Number.isInteger(statusCode) ? statusCode : null;
|
|
245
|
+
const outcome = await db.transaction(async (tx) => {
|
|
246
|
+
await lock(tx, key);
|
|
247
|
+
await ensureRow(tx, key);
|
|
248
|
+
const kind = failureKind ? String(failureKind).slice(0, 40) : null;
|
|
249
|
+
const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
|
|
250
|
+
if (callId !== null) {
|
|
251
|
+
await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
|
|
252
|
+
failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
|
|
253
|
+
} else {
|
|
254
|
+
await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
|
|
255
|
+
values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
|
|
256
|
+
}
|
|
257
|
+
const state = await readState(tx, key);
|
|
258
|
+
if (!countsAsFailure({ statusCode: status, failureKind })) {
|
|
259
|
+
if (state.circuitOpens > 0) {
|
|
260
|
+
await tx.execute(sql`update ${T.state} set circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
261
|
+
return { closed: true };
|
|
262
|
+
}
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
const result = {};
|
|
266
|
+
const dayStart = startOfUtcDay(nowMs);
|
|
267
|
+
if (await failuresSince(tx, key, dayStart) === policy.dailyFailureBudget) result.budgetSpent = { until: new Date(dayStart + DAY_MS).toISOString() };
|
|
268
|
+
if (isAuthRefusal(status)) {
|
|
269
|
+
if (policy.authStops === 'resource' && where) {
|
|
270
|
+
if (!state.stoppedResources[where]) {
|
|
271
|
+
await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: { statusCode: status, at: nowAt.toISOString() } })}::jsonb,
|
|
272
|
+
updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
273
|
+
result.resourceStopped = where;
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
if (!state.haltedReason) {
|
|
278
|
+
await tx.execute(sql`update ${T.state} set halted_reason = 'auth', halted_at = ${nowAt}, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
279
|
+
result.halted = 'auth';
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
const recentFailures = await failuresSince(tx, key, Math.max(nowMs - policy.circuitWindowMs, state.circuitOpenUntil.getTime()));
|
|
284
|
+
const open = circuitAfterFailure(state, { nowMs, recentFailures }, policy);
|
|
285
|
+
if (open) {
|
|
286
|
+
const until = new Date(nowMs + open.pauseMs);
|
|
287
|
+
await tx.execute(sql`update ${T.state} set circuit_open_until = ${until}, circuit_opens = circuit_opens + 1, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
288
|
+
result.opened = { until: until.toISOString(), pauseMs: open.pauseMs, opens: state.circuitOpens + 1 };
|
|
289
|
+
}
|
|
290
|
+
return result;
|
|
291
|
+
});
|
|
292
|
+
report(outcome, { status, failureKind, where });
|
|
293
|
+
await db.execute(sql`delete from ${T.calls} where ${C.key} = ${key} and ${C.at} < ${new Date(nowMs - policy.telemetryRetentionMs)}`);
|
|
294
|
+
return outcome;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// One log.error per stop, so the error service gets one report each. A
|
|
298
|
+
// circuit that opens again after a failed probe is a warning: the first
|
|
299
|
+
// opening was reported.
|
|
300
|
+
function report(outcome, { status, failureKind, where }) {
|
|
301
|
+
if (!reports) return;
|
|
302
|
+
const cause = status ? `HTTP ${status}` : failureKind;
|
|
303
|
+
if (outcome.halted) log.error?.(`${tag} ${name} refused the connection (${cause}). Calls are stopped until a person checks the ${name} settings or connects again.`);
|
|
304
|
+
if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped until a person checks the connection.`);
|
|
305
|
+
if (outcome.opened) {
|
|
306
|
+
const line = `${tag} ${name} calls failed repeatedly (last: ${cause}${where ? ` on ${where}` : ''}). Calls pause until ${outcome.opened.until}.`;
|
|
307
|
+
if (outcome.opened.opens === 1) log.error?.(line);
|
|
308
|
+
else log.warn?.(line);
|
|
309
|
+
}
|
|
310
|
+
if (outcome.budgetSpent) log.error?.(`${tag} ${policy.dailyFailureBudget} ${name} calls failed today (last: ${cause}). Calls stop until ${outcome.budgetSpent.until}.`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Stops every call until resume(): for a refusal found outside a call, for
|
|
314
|
+
// example a credential the provider no longer accepts. Reported once.
|
|
315
|
+
async function halt(rawKey, reason = 'auth', { detail = null } = {}) {
|
|
316
|
+
const key = cleanKey(rawKey);
|
|
317
|
+
const nowAt = new Date(Number(now()));
|
|
318
|
+
const changed = await db.transaction(async (tx) => {
|
|
319
|
+
await lock(tx, key);
|
|
320
|
+
await ensureRow(tx, key);
|
|
321
|
+
const { rows } = await tx.execute(sql`update ${T.state} set halted_reason = ${String(reason).slice(0, 40)}, halted_at = ${nowAt}, updated_at = ${nowAt}
|
|
322
|
+
where ${C.key} = ${key} and halted_reason is null returning 1`);
|
|
323
|
+
return rows.length > 0;
|
|
324
|
+
});
|
|
325
|
+
if (changed && reports) log.error?.(`${tag} ${name} calls are stopped until a person checks the ${name} settings or connects again${detail ? ` (${detail})` : ''}.`);
|
|
326
|
+
return changed;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A person acted: clear a halt and stopped resources, and close the
|
|
330
|
+
// circuit. The day's failure budget stays. No key: every key.
|
|
331
|
+
async function resume(rawKey = null) {
|
|
332
|
+
const nowAt = new Date(Number(now()));
|
|
333
|
+
if (rawKey === null) {
|
|
334
|
+
await db.execute(sql`update ${T.state} set halted_reason = null, halted_at = null, stopped_resources = '{}'::jsonb,
|
|
335
|
+
circuit_open_until = ${EPOCH}, circuit_opens = 0, updated_at = ${nowAt}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const key = cleanKey(rawKey);
|
|
339
|
+
await ensureRow(db, key);
|
|
340
|
+
await db.execute(sql`update ${T.state} set halted_reason = null, halted_at = null, stopped_resources = '{}'::jsonb,
|
|
341
|
+
circuit_open_until = ${EPOCH}, circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// What Settings and /health show, the same for every adapter.
|
|
345
|
+
async function status(rawKey) {
|
|
346
|
+
const key = cleanKey(rawKey);
|
|
347
|
+
const nowMs = Number(now());
|
|
348
|
+
const state = await readState(db, key);
|
|
349
|
+
const openUntil = state.circuitOpenUntil.getTime();
|
|
350
|
+
return {
|
|
351
|
+
circuit: state.haltedReason ? 'halted' : openUntil > nowMs ? 'open' : state.circuitOpens > 0 ? 'half_open' : 'closed',
|
|
352
|
+
circuitOpenUntil: openUntil > nowMs ? iso(openUntil) : null,
|
|
353
|
+
haltedReason: state.haltedReason,
|
|
354
|
+
haltedAt: iso(state.haltedAt),
|
|
355
|
+
stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry })),
|
|
356
|
+
failedToday: await failuresSince(db, key, startOfUtcDay(nowMs)),
|
|
357
|
+
dailyFailureBudget: policy.dailyFailureBudget,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { integration, policy, check, stoppedNow, record, halt, resume, status };
|
|
362
|
+
}
|
|
@@ -11,17 +11,23 @@ import { isMissingTable, migrateAdapterSchema } from './schema.js';
|
|
|
11
11
|
|
|
12
12
|
export const CAPABILITIES = Object.freeze([
|
|
13
13
|
'pullWorkItems', // billable work since a cursor (Seazona: completed cases)
|
|
14
|
+
'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
|
|
14
15
|
'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
|
|
15
16
|
'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
|
|
16
17
|
'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
|
|
18
|
+
'documentFacts', // what the customer's copy of a sent invoice says (number, reference, dates), from the system's copy in Flow; never a live call
|
|
17
19
|
'sendStatus', // { connected, writesAllowed }: may Flow send invoices now?
|
|
18
20
|
'pushBill', // send an approved bill; the Flow record id is the idempotency key
|
|
19
21
|
'pullPayments', // payments since a cursor, matched to invoices and bills
|
|
20
22
|
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
23
|
+
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
21
24
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
|
|
25
|
+
'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
|
|
22
26
|
'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
|
|
23
27
|
'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
|
|
28
|
+
'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
|
|
24
29
|
'health', // connection state for Settings
|
|
30
|
+
'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
|
|
25
31
|
'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
|
|
26
32
|
'browserMonitoring', // the public settings a browser needs to report errors and problems to that service
|
|
27
33
|
]);
|
|
@@ -71,7 +77,7 @@ export function defineAdapter(adapter) {
|
|
|
71
77
|
if (!adapter || typeof adapter !== 'object') {
|
|
72
78
|
throw new TypeError('An adapter must be an object');
|
|
73
79
|
}
|
|
74
|
-
const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup } = adapter;
|
|
80
|
+
const { id, name, bundled = false, capabilities, schema, start, stop, routes, setup, callGuard } = adapter;
|
|
75
81
|
if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
|
|
76
82
|
throw new TypeError(`Adapter id must match ${ID_PATTERN}`);
|
|
77
83
|
}
|
|
@@ -108,6 +114,16 @@ export function defineAdapter(adapter) {
|
|
|
108
114
|
throw new TypeError(`Adapter ${id} setup field ${field?.name} needs an upper-case setting name and a label`);
|
|
109
115
|
}
|
|
110
116
|
}
|
|
117
|
+
if (callGuard !== undefined && callGuard !== null) {
|
|
118
|
+
const { integration, tables } = callGuard;
|
|
119
|
+
if (integration !== id || !tables?.state || !tables?.calls) {
|
|
120
|
+
throw new TypeError(`Adapter ${id} callGuard must name integration ${id} and its tables.state and tables.calls`);
|
|
121
|
+
}
|
|
122
|
+
const owned = new Set(schema?.tables ?? []);
|
|
123
|
+
if (!owned.has(tables.state) || !owned.has(tables.calls)) {
|
|
124
|
+
throw new TypeError(`Adapter ${id} callGuard tables must be among its own schema tables`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
111
127
|
return Object.freeze({
|
|
112
128
|
id,
|
|
113
129
|
name,
|
|
@@ -120,6 +136,10 @@ export function defineAdapter(adapter) {
|
|
|
120
136
|
// stop() ends that work (tests, shutdown).
|
|
121
137
|
start: start ?? null,
|
|
122
138
|
stop: stop ?? null,
|
|
139
|
+
// The call guard (./call-guard.js) every outbound call goes through: the
|
|
140
|
+
// integration id, its two tables, and its policy. An adapter that calls
|
|
141
|
+
// an outside system declares it (docs/INTEGRATIONS.md, "Outbound calls").
|
|
142
|
+
callGuard: callGuard ? Object.freeze({ ...callGuard }) : null,
|
|
123
143
|
// Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
|
|
124
144
|
// mounted at /api/<id> (registry.routes).
|
|
125
145
|
routes: routes ?? null,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
|
|
3
3
|
import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
|
|
4
|
-
import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
|
|
4
|
+
import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaReference } from './schema.js';
|
|
5
5
|
|
|
6
6
|
// The billing feed behind the port's pullWorkItems: every order that changed
|
|
7
7
|
// since a cursor, oldest change first. A complete or shipped order is an
|
|
@@ -11,9 +11,16 @@ import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
|
|
|
11
11
|
//
|
|
12
12
|
// The cursor is opaque; pass back `cursor` from the last page. A change to
|
|
13
13
|
// the order or to its invoice moves the order to the end of the feed.
|
|
14
|
+
//
|
|
15
|
+
// A read that found no change moves the order to the end of the feed too, at
|
|
16
|
+
// most once an hour (`SEEN_STEP`). Without it, an order that never changes
|
|
17
|
+
// is reported once, its ledger report gets older than a day, and the ledger
|
|
18
|
+
// refuses its draft as "out of date" for ever, though the lanes read the
|
|
19
|
+
// order every few minutes.
|
|
14
20
|
|
|
15
21
|
const BILLABLE = /\b(complete|completed|shipped|delivered)\b/i;
|
|
16
22
|
const SHIPPED = /\b(shipped|delivered)\b/i;
|
|
23
|
+
const SEEN_STEP = 'hour';
|
|
17
24
|
|
|
18
25
|
function decodeCursor(cursor) {
|
|
19
26
|
if (!cursor) return null;
|
|
@@ -31,7 +38,7 @@ function encodeCursor(at, id) {
|
|
|
31
38
|
|
|
32
39
|
export async function readBillableWork(db, { cursor = null, limit = 200 } = {}) {
|
|
33
40
|
const after = decodeCursor(cursor);
|
|
34
|
-
const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}))`;
|
|
41
|
+
const changedAt = sql`greatest(${seazonaOrders.lastChangedAt}, coalesce(${seazonaInvoices.lastChangedAt}, ${seazonaOrders.lastChangedAt}), date_trunc(${SEEN_STEP}, ${seazonaOrders.lastSyncedAt}))`;
|
|
35
42
|
const pageSize = Math.max(1, Math.min(1000, limit));
|
|
36
43
|
const rows = await db.select({
|
|
37
44
|
order: seazonaOrders,
|
|
@@ -125,6 +132,28 @@ export async function listClients(db) {
|
|
|
125
132
|
];
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
// Seazona's products, from Flow's copy (the products lane reads them daily),
|
|
136
|
+
// for the price list's links and price check (server/core/price-list.js).
|
|
137
|
+
// Seazona says only whether a product is taxable: taxable reads as GST.
|
|
138
|
+
export async function listProducts(db) {
|
|
139
|
+
const rows = await db.select({
|
|
140
|
+
id: seazonaReference.id, code: seazonaReference.code, name: seazonaReference.name,
|
|
141
|
+
priceCents: seazonaReference.priceCents, taxable: seazonaReference.taxable, syncedAt: seazonaReference.lastSyncedAt,
|
|
142
|
+
}).from(seazonaReference).where(eq(seazonaReference.resource, 'products'));
|
|
143
|
+
return {
|
|
144
|
+
kind: 'work',
|
|
145
|
+
syncedAt: rows.reduce((at, r) => (!at || r.syncedAt > at ? r.syncedAt : at), null),
|
|
146
|
+
items: rows.map((r) => ({
|
|
147
|
+
id: r.id,
|
|
148
|
+
code: r.code,
|
|
149
|
+
name: r.name,
|
|
150
|
+
unitAmountCents: r.priceCents === null ? null : Number(r.priceCents),
|
|
151
|
+
taxCode: r.taxable === null ? null : (r.taxable ? 'GST' : 'GST_FREE'),
|
|
152
|
+
sold: true,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
// A final order (shipped, delivered, cancelled; map.js isFinalStatus) stays on
|
|
129
158
|
// the Jobs board this long after it last changed.
|
|
130
159
|
export const WORK_FINISHED_DAYS = 30;
|