@arcforgelabs/flow 2026.11.3 → 2026.11.4
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/SECURITY.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +68 -1
- package/server/app.js +3 -2
- package/server/auth/session.js +13 -7
- package/server/auth/settings.js +18 -7
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/ledger.js +3 -1
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +14 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +12 -7
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/flow.css +1 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +73 -5
- package/server/ui/jobs.js +14 -2
- package/skills/flow/SKILL.md +71 -2
- package/skills/flow/flow.sh +32 -1
|
@@ -190,6 +190,20 @@
|
|
|
190
190
|
"when": 1794019099844,
|
|
191
191
|
"tag": "0026_bitter_sersi",
|
|
192
192
|
"breakpoints": true
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"idx": 27,
|
|
196
|
+
"version": "7",
|
|
197
|
+
"when": 1794454051454,
|
|
198
|
+
"tag": "0027_high_doctor_strange",
|
|
199
|
+
"breakpoints": true
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"idx": 28,
|
|
203
|
+
"version": "7",
|
|
204
|
+
"when": 1794456064675,
|
|
205
|
+
"tag": "0028_sharp_power_pack",
|
|
206
|
+
"breakpoints": true
|
|
193
207
|
}
|
|
194
208
|
]
|
|
195
209
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -715,6 +715,27 @@ export const invoiceRules = pgTable('invoice_rules', {
|
|
|
715
715
|
check('invoice_rules_icon_valid', sql`${t.icon} is null or (length(${t.icon}) between 1 and 64 and ${t.icon} !~ '[^a-z0-9-]')`),
|
|
716
716
|
]);
|
|
717
717
|
|
|
718
|
+
// A person's exception to one invoice rule for one job (docs/INVOICE_RULES.md,
|
|
719
|
+
// "Exceptions"): the rule matched, and a person said this job is fine as it
|
|
720
|
+
// is, with a reason. The rule then leaves that job alone, and its card counts
|
|
721
|
+
// and lists each exception, so none is hidden.
|
|
722
|
+
export const invoiceRuleExceptions = pgTable('invoice_rule_exceptions', {
|
|
723
|
+
id: id(),
|
|
724
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
725
|
+
ruleId: uuid('rule_id').notNull().references(() => invoiceRules.id, { onDelete: 'cascade' }),
|
|
726
|
+
obligationId: uuid('obligation_id').notNull().references(() => billingObligations.id),
|
|
727
|
+
reason: text('reason').notNull(),
|
|
728
|
+
actor: text('actor').notNull(),
|
|
729
|
+
// What the exception was given for: the rule's item and the job's lines
|
|
730
|
+
// then. A change to either ends it, so a person looks again.
|
|
731
|
+
itemCode: text('item_code'),
|
|
732
|
+
lineFingerprint: text('line_fingerprint'),
|
|
733
|
+
createdAt: createdAt(),
|
|
734
|
+
}, (t) => [
|
|
735
|
+
uniqueIndex('invoice_rule_exceptions_rule_job').on(t.ruleId, t.obligationId),
|
|
736
|
+
check('invoice_rule_exceptions_reason_valid', sql`length(${t.reason}) between 1 and 200`),
|
|
737
|
+
]);
|
|
738
|
+
|
|
718
739
|
// Automations (server/core/automations.js): Flow's routines, each on or off,
|
|
719
740
|
// with its own settings. The routines are defined in code; a row holds an
|
|
720
741
|
// organisation's choice. No row means the routine's default.
|
|
@@ -217,9 +217,16 @@ export const WORK_FINISHED_DAYS = 30;
|
|
|
217
217
|
const WORK_LIMIT = 2000;
|
|
218
218
|
const FINAL_SQL = sql`coalesce(${seazonaOrders.status}, '') ~* '(shipped|delivered|cancelled|canceled|void|closed|archived|deleted)'`;
|
|
219
219
|
|
|
220
|
+
// A finished order that still has no invoice stays on the board past
|
|
221
|
+
// WORK_FINISHED_DAYS: its job has no Flow invoice and no accounting invoice
|
|
222
|
+
// linked, and the order was not cancelled. It leaves when it is billed.
|
|
223
|
+
const UNBILLED_SQL = sql`(coalesce(${seazonaOrders.status}, '') !~* '(cancelled|canceled|void|deleted)' and exists (
|
|
224
|
+
select 1 from billing_obligations o where o.source_system = 'seazona' and o.source_job_ref = ${seazonaOrders.id}
|
|
225
|
+
and o.invoice_id is null and o.accounting_ref is null))`;
|
|
226
|
+
|
|
220
227
|
// The orders behind the port's listWork, for the Jobs board: every open
|
|
221
|
-
// order,
|
|
222
|
-
// days, newest change first. An order deleted in Seazona is never listed.
|
|
228
|
+
// order, each final order that changed in the last WORK_FINISHED_DAYS
|
|
229
|
+
// days, and each finished order not billed yet, newest change first. An order deleted in Seazona is never listed.
|
|
223
230
|
// The client name is the Seazona client's company (or full name), else the
|
|
224
231
|
// order's own company. `statuses` lists each status once, with the first time
|
|
225
232
|
// Flow saw an order in it, so the board can order its columns.
|
|
@@ -228,7 +235,7 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
|
|
|
228
235
|
const rows = await db.select({ order: seazonaOrders, company: seazonaClients.company, fullName: seazonaClients.fullName })
|
|
229
236
|
.from(seazonaOrders)
|
|
230
237
|
.leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
|
|
231
|
-
.where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since))))
|
|
238
|
+
.where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since), UNBILLED_SQL)))
|
|
232
239
|
.orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
|
|
233
240
|
.limit(limit);
|
|
234
241
|
const firstSeen = new Map();
|
|
@@ -532,7 +532,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
532
532
|
});
|
|
533
533
|
if (recent) {
|
|
534
534
|
throw xeroError('xero_same_reference_different_amount',
|
|
535
|
-
`Xero ${recent.InvoiceNumber || 'invoice'} has the same client and reference at ${(cents(recent.Total) / 100).toFixed(2)}. Nothing was sent.
|
|
535
|
+
`Xero ${recent.InvoiceNumber || 'invoice'} has the same client and reference at ${(cents(recent.Total) / 100).toFixed(2)}. Nothing was sent. If that Xero invoice bills this job, press Use Xero invoice.`,
|
|
536
536
|
{ status: 409, details: { xero: shownXero(recent) } });
|
|
537
537
|
}
|
|
538
538
|
}
|
|
@@ -155,7 +155,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
155
155
|
pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
|
|
156
156
|
// Xero's invoice for one billable job, looked up live (ledger.js): the
|
|
157
157
|
// `liveCheck` for draftInvoiceForObligation.
|
|
158
|
-
checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
|
|
158
|
+
checkInvoice: ({ organisationId, obligation, reference = null }) => xeroLiveCheck(database, current(), organisationId)(obligation, { reference }),
|
|
159
159
|
// Xero's latest changes into the copy just before Flow drafts by rule or
|
|
160
160
|
// sends (webhooks.js); no call when the copy is current.
|
|
161
161
|
readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
|
|
@@ -81,7 +81,7 @@ async function several(runtime, client, found, checkedAt) {
|
|
|
81
81
|
|
|
82
82
|
// The Xero invoice for one obligation (a row of billing_obligations), looked
|
|
83
83
|
// up live. Costs 1 to 4 Xero calls.
|
|
84
|
-
export async function checkInvoice(db, runtime, client, organisationId, obligation) {
|
|
84
|
+
export async function checkInvoice(db, runtime, client, organisationId, obligation, { reference: draftReference = null } = {}) {
|
|
85
85
|
const checkedAt = new Date(runtime.now()).toISOString();
|
|
86
86
|
|
|
87
87
|
// 1. The Xero invoice Flow made from the job's Flow invoice.
|
|
@@ -132,8 +132,8 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
132
132
|
|
|
133
133
|
// 4. The reference Flow would give the job's invoice, for the same
|
|
134
134
|
// customer. Not exact: it refuses a draft and goes to a person.
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
135
|
+
const references = [...new Set([obligation.caseRef ?? obligation.sourceJobRef, draftReference].filter(Boolean))];
|
|
136
|
+
if (references.length) {
|
|
137
137
|
let customer = null;
|
|
138
138
|
if (obligation.contactId) {
|
|
139
139
|
const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
|
|
@@ -147,8 +147,13 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
// Another customer's invoice with the same reference is not this job's.
|
|
150
|
-
const
|
|
151
|
-
|
|
150
|
+
const byId = new Map();
|
|
151
|
+
for (const reference of references) {
|
|
152
|
+
for (const inv of sales(await client.findInvoicesByReference(reference))) {
|
|
153
|
+
if (!customer || inv.Contact?.ContactID === customer) byId.set(inv.InvoiceID, inv);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const found = [...byId.values()];
|
|
152
157
|
if (found.length === 1) return evidenceFor(runtime, client, found[0], 'reference', checkedAt);
|
|
153
158
|
if (found.length > 1) return several(runtime, client, found, checkedAt);
|
|
154
159
|
}
|
|
@@ -159,9 +164,9 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
159
164
|
// draftInvoiceForObligation(db, org, id, { liveCheck: xeroLiveCheck(db, runtime, org), actor })
|
|
160
165
|
// A Xero error throws, and the ledger then refuses the draft.
|
|
161
166
|
export function xeroLiveCheck(db, runtime, organisationId) {
|
|
162
|
-
return (obligation) => withXeroLane('reads', async () => {
|
|
167
|
+
return (obligation, options = {}) => withXeroLane('reads', async () => {
|
|
163
168
|
const { client } = await runtime.clientFor(db, organisationId);
|
|
164
|
-
return checkInvoice(db, runtime, client, organisationId, obligation);
|
|
169
|
+
return checkInvoice(db, runtime, client, organisationId, obligation, options);
|
|
165
170
|
});
|
|
166
171
|
}
|
|
167
172
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { contacts, priceItems } from '../../db/schema.js';
|
|
2
|
+
import { billingObligations, contacts, invoices, obligationEvents, priceItems } from '../../db/schema.js';
|
|
3
3
|
import { createContact } from '../../core/directory.js';
|
|
4
4
|
import { FlowError } from '../../core/errors.js';
|
|
5
5
|
import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
|
|
@@ -234,6 +234,44 @@ export async function linkXeroInvoice(db, organisationId, xeroInvoiceId, { invoi
|
|
|
234
234
|
return { xeroInvoiceId, invoiceId };
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// "Use the Xero invoice": Send stopped a Flow invoice because Xero already
|
|
238
|
+
// bills the same client and reference at another amount. The Xero invoice is
|
|
239
|
+
// the real bill. In one step Flow voids its own copy (never sent to Xero),
|
|
240
|
+
// makes its Flow invoice from the Xero one, and gives that to the job. A copy
|
|
241
|
+
// a person voided earlier, whose job still points at it, is cleaned up the
|
|
242
|
+
// same way. The
|
|
243
|
+
// Flow copy's job is the job; an invoice billing more than one job is linked
|
|
244
|
+
// by hand. The Xero invoice must be the same client's.
|
|
245
|
+
export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoiceId, actor }) {
|
|
246
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
247
|
+
const row = await rowFor(db, organisationId, xeroInvoiceId);
|
|
248
|
+
return db.transaction(async (tx) => {
|
|
249
|
+
const { rows: [invoice] } = await tx.execute(sql`select id, number, status, contact_id from invoices
|
|
250
|
+
where organisation_id = ${organisationId} and id = ${invoiceId} for update`);
|
|
251
|
+
if (!invoice) throw new FlowError('not_found', 'Flow invoice not found');
|
|
252
|
+
// A copy voided earlier, still holding its job, gives way the same way.
|
|
253
|
+
if (!['issued', 'void'].includes(invoice.status)) throw new FlowError('invalid', 'only an approved or void Flow invoice can give way to a Xero invoice');
|
|
254
|
+
if (row.contactId !== invoice.contact_id) throw new FlowError('invalid', 'the Xero invoice is for another client');
|
|
255
|
+
const { rows: pushed } = await tx.execute(sql`select 1 from xero_invoice_pushes where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null limit 1`);
|
|
256
|
+
if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
|
|
257
|
+
const jobs = await tx.select({ id: billingObligations.id }).from(billingObligations)
|
|
258
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)))
|
|
259
|
+
.for('update');
|
|
260
|
+
if (jobs.length > 1) throw new FlowError('invalid', 'the Flow invoice bills more than one job; link each by hand');
|
|
261
|
+
if (invoice.status === 'issued') await tx.update(invoices).set({ status: 'void', updatedAt: sql`now()` }).where(eq(invoices.id, invoiceId));
|
|
262
|
+
else if (!jobs.length) throw new FlowError('invalid', 'the void Flow invoice holds no job');
|
|
263
|
+
for (const job of jobs) {
|
|
264
|
+
await tx.update(billingObligations).set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` }).where(eq(billingObligations.id, job.id));
|
|
265
|
+
await tx.insert(obligationEvents).values({
|
|
266
|
+
createdAt: sql`clock_timestamp()`, organisationId, obligationId: job.id, kind: 'draft_deleted', actor,
|
|
267
|
+
evidence: { invoiceId, reason: 'used_xero_invoice', xeroInvoiceId, xeroNumber: row.number ?? null },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, actor });
|
|
271
|
+
return { voided: { id: invoiceId, number: invoice.number }, invoice: { id: made.invoice.id, number: made.invoice.number }, xeroInvoiceId, jobId: jobs[0]?.id ?? null };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
237
275
|
// Makes Flow's own invoice from a Xero invoice in the copy, finalised on the
|
|
238
276
|
// Xero invoice's date, and links the two. With `jobId`, the job gets it as its
|
|
239
277
|
// invoice. The client is Flow's customer of the same name, or a new one.
|
|
@@ -90,6 +90,7 @@ function invoiceRow(r) {
|
|
|
90
90
|
id: r.xero_invoice_id,
|
|
91
91
|
number: r.invoice_number,
|
|
92
92
|
contactId: r.xero_contact_id,
|
|
93
|
+
reference: r.reference ?? null,
|
|
93
94
|
status: r.status,
|
|
94
95
|
// The ledger's accounting state for this invoice.
|
|
95
96
|
state: ledgerState({ Status: r.status, AmountPaid: Number(r.amount_paid_cents ?? 0), AmountCredited: Number(r.amount_credited_cents ?? 0) }),
|
|
@@ -109,10 +110,11 @@ function invoiceRow(r) {
|
|
|
109
110
|
// Flow invoices Flow sent, or one customer within a date range. Answers the
|
|
110
111
|
// mirror's state with them, and the latest change time it holds.
|
|
111
112
|
export async function findInvoices(db, organisationId, query = {}) {
|
|
112
|
-
const { invoiceIds = [], numbers = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
|
|
113
|
+
const { invoiceIds = [], numbers = [], references = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
|
|
113
114
|
const where = [];
|
|
114
115
|
if (invoiceIds.length) where.push(sql`i.xero_invoice_id = any(${ids(invoiceIds)}::text[])`);
|
|
115
116
|
if (numbers.length) where.push(sql`upper(i.invoice_number) = any(${sql.param([...new Set(numbers.map((n) => String(n).toUpperCase()))])}::text[])`);
|
|
117
|
+
if (references.length) where.push(sql`upper(i.reference) = any(${sql.param([...new Set(references.map((r) => String(r).toUpperCase()))])}::text[])`);
|
|
116
118
|
if (flowInvoiceIds.length) where.push(sql`p.invoice_id = any(${ids(flowInvoiceIds)}::uuid[])`);
|
|
117
119
|
if (contactId) {
|
|
118
120
|
where.push(sql`(i.xero_contact_id = ${contactId}
|
|
@@ -8,7 +8,7 @@ import { createCustomer, linkCustomer, matchCustomer } from './create-contact.js
|
|
|
8
8
|
import { createXeroClient } from './client.js';
|
|
9
9
|
import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
|
|
10
10
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
11
|
-
import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings } from './links.js';
|
|
11
|
+
import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings, useXeroInvoice } from './links.js';
|
|
12
12
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
13
13
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
14
14
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
@@ -43,6 +43,10 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
43
43
|
// status and any difference, from the copy
|
|
44
44
|
// POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
|
|
45
45
|
// then checks that one invoice again (Mark resolved)
|
|
46
|
+
// POST /invoices/:invoiceId/use-xero owner or admin: { xeroInvoiceId } Send
|
|
47
|
+
// stopped this invoice for a Xero invoice of the
|
|
48
|
+
// same client and reference: void the Flow copy
|
|
49
|
+
// and give its job the Xero invoice (links.js)
|
|
46
50
|
// POST /obligations/:obligationId/check owner or admin: Xero's invoice for
|
|
47
51
|
// a billable job, recorded on the ledger
|
|
48
52
|
// POST /payments/sync owner or admin: Xero payments to the ledger
|
|
@@ -65,6 +69,7 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
65
69
|
export const XERO_ACTIONS = Object.freeze({
|
|
66
70
|
sync: { cooldownMs: 5 * 60_000 },
|
|
67
71
|
recheck: { cooldownMs: 30_000 },
|
|
72
|
+
useXero: { cooldownMs: 10_000 },
|
|
68
73
|
push: { cooldownMs: 0, budget: false },
|
|
69
74
|
createContact: { cooldownMs: 30_000 },
|
|
70
75
|
obligationCheck: { cooldownMs: 30_000 },
|
|
@@ -355,6 +360,18 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
355
360
|
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
356
361
|
}));
|
|
357
362
|
|
|
363
|
+
router.post('/invoices/:invoiceId/use-xero', ...manager, act('useXero', (req) => req.params.invoiceId), handle(log, async (req, res) => {
|
|
364
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
365
|
+
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
366
|
+
const xeroInvoiceId = typeof req.body?.xeroInvoiceId === 'string' && UUID.test(req.body.xeroInvoiceId) ? req.body.xeroInvoiceId : null;
|
|
367
|
+
if (!xeroInvoiceId) throw new FlowError('invalid', 'xeroInvoiceId must name a Xero invoice');
|
|
368
|
+
// The Xero invoice may be newer than Flow's copy: read Xero's changes first.
|
|
369
|
+
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
370
|
+
const result = await useXeroInvoice(db, organisationId, invoiceId, { xeroInvoiceId, actor: `person:${req.person.id}` });
|
|
371
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} used Xero invoice ${xeroInvoiceId} for Flow invoice ${invoiceId}`);
|
|
372
|
+
res.json({ ok: true, ...result });
|
|
373
|
+
}));
|
|
374
|
+
|
|
358
375
|
router.post('/invoices/push', ...manager, act('push'), handle(log, async (req, res) => {
|
|
359
376
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
360
377
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
package/server/ui/flow.css
CHANGED
|
@@ -1021,6 +1021,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
1021
1021
|
follows the pointer, the lane that takes it, and the "Waiting for
|
|
1022
1022
|
Seazona" chip of a stage move the work system has not made yet. */
|
|
1023
1023
|
.fx-job__wait { justify-self: start; margin-top: 8px; padding: 1px 8px; border: 1px dashed var(--t-accent); border-radius: 999px; color: var(--t-accent); font-size: 11.5px; font-weight: 600; }
|
|
1024
|
+
.fx-job__wait--warn { border-color: var(--t-warn-brd); background: var(--t-warn-bg); color: var(--t-warn); }
|
|
1024
1025
|
.fx-job--lifted { opacity: .35; }
|
|
1025
1026
|
.fx-job--ghost { position: fixed; top: 0; left: 0; z-index: 60; pointer-events: none; box-shadow: var(--t-shadow-lg, var(--t-shadow)); cursor: grabbing; }
|
|
1026
1027
|
.fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
|
|
@@ -41,7 +41,8 @@ export function ruleSummary(rule, meta) {
|
|
|
41
41
|
return `${field.label} ${test} ${value}`;
|
|
42
42
|
};
|
|
43
43
|
const then = rule.action.kind === 'pending' ? `Pending: ${rule.action.note}` : ACTION_LABELS[rule.action.kind] ?? rule.action.kind;
|
|
44
|
-
|
|
44
|
+
const excepted = rule.exceptions ? ` · ${rule.exceptions} ${rule.exceptions === 1 ? 'exception' : 'exceptions'}` : '';
|
|
45
|
+
return `${rule.conditions.map(part).join(' and ')} · ${then}${excepted}`;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function rulesSection(ui, title = 'Rules') {
|
|
@@ -169,7 +170,13 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
169
170
|
placeholder: 'Type a note',
|
|
170
171
|
});
|
|
171
172
|
const noteField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Note on the draft'), note);
|
|
172
|
-
|
|
173
|
+
// Optional: a price list code the draft offers to add in one press.
|
|
174
|
+
const addItem = h('input', {
|
|
175
|
+
class: 'fx-input', maxlength: '40', value: draft?.action.addItem ?? '', 'aria-label': 'Item to add',
|
|
176
|
+
placeholder: 'Price list code',
|
|
177
|
+
});
|
|
178
|
+
const addField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Offer to add'), addItem);
|
|
179
|
+
const showNote = () => { noteField.hidden = action.value !== 'pending'; addField.hidden = action.value !== 'pending'; };
|
|
173
180
|
action.addEventListener('change', showNote);
|
|
174
181
|
showNote();
|
|
175
182
|
let editors = [];
|
|
@@ -218,6 +225,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
218
225
|
label('If all of these match', list, more),
|
|
219
226
|
label('Then', action),
|
|
220
227
|
noteField,
|
|
228
|
+
addField,
|
|
221
229
|
remove),
|
|
222
230
|
confirm: rule ? 'Save' : 'Add rule',
|
|
223
231
|
});
|
|
@@ -231,7 +239,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
231
239
|
name: name.value,
|
|
232
240
|
icon: icon.value(),
|
|
233
241
|
conditions: editors.map((e) => e.read()),
|
|
234
|
-
action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
|
|
242
|
+
action: action.value === 'pending' ? { kind: 'pending', note: note.value, ...(addItem.value.trim() ? { addItem: addItem.value.trim() } : {}) } : { kind: action.value },
|
|
235
243
|
};
|
|
236
244
|
const body = rule
|
|
237
245
|
? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
|
package/server/ui/invoicing.js
CHANGED
|
@@ -940,6 +940,29 @@ const LINE_CARD_READ = { areas: ['desc desc amount', 'qty price gst'], columns:
|
|
|
940
940
|
const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
|
|
941
941
|
|
|
942
942
|
// A draft, or a new invoice before its first save.
|
|
943
|
+
// Lines this invoice has that its job in the work system does not (the
|
|
944
|
+
// invoice detail's sourceGap). Flow cannot add them there, so a yellow line
|
|
945
|
+
// says what to add and links to the job.
|
|
946
|
+
function sourceGapNotice(ui, invoice) {
|
|
947
|
+
const gap = invoice?.sourceGap ?? [];
|
|
948
|
+
if (!gap.length || !invoice.job) return null;
|
|
949
|
+
const system = invoice.job.systemName ?? 'the work system';
|
|
950
|
+
const what = gap.map((l) => `${l.description} (${formatCents(l.unitAmountCents * (l.quantity ?? 1))})`).join(', ');
|
|
951
|
+
const line = ui.notice(`This invoice has ${what}; the job in ${system} does not. Add it there so both agree.`, 'warn', `Not in ${system}.`);
|
|
952
|
+
if (!invoice.job.link?.url) return line;
|
|
953
|
+
return ui.h('div', { class: 'fx-offer' }, line,
|
|
954
|
+
ui.h('a', { class: 'fx-btn fx-btn--sm fx-btn--ghost', href: invoice.job.link.url, target: '_blank', rel: 'noopener' }, `Open in ${system}`));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// The exceptions people gave this job: the rule, why, and whether it still
|
|
958
|
+
// holds (it ends when the job's lines or the rule's item change).
|
|
959
|
+
function exceptionsNotice(ui, invoice) {
|
|
960
|
+
const list = invoice?.ruleExceptions ?? [];
|
|
961
|
+
if (!list.length) return null;
|
|
962
|
+
const text = list.map((e) => `${e.rule}: ${e.reason}${e.current ? '' : ' (ended: the job changed)'}`).join('; ');
|
|
963
|
+
return ui.notice(text, 'info', list.length === 1 ? 'Exception.' : 'Exceptions.');
|
|
964
|
+
}
|
|
965
|
+
|
|
943
966
|
function draftView(invoice, ui, context, redraw) {
|
|
944
967
|
const { h, flow, toast, go, notice, fieldError } = ui;
|
|
945
968
|
const isNew = !invoice;
|
|
@@ -1274,11 +1297,33 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1274
1297
|
: [];
|
|
1275
1298
|
// Every matching rule links to its own conditions and existing controls,
|
|
1276
1299
|
// including custom rules. The target enforces read-only roles itself.
|
|
1300
|
+
// A rule with an item to add offers it in one press, and Skip records an
|
|
1301
|
+
// exception with a reason (POST .../rules/:rule/add-item and /exception).
|
|
1302
|
+
const ruleFix = async (path, payload, done) => {
|
|
1303
|
+
try {
|
|
1304
|
+
const body = await flow(`/api/invoicing/invoices/${invoice.id}/rules/${path}`, { method: 'POST', body: JSON.stringify(payload ?? {}) });
|
|
1305
|
+
ui.toast(done, 'success');
|
|
1306
|
+
redraw(body.invoice);
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
ui.toast(error.message, 'danger');
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const skipRule = async (p) => {
|
|
1312
|
+
const reason = h('textarea', { class: 'fx-input', rows: 2, maxlength: 200, 'aria-label': 'Reason' });
|
|
1313
|
+
const ok = await ui.dialog({ title: `Skip ${p.short ?? 'this rule'} for this job?`, body: h('div', {}, h('p', {}, 'The rule lists this job as an exception, with your reason.'), reason), confirm: 'Skip' });
|
|
1314
|
+
if (!ok) return;
|
|
1315
|
+
if (!reason.value.trim()) { ui.toast('Give a reason.', 'danger'); return; }
|
|
1316
|
+
await ruleFix(`${p.rule}/exception`, { reason: reason.value.trim() }, 'Skipped. The rule shows the exception.');
|
|
1317
|
+
};
|
|
1277
1318
|
const ruleActions = [...new Map(problemList.filter(hasRuleReview).map((p) => [p.rule, p])).values()]
|
|
1278
|
-
.
|
|
1279
|
-
|
|
1280
|
-
onclick: (
|
|
1281
|
-
|
|
1319
|
+
.flatMap((p) => [
|
|
1320
|
+
p.addItem && context.canIssue ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: () => ruleFix(`${p.rule}/add-item`, null, `${p.addItem} added.`) }, `Add ${p.addItem}`) : null,
|
|
1321
|
+
p.addItem && context.canIssue ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--ghost', type: 'button', onclick: () => skipRule(p) }, 'Skip') : null,
|
|
1322
|
+
h('a', { class: 'fx-btn fx-btn--sm',
|
|
1323
|
+
href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
|
|
1324
|
+
onclick: (event) => go(event),
|
|
1325
|
+
}, `Review ${p.short ?? 'rule'}`),
|
|
1326
|
+
].filter(Boolean));
|
|
1282
1327
|
// Non-change holds use the Jobs workflow's existing instructions, not a
|
|
1283
1328
|
// comparison that does not exist. Reading them never releases the hold.
|
|
1284
1329
|
const holdSteps = fixes({ view: 'review_required', holdState: 'review',
|
|
@@ -1330,6 +1375,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1330
1375
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1331
1376
|
nonChargeable ? ui.notice(brief(invoice.nonChargeable?.[0]?.message ?? 'Non-chargeable.')) : null,
|
|
1332
1377
|
...problems,
|
|
1378
|
+
sourceGapNotice(ui, invoice),
|
|
1379
|
+
exceptionsNotice(ui, invoice),
|
|
1333
1380
|
h('div', { class: 'fx-invoice__grid' },
|
|
1334
1381
|
h('div', { class: 'fx-invoice__main' },
|
|
1335
1382
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -1491,7 +1538,26 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1491
1538
|
target: '_blank',
|
|
1492
1539
|
rel: 'noopener',
|
|
1493
1540
|
}, 'Open in Xero') : null;
|
|
1494
|
-
|
|
1541
|
+
// Send stopped it for a Xero invoice of the same client and Reference: that
|
|
1542
|
+
// Xero invoice is the bill. One press voids this copy and gives the job the
|
|
1543
|
+
// Xero invoice (POST /api/xero/invoices/:id/use-xero), never a bare void.
|
|
1544
|
+
const useXero = matched?.code === 'xero_same_reference_different_amount' && invoice.status === 'issued' && context.canIssue && organisationId
|
|
1545
|
+
? h('button', {
|
|
1546
|
+
class: 'fx-btn fx-btn--sm', type: 'button',
|
|
1547
|
+
onclick: async (event) => {
|
|
1548
|
+
event.currentTarget.disabled = true;
|
|
1549
|
+
try {
|
|
1550
|
+
const body = await ui.flow(`/api/xero/invoices/${invoice.id}/use-xero`, { method: 'POST', body: JSON.stringify({ organisationId, xeroInvoiceId: matched.xero.invoiceId }) });
|
|
1551
|
+
ui.toast(`${invoice.number} is void. The job has ${matched.xero.number ?? 'the Xero invoice'}.`, 'success');
|
|
1552
|
+
ui.go(`/invoices/${body.invoice.id}`);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
event.currentTarget.disabled = false;
|
|
1555
|
+
ui.toast(error.message, 'danger');
|
|
1556
|
+
}
|
|
1557
|
+
},
|
|
1558
|
+
}, 'Use Xero invoice') : null;
|
|
1559
|
+
const offers = [useXero, openXero].filter(Boolean);
|
|
1560
|
+
const problemRow = problemNotice ? (offers.length ? h('div', { class: 'fx-offer' }, problemNotice, ...offers) : problemNotice) : null;
|
|
1495
1561
|
const header = h('header', { class: 'fx-ph' },
|
|
1496
1562
|
h('div', { class: 'fx-ph__text' },
|
|
1497
1563
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
@@ -1519,6 +1585,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1519
1585
|
chrome.pager,
|
|
1520
1586
|
header,
|
|
1521
1587
|
problemRow,
|
|
1588
|
+
sourceGapNotice(ui, invoice),
|
|
1589
|
+
exceptionsNotice(ui, invoice),
|
|
1522
1590
|
h('div', { class: 'fx-invoice__grid' },
|
|
1523
1591
|
h('div', { class: 'fx-invoice__main' },
|
|
1524
1592
|
h('section', { class: 'fx-card fx-doc' },
|
package/server/ui/jobs.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// shows what to do there and the card waits in its new lane. On a phone a
|
|
13
13
|
// long press opens Move to. With a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
|
+
import { alertStrip } from '/assets/ui/alerts.js';
|
|
15
16
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
17
|
import { captureButton } from '/assets/ui/captures.js';
|
|
17
18
|
import { columnTable, dateCell, fieldColumn, groupHead, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
@@ -128,7 +129,10 @@ export async function renderJobs(main, ui) {
|
|
|
128
129
|
const frame = listFrame(main, host);
|
|
129
130
|
// A form photo from the Jobs page; Capture lists it with its result.
|
|
130
131
|
const shoot = captureButton(ui, { icon: true, added: () => ui.toast('Sent to Capture.', 'success') });
|
|
131
|
-
|
|
132
|
+
// Finished jobs that nothing is billing (server/core/alerts.js).
|
|
133
|
+
const alerts = alertStrip(ui, 'jobs');
|
|
134
|
+
const [title, ...top] = listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes;
|
|
135
|
+
main.append(title, alerts.element, ...top, frame.element, bulk.element);
|
|
132
136
|
|
|
133
137
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
134
138
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
@@ -336,6 +340,9 @@ export async function renderJobs(main, ui) {
|
|
|
336
340
|
state.systemFields = fields?.systemFields ?? [];
|
|
337
341
|
state.people = fields?.people ?? [];
|
|
338
342
|
state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), { value: v.value, updatedAt: v.updatedAt }]));
|
|
343
|
+
// The strip loads itself once; a later load follows a change.
|
|
344
|
+
if (state.loaded) alerts.refresh();
|
|
345
|
+
state.loaded = true;
|
|
339
346
|
} catch (error) {
|
|
340
347
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
341
348
|
return;
|
|
@@ -378,7 +385,12 @@ export async function renderJobs(main, ui) {
|
|
|
378
385
|
h('span', { class: 'fx-job__space' }),
|
|
379
386
|
problemMark(job),
|
|
380
387
|
h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))),
|
|
381
|
-
job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null
|
|
388
|
+
job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null,
|
|
389
|
+
// A rule asks for an item the job lacks in the work system (job-board.js).
|
|
390
|
+
// Fix it there: the lab prints from it too.
|
|
391
|
+
...(job.obligation?.sourceNeeds ?? []).map((need) => h('span', {
|
|
392
|
+
class: 'fx-job__wait fx-job__wait--warn', title: `${need.note} Add it in ${system}.`,
|
|
393
|
+
}, `${system} needs ${need.item}`)));
|
|
382
394
|
cardJobs.set(card, job);
|
|
383
395
|
return card;
|
|
384
396
|
}
|
package/skills/flow/SKILL.md
CHANGED
|
@@ -54,11 +54,21 @@ 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
|
+
| 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
|
+
| Add the rule's item to a draft | `flow.sh post invoicing/invoices/<id>/rules/<ruleId>/add-item '{}'` |
|
|
59
|
+
| 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":"..."}'` |
|
|
60
|
+
| Jobs the work system must fix | `flow.sh get 'invoicing/jobs?limit=200'`: each job's `sourceNeeds` lists the items a rule wants that the work system's job lacks |
|
|
61
|
+
| A rule's exceptions | `flow.sh get invoicing/rules/<id>/exceptions`; take one back: `flow.sh delete invoicing/rules/<id>/exceptions/<exceptionId>` |
|
|
57
62
|
| Automations | `flow.sh get invoicing/automations` |
|
|
58
63
|
| Flow settings | `flow.sh get invoicing/settings` |
|
|
59
64
|
|
|
60
|
-
Rule actions: `wait` (hold the job)
|
|
61
|
-
|
|
65
|
+
Rule actions: `wait` (hold the job), `skip` (never invoice it), `pending`
|
|
66
|
+
(draft it, but hold it on Pending with a note) or `non_chargeable`. Take
|
|
67
|
+
field, test and value only from `rule-fields`. A pending rule with `addItem`
|
|
68
|
+
offers that item. Add it or skip it only when the person says so, and give
|
|
69
|
+
the person's reason for a skip. An exception ends when the job's lines change
|
|
70
|
+
(`current: false` in the rule's exceptions list): ask the person again. The work system must get the item too: tell
|
|
71
|
+
the person which jobs show "needs <item>" on the job board.
|
|
62
72
|
|
|
63
73
|
Voids: the Void tab holds invoices voided in Flow **or voided or deleted in
|
|
64
74
|
Xero**. Before a clean-up, list the tab, group the rows (Flow void, Xero void,
|
|
@@ -68,6 +78,34 @@ confirm.
|
|
|
68
78
|
Sending: Flow sends each invoice to Xero as a draft unless the "Approve and
|
|
69
79
|
email" setting is on (`invoicing/settings`). Say which one applies before a send.
|
|
70
80
|
|
|
81
|
+
## Share links
|
|
82
|
+
|
|
83
|
+
When you name an invoice, a job or a contact to a person, give its Flow link.
|
|
84
|
+
Never search GitHub or guess a path: `flow.sh link` prints the address.
|
|
85
|
+
|
|
86
|
+
| Link to | Command | Id to use |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| An invoice | `flow.sh link invoice <id>` | the invoice's `id`, or a job's `invoiceId`. Never the invoice number |
|
|
89
|
+
| A job's Reconcile page | `flow.sh link reconcile <id>` | a job's `id` from `invoicing/jobs` only (never an invoice id) |
|
|
90
|
+
| A job on the Jobs board | `flow.sh link job <case number>` | the job's `caseRef`; if it is empty, its `sourceJobRef` |
|
|
91
|
+
| A contact | `flow.sh link contact <id>` | the contact's `id` |
|
|
92
|
+
|
|
93
|
+
For a job that has an `invoiceId`, give the invoice link: it opens that one
|
|
94
|
+
record. The Jobs link is a search, so it can also show other jobs whose text
|
|
95
|
+
holds the same number. Use it only for a job with no invoice.
|
|
96
|
+
|
|
97
|
+
Paste the command's output as a Markdown link with a short label, one per line.
|
|
98
|
+
For example, the commands print addresses like these:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
- [INV-0123](https://flow.example.com/invoices/3f6c2a1e-8b4d-4c2f-9a7e-1d2b3c4d5e6f) $242.00, Pending: add shipping
|
|
102
|
+
- [Case 3716](https://flow.example.com/jobs?q=3716) shipped, no invoice yet
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Never type or change an address yourself. If `flow.sh link` says the id is not
|
|
106
|
+
a UUID, read the record first (`flow.sh get 'invoicing/invoices?q=<number>'`)
|
|
107
|
+
and use its `id`.
|
|
108
|
+
|
|
71
109
|
## Prices and price codes
|
|
72
110
|
|
|
73
111
|
Flow's price list is the lab's own list of codes and prices
|
|
@@ -141,6 +179,32 @@ turned on "Hide patient details", patient, note, file and clinical fields are
|
|
|
141
179
|
left out. Do not copy patient names outside this chat. 429: wait a
|
|
142
180
|
minute and try once more.
|
|
143
181
|
|
|
182
|
+
## Fix a problem Flow shows
|
|
183
|
+
|
|
184
|
+
Flow's message is a flag, not the answer. Flow keeps three systems in line:
|
|
185
|
+
Seazona (the jobs), Flow (its invoices) and Xero (the invoices and payments).
|
|
186
|
+
No one system is always right. Find out what is true, then fix the wrong one.
|
|
187
|
+
|
|
188
|
+
1. Read all three, through Flow:
|
|
189
|
+
- Flow: `flow.sh get invoicing/invoices/<id>` and
|
|
190
|
+
`flow.sh get invoicing/jobs/<job id>/reconcile`.
|
|
191
|
+
- Seazona: `flow.sh get seazona/api/orders/<order id>` (the job's
|
|
192
|
+
`sourceJobRef`, also its case number). Its invoice number is the job's
|
|
193
|
+
`sourceInvoiceRef`.
|
|
194
|
+
- Xero: `flow.sh post xero/invoices/status '{"organisationId":"<org>","invoiceIds":["<Flow invoice id>"]}'`.
|
|
195
|
+
2. If they do not explain it, look deeper in the related records: history
|
|
196
|
+
import batches, older Seazona invoices, earlier Xero invoices for the same
|
|
197
|
+
client and reference, voids and credit notes. The cause is often one step
|
|
198
|
+
away from the job.
|
|
199
|
+
3. Say what is true and show the evidence. If the evidence does not decide it,
|
|
200
|
+
ask the person. A field in one system can be wrong while the invoice is
|
|
201
|
+
right (for example a Seazona subtotal of 0 with a total that matches the
|
|
202
|
+
lines): say so, and do not change Flow to hide it.
|
|
203
|
+
4. Fix the wrong system. Flow cannot write to Seazona: give the person the
|
|
204
|
+
exact fix and its link. Then read all three again to show they agree.
|
|
205
|
+
|
|
206
|
+
Ask the person before any send, void or tax change.
|
|
207
|
+
|
|
144
208
|
## Reconcile: Seazona jobs, Flow invoices, Xero
|
|
145
209
|
|
|
146
210
|
When the person says "reconcile", "tidy up" or "why is this job not
|
|
@@ -162,6 +226,11 @@ short groups, then act on the group they confirm.
|
|
|
162
226
|
draft by itself. It refuses a live Flow invoice: void that first, only
|
|
163
227
|
after the person says yes to that invoice
|
|
164
228
|
(`flow.sh post invoicing/invoices/<id>/void`).
|
|
229
|
+
- **Xero already bills the job** (Send stopped it: "same client and
|
|
230
|
+
reference", or its Flow copy was voided for that): after the person says
|
|
231
|
+
yes, `flow.sh post xero/invoices/<Flow invoice id>/use-xero '{"organisationId":"<org>","xeroInvoiceId":"<Xero InvoiceID>"}'`.
|
|
232
|
+
Flow voids its copy (if not void yet), makes its Flow invoice from the
|
|
233
|
+
Xero one and gives it to the job. Same client only; one job per copy.
|
|
165
234
|
- **Held or in review:** `flow.sh get invoicing/jobs/<job id>/activity`
|
|
166
235
|
shows why. Release it: `flow.sh post invoicing/jobs/<job id>/hold '{"state":"none","reason":"<why>"}'`;
|
|
167
236
|
hold it: `'{"state":"held","reason":"<why>"}'` (or `"review"`).
|