@arcforgelabs/flow 2026.10.26 → 2026.10.27
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 +5 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +231 -20
- package/server/app.js +28 -2
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +4 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +45 -12
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +8 -1
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +115 -0
- package/server/index.js +12 -4
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +5 -0
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +2 -1
- package/server/ui/app.js +14 -1
- package/server/ui/automations.js +96 -0
- package/server/ui/flow.css +88 -0
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/jobs.js +19 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +2 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { invoiceRules, invoicingSettings } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
|
|
6
|
+
// Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
|
|
7
|
+
// builds it from menus, so no lab's checks are fixed in Flow's code.
|
|
8
|
+
//
|
|
9
|
+
// When job_ready: the job is ready to invoice and has no invoice yet.
|
|
10
|
+
// If every condition matches: a field of the job, its customer or its
|
|
11
|
+
// lines, a test, and a value.
|
|
12
|
+
// Then wait: the job waits for a person; skip: rules never draft it.
|
|
13
|
+
//
|
|
14
|
+
// The rules read only Flow's own records, so they work with or without an
|
|
15
|
+
// accounting integration. The earlier fixed settings (old job limit, required
|
|
16
|
+
// practitioner, rush share) become starter rules, once per organisation.
|
|
17
|
+
|
|
18
|
+
const MAX_RULES = 100;
|
|
19
|
+
const MAX_CONDITIONS = 10;
|
|
20
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
21
|
+
|
|
22
|
+
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
23
|
+
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
24
|
+
const code = (line) => norm(line.itemCode);
|
|
25
|
+
const cents = (lines) => lines.reduce((sum, l) => sum + (l.lineAmountCents ?? 0), 0);
|
|
26
|
+
|
|
27
|
+
// The share of the item's lines in the job's other lines, as the charged and
|
|
28
|
+
// the expected cents for `percent`.
|
|
29
|
+
function share(lines, item, percent) {
|
|
30
|
+
const own = lines.filter((l) => code(l) === norm(item));
|
|
31
|
+
if (!own.length) return null;
|
|
32
|
+
const others = cents(lines.filter((l) => code(l) !== norm(item)));
|
|
33
|
+
return { charged: cents(own), others, expected: Math.round((others * percent) / 100) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Each field: what it reads from a job, its kind, and the tests it allows.
|
|
37
|
+
export const FIELDS = Object.freeze({
|
|
38
|
+
job_age_days: { label: 'Job age (days)', kind: 'number', read: (j, { now }) => (j.jobDate ? Math.floor((now - day(j.jobDate)) / 86_400_000) : null) },
|
|
39
|
+
job_total: { label: 'Job total', kind: 'money', read: (j) => (j.totalCents ?? 0) / 100 },
|
|
40
|
+
job_status: { label: 'Job status', kind: 'choice', options: 'statuses', read: (j) => norm(j.sourceStatus) || null },
|
|
41
|
+
practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
42
|
+
customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
|
|
43
|
+
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
44
|
+
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).map(code) },
|
|
45
|
+
line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const TESTS = Object.freeze({
|
|
49
|
+
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
50
|
+
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
51
|
+
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
52
|
+
text: ['is', 'is_not', 'is_empty', 'is_not_empty'],
|
|
53
|
+
boolean: ['is'],
|
|
54
|
+
items: ['has', 'has_not'],
|
|
55
|
+
share: ['is', 'is_not'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const ACTIONS = Object.freeze(['wait', 'skip']);
|
|
59
|
+
|
|
60
|
+
function readCondition(raw, index) {
|
|
61
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
62
|
+
const field = FIELDS[raw.field];
|
|
63
|
+
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
64
|
+
if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
|
|
65
|
+
const out = { field: raw.field, test: raw.test };
|
|
66
|
+
if (['is_empty', 'is_not_empty'].includes(raw.test)) return out;
|
|
67
|
+
const { value } = raw;
|
|
68
|
+
if (field.kind === 'boolean') {
|
|
69
|
+
if (typeof value !== 'boolean') throw invalid(`condition ${index + 1}: the value must be true or false`);
|
|
70
|
+
} else if (['number', 'money', 'share'].includes(field.kind)) {
|
|
71
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1e9) throw invalid(`condition ${index + 1}: the value must be a number`);
|
|
72
|
+
} else if (raw.test === 'is_one_of') {
|
|
73
|
+
if (!Array.isArray(value) || !value.length || value.length > 50 || value.some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) {
|
|
74
|
+
throw invalid(`condition ${index + 1}: pick 1 to 50 values`);
|
|
75
|
+
}
|
|
76
|
+
} else if (typeof value !== 'string' || !value.trim() || value.length > 200) {
|
|
77
|
+
throw invalid(`condition ${index + 1}: the value must be short text`);
|
|
78
|
+
}
|
|
79
|
+
out.value = typeof value === 'string' ? value.trim() : value;
|
|
80
|
+
if (field.kind === 'share') {
|
|
81
|
+
if (typeof raw.item !== 'string' || !raw.item.trim() || raw.item.length > 100) throw invalid(`condition ${index + 1}: pick the line item`);
|
|
82
|
+
out.item = raw.item.trim();
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRule(raw) {
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
|
|
89
|
+
const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
|
|
90
|
+
if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
|
|
91
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
92
|
+
if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
|
|
93
|
+
const enabled = raw.enabled ?? true;
|
|
94
|
+
if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
|
|
95
|
+
if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
|
|
96
|
+
throw invalid(`the rule needs 1 to ${MAX_CONDITIONS} conditions`);
|
|
97
|
+
}
|
|
98
|
+
if (!ACTIONS.includes(raw.action?.kind)) throw invalid(`the action must be one of ${ACTIONS.join(', ')}`);
|
|
99
|
+
return { name, enabled, conditions: raw.conditions.map(readCondition), action: { kind: raw.action.kind } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function matches(condition, job, context) {
|
|
103
|
+
const field = FIELDS[condition.field];
|
|
104
|
+
const have = field.read(job, context);
|
|
105
|
+
const { test, value } = condition;
|
|
106
|
+
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
107
|
+
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
108
|
+
switch (field.kind) {
|
|
109
|
+
case 'items': {
|
|
110
|
+
const found = have.includes(norm(value));
|
|
111
|
+
return test === 'has' ? found : !found;
|
|
112
|
+
}
|
|
113
|
+
case 'share': {
|
|
114
|
+
const s = share(have, condition.item, value);
|
|
115
|
+
if (!s) return false;
|
|
116
|
+
const right = Math.abs(s.charged - s.expected) <= 1;
|
|
117
|
+
return test === 'is' ? right : !right;
|
|
118
|
+
}
|
|
119
|
+
case 'number':
|
|
120
|
+
case 'money':
|
|
121
|
+
if (have === null) return false;
|
|
122
|
+
if (test === 'more_than') return have > value;
|
|
123
|
+
if (test === 'at_most') return have <= value;
|
|
124
|
+
if (test === 'is') return have === value;
|
|
125
|
+
return have !== value;
|
|
126
|
+
case 'boolean':
|
|
127
|
+
return have === value;
|
|
128
|
+
default: {
|
|
129
|
+
const left = norm(have);
|
|
130
|
+
if (test === 'is_one_of') return value.some((v) => norm(v) === left);
|
|
131
|
+
return test === 'is' ? left === norm(value) : left !== norm(value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The reasons the enabled rules give for one job, in rule order. An empty list
|
|
137
|
+
// means no rule stops its draft. `customer` is the customer's own billing
|
|
138
|
+
// settings (invoicing-rules.js).
|
|
139
|
+
export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
140
|
+
const context = { customer, now };
|
|
141
|
+
const reasons = [];
|
|
142
|
+
for (const rule of rules) {
|
|
143
|
+
if (!rule.enabled || rule.trigger !== 'job_ready') continue;
|
|
144
|
+
if (!rule.conditions.every((c) => matches(c, job, context))) continue;
|
|
145
|
+
reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
|
|
146
|
+
}
|
|
147
|
+
return reasons;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The starter rules made from the earlier fixed settings.
|
|
151
|
+
export function starterRules(settings) {
|
|
152
|
+
const rules = [
|
|
153
|
+
{ starterKey: 'not_billable', name: 'Customer not billable', enabled: true, conditions: [{ field: 'customer_billable', test: 'is', value: false }], action: { kind: 'skip' } },
|
|
154
|
+
{ starterKey: 'zero_total', name: 'No amount', enabled: true, conditions: [{ field: 'job_total', test: 'at_most', value: 0 }], action: { kind: 'wait' } },
|
|
155
|
+
{ starterKey: 'old_job', name: 'Old job', enabled: true, conditions: [{ field: 'job_age_days', test: 'more_than', value: settings.staleDays ?? 90 }], action: { kind: 'wait' } },
|
|
156
|
+
{ starterKey: 'practitioner', name: 'Practitioner required', enabled: Boolean(settings.requirePractitioner), conditions: [{ field: 'practitioner', test: 'is_empty' }], action: { kind: 'wait' } },
|
|
157
|
+
];
|
|
158
|
+
if (settings.rushItemCode) {
|
|
159
|
+
rules.push({
|
|
160
|
+
starterKey: 'rush_share', name: 'Rush charge share', enabled: true,
|
|
161
|
+
conditions: [{ field: 'line_share', item: settings.rushItemCode, test: 'is_not', value: settings.rushPercent ?? 20 }],
|
|
162
|
+
action: { kind: 'wait' },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return rules.map((r) => ({ trigger: 'job_ready', ...r }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Makes this organisation's starter rules once, from its fixed settings.
|
|
169
|
+
async function seed(db, organisationId) {
|
|
170
|
+
await db.transaction(async (tx) => {
|
|
171
|
+
await tx.insert(invoicingSettings).values({ organisationId }).onConflictDoNothing();
|
|
172
|
+
const [settings] = await tx.select().from(invoicingSettings).where(eq(invoicingSettings.organisationId, organisationId)).for('update');
|
|
173
|
+
if (settings.rulesSeeded) return;
|
|
174
|
+
const rules = starterRules(settings);
|
|
175
|
+
await tx.insert(invoiceRules).values(rules.map((r, position) => ({ organisationId, position, ...r }))).onConflictDoNothing();
|
|
176
|
+
await tx.update(invoicingSettings).set({ rulesSeeded: true }).where(eq(invoicingSettings.organisationId, organisationId));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const shape = (r) => ({
|
|
181
|
+
id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
|
|
182
|
+
conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// The organisation's rules, in order. The first read makes the starter rules.
|
|
186
|
+
export async function listRules(db, organisationId) {
|
|
187
|
+
const [settings] = await db.select({ seeded: invoicingSettings.rulesSeeded }).from(invoicingSettings)
|
|
188
|
+
.where(eq(invoicingSettings.organisationId, organisationId));
|
|
189
|
+
if (!settings?.seeded) await seed(db, organisationId);
|
|
190
|
+
const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
|
|
191
|
+
.orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
|
|
192
|
+
return rows.map(shape);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function createRule(db, organisationId, raw, { personId = null } = {}) {
|
|
196
|
+
const rule = readRule(raw);
|
|
197
|
+
const existing = await listRules(db, organisationId);
|
|
198
|
+
if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
|
|
199
|
+
const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
|
|
200
|
+
const [row] = await db.insert(invoiceRules).values({ organisationId, position, ...rule, updatedBy: personId }).returning();
|
|
201
|
+
return shape(row);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function loadRule(db, organisationId, ruleId) {
|
|
205
|
+
const [row] = await db.select().from(invoiceRules).where(and(eq(invoiceRules.organisationId, organisationId), eq(invoiceRules.id, ruleId)));
|
|
206
|
+
if (!row) throw notFound('rule');
|
|
207
|
+
return row;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Changes a rule. `changes` holds any of name, enabled, conditions, action;
|
|
211
|
+
// the result must be a whole valid rule.
|
|
212
|
+
export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
|
|
213
|
+
const row = await loadRule(db, organisationId, ruleId);
|
|
214
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
215
|
+
const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
216
|
+
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
217
|
+
.where(eq(invoiceRules.id, row.id)).returning();
|
|
218
|
+
return shape(saved);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function deleteRule(db, organisationId, ruleId) {
|
|
222
|
+
const row = await loadRule(db, organisationId, ruleId);
|
|
223
|
+
await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
|
|
224
|
+
}
|
package/server/core/invoices.js
CHANGED
|
@@ -96,6 +96,8 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
|
|
|
96
96
|
}
|
|
97
97
|
if ('reference' in changes) fields.reference = changes.reference;
|
|
98
98
|
if ('dueDate' in changes) fields.dueDate = changes.dueDate;
|
|
99
|
+
// A changed draft needs a person to check it again.
|
|
100
|
+
if (Object.keys(fields).length || 'lines' in changes) Object.assign(fields, { readyAt: null, readyBy: null });
|
|
99
101
|
let row = invoice;
|
|
100
102
|
if (Object.keys(fields).length) {
|
|
101
103
|
[row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
|
|
@@ -160,7 +162,8 @@ export async function reviseInvoice(db, organisationId, invoiceId) {
|
|
|
160
162
|
return db.transaction(async (tx) => {
|
|
161
163
|
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
162
164
|
if (invoice.status !== 'issued') throw new FlowError('not_issued', 'only an issued invoice can be revised');
|
|
163
|
-
|
|
165
|
+
// A revision is a new version: what happened to the last send no longer applies.
|
|
166
|
+
const [row] = await tx.update(invoices).set({ status: 'draft', readyAt: null, readyBy: null, sendState: null, sendError: null, updatedAt: sql`now()` })
|
|
164
167
|
.where(eq(invoices.id, invoice.id)).returning();
|
|
165
168
|
return row;
|
|
166
169
|
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import {
|
|
3
|
+
billingObligations, contacts, customerBillingSettings, invoicingSettings, SEND_MODES,
|
|
4
|
+
} from '../db/schema.js';
|
|
5
|
+
import { FlowError, notFound } from './errors.js';
|
|
6
|
+
import { listRules, ruleReasons } from './invoice-rules.js';
|
|
7
|
+
import { draftInvoiceForObligation } from './ledger.js';
|
|
8
|
+
|
|
9
|
+
// Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
|
|
10
|
+
// is ready to invoice passes before Flow drafts its invoice, and how a checked
|
|
11
|
+
// draft goes to the accounting system. Each rule is a setting of the
|
|
12
|
+
// organisation; a customer can override some of them. The rules come from
|
|
13
|
+
// the earlier lab app, where they were fixed in its code. When Flow drafts is
|
|
14
|
+
// an automation (automations.js): Flow's own "ready to invoice" stage
|
|
15
|
+
// triggers it, never another system's status.
|
|
16
|
+
//
|
|
17
|
+
// The ledger's own checks (ledger.js, draftBlockers) still guard every draft:
|
|
18
|
+
// the job is not held, every line maps to a price item, it has a customer, the
|
|
19
|
+
// accounting copy holds no invoice for it, and the work system's report is
|
|
20
|
+
// recent. The lab's own checks are invoice rules it builds (invoice-rules.js);
|
|
21
|
+
// the old job limit, practitioner and rush settings here only seed them.
|
|
22
|
+
|
|
23
|
+
export const ACTOR = 'system:rules';
|
|
24
|
+
const DRAFT_BATCH = 200;
|
|
25
|
+
|
|
26
|
+
export const INVOICING_DEFAULTS = Object.freeze({
|
|
27
|
+
sendMode: 'accounting_draft',
|
|
28
|
+
staleDays: 90,
|
|
29
|
+
paymentTermsDays: 30,
|
|
30
|
+
requirePractitioner: false,
|
|
31
|
+
rushItemCode: null,
|
|
32
|
+
rushPercent: 20,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CUSTOMER_DEFAULTS = Object.freeze({
|
|
36
|
+
billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const pick = (row, keys) => Object.fromEntries(keys.map((k) => [k, row[k]]));
|
|
40
|
+
const SETTING_KEYS = Object.keys(INVOICING_DEFAULTS);
|
|
41
|
+
const CUSTOMER_KEYS = Object.keys(CUSTOMER_DEFAULTS);
|
|
42
|
+
|
|
43
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
44
|
+
|
|
45
|
+
function wholeNumber(value, name, min, max) {
|
|
46
|
+
if (!Number.isInteger(value) || value < min || value > max) throw invalid(`${name} must be a whole number from ${min} to ${max}`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function flag(value, name) {
|
|
51
|
+
if (typeof value !== 'boolean') throw invalid(`${name} must be true or false`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function shortText(value, name) {
|
|
56
|
+
if (value === null || value === '') return null;
|
|
57
|
+
if (typeof value !== 'string' || value.length > 200) throw invalid(`${name} must be text of 200 characters at most`);
|
|
58
|
+
return value.trim() || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sendMode(value, name) {
|
|
62
|
+
if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The organisation's settings as stored, or the defaults.
|
|
67
|
+
export async function readInvoicingSettings(db, organisationId) {
|
|
68
|
+
const [row] = await db.select().from(invoicingSettings).where(eq(invoicingSettings.organisationId, organisationId));
|
|
69
|
+
return row ? pick(row, SETTING_KEYS) : { ...INVOICING_DEFAULTS };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSettingChanges(changes) {
|
|
73
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
76
|
+
if (key === 'requirePractitioner') out[key] = flag(value, key);
|
|
77
|
+
else if (key === 'sendMode') out[key] = sendMode(value, key);
|
|
78
|
+
else if (key === 'staleDays') out[key] = wholeNumber(value, key, 1, 3650);
|
|
79
|
+
else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
|
|
80
|
+
else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
|
|
81
|
+
else if (key === 'rushItemCode') out[key] = shortText(value, key);
|
|
82
|
+
else throw invalid(`unknown setting: ${key}`);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function saveInvoicingSettings(db, organisationId, changes, { personId = null } = {}) {
|
|
88
|
+
const fields = readSettingChanges(changes);
|
|
89
|
+
await db.insert(invoicingSettings)
|
|
90
|
+
.values({ organisationId, ...fields, updatedBy: personId })
|
|
91
|
+
.onConflictDoUpdate({ target: invoicingSettings.organisationId, set: { ...fields, updatedBy: personId, updatedAt: sql`now()` } });
|
|
92
|
+
return readInvoicingSettings(db, organisationId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function requireCustomer(db, organisationId, contactId) {
|
|
96
|
+
const [contact] = await db.select({ id: contacts.id, kind: contacts.kind }).from(contacts)
|
|
97
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
|
|
98
|
+
if (!contact) throw notFound('customer');
|
|
99
|
+
if (contact.kind !== 'customer') throw invalid('invoicing settings are for a customer contact');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One customer's own settings. A null value takes the organisation's setting.
|
|
103
|
+
export async function readCustomerBilling(db, organisationId, contactId) {
|
|
104
|
+
await requireCustomer(db, organisationId, contactId);
|
|
105
|
+
const [row] = await db.select().from(customerBillingSettings)
|
|
106
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.contactId, contactId)));
|
|
107
|
+
return row ? pick(row, CUSTOMER_KEYS) : { ...CUSTOMER_DEFAULTS };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function saveCustomerBilling(db, organisationId, contactId, changes, { personId = null } = {}) {
|
|
111
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
|
|
112
|
+
await requireCustomer(db, organisationId, contactId);
|
|
113
|
+
const fields = {};
|
|
114
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
115
|
+
if (key === 'billable') fields[key] = flag(value, key);
|
|
116
|
+
else if (key === 'defaultPractitioner') fields[key] = shortText(value, key);
|
|
117
|
+
else if (key === 'autoDraft') fields[key] = value === null ? null : flag(value, key);
|
|
118
|
+
else if (key === 'sendMode') fields[key] = value === null ? null : sendMode(value, key);
|
|
119
|
+
else if (key === 'paymentTermsDays') fields[key] = value === null ? null : wholeNumber(value, key, 0, 365);
|
|
120
|
+
else throw invalid(`unknown setting: ${key}`);
|
|
121
|
+
}
|
|
122
|
+
await db.insert(customerBillingSettings)
|
|
123
|
+
.values({ organisationId, contactId, ...fields, updatedBy: personId })
|
|
124
|
+
.onConflictDoUpdate({
|
|
125
|
+
target: [customerBillingSettings.organisationId, customerBillingSettings.contactId],
|
|
126
|
+
set: { ...fields, updatedBy: personId, updatedAt: sql`now()` },
|
|
127
|
+
});
|
|
128
|
+
return readCustomerBilling(db, organisationId, contactId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The customers' own settings, by contact id.
|
|
132
|
+
export async function customerBillingByContact(db, organisationId, contactIds) {
|
|
133
|
+
const ids = [...new Set(contactIds.filter(Boolean))];
|
|
134
|
+
if (!ids.length) return new Map();
|
|
135
|
+
const rows = await db.select().from(customerBillingSettings)
|
|
136
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.contactId, ids)));
|
|
137
|
+
return new Map(rows.map((r) => [r.contactId, pick(r, CUSTOMER_KEYS)]));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The settings that apply to one customer: its own, else the organisation's.
|
|
141
|
+
// `autoDraft` is the customer's own switch for the draft automation.
|
|
142
|
+
export function effectiveSettings(settings, customer = CUSTOMER_DEFAULTS) {
|
|
143
|
+
const own = { ...CUSTOMER_DEFAULTS, ...customer };
|
|
144
|
+
return {
|
|
145
|
+
...settings,
|
|
146
|
+
billable: own.billable,
|
|
147
|
+
defaultPractitioner: own.defaultPractitioner,
|
|
148
|
+
autoDraft: own.autoDraft ?? true,
|
|
149
|
+
sendMode: own.sendMode ?? settings.sendMode,
|
|
150
|
+
paymentTermsDays: own.paymentTermsDays ?? settings.paymentTermsDays,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// How one customer's checked drafts go to the accounting system.
|
|
155
|
+
export async function sendModeFor(db, organisationId, contactId) {
|
|
156
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
157
|
+
const customers = await customerBillingByContact(db, organisationId, [contactId]);
|
|
158
|
+
return effectiveSettings(settings, customers.get(contactId)).sendMode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What a job still waits on before Flow drafts it, as reasons a person can
|
|
162
|
+
// read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
|
|
163
|
+
// in their order. An empty list means the job may be drafted. `rules` is the
|
|
164
|
+
// organisation's rules, read once per run with listRules.
|
|
165
|
+
export function ruleBlockers(obligation, effective, { now = new Date(), rules = [] } = {}) {
|
|
166
|
+
const reasons = [];
|
|
167
|
+
if (!obligation.readyAt) reasons.push('the job is not marked ready to invoice');
|
|
168
|
+
return [...reasons, ...ruleReasons(rules, obligation, { customer: effective, now })];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function addDays(date, days) {
|
|
172
|
+
const at = new Date(`${date}T00:00:00Z`);
|
|
173
|
+
at.setUTCDate(at.getUTCDate() + days);
|
|
174
|
+
return at.toISOString().slice(0, 10);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const isoDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`;
|
|
178
|
+
|
|
179
|
+
// The ledger's live check, answered from the accounting system's copy in Flow
|
|
180
|
+
// instead of a call for each job: the copy is complete and was read lately,
|
|
181
|
+
// the matcher checked the job after the copy last changed, and the copy holds
|
|
182
|
+
// no invoice with the job's own invoice number.
|
|
183
|
+
export function copyCheck(accounting, organisationId) {
|
|
184
|
+
return async (obligation) => {
|
|
185
|
+
const { complete, fresh, changedAt, invoices } = await accounting.capabilities.findInvoices({
|
|
186
|
+
organisationId,
|
|
187
|
+
numbers: obligation.sourceInvoiceRef ? [obligation.sourceInvoiceRef] : [],
|
|
188
|
+
});
|
|
189
|
+
if (!complete || !fresh) throw new Error('the accounting copy is not complete or not fresh');
|
|
190
|
+
if (obligation.accountingState !== 'not_found') return { state: obligation.accountingState, matchedBy: 'copy' };
|
|
191
|
+
if (changedAt && (!obligation.accountingCheckedAt || new Date(obligation.accountingCheckedAt) < changedAt)) {
|
|
192
|
+
throw new Error('the accounting copy changed after the job was last matched');
|
|
193
|
+
}
|
|
194
|
+
const sales = invoices.filter((i) => !['voided', 'deleted'].includes(i.state));
|
|
195
|
+
if (sales.length) return { state: sales[0].state, accountingRef: sales[0].id, matchedBy: 'copy' };
|
|
196
|
+
return { state: 'not_found', matchedBy: 'copy' };
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Drafts the invoice of each job that is ready to invoice and passes the
|
|
201
|
+
// ledger's checks and every rule, except for customers whose drafting is off.
|
|
202
|
+
// It writes only Flow's own records: the drafts wait in Flow for a person.
|
|
203
|
+
// The draft automation (automations.js) runs it. Answers counts.
|
|
204
|
+
// With `accounting` null no accounting system is connected: Flow's own records
|
|
205
|
+
// are the only check, as for a person's draft (ledger.js, ledgerView).
|
|
206
|
+
export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
207
|
+
const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
|
|
208
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
209
|
+
const rules = await listRules(db, organisationId);
|
|
210
|
+
if (accounting) {
|
|
211
|
+
const mirror = await accounting.capabilities.findInvoices({ organisationId });
|
|
212
|
+
if (!mirror.complete || !mirror.fresh) return { ...result, skipped: 'the accounting copy is not complete or not fresh' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const candidates = await db.select().from(billingObligations).where(and(
|
|
216
|
+
eq(billingObligations.organisationId, organisationId),
|
|
217
|
+
isNull(billingObligations.invoiceId),
|
|
218
|
+
sql`${billingObligations.readyAt} is not null`,
|
|
219
|
+
eq(billingObligations.holdState, 'none'),
|
|
220
|
+
accounting
|
|
221
|
+
? eq(billingObligations.accountingState, 'not_found')
|
|
222
|
+
: inArray(billingObligations.accountingState, ['not_found', 'unknown']),
|
|
223
|
+
eq(billingObligations.mappingComplete, true),
|
|
224
|
+
sql`${billingObligations.contactId} is not null`,
|
|
225
|
+
// Newest first: a backlog of jobs that wait on a rule never hides new work.
|
|
226
|
+
)).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
|
|
227
|
+
const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
|
|
228
|
+
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
229
|
+
const invoiceDate = isoDay(now);
|
|
230
|
+
|
|
231
|
+
for (const obligation of candidates) {
|
|
232
|
+
const effective = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
233
|
+
if (!effective.autoDraft) {
|
|
234
|
+
result.off += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
238
|
+
result.waiting += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await draftInvoiceForObligation(db, organisationId, obligation.id, {
|
|
243
|
+
liveCheck,
|
|
244
|
+
actor,
|
|
245
|
+
now,
|
|
246
|
+
dueDate: addDays(invoiceDate, effective.paymentTermsDays),
|
|
247
|
+
});
|
|
248
|
+
result.drafted += 1;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error.code !== 'not_ready') throw error;
|
|
251
|
+
result.refused += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
package/server/core/ledger.js
CHANGED
|
@@ -8,8 +8,8 @@ import { createDraftInvoice } from './invoices.js';
|
|
|
8
8
|
import { invoiceTotals, lineTotals } from './money.js';
|
|
9
9
|
|
|
10
10
|
// The billing obligation ledger. One billable job in a work system is one
|
|
11
|
-
// obligation. Its view (Ready, Pending, Held, Drafted, Invoiced,
|
|
12
|
-
// required) comes only from the evidence stored on it, never from a fresh
|
|
11
|
+
// obligation. Its view (In work, Ready, Pending, Held, Drafted, Invoiced,
|
|
12
|
+
// Paid, Review required) comes only from the evidence stored on it, never from a fresh
|
|
13
13
|
// guess, and each change of evidence is an event. To make a draft invoice is a
|
|
14
14
|
// guarded transition that fails closed.
|
|
15
15
|
|
|
@@ -32,8 +32,11 @@ async function loadObligation(tx, organisationId, obligationId, { lock = false }
|
|
|
32
32
|
return row;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// clock_timestamp, not now(): events written in one transaction keep their
|
|
36
|
+
// order when they are read back by time.
|
|
35
37
|
async function addEvent(tx, obligation, kind, actor, evidence = {}) {
|
|
36
38
|
await tx.insert(obligationEvents).values({
|
|
39
|
+
createdAt: sql`clock_timestamp()`,
|
|
37
40
|
organisationId: obligation.organisationId,
|
|
38
41
|
obligationId: obligation.id,
|
|
39
42
|
kind,
|
|
@@ -110,6 +113,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
110
113
|
practitioner: job.practitioner ?? null,
|
|
111
114
|
shippedOn: job.shippedOn ?? null,
|
|
112
115
|
jobDate: job.jobDate ?? null,
|
|
116
|
+
sourceStatus: job.sourceStatus ?? null,
|
|
113
117
|
lines,
|
|
114
118
|
lineFingerprint: fingerprint(lines),
|
|
115
119
|
...invoiceTotals(lines),
|
|
@@ -138,7 +142,9 @@ export async function observeJob(db, organisationId, job) {
|
|
|
138
142
|
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
139
143
|
.filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
140
144
|
if (!changed.length) {
|
|
141
|
-
return update(tx, existing, {
|
|
145
|
+
return update(tx, existing, {
|
|
146
|
+
sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
|
|
147
|
+
});
|
|
142
148
|
}
|
|
143
149
|
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
144
150
|
if (existing.invoiceId && changed.some((key) => ['lineFingerprint', 'totalCents', 'contactId'].includes(key))) {
|
|
@@ -313,6 +319,20 @@ export async function recordWritebackResult(db, organisationId, writebackId, { o
|
|
|
313
319
|
});
|
|
314
320
|
}
|
|
315
321
|
|
|
322
|
+
// Marks a job ready to invoice, or back in work. `actor` is a person or the
|
|
323
|
+
// automation that decided. A job with an invoice stays as it is.
|
|
324
|
+
export async function setReady(db, organisationId, obligationId, { ready = true, actor }) {
|
|
325
|
+
requireActor(actor);
|
|
326
|
+
return db.transaction(async (tx) => {
|
|
327
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
328
|
+
if (Boolean(obligation.readyAt) === ready) return obligation;
|
|
329
|
+
if (!ready && obligation.invoiceId) throw new FlowError('invalid', 'the job has an invoice already');
|
|
330
|
+
const row = await update(tx, obligation, ready ? { readyAt: sql`now()`, readyBy: actor } : { readyAt: null, readyBy: null });
|
|
331
|
+
await addEvent(tx, row, ready ? 'ready' : 'in_work', actor, {});
|
|
332
|
+
return row;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
316
336
|
// Puts an obligation on hold, sends it to review, or releases it.
|
|
317
337
|
export async function setHold(db, organisationId, obligationId, { holdState, reason = null, actor }) {
|
|
318
338
|
requireActor(actor);
|
|
@@ -329,7 +349,9 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
|
|
|
329
349
|
}
|
|
330
350
|
|
|
331
351
|
// The obligation's view and the reasons for it, from its stored evidence only.
|
|
332
|
-
|
|
352
|
+
// `accounting` is false while no accounting system is connected: Flow's own
|
|
353
|
+
// records are then the only check, so a job nobody checked in one can draft.
|
|
354
|
+
export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, accounting = true } = {}) {
|
|
333
355
|
const o = obligation;
|
|
334
356
|
if (o.holdState === 'review') return { view: 'review_required', reasons: [o.holdReason ?? 'sent to review'] };
|
|
335
357
|
if (['ambiguous', 'conflict'].includes(o.accountingState)) {
|
|
@@ -344,15 +366,20 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
344
366
|
return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
|
|
345
367
|
}
|
|
346
368
|
if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
|
|
347
|
-
const reasons = draftBlockers(o, { now, sourceMaxAgeMs });
|
|
369
|
+
const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
|
|
370
|
+
// Flow's own stage: a job is in work until a person or an automation marks
|
|
371
|
+
// it ready to invoice (setReady).
|
|
372
|
+
if (!o.readyAt) return { view: 'in_work', reasons: ['the job is not marked ready to invoice', ...reasons] };
|
|
348
373
|
return reasons.length ? { view: 'pending', reasons } : { view: 'ready', reasons: ['all checks pass'] };
|
|
349
374
|
}
|
|
350
375
|
|
|
351
|
-
function draftBlockers(o, { now, sourceMaxAgeMs }) {
|
|
376
|
+
function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
|
|
352
377
|
const reasons = [];
|
|
353
378
|
if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
|
|
354
379
|
if (o.invoiceId) reasons.push('the job already has an invoice');
|
|
355
|
-
if (o.accountingState === 'unknown')
|
|
380
|
+
if (o.accountingState === 'unknown') {
|
|
381
|
+
if (accounting) reasons.push('the accounting system was not checked');
|
|
382
|
+
}
|
|
356
383
|
else if (o.accountingState !== 'not_found') reasons.push(`the accounting system reports ${o.accountingState}`);
|
|
357
384
|
if (!o.mappingComplete) reasons.push('a line does not map to a price item');
|
|
358
385
|
if (!o.contactId) reasons.push('the job has no customer contact');
|
|
@@ -365,15 +392,17 @@ function draftBlockers(o, { now, sourceMaxAgeMs }) {
|
|
|
365
392
|
// the write, finds no invoice for the job. Any doubt refuses the draft, and
|
|
366
393
|
// the refusal is recorded.
|
|
367
394
|
export async function draftInvoiceForObligation(db, organisationId, obligationId, {
|
|
368
|
-
liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS,
|
|
395
|
+
liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null,
|
|
369
396
|
}) {
|
|
370
397
|
requireActor(actor);
|
|
371
|
-
|
|
398
|
+
// A null liveCheck says no accounting system is connected (ledgerView).
|
|
399
|
+
if (liveCheck !== null && typeof liveCheck !== 'function') throw new FlowError('invalid', 'liveCheck must be a function, or null with no accounting system');
|
|
400
|
+
const accounting = liveCheck !== null;
|
|
372
401
|
const outcome = await db.transaction(async (tx) => {
|
|
373
402
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
374
|
-
const reasons = draftBlockers(obligation, { now, sourceMaxAgeMs });
|
|
403
|
+
const reasons = draftBlockers(obligation, { now, sourceMaxAgeMs, accounting });
|
|
375
404
|
let live = null;
|
|
376
|
-
if (!reasons.length) {
|
|
405
|
+
if (!reasons.length && accounting) {
|
|
377
406
|
try {
|
|
378
407
|
live = await liveCheck(obligation);
|
|
379
408
|
} catch (error) {
|
|
@@ -388,11 +417,15 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
388
417
|
const invoice = await createDraftInvoice(tx, organisationId, {
|
|
389
418
|
contactId: obligation.contactId,
|
|
390
419
|
reference: obligation.caseRef ?? obligation.sourceJobRef,
|
|
420
|
+
dueDate,
|
|
391
421
|
lines: obligation.lines.map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => (
|
|
392
422
|
{ priceItemId, description, quantity, unitAmountCents, taxCode }
|
|
393
423
|
)),
|
|
394
424
|
});
|
|
395
|
-
|
|
425
|
+
// Drafting the invoice says the job is ready, whoever asked.
|
|
426
|
+
const row = await update(tx, obligation, {
|
|
427
|
+
invoiceId: invoice.id, accountingCheckedAt: now, ...(obligation.readyAt ? {} : { readyAt: now, readyBy: actor }),
|
|
428
|
+
});
|
|
396
429
|
await addEvent(tx, row, 'drafted', actor, { invoiceId: invoice.id, totalCents: invoice.totalCents, live });
|
|
397
430
|
return { obligation: row, invoice };
|
|
398
431
|
});
|