@arcforgelabs/flow 2026.11.5 → 2026.11.7
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +5 -2
- package/server/app.js +1 -1
- package/server/core/automations.js +4 -1
- package/server/core/invoice-rules.js +64 -15
- package/server/core/invoicing-rules.js +50 -6
- package/server/core/job-board.js +3 -2
- package/server/core/ledger.js +10 -2
- package/server/core/pipeline.js +5 -2
- package/server/core/price-list.js +41 -12
- package/server/core/sending.js +3 -2
- package/server/core/sync-now.js +38 -0
- package/server/db/migrations/0029_job_remake.sql +1 -0
- package/server/db/migrations/meta/0029_snapshot.json +5069 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/feed.js +1 -0
- package/server/integrations/seazona/history/index.js +5 -2
- package/server/integrations/seazona/index.js +44 -0
- package/server/integrations/seazona/lanes.js +3 -0
- package/server/integrations/seazona/ledger.js +1 -0
- package/server/integrations/seazona/map.js +2 -0
- package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
- package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/scheduler.js +13 -2
- package/server/integrations/seazona/schema.js +2 -0
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/seazona/store.js +14 -0
- package/server/integrations/seazona/sweep.js +13 -3
- package/server/integrations/sentry/index.js +9 -4
- package/server/integrations/setup.js +21 -1
- package/server/integrations/xero/catalogue.js +40 -0
- package/server/integrations/xero/drafts.js +16 -5
- package/server/integrations/xero/index.js +35 -0
- package/server/integrations/xero/lookup.js +11 -4
- package/server/integrations/xero/settings.js +8 -0
- package/server/integrations/xero/webhooks.js +4 -3
- package/server/ui/app.js +64 -3
- package/server/ui/flow.css +2 -1
- package/server/ui/kit.js +3 -2
- package/server/ui/monitoring.js +63 -3
- package/server/ui/price-list.js +5 -2
- package/skills/flow/SKILL.md +31 -1
package/server/ui/monitoring.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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;
|
package/server/ui/price-list.js
CHANGED
|
@@ -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
|
}));
|
package/skills/flow/SKILL.md
CHANGED
|
@@ -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)
|
|
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
|
|