@arcforgelabs/flow 2026.11.5 → 2026.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +5 -2
  4. package/server/core/automations.js +4 -1
  5. package/server/core/invoice-rules.js +64 -15
  6. package/server/core/invoicing-rules.js +50 -6
  7. package/server/core/job-board.js +3 -2
  8. package/server/core/ledger.js +10 -2
  9. package/server/core/pipeline.js +5 -2
  10. package/server/core/price-list.js +41 -12
  11. package/server/core/sending.js +3 -2
  12. package/server/db/migrations/0029_job_remake.sql +1 -0
  13. package/server/db/migrations/meta/0029_snapshot.json +5069 -0
  14. package/server/db/migrations/meta/_journal.json +7 -0
  15. package/server/db/schema.js +3 -0
  16. package/server/integrations/port.js +1 -0
  17. package/server/integrations/seazona/feed.js +1 -0
  18. package/server/integrations/seazona/history/index.js +5 -2
  19. package/server/integrations/seazona/lanes.js +3 -0
  20. package/server/integrations/seazona/ledger.js +1 -0
  21. package/server/integrations/seazona/map.js +2 -0
  22. package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
  23. package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
  24. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/seazona/schema.js +2 -0
  26. package/server/integrations/seazona/store.js +14 -0
  27. package/server/integrations/seazona/sweep.js +3 -0
  28. package/server/integrations/sentry/index.js +9 -4
  29. package/server/integrations/xero/catalogue.js +40 -0
  30. package/server/integrations/xero/drafts.js +16 -5
  31. package/server/integrations/xero/index.js +10 -0
  32. package/server/integrations/xero/lookup.js +11 -4
  33. package/server/integrations/xero/settings.js +5 -0
  34. package/server/ui/monitoring.js +63 -3
  35. package/server/ui/price-list.js +5 -2
  36. package/skills/flow/SKILL.md +31 -1
@@ -57,6 +57,13 @@
57
57
  "when": 1792472936253,
58
58
  "tag": "0007_stage_moves",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1794463203447,
65
+ "tag": "0008_order_remake",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
@@ -74,6 +74,8 @@ export const seazonaOrders = pgTable('seazona_orders', {
74
74
  lines: jsonb('lines').notNull().default([]),
75
75
  subtotalCents: cents('subtotal_cents'),
76
76
  patientReference: text('patient_reference'),
77
+ // Seazona's isRemake on the order: a remake of earlier work.
78
+ remake: boolean('remake').notNull().default(false),
77
79
  // Issue #12: Seazona answered 404. The row and its history stay; the order
78
80
  // reads as "Deleted in Seazona" and is out of billing. A later successful
79
81
  // read clears it.
@@ -81,6 +81,20 @@ export async function touchSynced(db, mirror, keys, { at }) {
81
81
  await db.update(table).set({ lastSyncedAt: at }).where(inArray(table[key], keys));
82
82
  }
83
83
 
84
+ // The remake flag of orders whose other list fields did not change, from the
85
+ // list rows (`flags`, Map(id -> boolean)): no detail read is needed. A change
86
+ // counts as a change of the order, so the billing feed reads it again.
87
+ export async function syncRemakeFlags(db, flags, { at }) {
88
+ const ids = [...flags.keys()];
89
+ if (!ids.length) return 0;
90
+ const values = ids.map((id) => flags.get(id) === true);
91
+ const { rowCount } = await db.execute(sql`
92
+ update seazona_orders o set remake = f.remake, last_changed_at = ${at}
93
+ from unnest(${sql.param(ids)}::text[], ${sql.param(values)}::boolean[]) as f(id, remake)
94
+ where o.id = f.id and o.remake is distinct from f.remake`);
95
+ return rowCount ?? 0;
96
+ }
97
+
84
98
  // Issue #12: Seazona answered 404. Keep the row and its history, mark it, and
85
99
  // refresh its sync time so it never comes first in the rotation again. An
86
100
  // order seen in a list but never stored is kept from that list row.
@@ -17,6 +17,7 @@ import {
17
17
  readOrderFailures,
18
18
  readQuarantinedRecords,
19
19
  recordRecordFailure,
20
+ syncRemakeFlags,
20
21
  touchSynced,
21
22
  upsertRecords,
22
23
  } from './store.js';
@@ -216,6 +217,8 @@ async function sweepOrderDay(ctx, day, progress, helpers, found) {
216
217
  }
217
218
  }
218
219
  await touchSynced(db, 'orders', unchanged, { at });
220
+ // The remake flag is on the list row: kept with no detail read.
221
+ await syncRemakeFlags(db, new Map(unchanged.map((id) => [id, listed.get(id)?.isRemake === true])), { at });
219
222
 
220
223
  // Held for this day, left out of the list, and not seen this cycle.
221
224
  for (const row of held) {
@@ -1,6 +1,6 @@
1
1
  import * as SentryNode from '@sentry/node';
2
2
  import { isReplitWorkspace } from '../../core/host.js';
3
- import { scrubSpan } from '../../ui/monitoring.js';
3
+ import { scrubEvent, scrubSpan } from '../../ui/monitoring.js';
4
4
 
5
5
  // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
6
6
  // browser's errors and problem reports, and performance traces from both
@@ -48,6 +48,7 @@ export const SENTRY_SETUP = {
48
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', advanced: true, help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
49
49
  { name: 'SENTRY_CLIENT', label: 'Client', advanced: true, help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
50
50
  { name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', default: 'on', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
51
+ { name: 'SENTRY_SCREENSHOT', label: 'Screenshot', kind: 'onoff', default: 'off', help: 'On: a report can add a screenshot. A screenshot shows the screen as it is, with names and prices, so it is off by default.' },
51
52
  { name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', default: 'on', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
52
53
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', advanced: true, help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
53
54
  ],
@@ -61,6 +62,7 @@ export function readSentrySettings(env) {
61
62
  // labelled apart so they never read as the live app's.
62
63
  const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
63
64
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
65
+ const screenshot = String(env.SENTRY_SCREENSHOT ?? 'off').trim() === 'on';
64
66
  const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
65
67
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
66
68
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
@@ -72,7 +74,7 @@ export function readSentrySettings(env) {
72
74
  // Every event says which product and, when set, which client it is from,
73
75
  // so one support desk can serve many installations.
74
76
  const tags = client ? { product: 'flow', client } : { product: 'flow' };
75
- return { dsn, environment, feedback, replay, tracesSampleRate, tags };
77
+ return { dsn, environment, feedback, screenshot, replay, tracesSampleRate, tags };
76
78
  }
77
79
 
78
80
  // No trace for the host's health check: it is not a person waiting.
@@ -105,8 +107,10 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
105
107
  environment: settings.environment,
106
108
  release: release ?? undefined,
107
109
  initialScope: { tags: settings.tags },
108
- // Flow sends what it logs; no request bodies, cookies or IP addresses.
110
+ // Flow sends what it logs, with private words masked (scrubEvent);
111
+ // no request bodies, cookies or IP addresses.
109
112
  sendDefaultPii: false,
113
+ beforeSend: scrubEvent,
110
114
  // A request the browser traced keeps the browser's choice, so a
111
115
  // click and the server work it caused stay one trace.
112
116
  tracesSampler: ({ attributes, inheritOrSampleWith }) => (
@@ -139,12 +143,13 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
139
143
  // meant for browsers.
140
144
  browserMonitoring() {
141
145
  if (!settings) return null;
142
- const { tracesSampleRate, replay } = settings;
146
+ const { tracesSampleRate, replay, screenshot } = settings;
143
147
  return {
144
148
  dsn: settings.dsn,
145
149
  environment: settings.environment,
146
150
  release,
147
151
  feedback: settings.feedback,
152
+ screenshot,
148
153
  replay,
149
154
  tags: settings.tags,
150
155
  tracesSampleRate,
@@ -223,6 +223,46 @@ export async function itemsByCode(db, organisationId, codes) {
223
223
  return new Map(rows.map((row) => [row.code, itemRow(row)]));
224
224
  }
225
225
 
226
+ // The Xero item each Flow price code sends as. A Flow item linked to a Xero
227
+ // item (the price list's link, by Xero's ItemID, so a code changed in Xero
228
+ // still finds it) sends as that item, under Xero's own code. A link whose
229
+ // Xero item has left Xero's list is `linkMissing`: never fall back to the
230
+ // Flow code then. A Flow item with no link sends as the Xero item with the
231
+ // same code. Codes with neither are absent from the map.
232
+ export async function xeroItemsForCodes(db, organisationId, codes) {
233
+ const wanted = [...new Set(codes.filter(Boolean))];
234
+ if (!wanted.length) return new Map();
235
+ const { rows } = await db.execute(sql`
236
+ select p.code as flow_code, l.external_id as link_id, x.*
237
+ from unnest(${sql.param(wanted)}::text[]) as w(code)
238
+ left join price_items p on p.organisation_id = ${organisationId} and p.code = w.code
239
+ left join lateral (
240
+ select external_id from price_item_links pl
241
+ where pl.organisation_id = ${organisationId} and pl.price_item_id = p.id and pl.system = 'xero'
242
+ order by pl.created_at desc limit 1) l on true
243
+ left join xero_items x on x.organisation_id = ${organisationId}
244
+ and (case when l.external_id is not null then x.xero_item_id = l.external_id else x.code = w.code end)`);
245
+ const found = new Map();
246
+ for (const row of rows) {
247
+ const code = row.flow_code;
248
+ if (!code) continue;
249
+ if (row.link_id && !row.xero_item_id) found.set(code, { linkMissing: true, xeroItemId: row.link_id });
250
+ else if (row.xero_item_id) found.set(code, { ...itemRow(row), linked: Boolean(row.link_id) });
251
+ }
252
+ // A code with no Flow price item (an older document) still matches by code.
253
+ const rest = wanted.filter((c) => !found.has(c));
254
+ if (rest.length) for (const [code, item] of await itemsByCode(db, organisationId, rest)) found.set(code, item);
255
+ return found;
256
+ }
257
+
258
+ // The Flow codes that send as a Xero item that sells: the rule field "Every
259
+ // item is in accounting" (invoice-rules.js) reads them. A gone link, a
260
+ // missing item and an item not set to be sold do not count.
261
+ export async function sellingCodes(db, organisationId, codes) {
262
+ const items = await xeroItemsForCodes(db, organisationId, codes);
263
+ return [...items].filter(([, item]) => !item.linkMissing && item.isSold !== false).map(([code]) => code);
264
+ }
265
+
226
266
  // The Xero contact for a Flow contact name: exactly one active contact with
227
267
  // the same name, or why not. A missing name can be created from the Flow
228
268
  // contact when that setting is on (create-contact.js). A doubled name is
@@ -5,7 +5,7 @@ import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
5
5
  import { sendModeFor } from '../../core/invoicing-rules.js';
6
6
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
7
7
  import { readConnection } from './connections.js';
8
- import { itemsByCode, lastSuccessfulSyncAt, matchContactFor, normaliseName } from './catalogue.js';
8
+ import { lastSuccessfulSyncAt, xeroItemsForCodes, matchContactFor, normaliseName } from './catalogue.js';
9
9
  import { writesAllowed } from './settings.js';
10
10
  import { xeroError } from './client.js';
11
11
  import { withXeroLane } from './governor.js';
@@ -162,8 +162,19 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
162
162
  TaxType: settings.taxTypes[line.taxCode],
163
163
  };
164
164
  if (line.itemCode) {
165
- if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
166
- else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} not in Xero` }));
165
+ // The Xero item this Flow code sends as (catalogue.js, xeroItemsForCodes):
166
+ // the linked item under Xero's own code, else the same code.
167
+ const item = items.get(line.itemCode);
168
+ if (item?.linkMissing) {
169
+ problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is linked to a Xero item that is no longer in Xero. Link it again in Price list, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} link gone` }));
170
+ } else if (!item) {
171
+ problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} not in Xero` }));
172
+ } else if (item.isSold === false) {
173
+ problems.push(problem('xero_item_missing', `Line ${line.position}: Xero item ${item.code || line.itemCode} is not set to be sold. Turn on "I sell this item" in Xero, then sync.`, { itemCode: line.itemCode, short: `${item.code || line.itemCode} not sold in Xero` }));
174
+ } else {
175
+ out.ItemCode = item.code || line.itemCode;
176
+ if (settings.lineText === 'item' && item.name) out.Description = item.name;
177
+ }
167
178
  } else if (line.accountCode) {
168
179
  // A line with no item books to its own account, such as a surcharge.
169
180
  out.AccountCode = line.accountCode;
@@ -324,7 +335,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
324
335
  const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
325
336
  if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
326
337
  if (match.state === 'ambiguous') problems.push(twiceContact(document.contact.name));
327
- const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
338
+ const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
328
339
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
329
340
  const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
330
341
  problems.push(...lineProblems);
@@ -509,7 +520,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
509
520
  const found = match.state === 'missing' ? missingContact(document.contact.name, { offerCreate }) : twiceContact(document.contact.name);
510
521
  throw xeroError(found.code, found.message, { status: 409, short: found.short });
511
522
  }
512
- const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
523
+ const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
513
524
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
514
525
  const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
515
526
  if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409, short: problems[0].short });
@@ -7,6 +7,7 @@ import { deleteXeroInvoice } from './delete-invoice.js';
7
7
  import { createCustomer } from './create-contact.js';
8
8
  import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
9
9
  import { readConnection } from './connections.js';
10
+ import { lastSuccessfulSyncAt, sellingCodes } from './catalogue.js';
10
11
  import { createImportWorker } from './import.js';
11
12
  import { readPayments, xeroLiveCheck } from './ledger.js';
12
13
  import { findInvoices, listCustomers, listPriceItems, mirrorState } from './lookup.js';
@@ -166,6 +167,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
166
167
  createCustomer: ({ organisationId, contactId, actor = null }) => createCustomer(database, current(), { organisationId, contactId, actor }),
167
168
  // Sales invoices from the mirror, with the mirror's state (lookup.js).
168
169
  findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
170
+ // The Flow codes that send as a Xero item that sells (catalogue.js),
171
+ // for the invoice rule field "Every item is in accounting". Null while
172
+ // not connected or before the first item sync.
173
+ itemMatches: async ({ organisationId, codes = [] }) => {
174
+ const connection = await readConnection(database, organisationId);
175
+ if (!connection || connection.mode !== current().settings.mode) return null;
176
+ if (!(await lastSuccessfulSyncAt(database, organisationId))) return null;
177
+ return sellingCodes(database, organisationId, codes);
178
+ },
169
179
  // Xero items, from Flow's copy, for the price list (lookup.js).
170
180
  listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
171
181
  },
@@ -107,7 +107,8 @@ function invoiceRow(r) {
107
107
  }
108
108
 
109
109
  // Sales invoices from the mirror, by any of: Xero ids, invoice numbers, the
110
- // Flow invoices Flow sent, or one customer within a date range. Answers the
110
+ // Flow invoices Flow sent or the Xero import linked, or one customer within a
111
+ // date range. Answers the
111
112
  // mirror's state with them, and the latest change time it holds.
112
113
  export async function findInvoices(db, organisationId, query = {}) {
113
114
  const { invoiceIds = [], numbers = [], references = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
@@ -127,9 +128,15 @@ export async function findInvoices(db, organisationId, query = {}) {
127
128
  select i.*, p.invoice_id as flow_invoice_id
128
129
  from xero_invoices i
129
130
  left join lateral (
130
- select invoice_id from xero_invoice_pushes
131
- where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
132
- order by version desc limit 1
131
+ -- The Flow invoice this Xero invoice is: the one Flow sent it from,
132
+ -- else the one the Xero import linked or made from it.
133
+ select invoice_id from (
134
+ select invoice_id, 0 as pick, version from xero_invoice_pushes
135
+ where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
136
+ union all
137
+ select invoice_id, 1, 0 from xero_invoice_links
138
+ where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
139
+ ) f order by pick, version desc limit 1
133
140
  ) p on true
134
141
  where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
135
142
  and (${sql.join(where, sql` or `)})
@@ -163,6 +163,8 @@ export function resolveXeroSettings(env = process.env) {
163
163
 
164
164
  const invoiceNumber = (readEnv(env, 'XERO_INVOICE_NUMBER') || 'xero').toLowerCase();
165
165
  if (!['xero', 'flow'].includes(invoiceNumber)) throw new Error('XERO_INVOICE_NUMBER must be "xero", "flow" or unset');
166
+ const lineText = (readEnv(env, 'XERO_LINE_TEXT') || 'flow').toLowerCase();
167
+ if (!['flow', 'item'].includes(lineText)) throw new Error('XERO_LINE_TEXT must be "flow", "item" or unset');
166
168
 
167
169
  return {
168
170
  mode,
@@ -184,6 +186,9 @@ export function resolveXeroSettings(env = process.env) {
184
186
  tracking: resolveTracking(env),
185
187
  defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
186
188
  invoiceNumber,
189
+ // The text on each Xero line with an item: `flow` (default) the Flow
190
+ // line's own text; `item` the Xero item's name.
191
+ lineText,
187
192
  replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
188
193
  // Free text on Xero records (references, line descriptions) is the lab's
189
194
  // own data, and the Import matches on it. On unless the lab turns it off.
@@ -126,6 +126,62 @@ export function scrubRecordingEvent(event) {
126
126
  return { ...event, data: { ...event.data, payload: next } };
127
127
  }
128
128
 
129
+ // Private data in an event's words. Flow's own lines name a step and a code;
130
+ // the text they pass on from the database, Xero or Seazona can hold a name,
131
+ // an email, an amount or a record id. Each becomes a placeholder, so the event
132
+ // keeps the fault and drops the data, and the same fault with other records
133
+ // is one issue in Sentry, not one issue per record.
134
+ const TEXT_MASKS = [
135
+ [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '<email>'],
136
+ [/\b\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?\b/g, '<time>'],
137
+ [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>'],
138
+ // A database's "Key (column)=(value)".
139
+ [/\(([^()\n]{1,200})\)=\(([^()\n]{0,200})\)/g, '(<field>)=(<value>)'],
140
+ // Quoted text: a contact, a reference, a file name. An apostrophe inside a
141
+ // word ("Flow's") is not a quote.
142
+ [/"[^"\n]{1,200}"|“[^”\n]{1,200}”|‘[^’\n]{1,200}’|(?<![\w'])'[^'\n]{1,200}'(?![\w'])/g, '<text>'],
143
+ [/[$£€]\s?\d[\d,]*(\.\d+)?|\b\d[\d,]*\.\d{2}\b/g, '<amount>'],
144
+ [/\b[A-Z]{2,6}-\d{2,}\b/g, '<ref>'],
145
+ [/\+?\d[\d ().-]{6,}\d/g, '<number>'],
146
+ [/\b\d{3,}\b/g, '<n>'],
147
+ ];
148
+ // Only the address-like words: for what a person types in a problem report,
149
+ // where a number can be the point of the report.
150
+ const CONTACT_MASKS = TEXT_MASKS.slice(0, 1).concat([[/\+?\d[\d ()-]{8,}\d/g, '<number>']]);
151
+ export function maskText(value, masks = TEXT_MASKS) {
152
+ if (typeof value !== 'string') return value;
153
+ return masks.reduce((text, [pattern, mark]) => text.replace(pattern, mark), value);
154
+ }
155
+
156
+ // Every event before it leaves Flow, on the server and in the browser
157
+ // (beforeSend). The words are masked (maskText); a request keeps its path,
158
+ // never its body, query string, cookies or headers; the person is their Flow
159
+ // id only. A problem report keeps what the person typed, with addresses and
160
+ // phone numbers masked, and keeps their email only when they asked for a
161
+ // response (notify=yes, tagNotify).
162
+ export function scrubEvent(event) {
163
+ if (!event || typeof event !== 'object') return event;
164
+ const report = event.type === 'feedback';
165
+ if (!report) {
166
+ event.message = maskText(event.message);
167
+ if (event.logentry) event.logentry = { ...event.logentry, message: maskText(event.logentry.message), formatted: maskText(event.logentry.formatted), params: undefined };
168
+ for (const value of event.exception?.values ?? []) value.value = maskText(value.value);
169
+ if (event.extra) event.extra = Object.fromEntries(Object.entries(event.extra).map(([key, value]) => [key, maskText(typeof value === 'string' ? value : '<data>')]));
170
+ }
171
+ if (Array.isArray(event.breadcrumbs)) {
172
+ event.breadcrumbs = event.breadcrumbs.map(({ data, message, ...crumb }) => ({ ...crumb, ...(message ? { message: maskText(message) } : {}), ...(data?.url ? { data: { url: withoutQuery(data.url), method: data.method, status_code: data.status_code } } : {}) }));
173
+ }
174
+ if (event.request) event.request = { method: event.request.method, url: withoutQuery(event.request.url) };
175
+ if (event.user) event.user = event.user.id == null ? {} : { id: event.user.id };
176
+ const feedback = event.contexts?.feedback;
177
+ if (feedback) {
178
+ const keepContact = event.tags?.notify === 'yes';
179
+ event.contexts.feedback = { ...feedback, message: maskText(feedback.message, CONTACT_MASKS), name: undefined, contact_email: keepContact ? feedback.contact_email : undefined };
180
+ if (keepContact && feedback.contact_email) event.user = { ...event.user, email: feedback.contact_email };
181
+ }
182
+ return event;
183
+ }
184
+
129
185
  /** Resolves to a function that opens the "Report a problem" form, or null. */
130
186
  export async function startMonitoring({ flow, person }) {
131
187
  const config = await flow('/api/monitoring');
@@ -142,7 +198,9 @@ export async function startMonitoring({ flow, person }) {
142
198
  showName: false,
143
199
  // The form fills in the signed-in person's name and email (setUser).
144
200
  useSentryUser: { name: 'username', email: 'email' },
145
- enableScreenshot: true,
201
+ // A screenshot is the screen as it is, names and all: off unless the
202
+ // installation turns it on (SENTRY_SCREENSHOT).
203
+ enableScreenshot: Boolean(config.screenshot),
146
204
  triggerLabel: 'Report a problem',
147
205
  formTitle: 'Report a problem',
148
206
  messageLabel: 'What went wrong?',
@@ -180,13 +238,15 @@ export async function startMonitoring({ flow, person }) {
180
238
  // Replay: never a whole visit, only the minute before a report or error.
181
239
  replaysSessionSampleRate: 0,
182
240
  replaysOnErrorSampleRate: config.replay ? 1 : 0,
241
+ beforeSend: scrubEvent,
183
242
  beforeSendSpan: scrubSpan,
184
243
  initialScope: { tags: config.tags ?? {} },
185
244
  });
186
245
  // Read when the report is sent: the tick box of the form open now.
187
246
  let wantsResponse = false;
188
- Sentry.getClient()?.on('beforeSendFeedback', (event) => tagNotify(event, wantsResponse));
189
- // Who reported or met the error, so the lab can follow up.
247
+ Sentry.getClient()?.on('beforeSendFeedback', (event) => { tagNotify(event, wantsResponse); scrubEvent(event); });
248
+ // Fills in the report form. Events keep only the Flow id (scrubEvent); a
249
+ // report keeps the email only when the person asks for a response.
190
250
  Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
191
251
  const feedback = config.feedback ? Sentry.getFeedback() : null;
192
252
  if (!feedback) return null;
@@ -245,8 +245,11 @@ export async function renderPriceList(main, ui) {
245
245
  type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
246
246
  }, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
247
247
 
248
+ // A list Flow could not read: its differences are not shown.
249
+ const unread = (data.unread ?? []).length
250
+ ? notice(`${data.unread.map((u) => u.name).join(' and ')} list not read, so its differences are not shown. Flow reads it again at its next sync, in a few minutes: reload this page then. If it stays, open Integrations.`, 'warn') : null;
248
251
  if (!shown.length) {
249
- host.replaceChildren(h('p', { class: 'fx-empty' }, words || view.groups.length ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet.'));
252
+ host.replaceChildren(...[unread, h('p', { class: 'fx-empty' }, words || view.groups.length ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet.')].filter(Boolean));
250
253
  } else {
251
254
  const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
252
255
  const itemRow = (item) => h('tr', { class: 'fx-row-link', tabindex: '0', onclick: () => edit(item), onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
@@ -260,7 +263,7 @@ export async function renderPriceList(main, ui) {
260
263
  open: !folded.has(label), onFold: () => { if (folded.has(label)) folded.delete(label); else folded.add(label); draw(); },
261
264
  span: columns.length + 1,
262
265
  });
263
- host.replaceChildren(heads.table({
266
+ host.replaceChildren(...[unread].filter(Boolean), heads.table({
264
267
  className: 'fx-table--rows fx-price__table', columns,
265
268
  rows: groups.map((g) => h('tbody', {}, g.label == null ? null : head(g), g.label != null && folded.has(g.label) ? null : g.jobs.map(itemRow))),
266
269
  }));
@@ -54,6 +54,7 @@ for sends, voids and deletes get the person's yes in the chat.
54
54
  | Add a rule | `flow.sh post invoicing/rules '{"name":"...","conditions":[{"field":"job_total","test":"at_most","value":0}],"action":{"kind":"wait"}}'` |
55
55
  | Change or turn off a rule | `flow.sh put invoicing/rules/<id> '{"enabled":false}'` |
56
56
  | Delete a rule | `flow.sh delete invoicing/rules/<id>` |
57
+ | Remakes are never billed | `flow.sh post invoicing/rules '{"name":"Remake","conditions":[{"field":"remake","test":"is","value":true}],"action":{"kind":"non_chargeable"}}'` |
57
58
  | Rule that offers an item (shipping) | `flow.sh post invoicing/rules '{"name":"Shipping","conditions":[{"field":"line_item","test":"has_not","value":"S/H"}],"action":{"kind":"pending","note":"Add shipping, or skip with a reason.","addItem":"S/H"}}'` |
58
59
  | Add the rule's item to a draft | `flow.sh post invoicing/invoices/<id>/rules/<ruleId>/add-item '{}'` |
59
60
  | Skip a rule for one job | on its draft: `flow.sh post invoicing/invoices/<id>/rules/<ruleId>/exception '{"reason":"..."}'`; no draft yet: `flow.sh post invoicing/jobs/<jobId>/rules/<ruleId>/exception '{"reason":"..."}'` |
@@ -70,6 +71,10 @@ the person's reason for a skip. An exception ends when the job's lines change
70
71
  (`current: false` in the rule's exceptions list): ask the person again. The work system must get the item too: tell
71
72
  the person which jobs show "needs <item>" on the job board.
72
73
 
74
+ A matching `non_chargeable` rule wins over wait rules, and it releases old
75
+ held history jobs that have no accounting invoice. Settle reconciliation work
76
+ (remakes, own clinics) with rules, never with one-off clean-ups.
77
+
73
78
  Keep the rules few and clear. When you work with rules, read all of them
74
79
  (`flow.sh get invoicing/rules`) and look for rules that do the same job, that
75
80
  overlap, or that no longer match anything. Show the person a short list: which
@@ -139,8 +144,33 @@ How to read `price-list`:
139
144
  - `items`: each Flow code with its `links` (the linked Xero item or Seazona
140
145
  product, as `record`) and `findings`. A finding `kind` is `price` (Flow and
141
146
  the other system differ: `flow` and `other` in cents), `tax`, `missing` (the
142
- linked record is gone) or `not_linked` (no Xero item for this code).
147
+ linked record is gone), `not_linked` (no Xero item for this code) or `code`
148
+ (the linked Xero item has another code while another Xero item has the
149
+ Flow code: check that the link is the right one).
143
150
  - `unlinked`: Xero items and Seazona products that no Flow code has yet.
151
+ - `unread`: lists Flow could not read now. Their differences are not shown,
152
+ so say "not read", never "no differences", and read again later.
153
+
154
+ How the three lists fit:
155
+
156
+ - Each system keeps its own names and codes. Flow joins them by links, never
157
+ by renaming. Do not ask the person to rename a record only to match.
158
+ - Many Seazona products can link to one Flow code (two names, one price).
159
+ One Flow code has one Xero item at most.
160
+ - A send uses the Xero link (by Xero's id), so a Flow code can send as a
161
+ Xero item with another code. With no link, it sends as the same code.
162
+ - A link to a record that another code had moves it. The answer has `moved`
163
+ (`price-links`) or `movedFrom` (one link): tell the person which code
164
+ lost it.
165
+ - The rule field `accounting_item` ("Every item is in accounting") holds a job
166
+ with a line whose code does not send as a Xero item that sells. The reason
167
+ names the codes. A lab that must never invoice an item Xero does not have
168
+ keeps one such rule (Then wait, or pending with a note).
169
+
170
+ Before you call a difference a new price or a new product, check the mapping:
171
+ a wrong line mapping, a wrong link or a rule is the usual cause. Prices and
172
+ products change seldom. Read the line's `line-labels` row, its mapping and
173
+ the code's links first, and say which one is wrong.
144
174
 
145
175
  Reconcile steps:
146
176