@arcforgelabs/flow 2026.10.38 → 2026.10.39
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 +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/ui/app.js +35 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CUSTOM_FIELD_TYPES, integrationSettingEvents, integrationSettings, jobFieldEvents, jobFields, jobFieldSettings, jobFieldValues, people,
|
|
5
|
+
} from '../db/schema.js';
|
|
4
6
|
import { FlowError, notFound } from './errors.js';
|
|
5
7
|
|
|
6
8
|
// Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
|
|
@@ -14,6 +16,14 @@ export { CUSTOM_FIELD_TYPES };
|
|
|
14
16
|
// person, or yes and no.
|
|
15
17
|
export const GROUPABLE_TYPES = ['dropdown', 'person', 'checkbox'];
|
|
16
18
|
export const OPTION_COLORS = ['gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'];
|
|
19
|
+
// Each type group's own colour, until the lab picks another.
|
|
20
|
+
export const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
|
|
21
|
+
|
|
22
|
+
function readColor(raw, what) {
|
|
23
|
+
if (raw === null) return null;
|
|
24
|
+
if (!OPTION_COLORS.includes(raw)) throw invalid(`${what} must be one of ${OPTION_COLORS.join(', ')}`);
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
const MAX_FIELDS = 50;
|
|
19
29
|
const MAX_OPTIONS = 50;
|
|
@@ -74,6 +84,7 @@ export function readField(raw, { partial = false, previous = null } = {}) {
|
|
|
74
84
|
} else if (input.type !== undefined && input.type !== previous?.type) {
|
|
75
85
|
throw invalid('the type of a field cannot change; make a new field instead');
|
|
76
86
|
}
|
|
87
|
+
if (input.color !== undefined) out.color = readColor(input.color, 'color');
|
|
77
88
|
const type = out.type ?? previous?.type;
|
|
78
89
|
if (type === 'dropdown' && (!partial || input.options !== undefined)) out.options = readOptions(input.options, previous?.options ?? []);
|
|
79
90
|
else if (type !== 'dropdown' && input.options !== undefined && input.options !== null && !(Array.isArray(input.options) && !input.options.length)) {
|
|
@@ -87,6 +98,7 @@ export const publicField = (row) => ({
|
|
|
87
98
|
name: row.name,
|
|
88
99
|
type: row.type,
|
|
89
100
|
options: row.options ?? [],
|
|
101
|
+
color: row.color ?? null,
|
|
90
102
|
position: row.position,
|
|
91
103
|
groupable: GROUPABLE_TYPES.includes(row.type),
|
|
92
104
|
archived: Boolean(row.archivedAt),
|
|
@@ -95,11 +107,15 @@ export const publicField = (row) => ({
|
|
|
95
107
|
updatedAt: row.updatedAt?.toISOString?.() ?? row.updatedAt,
|
|
96
108
|
});
|
|
97
109
|
|
|
110
|
+
// `valueCount` is how many jobs have a value, so Delete can say what goes.
|
|
98
111
|
export async function listFields(db, organisationId, { archived = false } = {}) {
|
|
99
112
|
const where = [eq(jobFields.organisationId, organisationId)];
|
|
100
113
|
if (!archived) where.push(isNull(jobFields.archivedAt));
|
|
101
|
-
const rows = await db.select(
|
|
102
|
-
|
|
114
|
+
const rows = await db.select({
|
|
115
|
+
field: jobFields,
|
|
116
|
+
valueCount: sql`(select count(*) from ${jobFieldValues} v where v.organisation_id = ${jobFields.organisationId} and v.field_id = ${jobFields.id})`.mapWith(Number),
|
|
117
|
+
}).from(jobFields).where(and(...where)).orderBy(asc(jobFields.position), asc(jobFields.createdAt));
|
|
118
|
+
return rows.map(({ field, valueCount }) => ({ ...publicField(field), valueCount }));
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
async function readRow(db, organisationId, id) {
|
|
@@ -135,11 +151,20 @@ export async function createField(db, organisationId, raw, { personId = null } =
|
|
|
135
151
|
|
|
136
152
|
// Every lab gets an Assignee: a person field, made once, the first time
|
|
137
153
|
// the lab's fields are read and it has no person field at all (live or
|
|
138
|
-
// archived). The lab can then rename
|
|
139
|
-
// Flow does not make it again.
|
|
140
|
-
//
|
|
154
|
+
// archived). The lab can then rename, archive or delete it like any field,
|
|
155
|
+
// and Flow does not make it again: job_field_settings.assignee_made_at
|
|
156
|
+
// records that it was made (or that the lab already had a person field).
|
|
157
|
+
// A field already named Assignee of another type is left alone.
|
|
141
158
|
export const ASSIGNEE = { name: 'Assignee', type: 'person' };
|
|
142
159
|
export async function ensureAssignee(db, organisationId) {
|
|
160
|
+
const [settings] = await db.select({ at: jobFieldSettings.assigneeMadeAt }).from(jobFieldSettings)
|
|
161
|
+
.where(eq(jobFieldSettings.organisationId, organisationId));
|
|
162
|
+
if (settings?.at) return null;
|
|
163
|
+
// Only the request that marks it makes it.
|
|
164
|
+
const [marked] = await db.insert(jobFieldSettings).values({ organisationId, assigneeMadeAt: new Date() })
|
|
165
|
+
.onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { assigneeMadeAt: new Date() }, setWhere: isNull(jobFieldSettings.assigneeMadeAt) })
|
|
166
|
+
.returning({ organisationId: jobFieldSettings.organisationId });
|
|
167
|
+
if (!marked) return null;
|
|
143
168
|
const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFields)
|
|
144
169
|
.where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.type, 'person')));
|
|
145
170
|
if (count > 0) return null;
|
|
@@ -151,6 +176,39 @@ export async function ensureAssignee(db, organisationId) {
|
|
|
151
176
|
}
|
|
152
177
|
}
|
|
153
178
|
|
|
179
|
+
// Each type group's colour: TYPE_COLORS, with the lab's own choices on top.
|
|
180
|
+
export async function readGroupColors(db, organisationId) {
|
|
181
|
+
const [row] = await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
|
|
182
|
+
.where(eq(jobFieldSettings.organisationId, organisationId));
|
|
183
|
+
const own = row?.colors ?? {};
|
|
184
|
+
return Object.fromEntries(CUSTOM_FIELD_TYPES.map((t) => [t, OPTION_COLORS.includes(own[t]) ? own[t] : TYPE_COLORS[t]]));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Give one type group a colour. null puts back its own colour.
|
|
188
|
+
export async function setGroupColor(db, organisationId, type, raw, { personId = null } = {}) {
|
|
189
|
+
if (!CUSTOM_FIELD_TYPES.includes(type)) throw notFound('group');
|
|
190
|
+
const color = raw === undefined ? undefined : readColor(raw, 'color');
|
|
191
|
+
if (color === undefined) throw invalid('color is required (null puts back the group\'s own colour)');
|
|
192
|
+
const own = { ...(await db.select({ colors: jobFieldSettings.groupColors }).from(jobFieldSettings)
|
|
193
|
+
.where(eq(jobFieldSettings.organisationId, organisationId)))[0]?.colors };
|
|
194
|
+
if (color === null || color === TYPE_COLORS[type]) delete own[type]; else own[type] = color;
|
|
195
|
+
await db.insert(jobFieldSettings).values({ organisationId, groupColors: own, updatedBy: personId })
|
|
196
|
+
.onConflictDoUpdate({ target: jobFieldSettings.organisationId, set: { groupColors: own, updatedBy: personId, updatedAt: new Date() } });
|
|
197
|
+
return readGroupColors(db, organisationId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// When an integration was connected: the first time its settings were saved
|
|
201
|
+
// or it was turned on in Flow, or, for one set up in the host's own
|
|
202
|
+
// settings, the first time Flow saw one of its records (`seenSince`).
|
|
203
|
+
export async function integrationConnectedAt(db, adapterId, seenSince = null) {
|
|
204
|
+
const [[saved], [event]] = await Promise.all([
|
|
205
|
+
db.select({ at: sql`min(${integrationSettings.createdAt})` }).from(integrationSettings).where(eq(integrationSettings.adapterId, adapterId)),
|
|
206
|
+
db.select({ at: sql`min(${integrationSettingEvents.createdAt})` }).from(integrationSettingEvents).where(eq(integrationSettingEvents.adapterId, adapterId)),
|
|
207
|
+
]);
|
|
208
|
+
const times = [saved?.at, event?.at, seenSince].filter(Boolean).map((t) => new Date(t)).filter((t) => !Number.isNaN(t.getTime()));
|
|
209
|
+
return times.length ? new Date(Math.min(...times)).toISOString() : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
export async function updateField(db, organisationId, id, raw, { personId = null } = {}) {
|
|
155
213
|
const previous = await readRow(db, organisationId, id);
|
|
156
214
|
if (previous.archivedAt) throw new FlowError('conflict', 'the field is archived; bring it back first');
|
|
@@ -171,6 +229,16 @@ export async function archiveField(db, organisationId, id, { archived = true, pe
|
|
|
171
229
|
return publicField(row);
|
|
172
230
|
}
|
|
173
231
|
|
|
232
|
+
// Delete a field for good, with every job's value and its history (the
|
|
233
|
+
// tables cascade). Archive is the step that keeps them.
|
|
234
|
+
export async function deleteField(db, organisationId, id) {
|
|
235
|
+
const field = await readRow(db, organisationId, id);
|
|
236
|
+
const [{ count }] = await db.select({ count: sql`count(*)`.mapWith(Number) }).from(jobFieldValues)
|
|
237
|
+
.where(and(eq(jobFieldValues.organisationId, organisationId), eq(jobFieldValues.fieldId, field.id)));
|
|
238
|
+
await db.delete(jobFields).where(and(eq(jobFields.organisationId, organisationId), eq(jobFields.id, field.id)));
|
|
239
|
+
return { id: field.id, name: field.name, valuesDeleted: count };
|
|
240
|
+
}
|
|
241
|
+
|
|
174
242
|
// Put the fields in this order. `ids` lists every field that is not archived.
|
|
175
243
|
export async function orderFields(db, organisationId, ids, { personId = null } = {}) {
|
|
176
244
|
if (!Array.isArray(ids) || ids.some((i) => typeof i !== 'string')) throw invalid('ids must be a list of field ids');
|
package/server/core/ledger.js
CHANGED
|
@@ -305,20 +305,21 @@ function changedReview(obligation, compare) {
|
|
|
305
305
|
|
|
306
306
|
// "Use new lines": a person takes the work system's new lines (and its
|
|
307
307
|
// customer, when that changed) into Flow's own unsent draft. Flow writes only
|
|
308
|
-
// its own draft, never another system. Refused when the
|
|
309
|
-
//
|
|
310
|
-
|
|
308
|
+
// its own draft, never another system. Refused when the invoice has left
|
|
309
|
+
// Flow. When the draft changed too, `accept` (a person's "Accept change")
|
|
310
|
+
// takes the work system's value over the draft's; without it, refused.
|
|
311
|
+
export async function useSourceLines(db, organisationId, obligationId, { actor, accept = false }) {
|
|
311
312
|
requireActor(actor);
|
|
312
313
|
return db.transaction(async (tx) => {
|
|
313
314
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
314
315
|
if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
|
|
315
316
|
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
316
|
-
if (compare.outcome === 'conflict') {
|
|
317
|
-
throw new FlowError('conflict', compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
317
|
+
if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
|
|
318
|
+
throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
318
319
|
}
|
|
319
320
|
const changes = {};
|
|
320
321
|
for (const { field, changedIn } of compare.fields) {
|
|
321
|
-
if (changedIn !== 'work') continue;
|
|
322
|
+
if (changedIn !== 'work' && !(accept && changedIn === 'both')) continue;
|
|
322
323
|
if (field === 'lines') {
|
|
323
324
|
changes.lines = obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
324
325
|
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
@@ -338,12 +339,30 @@ export async function useSourceLines(db, organisationId, obligationId, { actor }
|
|
|
338
339
|
const fields = { draftBasis: basisOf(obligation) };
|
|
339
340
|
if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
|
|
340
341
|
const row = await update(tx, obligation, fields);
|
|
341
|
-
await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents });
|
|
342
|
+
await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents, ...(accept ? { accepted: compare.outcome } : {}) });
|
|
342
343
|
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'used_new_lines', actor });
|
|
343
344
|
return { obligation: row, invoice };
|
|
344
345
|
});
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
// After a person rechecks a job (POST /jobs/:id/recheck): a "job changed"
|
|
349
|
+
// hold whose job now matches the draft again (the work system was fixed, or
|
|
350
|
+
// the draft was) is over. Answers the obligation.
|
|
351
|
+
export async function settleJobChange(db, organisationId, obligationId, { actor }) {
|
|
352
|
+
requireActor(actor);
|
|
353
|
+
return db.transaction(async (tx) => {
|
|
354
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
355
|
+
if (!obligation.invoiceId || obligation.holdState !== 'review' || obligation.holdReason !== CHANGED_REASON) return obligation;
|
|
356
|
+
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
357
|
+
const settled = compare.fields.every(({ changedIn }) => ['none', 'same', 'flow'].includes(changedIn)) && !compare.missing;
|
|
358
|
+
if (!settled) return obligation;
|
|
359
|
+
const row = await update(tx, obligation, { holdState: 'none', holdReason: null, draftBasis: basisOf(obligation) });
|
|
360
|
+
await addEvent(tx, row, 'review_settled', actor, { invoiceId: obligation.invoiceId });
|
|
361
|
+
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'job_matches_invoice', actor });
|
|
362
|
+
return row;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
347
366
|
// What changed in a job after its invoice was made, for the invoice page:
|
|
348
367
|
// each line as it was (the draft basis) and as the work system has it now.
|
|
349
368
|
// Lines pair by price item, else item code, account or description; an
|
|
@@ -411,6 +430,9 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
|
411
430
|
outcome: compare.outcome,
|
|
412
431
|
sent: compare.sent,
|
|
413
432
|
canAdopt: compare.outcome === 'offer',
|
|
433
|
+
// "Accept change": the draft takes the work system's values, even when
|
|
434
|
+
// the draft changed too. Not once the invoice has left Flow.
|
|
435
|
+
canAccept: !compare.sent && !compare.missing,
|
|
414
436
|
lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
|
|
415
437
|
contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
|
|
416
438
|
total: { was: was.totalCents ?? null, now: obligation.totalCents },
|
|
@@ -174,13 +174,13 @@ export async function linkByCode(db, organisationId, catalogue, { items = null,
|
|
|
174
174
|
// A person's link: `externalId` in `system`'s list goes to this price item.
|
|
175
175
|
// For an accounting system it replaces the item's earlier link; a record
|
|
176
176
|
// linked elsewhere moves here.
|
|
177
|
-
|
|
177
|
+
async function writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId }) {
|
|
178
178
|
const item = await loadItem(db, organisationId, priceItemId);
|
|
179
179
|
const catalogue = catalogues.get(system);
|
|
180
180
|
if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
|
|
181
181
|
const record = catalogue.byId.get(String(externalId ?? ''));
|
|
182
182
|
if (!record) throw notFound(`${catalogue.name} record`);
|
|
183
|
-
|
|
183
|
+
return db.transaction(async (tx) => {
|
|
184
184
|
await tx.delete(priceItemLinks).where(and(
|
|
185
185
|
eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
|
|
186
186
|
));
|
|
@@ -194,10 +194,35 @@ export async function linkPriceItem(db, organisationId, priceItemId, { system, e
|
|
|
194
194
|
}).returning();
|
|
195
195
|
return row;
|
|
196
196
|
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function linkPriceItem(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId = null }) {
|
|
200
|
+
const link = await writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId });
|
|
197
201
|
await remapJobs(db, organisationId);
|
|
198
202
|
return link;
|
|
199
203
|
}
|
|
200
204
|
|
|
205
|
+
// Many links at once (Reconcile prices, "Link sure matches"): each one as
|
|
206
|
+
// linkPriceItem makes it, then one remap. A link that fails stops the rest;
|
|
207
|
+
// the ones before it stay.
|
|
208
|
+
export const MAX_LINKS_AT_ONCE = 500;
|
|
209
|
+
export async function linkPriceItems(db, organisationId, links, { catalogues, personId = null }) {
|
|
210
|
+
if (!Array.isArray(links) || !links.length) throw invalid('links must be a list of { priceItemId, system, externalId }');
|
|
211
|
+
if (links.length > MAX_LINKS_AT_ONCE) throw invalid(`${MAX_LINKS_AT_ONCE} links at once at most`);
|
|
212
|
+
const made = [];
|
|
213
|
+
try {
|
|
214
|
+
for (const link of links) {
|
|
215
|
+
if (!link || typeof link !== 'object' || !link.priceItemId || !link.system || !link.externalId) {
|
|
216
|
+
throw invalid('each link needs priceItemId, system and externalId');
|
|
217
|
+
}
|
|
218
|
+
made.push(await writeLink(db, organisationId, link.priceItemId, link, { catalogues, personId }));
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
if (made.length) await remapJobs(db, organisationId);
|
|
222
|
+
}
|
|
223
|
+
return made;
|
|
224
|
+
}
|
|
225
|
+
|
|
201
226
|
export async function unlinkPriceItem(db, organisationId, linkId) {
|
|
202
227
|
const rows = await db.delete(priceItemLinks)
|
|
203
228
|
.where(and(eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.id, linkId))).returning();
|
|
@@ -30,11 +30,12 @@ const NO_LONGER_BILLABLE = 'the work system no longer reports the job as billabl
|
|
|
30
30
|
// Answers Map(obligationId -> { outcome, obligation, reason? }), where
|
|
31
31
|
// outcome is fresh (no read needed), read, review (sent to review) or
|
|
32
32
|
// waiting (the read could not run).
|
|
33
|
-
|
|
33
|
+
// `force` reads even a fresh report (a person's "Recheck").
|
|
34
|
+
export async function readBeforeAct(db, organisationId, obligations, { work, now = new Date(), actor, force = false }) {
|
|
34
35
|
const outcomes = new Map();
|
|
35
36
|
let toJob = null;
|
|
36
37
|
for (const obligation of obligations) {
|
|
37
|
-
if (!needsRead(obligation, { work, now })) {
|
|
38
|
+
if (!needsRead(obligation, { work, now: force ? new Date(8.64e15) : now })) {
|
|
38
39
|
outcomes.set(obligation.id, { outcome: 'fresh', obligation });
|
|
39
40
|
continue;
|
|
40
41
|
}
|
package/server/core/sending.js
CHANGED
|
@@ -3,19 +3,19 @@ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { issueInvoice } from './invoices.js';
|
|
6
|
+
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
6
7
|
import { soleOrganisation } from './pipeline.js';
|
|
7
8
|
import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
8
9
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
+
// Pending, Ready and Send all ready (docs/INVOICE_SENDING.md).
|
|
11
12
|
//
|
|
12
13
|
// Flow checks every draft. It checks its own data first: the draft has lines,
|
|
13
14
|
// a total above zero, and a price item (or, for a line such as a rush
|
|
14
15
|
// charge, an account) on every line. When an accounting
|
|
15
16
|
// system is connected, its own checks are added (for Xero: the customer
|
|
16
17
|
// matches one Xero contact, every item is in Xero). A draft with a problem is
|
|
17
|
-
//
|
|
18
|
-
// it ready; only Move to Drafts (markNotReady) takes it back.
|
|
18
|
+
// Pending (the "problem" view). A draft without one is Ready.
|
|
19
19
|
//
|
|
20
20
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
21
21
|
// accounting system is connected, queues it to go there. The send worker
|
|
@@ -35,19 +35,35 @@ const LEASE_MS = 15 * 60 * 1000;
|
|
|
35
35
|
// Outcomes that mean the accounting system holds the invoice.
|
|
36
36
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// `short` is the few words the Invoices list shows; `message` says how to fix it.
|
|
39
|
+
const problem = (code, message, short = null) => ({ code, message, ...(short ? { short } : {}) });
|
|
39
40
|
|
|
40
41
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
41
42
|
export function draftProblems(invoice, lines) {
|
|
42
43
|
const problems = [];
|
|
43
|
-
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
|
|
44
|
-
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
|
|
44
|
+
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
|
|
45
|
+
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
|
|
45
46
|
for (const line of lines) {
|
|
46
|
-
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item
|
|
47
|
+
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`));
|
|
47
48
|
}
|
|
48
49
|
return problems;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// The few words for a job in review: what changed, for this job.
|
|
53
|
+
export function reviewShort(obligation) {
|
|
54
|
+
if (obligation.holdReason === STATUS_BACK_REASON) return 'Status moved back';
|
|
55
|
+
if (obligation.holdReason !== CHANGED_REASON) return /deleted/.test(obligation.holdReason ?? '') ? 'Job deleted' : 'Job needs a look';
|
|
56
|
+
const basis = obligation.draftBasis;
|
|
57
|
+
if (!basis) return 'Job changed';
|
|
58
|
+
const changes = diffJobLines(basis.lines ?? [], obligation.lines ?? []);
|
|
59
|
+
const words = changes.map((c) => (['added', 'removed'].includes(c.change)
|
|
60
|
+
? `Line ${c.position} ${c.change}`
|
|
61
|
+
: `Line ${c.position} ${{ item: 'item', description: 'text', quantity: 'qty', price: 'price' }[c.fields[0]] ?? 'changed'}`));
|
|
62
|
+
if ((basis.contactId ?? null) !== (obligation.contactId ?? null)) words.unshift('Client changed');
|
|
63
|
+
if (!words.length) return 'Job changed';
|
|
64
|
+
return words.length > 1 ? `${words[0]} +${words.length - 1}` : words[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
// Flow's checks, and the accounting system's, for some drafts. Answers
|
|
52
68
|
// Map(id -> problems) holding only the drafts that have one.
|
|
53
69
|
async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
@@ -63,11 +79,11 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
63
79
|
}
|
|
64
80
|
// A draft whose job the ledger sent to review (the job changed after the
|
|
65
81
|
// draft was made, or the work system deleted it) waits for a person.
|
|
66
|
-
const reviewed = await db.select(
|
|
67
|
-
.from(billingObligations)
|
|
82
|
+
const reviewed = await db.select().from(billingObligations)
|
|
68
83
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
|
|
69
|
-
for (const
|
|
70
|
-
const
|
|
84
|
+
for (const obligation of reviewed) {
|
|
85
|
+
const { invoiceId, holdReason: reason } = obligation;
|
|
86
|
+
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
|
|
71
87
|
problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
|
|
72
88
|
}
|
|
73
89
|
if (accounting) {
|
|
@@ -80,8 +96,8 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
// The views of the Invoices page. `accounting` is the accounting adapter, or
|
|
83
|
-
// null. Every draft is in one view: Problem (a check failed
|
|
84
|
-
//
|
|
99
|
+
// null. Every draft is in one view: Problem (a check failed; the Pending tab)
|
|
100
|
+
// or Ready (its checks pass). An issued invoice is Sending while
|
|
85
101
|
// queued, and a Problem when the accounting system refused it. Answers
|
|
86
102
|
// { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
|
|
87
103
|
export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
|
|
@@ -96,12 +112,14 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
|
|
|
96
112
|
.orderBy(desc(invoices.sendUpdatedAt));
|
|
97
113
|
for (const row of issued) {
|
|
98
114
|
if (row.sendState === 'queued') sending.add(row.id);
|
|
99
|
-
else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.')]);
|
|
115
|
+
else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')]);
|
|
100
116
|
}
|
|
117
|
+
// A draft whose checks pass is ready (Samuel, 2026-10-20): no person
|
|
118
|
+
// needs to mark it. toCheck stays for callers that still read it.
|
|
101
119
|
const clean = drafts.filter((d) => !problems.has(d.id));
|
|
102
120
|
return {
|
|
103
|
-
toCheck:
|
|
104
|
-
ready: clean.
|
|
121
|
+
toCheck: [],
|
|
122
|
+
ready: clean.map((d) => d.id),
|
|
105
123
|
problems,
|
|
106
124
|
sending,
|
|
107
125
|
};
|
|
@@ -112,11 +130,11 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
|
|
|
112
130
|
if (invoice.status === 'void') return { view: null, problems: [] };
|
|
113
131
|
if (invoice.status === 'issued') {
|
|
114
132
|
if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
|
|
115
|
-
if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.')] };
|
|
133
|
+
if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')] };
|
|
116
134
|
return { view: null, problems: [] };
|
|
117
135
|
}
|
|
118
136
|
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
119
|
-
return { view: found.length ? 'problem' :
|
|
137
|
+
return { view: found.length ? 'problem' : 'ready', problems: found };
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
// Mark ready: a person checked these drafts. A draft with a problem stays
|
package/server/db/doctor.js
CHANGED
|
@@ -12,12 +12,10 @@ import { migrationsTable } from '../integrations/schema.js';
|
|
|
12
12
|
// - the tables in public that neither declares (another app's, or an
|
|
13
13
|
// earlier Flow's).
|
|
14
14
|
//
|
|
15
|
-
// On Replit
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// comparison says what that step will show (docs/REPLIT.md, "The workspace
|
|
20
|
-
// updater"). Anyone who runs Flow can run it: `flow-server doctor`.
|
|
15
|
+
// On Replit, `flow-server doctor --live <address>` compares the workspace's
|
|
16
|
+
// database with the live app's GET /health, which reports the same migration
|
|
17
|
+
// counts, and says what Replit's publish step will show (docs/REPLIT.md, "The
|
|
18
|
+
// workspace database"). Anyone who runs Flow can run it: `flow-server doctor`.
|
|
21
19
|
|
|
22
20
|
const CORE_MIGRATIONS = fileURLToPath(new URL('./migrations', import.meta.url));
|
|
23
21
|
const CORE_TABLE = '__drizzle_migrations';
|
|
@@ -114,26 +112,27 @@ export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
|
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
// What Replit's publish step will
|
|
115
|
+
// What Replit's publish step will show, from the workspace's report and the
|
|
118
116
|
// live app's migration counts (GET /health's schema.migrations, or null when
|
|
119
|
-
// the live app could not say).
|
|
117
|
+
// the live app could not say). Flow migrates production itself when it
|
|
118
|
+
// starts, so the answer to "Development database changes detected" is always
|
|
119
|
+
// Cancel (docs/REPLIT.md, "The workspace database").
|
|
120
120
|
//
|
|
121
|
-
// broken the workspace database is not at this build: do not publish
|
|
122
|
-
// behind production has migrations the workspace lacks: the list would
|
|
123
|
-
// drop them, so Cancel and update the workspace first
|
|
124
|
-
// adds the workspace is ahead: the list holds additions only; approve
|
|
125
121
|
// same the two match: no list
|
|
126
|
-
//
|
|
122
|
+
// behind production has migrations the workspace lacks: follow production
|
|
123
|
+
// ahead the workspace has migrations production does not run
|
|
124
|
+
// unknown the live app did not say
|
|
125
|
+
export const CANCEL_RULE = 'If Replit shows "Development database changes detected", press Cancel.';
|
|
126
|
+
|
|
127
127
|
export function publishVerdict(report, live) {
|
|
128
|
-
if (!
|
|
129
|
-
if (!live) return { verdict: 'unknown', say: 'The live app did not report its migrations. Publish: approve a list of additions; Cancel any DROP.' };
|
|
128
|
+
if (!live) return { verdict: 'unknown', say: `The live app did not report its migrations. ${CANCEL_RULE}` };
|
|
130
129
|
const behind = Object.entries(live).filter(([id, n]) => n > (report.migrations[id] ?? 0)).map(([id]) => id);
|
|
131
|
-
if (behind.length) {
|
|
132
|
-
return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Do not publish: Replit would offer to DROP them. Update the workspace first.` };
|
|
133
|
-
}
|
|
134
130
|
const ahead = Object.entries(report.migrations).filter(([id, n]) => n > (live[id] ?? 0)).map(([id]) => id);
|
|
135
131
|
if (ahead.length) {
|
|
136
|
-
return { verdict: '
|
|
132
|
+
return { verdict: 'ahead', say: `The workspace has ${ahead.join(', ')} migrations production does not run. ${CANCEL_RULE} Production applies its own migrations when it starts.` };
|
|
133
|
+
}
|
|
134
|
+
if (behind.length) {
|
|
135
|
+
return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Run npm run db:follow-production (or press Run). ${CANCEL_RULE}` };
|
|
137
136
|
}
|
|
138
137
|
return { verdict: 'same', say: 'The workspace and production are at the same migrations. Publish: Replit shows no database changes.' };
|
|
139
138
|
}
|
package/server/db/index.js
CHANGED
|
@@ -6,6 +6,13 @@ import * as schema from './schema.js';
|
|
|
6
6
|
|
|
7
7
|
const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url));
|
|
8
8
|
|
|
9
|
+
// Session advisory lock held while any migration runs (Flow's own, each
|
|
10
|
+
// integration's, and the Replit workspace sync), so two processes sharing one
|
|
11
|
+
// database never apply the same migration at once. Field uses the same
|
|
12
|
+
// pattern with its own key.
|
|
13
|
+
export const MIGRATION_LOCK_KEY = 7_139_000_101n;
|
|
14
|
+
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 300_000;
|
|
15
|
+
|
|
9
16
|
// Opens Flow's PostgreSQL database. The caller owns the pool: call close().
|
|
10
17
|
export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {}) {
|
|
11
18
|
if (!url) throw new Error('flow: DATABASE_URL is not set');
|
|
@@ -14,8 +21,72 @@ export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {})
|
|
|
14
21
|
return { db, pool, close: () => pool.end() };
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
// A transaction-mode pooler (PgBouncer, Neon's "-pooler" hosts) does not keep
|
|
25
|
+
// a session, so it cannot hold a session lock. Neon names a pooled endpoint
|
|
26
|
+
// by adding "-pooler" to the endpoint id; the same host without it is the
|
|
27
|
+
// direct endpoint, which migrations use instead (as Field does).
|
|
28
|
+
const POOLER_HOST = /-pooler(?=\.|$)/i;
|
|
29
|
+
|
|
30
|
+
export function directUrl(url) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!POOLER_HOST.test(parsed.hostname)) return null;
|
|
38
|
+
parsed.hostname = parsed.hostname.replace(POOLER_HOST, '');
|
|
39
|
+
return parsed.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function migrationLockTimeoutMs(env = process.env) {
|
|
43
|
+
const value = Number.parseInt(env.FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS || '', 10);
|
|
44
|
+
return Number.isInteger(value) && value >= 0 ? value : DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Runs fn(lockedDb) under MIGRATION_LOCK_KEY. The lock and fn share one
|
|
48
|
+
// connection taken from db's pool (or the pooler's direct endpoint), because
|
|
49
|
+
// a session lock belongs to one connection. It waits up to
|
|
50
|
+
// FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS (300000) for another process to finish.
|
|
51
|
+
export async function withMigrationLock(db, fn, { timeoutMs = migrationLockTimeoutMs() } = {}) {
|
|
52
|
+
const source = db?.$client;
|
|
53
|
+
if (!source) throw new Error('flow: a migration needs a PostgreSQL database from openDatabase');
|
|
54
|
+
const direct = source instanceof pg.Pool ? directUrl(source.options?.connectionString) : null;
|
|
55
|
+
let client;
|
|
56
|
+
if (direct) {
|
|
57
|
+
client = new pg.Client({ connectionString: direct });
|
|
58
|
+
await client.connect();
|
|
59
|
+
} else {
|
|
60
|
+
client = source instanceof pg.Pool ? await source.connect() : source;
|
|
61
|
+
}
|
|
62
|
+
let broken = false;
|
|
63
|
+
try {
|
|
64
|
+
const deadline = Date.now() + timeoutMs;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { rows } = await client.query('select pg_try_advisory_lock($1::bigint) as locked', [MIGRATION_LOCK_KEY.toString()]);
|
|
67
|
+
if (rows[0].locked) break;
|
|
68
|
+
if (Date.now() >= deadline) {
|
|
69
|
+
throw new Error(`flow: another process held the migration lock (advisory lock ${MIGRATION_LOCK_KEY}) for over ${timeoutMs} ms; is a migration hung? (FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS)`);
|
|
70
|
+
}
|
|
71
|
+
await new Promise((wake) => setTimeout(wake, 250));
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return await fn(drizzle(client, { schema }));
|
|
75
|
+
} finally {
|
|
76
|
+
// A connection that cannot unlock is closed, which also frees the lock.
|
|
77
|
+
await client.query('select pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY.toString()]).catch(() => { broken = true; });
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
if (direct) await client.end().catch(() => {});
|
|
81
|
+
else if (client !== source) client.release(broken);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Applies the migrations in `folder` (this build's own by default) under the
|
|
86
|
+
// migration lock. `table` names the journal: drizzle.__drizzle_migrations for
|
|
87
|
+
// Flow's own, one per integration (server/integrations/schema.js).
|
|
88
|
+
export function migrateDatabase(db, { folder = migrationsFolder, table } = {}) {
|
|
89
|
+
return withMigrationLock(db, (locked) => migrate(locked, { migrationsFolder: folder, ...(table ? { migrationsTable: table } : {}) }));
|
|
19
90
|
}
|
|
20
91
|
|
|
21
|
-
export { schema };
|
|
92
|
+
export { migrationsFolder, schema };
|
package/server/db/migrate-all.js
CHANGED
|
@@ -3,10 +3,9 @@ import { adapters as shipped } from '../integrations/adapters.js';
|
|
|
3
3
|
import { migrateAdapterSchema } from '../integrations/schema.js';
|
|
4
4
|
|
|
5
5
|
// Brings a database up to this build: the core migrations, then the tables
|
|
6
|
-
// of every adapter this build ships, whether or not it is on
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// publishing never offers to drop one (docs/REPLIT.md).
|
|
6
|
+
// of every adapter this build ships, whether or not it is on
|
|
7
|
+
// (`flow-server migrate`). Not for a Replit workspace: its database follows
|
|
8
|
+
// production instead (scripts/replit-workspace-db.js, docs/REPLIT.md).
|
|
10
9
|
export async function migrateEverything(db, { adapters = shipped, log = () => {} } = {}) {
|
|
11
10
|
await migrateDatabase(db);
|
|
12
11
|
for (const adapter of adapters) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "job_field_settings" (
|
|
2
|
+
"organisation_id" uuid PRIMARY KEY NOT NULL,
|
|
3
|
+
"group_colors" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
4
|
+
"assignee_made_at" timestamp with time zone,
|
|
5
|
+
"updated_by" uuid,
|
|
6
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "job_fields" ADD COLUMN IF NOT EXISTS "color" text;--> statement-breakpoint
|
|
10
|
+
DO $flow_guard$ BEGIN
|
|
11
|
+
ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
|
|
12
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
13
|
+
END $flow_guard$;--> statement-breakpoint
|
|
14
|
+
DO $flow_guard$ BEGIN
|
|
15
|
+
ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
16
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
17
|
+
END $flow_guard$;--> statement-breakpoint
|
|
18
|
+
DO $flow_guard$ BEGIN
|
|
19
|
+
ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_color_valid" CHECK ("job_fields"."color" is null or "job_fields"."color" in ('gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'));
|
|
20
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
21
|
+
END $flow_guard$;
|