@arcforgelabs/flow 2026.11.8 → 2026.11.10
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 +18 -2
- package/server/core/contacts.js +79 -5
- package/server/core/invoice-rules.js +14 -1
- package/server/core/invoices.js +2 -0
- package/server/core/invoicing-rules.js +55 -4
- package/server/core/ledger.js +25 -2
- package/server/core/merge-contacts.js +182 -0
- package/server/core/pipeline.js +77 -13
- package/server/db/migrations/0030_contact_hidden.sql +1 -0
- package/server/db/migrations/0031_bill_to.sql +10 -0
- package/server/db/migrations/0032_contact_merge.sql +12 -0
- package/server/db/migrations/meta/0030_snapshot.json +5076 -0
- package/server/db/migrations/meta/0031_snapshot.json +5123 -0
- package/server/db/migrations/meta/0032_snapshot.json +5167 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +28 -0
- package/server/integrations/seazona/sweep.js +58 -2
- package/server/integrations/xero/create-contact.js +15 -1
- package/server/integrations/xero/delete-invoice.js +1 -1
- package/server/integrations/xero/drafts.js +20 -5
- package/server/integrations/xero/ledger.js +7 -4
- package/server/integrations/xero/links.js +85 -18
- package/server/integrations/xero/lookup.js +7 -4
- package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
- package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +63 -8
- package/server/integrations/xero/reconcile.js +1 -1
- package/server/integrations/xero/routes.js +22 -2
- package/server/ui/app.js +31 -2
- package/server/ui/contacts.js +150 -15
|
@@ -211,6 +211,27 @@
|
|
|
211
211
|
"when": 1794463202822,
|
|
212
212
|
"tag": "0029_job_remake",
|
|
213
213
|
"breakpoints": true
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"idx": 30,
|
|
217
|
+
"version": "7",
|
|
218
|
+
"when": 1794487419926,
|
|
219
|
+
"tag": "0030_contact_hidden",
|
|
220
|
+
"breakpoints": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"idx": 31,
|
|
224
|
+
"version": "7",
|
|
225
|
+
"when": 1794488676084,
|
|
226
|
+
"tag": "0031_bill_to",
|
|
227
|
+
"breakpoints": true
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"idx": 32,
|
|
231
|
+
"version": "7",
|
|
232
|
+
"when": 1794491329391,
|
|
233
|
+
"tag": "0032_contact_merge",
|
|
234
|
+
"breakpoints": true
|
|
214
235
|
}
|
|
215
236
|
]
|
|
216
237
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -61,10 +61,27 @@ export const contacts = pgTable('contacts', {
|
|
|
61
61
|
address: jsonb('address'),
|
|
62
62
|
practitioners: jsonb('practitioners').notNull().default([]),
|
|
63
63
|
active: boolean('active').notNull().default(true),
|
|
64
|
+
// Not a client: Flow made the contact from an accounting contact that the
|
|
65
|
+
// accounting system does not mark a customer, and nothing in Flow makes it
|
|
66
|
+
// one. It stays, out of the lists (docs/CONTACTS.md, "Hidden contacts").
|
|
67
|
+
hidden: boolean('hidden').notNull().default(false),
|
|
68
|
+
// Merge (docs/CONTACTS.md, "Merge two contacts"): a person merged this
|
|
69
|
+
// contact into another. Its jobs, invoices and links moved there; the row
|
|
70
|
+
// stays, out of every list. The kept contact holds its name in `aliases`.
|
|
71
|
+
mergedIntoId: uuid('merged_into_id'),
|
|
72
|
+
mergedAt: timestamp('merged_at', { withTimezone: true }),
|
|
73
|
+
mergedBy: text('merged_by'),
|
|
74
|
+
aliases: jsonb('aliases').notNull().default([]),
|
|
64
75
|
createdAt: createdAt(),
|
|
65
76
|
updatedAt: updatedAt(),
|
|
66
77
|
}, (t) => [
|
|
67
78
|
unique('contacts_org_id').on(t.organisationId, t.id),
|
|
79
|
+
foreignKey({
|
|
80
|
+
name: 'contacts_merged_into_same_org',
|
|
81
|
+
columns: [t.organisationId, t.mergedIntoId],
|
|
82
|
+
foreignColumns: [t.organisationId, t.id],
|
|
83
|
+
}),
|
|
84
|
+
check('contacts_merged_into_other', sql`${t.mergedIntoId} is null or ${t.mergedIntoId} <> ${t.id}`),
|
|
68
85
|
unique('contacts_org_kind_name').on(t.organisationId, t.kind, t.name),
|
|
69
86
|
check('contacts_kind_valid', sql`${t.kind} in ('customer', 'supplier')`),
|
|
70
87
|
]);
|
|
@@ -678,6 +695,10 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
|
|
|
678
695
|
autoDraft: boolean('auto_draft'),
|
|
679
696
|
sendMode: text('send_mode'),
|
|
680
697
|
paymentTermsDays: integer('payment_terms_days'),
|
|
698
|
+
// The payer: another Flow customer whose accounting contact this
|
|
699
|
+
// customer's work is billed to (a parent organisation). Many customers may
|
|
700
|
+
// name one payer. Null bills the customer itself (docs/CONTACTS.md, "Bill to").
|
|
701
|
+
billToContactId: uuid('bill_to_contact_id'),
|
|
681
702
|
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
682
703
|
updatedAt: updatedAt(),
|
|
683
704
|
}, (t) => [
|
|
@@ -687,6 +708,13 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
|
|
|
687
708
|
columns: [t.organisationId, t.contactId],
|
|
688
709
|
foreignColumns: [contacts.organisationId, contacts.id],
|
|
689
710
|
}),
|
|
711
|
+
foreignKey({
|
|
712
|
+
name: 'customer_billing_settings_bill_to_same_org',
|
|
713
|
+
columns: [t.organisationId, t.billToContactId],
|
|
714
|
+
foreignColumns: [contacts.organisationId, contacts.id],
|
|
715
|
+
}),
|
|
716
|
+
index('customer_billing_settings_bill_to').on(t.organisationId, t.billToContactId),
|
|
717
|
+
check('customer_billing_settings_bill_to_other', sql`${t.billToContactId} is null or ${t.billToContactId} <> ${t.contactId}`),
|
|
690
718
|
check('customer_billing_settings_send_mode_valid', sql`${t.sendMode} is null or ${t.sendMode} in ('accounting_draft', 'approve_and_email')`),
|
|
691
719
|
check('customer_billing_settings_terms_valid', sql`${t.paymentTermsDays} is null or ${t.paymentTermsDays} between 0 and 365`),
|
|
692
720
|
]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, eq, gt, gte, inArray, isNull, like, lt, or, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, or, sql } from 'drizzle-orm';
|
|
2
2
|
import {
|
|
3
3
|
clientListSignature,
|
|
4
4
|
invoiceListSignature,
|
|
@@ -492,6 +492,61 @@ async function readHighWater(db, laneStates, sync, nowMs) {
|
|
|
492
492
|
|
|
493
493
|
// The sweep lane's run. `helpers` come from lanes.js: readOrder (issue #12
|
|
494
494
|
// for each order read), isLaneLevel, describeReadFailure, emptyResponse and
|
|
495
|
+
// Orders whose invoice Flow's copy does not hold. The invoice lane reads
|
|
496
|
+
// invoices changed since its first run, so an invoice last changed before
|
|
497
|
+
// that was never read, and its job took the order's products alone: no
|
|
498
|
+
// Shipping/Handling or Rush, which are service lines on the invoice. Seazona
|
|
499
|
+
// gives an order's invoice the order's own id. The sweep reads it and keeps it
|
|
500
|
+
// when its number is the order's; the feed then gives the job the invoice's
|
|
501
|
+
// lines and totals. An order Seazona answers 404 for, or with another number,
|
|
502
|
+
// is noted (`no_invoice`) and not asked again.
|
|
503
|
+
export const ORDER_INVOICE_KIND = 'no_invoice';
|
|
504
|
+
async function readOrderInvoices(ctx, helpers, found) {
|
|
505
|
+
const { db, client, sync, at } = ctx;
|
|
506
|
+
const rows = await db.select({ id: seazonaOrders.id, invoiceNumber: seazonaOrders.invoiceNumber })
|
|
507
|
+
.from(seazonaOrders)
|
|
508
|
+
.leftJoin(seazonaInvoices, eq(seazonaInvoices.invoiceNumber, seazonaOrders.invoiceNumber))
|
|
509
|
+
.leftJoin(seazonaSweepFindings, and(
|
|
510
|
+
eq(seazonaSweepFindings.resource, 'orders'),
|
|
511
|
+
eq(seazonaSweepFindings.recordKey, seazonaOrders.id),
|
|
512
|
+
eq(seazonaSweepFindings.kind, ORDER_INVOICE_KIND),
|
|
513
|
+
))
|
|
514
|
+
.where(and(
|
|
515
|
+
isNull(seazonaOrders.deletedInSourceAt),
|
|
516
|
+
isNotNull(seazonaOrders.invoiceNumber),
|
|
517
|
+
isNull(seazonaInvoices.invoiceNumber),
|
|
518
|
+
isNull(seazonaSweepFindings.recordKey),
|
|
519
|
+
))
|
|
520
|
+
.orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
|
|
521
|
+
.limit(CONFIRM_BATCH);
|
|
522
|
+
for (const row of rows) {
|
|
523
|
+
if (!client.hasCall()) throw new SweepBudgetSpent();
|
|
524
|
+
let detail;
|
|
525
|
+
try {
|
|
526
|
+
detail = await client.get(`invoices/${encodeURIComponent(row.id)}`);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error instanceof SweepBudgetSpent || helpers.isLaneLevel(error)) throw error;
|
|
529
|
+
if (error?.statusCode === 404) {
|
|
530
|
+
await recordFinding(db, 'orders', row.id, ORDER_INVOICE_KIND, { at, detail: { status: 404 } });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
// Any other failure: try again on a later run.
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const invoice = detail && typeof detail === 'object' && !Array.isArray(detail)
|
|
537
|
+
? mapInvoice({}, detail, { keepPatientReference: sync.keepPatientReference }) : null;
|
|
538
|
+
if (!invoice || invoice.invoiceNumber !== row.invoiceNumber) {
|
|
539
|
+
await recordFinding(db, 'orders', row.id, ORDER_INVOICE_KIND, { at, detail: { otherNumber: Boolean(invoice?.invoiceNumber) } });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const summary = await upsertRecords(db, 'invoices', [invoice], { at });
|
|
543
|
+
if (summary.created) {
|
|
544
|
+
found.created += 1;
|
|
545
|
+
await recordFinding(db, 'invoices', invoice.invoiceNumber, 'created', { at, detail: { lastModified: invoice.lastModifiedText } });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
495
550
|
// LaneError.
|
|
496
551
|
export async function runSweep(ctx, helpers) {
|
|
497
552
|
const { db, sync, at, state } = ctx;
|
|
@@ -506,6 +561,7 @@ export async function runSweep(ctx, helpers) {
|
|
|
506
561
|
const save = () => ({ ...cursor, budget: { day: today, calls: spentToday + budget.spent } });
|
|
507
562
|
|
|
508
563
|
try {
|
|
564
|
+
await readOrderInvoices(sweepCtx, helpers, found);
|
|
509
565
|
for (const kind of SWEEP_KINDS) {
|
|
510
566
|
if (budget.left <= 0) break;
|
|
511
567
|
if (!isDue(cursor[kind], at, sync, kind)) continue;
|
|
@@ -557,7 +613,7 @@ export async function readSweepHealth(db, settings, { now = Date.now } = {}) {
|
|
|
557
613
|
.groupBy(seazonaSweepFindings.resource, seazonaSweepFindings.kind);
|
|
558
614
|
const foundLastWeek = {};
|
|
559
615
|
for (const row of counts) {
|
|
560
|
-
if (row.kind === 'missing') continue;
|
|
616
|
+
if (row.kind === 'missing' || row.kind === ORDER_INVOICE_KIND) continue;
|
|
561
617
|
foundLastWeek[row.resource] = { ...foundLastWeek[row.resource], [row.kind]: row.records };
|
|
562
618
|
}
|
|
563
619
|
const missingRows = await db.select({ resource: seazonaSweepFindings.resource, key: seazonaSweepFindings.recordKey })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
|
-
import { getContact } from '../../core/contacts.js';
|
|
3
|
+
import { billedContact, getContact } from '../../core/contacts.js';
|
|
4
4
|
import { flowIdFor, linkRecord, supersedeLink } from '../../core/links.js';
|
|
5
5
|
import { mapContact, matchContactFor, normaliseName, upsertContact } from './catalogue.js';
|
|
6
6
|
import { xeroError } from './client.js';
|
|
@@ -79,8 +79,20 @@ export async function matchCustomer(db, runtime, { organisationId, contactId })
|
|
|
79
79
|
const connected = Boolean(connection && connection.mode === runtime.settings.mode);
|
|
80
80
|
if (!connected) return { state: 'off', offerCreate: false, offerLink: false, xeroContactId: null, name: null, differs: [] };
|
|
81
81
|
const contact = await getContact(db, organisationId, contactId);
|
|
82
|
+
// Bill to: a customer with a payer is billed as the payer. Link and Create
|
|
83
|
+
// are for the payer's own page.
|
|
84
|
+
const payer = await billedContact(db, organisationId, contactId);
|
|
85
|
+
if (payer.id !== contactId) {
|
|
86
|
+
const billed = await matchContactFor(db, organisationId, { contactId: payer.id, name: payer.name });
|
|
87
|
+
return {
|
|
88
|
+
state: billed.state, offerCreate: false, offerLink: false, differs: [],
|
|
89
|
+
xeroContactId: billed.contact?.xeroContactId ?? null, name: billed.contact?.name ?? null,
|
|
90
|
+
billTo: { id: payer.id, name: payer.name },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
82
93
|
const match = await matchContactFor(db, organisationId, { contactId, name: contact.name });
|
|
83
94
|
return {
|
|
95
|
+
billTo: null,
|
|
84
96
|
// Fields both sides changed (Two-way edits, contact-sync.js): alert only.
|
|
85
97
|
differs: match.state === 'matched' ? (await contactClashes(db, organisationId, contactId)).map(({ field, flow, xero }) => ({ field, flow, xero })) : [],
|
|
86
98
|
state: match.state,
|
|
@@ -97,6 +109,7 @@ export async function createCustomer(db, runtime, { organisationId, contactId, a
|
|
|
97
109
|
}
|
|
98
110
|
runtime.assertWritesAllowed();
|
|
99
111
|
const flowContact = await getContact(db, organisationId, contactId);
|
|
112
|
+
if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
|
|
100
113
|
const known = await matchContactFor(db, organisationId, { contactId, name: flowContact.name });
|
|
101
114
|
if (known.state === 'ambiguous') {
|
|
102
115
|
throw xeroError('xero_contact_ambiguous', `More than one Xero contact is named "${flowContact.name}". Merge or rename them in Xero, then sync.`, { status: 409 });
|
|
@@ -140,6 +153,7 @@ export async function linkCustomer(db, runtime, { organisationId, contactId, xer
|
|
|
140
153
|
throw xeroError('xero_not_connected', 'Connect Xero first.', { status: 409 });
|
|
141
154
|
}
|
|
142
155
|
const flowContact = await getContact(db, organisationId, contactId);
|
|
156
|
+
if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
|
|
143
157
|
const { rows: [row] } = await db.execute(sql`select xero_contact_id, name, status from xero_contacts
|
|
144
158
|
where organisation_id = ${organisationId} and xero_contact_id = ${xeroContactId}`);
|
|
145
159
|
if (!row) throw xeroError('xero_contact_unknown', 'Flow has no copy of that Xero contact. Sync, then try again.', { status: 404 });
|
|
@@ -30,7 +30,7 @@ async function linkedXeroId(db, organisationId, invoiceId) {
|
|
|
30
30
|
if (pushes[0]?.xero_invoice_id) return pushes[0].xero_invoice_id;
|
|
31
31
|
const { rows: links } = await db.execute(sql`
|
|
32
32
|
select xero_invoice_id from xero_invoice_links
|
|
33
|
-
where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
|
|
33
|
+
where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and released_at is null`);
|
|
34
34
|
return links[0]?.xero_invoice_id ?? null;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { isCallsStopped } from '../call-guard.js';
|
|
4
|
+
import { billedContact } from '../../core/contacts.js';
|
|
4
5
|
import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
|
|
5
6
|
import { sendModeFor } from '../../core/invoicing-rules.js';
|
|
6
7
|
import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
|
|
@@ -325,15 +326,29 @@ export async function previewDrafts(db, runtime, organisationId, invoiceIds) {
|
|
|
325
326
|
return { syncedAt, warnings, invoices: results };
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
// The Xero contact an invoice goes to: its customer's, or its payer's when the
|
|
330
|
+
// customer bills to another customer (docs/CONTACTS.md, "Bill to"). A missing
|
|
331
|
+
// payer is linked or made on the payer's own contact page, not here.
|
|
332
|
+
async function accountingContactFor(db, organisationId, invoice, document) {
|
|
333
|
+
const payer = await billedContact(db, organisationId, invoice.contactId);
|
|
334
|
+
const own = payer.id === invoice.contactId;
|
|
335
|
+
const name = own ? document.contact.name : payer.name;
|
|
336
|
+
return { match: await matchContactFor(db, organisationId, { contactId: payer.id, name }), name, own };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const payerMissing = (name) => problem('xero_contact_missing',
|
|
340
|
+
`This client is billed to ${name}, and no Xero contact is named "${name}". Link ${name} to its Xero client on its contact page, then check again.`,
|
|
341
|
+
{ short: 'Payer not in Xero' });
|
|
342
|
+
|
|
328
343
|
async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
329
344
|
const { invoice, version, document } = await documentFor(db, organisationId, invoiceId);
|
|
330
345
|
const problems = [];
|
|
331
346
|
if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
|
|
332
347
|
if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
|
|
333
|
-
const match = await
|
|
348
|
+
const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
|
|
334
349
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
335
|
-
if (match.state === 'missing') problems.push(missingContact(
|
|
336
|
-
if (match.state === 'ambiguous') problems.push(twiceContact(
|
|
350
|
+
if (match.state === 'missing') problems.push(own ? missingContact(name, { offerCreate }) : payerMissing(name));
|
|
351
|
+
if (match.state === 'ambiguous') problems.push(own ? twiceContact(name) : { ...twiceContact(name), offerLink: false });
|
|
337
352
|
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
338
353
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
339
354
|
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
@@ -513,10 +528,10 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
513
528
|
}
|
|
514
529
|
}
|
|
515
530
|
|
|
516
|
-
const match = await
|
|
531
|
+
const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
|
|
517
532
|
if (match.state !== 'matched') {
|
|
518
533
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
519
|
-
const found = match.state === 'missing' ? missingContact(
|
|
534
|
+
const found = match.state === 'missing' ? (own ? missingContact(name, { offerCreate }) : payerMissing(name)) : { ...twiceContact(name), offerLink: own };
|
|
520
535
|
throw xeroError(found.code, found.message, { status: 409, short: found.short });
|
|
521
536
|
}
|
|
522
537
|
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
2
|
+
import { payersOf } from '../../core/contacts.js';
|
|
2
3
|
import { recordAccountingEvidence, recordPayment } from '../../core/ledger.js';
|
|
3
4
|
import { billingObligations, contacts as flowContacts } from '../../db/schema.js';
|
|
4
|
-
import {
|
|
5
|
+
import { matchContactFor, parseXeroDate } from './catalogue.js';
|
|
5
6
|
import { withXeroLane } from './governor.js';
|
|
6
7
|
|
|
7
8
|
// What the billing obligation ledger (server/core/ledger.js) needs from Xero:
|
|
@@ -136,9 +137,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
136
137
|
if (references.length) {
|
|
137
138
|
let customer = null;
|
|
138
139
|
if (obligation.contactId) {
|
|
140
|
+
// A customer with a payer is billed under the payer's name (Bill to).
|
|
141
|
+
const payer = (await payersOf(db, organisationId, [obligation.contactId])).get(obligation.contactId);
|
|
139
142
|
const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
|
|
140
|
-
.where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id,
|
|
141
|
-
const match = contact ? await
|
|
143
|
+
.where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id, payer)));
|
|
144
|
+
const match = contact ? await matchContactFor(db, organisationId, { contactId: payer, name: contact.name }) : { state: 'missing' };
|
|
142
145
|
customer = match.state === 'matched' ? match.contact.xeroContactId : null;
|
|
143
146
|
if (match.state === 'ambiguous') {
|
|
144
147
|
const error = new Error(`More than one Xero contact is named "${contact.name}"; Flow cannot tell which invoices are this customer's.`);
|
|
@@ -231,7 +234,7 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
|
|
|
231
234
|
const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
|
|
232
235
|
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
|
|
233
236
|
union all select xero_invoice_id, invoice_id from xero_invoice_links
|
|
234
|
-
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
|
|
237
|
+
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[]) and released_at is null`);
|
|
235
238
|
for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
|
|
236
239
|
}
|
|
237
240
|
const flowInvoiceIds = [...new Set(byFlowInvoice.values())];
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { and, eq, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, eq, isNull, sql } from 'drizzle-orm';
|
|
2
2
|
import { billingObligations, contacts, invoices, obligationEvents, priceItems } from '../../db/schema.js';
|
|
3
|
+
import { payersOf } from '../../core/contacts.js';
|
|
3
4
|
import { createContact } from '../../core/directory.js';
|
|
4
5
|
import { FlowError } from '../../core/errors.js';
|
|
5
6
|
import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
|
|
6
|
-
import { linkJobToInvoice } from '../../core/ledger.js';
|
|
7
|
+
import { linkJobToInvoice, recordAccountingEvidence, releaseAccountingRef } from '../../core/ledger.js';
|
|
7
8
|
import { normaliseName } from './catalogue.js';
|
|
9
|
+
import { findInvoices } from './lookup.js';
|
|
8
10
|
import { canFit, markRivals, scoreFit, sureFits } from './fit.js';
|
|
9
11
|
|
|
10
12
|
// The Import page (docs/XERO_IMPORT.md): each sales invoice in Flow's copy of
|
|
@@ -54,7 +56,7 @@ async function classify(db, organisationId) {
|
|
|
54
56
|
select xero_invoice_id, invoice_id, 'sent' as matched_by from xero_invoice_pushes
|
|
55
57
|
where organisation_id = ${organisationId} and xero_invoice_id is not null
|
|
56
58
|
union all
|
|
57
|
-
select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId}`);
|
|
59
|
+
select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId} and released_at is null`);
|
|
58
60
|
const linkOf = new Map();
|
|
59
61
|
const linkedInvoices = new Set();
|
|
60
62
|
for (const r of linkRows) {
|
|
@@ -63,13 +65,23 @@ async function classify(db, organisationId) {
|
|
|
63
65
|
}
|
|
64
66
|
// Flow's customers by name, as the drafts match them (only a name with one
|
|
65
67
|
// contact counts).
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// A merged contact is gone; the kept one answers to its aliases too.
|
|
69
|
+
const people = await db.select({ id: contacts.id, name: contacts.name, aliases: contacts.aliases }).from(contacts)
|
|
70
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId)));
|
|
68
71
|
const byName = new Map();
|
|
69
72
|
for (const p of people) {
|
|
70
|
-
const
|
|
71
|
-
|
|
73
|
+
for (const name of [p.name, ...(p.aliases ?? [])]) {
|
|
74
|
+
const key = normaliseName(name);
|
|
75
|
+
if (byName.has(key) && byName.get(key) === p.id) continue;
|
|
76
|
+
byName.set(key, byName.has(key) ? null : p.id);
|
|
77
|
+
}
|
|
72
78
|
}
|
|
79
|
+
// The identity-map link first, so a Xero contact of another name finds its
|
|
80
|
+
// Flow customer, then the name.
|
|
81
|
+
const { rows: contactLinks } = await db.execute(sql`select l.external_id, l.flow_id from external_links l
|
|
82
|
+
join contacts c on c.organisation_id = l.organisation_id and c.id = l.flow_id and c.kind = 'customer'
|
|
83
|
+
where l.organisation_id = ${organisationId} and l.system = 'xero' and l.entity = 'contact' and l.state = 'active'`);
|
|
84
|
+
const byLink = new Map(contactLinks.map((l) => [l.external_id, l.flow_id]));
|
|
73
85
|
const { rows: flowInvoices } = await db.execute(sql`
|
|
74
86
|
select i.id, i.number, i.contact_id, i.total_cents, i.subtotal_cents, i.reference, i.status, coalesce(i.issue_date, i.created_at::date) as date,
|
|
75
87
|
coalesce((select array_agg(distinct p.code) from invoice_lines l join price_items p on p.id = l.price_item_id
|
|
@@ -83,6 +95,11 @@ async function classify(db, organisationId) {
|
|
|
83
95
|
select o.id, o.contact_id, o.total_cents, o.job_date, o.practitioner, o.case_ref, o.accounting_ref, o.source_invoice_ref, o.lines
|
|
84
96
|
from billing_obligations o
|
|
85
97
|
where o.organisation_id = ${organisationId} and o.invoice_id is null`);
|
|
98
|
+
// Bill to (docs/CONTACTS.md): a customer with a payer is billed under the
|
|
99
|
+
// payer's Xero contact, so its jobs and invoices fit that contact's invoices.
|
|
100
|
+
// A Xero row of a client's own Xero contact fits as that client's payer too.
|
|
101
|
+
const payerOf = await payersOf(db, organisationId, people.map((p) => p.id));
|
|
102
|
+
const billedAs = (contactId) => (contactId ? payerOf.get(contactId) ?? contactId : null);
|
|
86
103
|
const jobByRef = new Map(jobs.filter((j) => j.accounting_ref).map((j) => [j.accounting_ref, j]));
|
|
87
104
|
// The work system's own invoice number, as Xero numbers it (a trailing R is
|
|
88
105
|
// a reissue). Only a number one job holds counts.
|
|
@@ -95,7 +112,7 @@ async function classify(db, organisationId) {
|
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
const rows = xero.map((x) => {
|
|
98
|
-
const contactId = x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null;
|
|
115
|
+
const contactId = byLink.get(x.xero_contact_id) ?? (x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null);
|
|
99
116
|
const row = {
|
|
100
117
|
xeroInvoiceId: x.xero_invoice_id,
|
|
101
118
|
number: x.invoice_number,
|
|
@@ -117,10 +134,11 @@ async function classify(db, organisationId) {
|
|
|
117
134
|
return { ...row, state: 'linked' };
|
|
118
135
|
}
|
|
119
136
|
row.itemCodes = [...new Set((x.lines ?? []).map((l) => l.itemCode).filter(Boolean))];
|
|
120
|
-
const
|
|
137
|
+
const billedTo = billedAs(contactId);
|
|
138
|
+
const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId: billedTo, totalCents: row.totalCents, issuedOn: row.issuedOn,
|
|
121
139
|
itemCodes: (x.lines ?? []).map((l) => l.itemCode).filter(Boolean), reference: x.reference };
|
|
122
140
|
row.invoices = open.map((i) => {
|
|
123
|
-
const flowFit = { id: i.id, contactId: i.contact_id, totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
|
|
141
|
+
const flowFit = { id: i.id, contactId: billedAs(i.contact_id), totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
|
|
124
142
|
itemCodes: i.item_codes ?? [], reference: i.reference, jobs: i.jobs ?? [] };
|
|
125
143
|
const scored = scoreFit(xeroFit, flowFit);
|
|
126
144
|
return canFit(xeroFit, flowFit, scored)
|
|
@@ -129,8 +147,8 @@ async function classify(db, organisationId) {
|
|
|
129
147
|
: null;
|
|
130
148
|
}).filter(Boolean);
|
|
131
149
|
const tied = jobByRef.get(x.xero_invoice_id) ?? (x.invoice_number ? jobByNumber.get(workNumber(x.invoice_number)) : null) ?? null;
|
|
132
|
-
const fits = tied ? [tied] :
|
|
133
|
-
? jobs.filter((j) => !j.accounting_ref && j.contact_id ===
|
|
150
|
+
const fits = tied ? [tied] : billedTo
|
|
151
|
+
? jobs.filter((j) => !j.accounting_ref && billedAs(j.contact_id) === billedTo && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
|
|
134
152
|
: [];
|
|
135
153
|
row.jobs = fits.map((j) => ({ id: j.id, jobDate: day(j.job_date), practitioner: j.practitioner, caseRef: j.case_ref, totalCents: num(j.total_cents), tied: j === tied,
|
|
136
154
|
itemCodes: [...new Set((j.lines ?? []).map((l) => l.itemCode).filter(Boolean))] }));
|
|
@@ -208,9 +226,16 @@ export async function saveImportSettings(db, organisationId, { autoLink, makeUnm
|
|
|
208
226
|
}
|
|
209
227
|
|
|
210
228
|
async function insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy, actor }) {
|
|
229
|
+
// A released link (releaseXeroLink) gives its row to the new one.
|
|
230
|
+
const { rows: held } = await db.execute(sql`select 1 from xero_invoice_links where organisation_id = ${organisationId}
|
|
231
|
+
and invoice_id = ${invoiceId} and xero_invoice_id <> ${xeroInvoiceId} limit 1`);
|
|
232
|
+
if (held.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
|
|
211
233
|
const { rows } = await db.execute(sql`insert into xero_invoice_links (organisation_id, xero_invoice_id, invoice_id, matched_by, linked_by)
|
|
212
234
|
values (${organisationId}, ${xeroInvoiceId}, ${invoiceId}, ${matchedBy}, ${actor})
|
|
213
|
-
on conflict do
|
|
235
|
+
on conflict (organisation_id, xero_invoice_id) do update set invoice_id = excluded.invoice_id, matched_by = excluded.matched_by,
|
|
236
|
+
linked_by = excluded.linked_by, created_at = now(), released_by = null, released_at = null, deleted_by = null, deleted_at = null
|
|
237
|
+
where xero_invoice_links.released_at is not null
|
|
238
|
+
returning xero_invoice_id`);
|
|
214
239
|
if (!rows.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
|
|
215
240
|
}
|
|
216
241
|
|
|
@@ -251,7 +276,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
|
|
|
251
276
|
if (!invoice) throw new FlowError('not_found', 'Flow invoice not found');
|
|
252
277
|
// A copy voided earlier, still holding its job, gives way the same way.
|
|
253
278
|
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
|
-
|
|
279
|
+
const payers = await payersOf(tx, organisationId, [invoice.contact_id, row.contactId]);
|
|
280
|
+
if (!row.contactId || payers.get(row.contactId) !== payers.get(invoice.contact_id)) throw new FlowError('invalid', 'the Xero invoice is for another client');
|
|
255
281
|
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
282
|
if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
|
|
257
283
|
const jobs = await tx.select({ id: billingObligations.id }).from(billingObligations)
|
|
@@ -267,7 +293,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
|
|
|
267
293
|
evidence: { invoiceId, reason: 'used_xero_invoice', xeroInvoiceId, xeroNumber: row.number ?? null },
|
|
268
294
|
});
|
|
269
295
|
}
|
|
270
|
-
|
|
296
|
+
// The new Flow invoice stays with the client of the one it replaces.
|
|
297
|
+
const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, contactId: invoice.contact_id, actor });
|
|
271
298
|
return { voided: { id: invoiceId, number: invoice.number }, invoice: { id: made.invoice.id, number: made.invoice.number }, xeroInvoiceId, jobId: jobs[0]?.id ?? null };
|
|
272
299
|
});
|
|
273
300
|
}
|
|
@@ -281,14 +308,21 @@ export async function makeFlowInvoice(db, organisationId, xeroInvoiceId, { jobId
|
|
|
281
308
|
return makeFrom(db, organisationId, await rowFor(db, organisationId, xeroInvoiceId), { jobId, actor });
|
|
282
309
|
}
|
|
283
310
|
|
|
284
|
-
async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
|
|
311
|
+
async function makeFrom(db, organisationId, row, { jobId = null, contactId: chosen = null, actor }) {
|
|
285
312
|
const { xeroInvoiceId } = row;
|
|
286
313
|
const { rows: [x] } = await db.execute(sql`select x.lines, x.due_on, c.email from xero_invoices x
|
|
287
314
|
left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
|
|
288
315
|
where x.organisation_id = ${organisationId} and x.xero_invoice_id = ${xeroInvoiceId}`);
|
|
289
316
|
if (!row.clientName) throw new FlowError('invalid', 'the Xero invoice has no contact in the copy; sync contacts first');
|
|
290
317
|
return db.transaction(async (tx) => {
|
|
291
|
-
|
|
318
|
+
// The job's own customer when it bills to this payer (Bill to), so the
|
|
319
|
+
// Flow invoice stays with the client the work was for.
|
|
320
|
+
const { rows: [job] } = jobId ? await tx.execute(sql`select contact_id from billing_obligations
|
|
321
|
+
where organisation_id = ${organisationId} and id = ${jobId}`) : { rows: [] };
|
|
322
|
+
const payers = await payersOf(tx, organisationId, [job?.contact_id, row.contactId]);
|
|
323
|
+
const samePayer = job?.contact_id && row.contactId && payers.get(job.contact_id) === payers.get(row.contactId);
|
|
324
|
+
const contactId = chosen ?? (samePayer ? job.contact_id : null)
|
|
325
|
+
?? row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
|
|
292
326
|
const items = await tx.select({ id: priceItems.id, code: priceItems.code, name: priceItems.name }).from(priceItems)
|
|
293
327
|
.where(eq(priceItems.organisationId, organisationId));
|
|
294
328
|
const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
|
|
@@ -308,7 +342,16 @@ async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
|
|
|
308
342
|
const draft = await createDraftInvoice(tx, organisationId, { contactId, reference: row.reference ?? null, dueDate: day(x.due_on), lines });
|
|
309
343
|
const invoice = await issueInvoice(tx, organisationId, draft.id, { issuedBy: actor, issueDate: row.issuedOn ?? new Date().toISOString().slice(0, 10) });
|
|
310
344
|
await insertLink(tx, organisationId, { xeroInvoiceId, invoiceId: invoice.id, matchedBy: 'made', actor });
|
|
311
|
-
if (jobId)
|
|
345
|
+
if (jobId) {
|
|
346
|
+
await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
|
|
347
|
+
// The job's accounting state in the same step, from the copy the
|
|
348
|
+
// invoice was made from: the ledger says billed or paid at once, not
|
|
349
|
+
// after the matcher's next pass.
|
|
350
|
+
const { invoices: [copy] } = await findInvoices(tx, organisationId, { invoiceIds: [xeroInvoiceId] });
|
|
351
|
+
if (copy?.state) {
|
|
352
|
+
await recordAccountingEvidence(tx, organisationId, jobId, { state: copy.state, accountingRef: xeroInvoiceId, matchedBy: 'accounting_ref', actor });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
312
355
|
return { xeroInvoiceId, invoice };
|
|
313
356
|
});
|
|
314
357
|
}
|
|
@@ -329,3 +372,27 @@ export async function matchXero(db, organisationId, { actor = 'automation:xero-i
|
|
|
329
372
|
}
|
|
330
373
|
return result;
|
|
331
374
|
}
|
|
375
|
+
|
|
376
|
+
// A person releases a wrong pair: the Xero invoice is linked to a Flow invoice
|
|
377
|
+
// that was voided, and is another job's bill. The link stays as a record
|
|
378
|
+
// (released_by, released_at) and the Xero invoice can link again. Each job
|
|
379
|
+
// that points at the Xero invoice and no longer holds a Flow invoice lets go
|
|
380
|
+
// of it, and the matcher never pairs them again. A live Flow invoice keeps
|
|
381
|
+
// its link: void it first.
|
|
382
|
+
export async function releaseXeroLink(db, organisationId, xeroInvoiceId, { actor, reason }) {
|
|
383
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
384
|
+
if (!reason?.trim()) throw new FlowError('invalid', 'say why the link is released');
|
|
385
|
+
return db.transaction(async (tx) => {
|
|
386
|
+
const { rows: [link] } = await tx.execute(sql`select l.invoice_id, i.number, i.status from xero_invoice_links l
|
|
387
|
+
join invoices i on i.organisation_id = l.organisation_id and i.id = l.invoice_id
|
|
388
|
+
where l.organisation_id = ${organisationId} and l.xero_invoice_id = ${xeroInvoiceId} and l.released_at is null for update of l`);
|
|
389
|
+
if (!link) throw new FlowError('not_found', 'the Xero invoice has no link to release');
|
|
390
|
+
if (link.status !== 'void') throw new FlowError('invalid', `Flow invoice ${link.number ?? link.invoice_id} is ${link.status}: void it first`);
|
|
391
|
+
await tx.execute(sql`update xero_invoice_links set released_by = ${actor}, released_at = now()
|
|
392
|
+
where organisation_id = ${organisationId} and xero_invoice_id = ${xeroInvoiceId}`);
|
|
393
|
+
const { rows: jobs } = await tx.execute(sql`select id from billing_obligations
|
|
394
|
+
where organisation_id = ${organisationId} and accounting_ref = ${xeroInvoiceId} and invoice_id is null`);
|
|
395
|
+
for (const job of jobs) await releaseAccountingRef(tx, organisationId, job.id, { accountingRef: xeroInvoiceId, system: 'xero', actor, reason });
|
|
396
|
+
return { xeroInvoiceId, invoice: { id: link.invoice_id, number: link.number }, jobs: jobs.map((j) => j.id) };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
@@ -15,8 +15,10 @@ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
|
15
15
|
export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
|
|
16
16
|
|
|
17
17
|
// `contact` is the Flow contact a new client becomes (docs/CONTACTS.md,
|
|
18
|
-
// "Add Xero clients").
|
|
19
|
-
//
|
|
18
|
+
// "Add Xero clients"). Only a contact Xero marks a customer (it has a sales
|
|
19
|
+
// invoice there) becomes one (`adoptable`). The others (suppliers, staff,
|
|
20
|
+
// names from bank lines) still link by name. `customer` is Xero's mark: the
|
|
21
|
+
// core hides a Flow contact made from one that has none ("Hidden contacts").
|
|
20
22
|
export async function listCustomers(db, organisationId) {
|
|
21
23
|
const { rows } = await db.execute(sql`
|
|
22
24
|
select xero_contact_id, name, first_name, last_name, email, phone, mobile, account_number, tax_number, address,
|
|
@@ -27,7 +29,8 @@ export async function listCustomers(db, organisationId) {
|
|
|
27
29
|
return rows.map((r) => ({
|
|
28
30
|
id: r.xero_contact_id,
|
|
29
31
|
name: r.name,
|
|
30
|
-
adoptable: Boolean(r.is_customer
|
|
32
|
+
adoptable: Boolean(r.is_customer),
|
|
33
|
+
customer: Boolean(r.is_customer),
|
|
31
34
|
contact: {
|
|
32
35
|
kind: 'customer', name: r.name, firstName: r.first_name, lastName: r.last_name, email: r.email, phone: r.phone,
|
|
33
36
|
mobile: r.mobile, accountNumber: r.account_number, taxNumber: r.tax_number, address: r.address,
|
|
@@ -135,7 +138,7 @@ export async function findInvoices(db, organisationId, query = {}) {
|
|
|
135
138
|
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
136
139
|
union all
|
|
137
140
|
select invoice_id, 1, 0 from xero_invoice_links
|
|
138
|
-
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
141
|
+
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id and released_at is null
|
|
139
142
|
) f order by pick, version desc limit 1
|
|
140
143
|
) p on true
|
|
141
144
|
where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- A person releases a Xero invoice's link to a Flow invoice that was voided
|
|
2
|
+
-- (links.js, releaseXeroLink): the pair was wrong, and the Xero invoice is
|
|
3
|
+
-- free to link again. The row stays, with who and when; a new link takes its
|
|
4
|
+
-- place. Nothing is deleted.
|
|
5
|
+
ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_by" text;
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_at" timestamp with time zone;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- The version of the copy each resource was read with: the mapper's version
|
|
2
|
+
-- (mirror.js COPY_VERSION) and whether free text is kept. When it changes,
|
|
3
|
+
-- the next read starts again from the beginning, so old rows get a new field.
|
|
4
|
+
-- Null: read before Flow noted it; the first read notes it without starting
|
|
5
|
+
-- again (kept_reference still starts one when the setting differs).
|
|
6
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "copy_version" text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
-- Who asked for a full read again, and when (POST /api/xero/mirror/reread).
|
|
9
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_by" text;
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_at" timestamp with time zone;
|
|
@@ -106,6 +106,20 @@
|
|
|
106
106
|
"when": 1792570000000,
|
|
107
107
|
"tag": "0014_xero_mirror_reference",
|
|
108
108
|
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "7",
|
|
113
|
+
"when": 1792580000000,
|
|
114
|
+
"tag": "0015_xero_link_release",
|
|
115
|
+
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1792590000000,
|
|
121
|
+
"tag": "0016_xero_copy_version",
|
|
122
|
+
"breakpoints": true
|
|
109
123
|
}
|
|
110
124
|
]
|
|
111
125
|
}
|