@voyant-travel/finance 0.171.0 → 0.171.2
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/dist/app-api-runtime.d.ts +4 -1
- package/dist/app-api-runtime.js +472 -4
- package/dist/routes-runtime.d.ts +0 -1
- package/dist/routes-shared.d.ts +0 -1
- package/dist/runtime-contributor.js +1 -1
- package/dist/schema/invoice-documents.d.ts +731 -0
- package/dist/schema/invoice-documents.js +94 -1
- package/dist/service-invoice-artifacts.d.ts +18 -0
- package/dist/service-invoice-artifacts.js +3 -0
- package/dist/service-invoice-core.js +4 -29
- package/dist/service-invoice-payments.js +1 -0
- package/dist/service-issue.d.ts +7 -0
- package/dist/service-issue.js +5 -0
- package/dist/service-payment-session-completion.js +1 -0
- package/dist/service-reference-data.d.ts +12 -0
- package/dist/service-rendition-wait.d.ts +3 -0
- package/dist/service-shared.d.ts +10 -0
- package/dist/service.d.ts +30 -0
- package/dist/voyant-event-schemas.d.ts +134 -11
- package/dist/voyant-event-schemas.js +101 -4
- package/dist/voyant.js +25 -10
- package/migrations/20260718105842_app_finance_artifact_sync.sql +10 -0
- package/migrations/20260718110120_app_finance_sync_observations.sql +15 -0
- package/migrations/20260718115806_app_finance_lifecycle_settlement.sql +47 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +4 -4
|
@@ -1,2 +1,5 @@
|
|
|
1
|
+
import type { VoyantRuntimeHostPrimitives } from "@voyant-travel/core";
|
|
1
2
|
import type { FinanceAppApiRuntime } from "@voyant-travel/finance-contracts/app-api";
|
|
2
|
-
|
|
3
|
+
type ArtifactRuntimePrimitives = Pick<VoyantRuntimeHostPrimitives, "storage">;
|
|
4
|
+
export declare function createFinanceAppApiRuntime(primitives?: ArtifactRuntimePrimitives): FinanceAppApiRuntime;
|
|
5
|
+
export {};
|
package/dist/app-api-runtime.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { bookings } from "@voyant-travel/bookings/schema";
|
|
2
2
|
import { FinanceAppApiNumberConflictError } from "@voyant-travel/finance-contracts/app-api";
|
|
3
|
-
import { and, asc, eq, sql } from "drizzle-orm";
|
|
4
|
-
import { invoiceExternalRefs, invoiceLineItems, invoiceNumberSeries, invoices, taxRegimes, } from "./schema.js";
|
|
3
|
+
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
|
4
|
+
import { invoiceExternalLifecycleOperations, invoiceExternalPaymentIdentifiers, invoiceExternalRefs, invoiceExternalSettlementObservations, invoiceExternalSyncObservations, invoiceLineItems, invoiceNumberSeries, invoiceRenditions, invoices, taxRegimes, } from "./schema.js";
|
|
5
5
|
import { financeService } from "./service.js";
|
|
6
6
|
import { buildInvoiceIssuedEvent } from "./service-issue.js";
|
|
7
7
|
import { InvoiceNumberConflictError, toRows } from "./service-shared.js";
|
|
8
|
-
export function createFinanceAppApiRuntime() {
|
|
8
|
+
export function createFinanceAppApiRuntime(primitives) {
|
|
9
9
|
return {
|
|
10
10
|
async getIssuanceDocument(db, documentId) {
|
|
11
11
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, documentId)).limit(1);
|
|
@@ -184,8 +184,363 @@ export function createFinanceAppApiRuntime() {
|
|
|
184
184
|
allocationOutcome,
|
|
185
185
|
};
|
|
186
186
|
},
|
|
187
|
+
async attachPdfArtifact(db, environment, documentId, provider, input) {
|
|
188
|
+
const checksum = await sha256(input.bytes);
|
|
189
|
+
const idempotencyDigest = await sha256(new TextEncoder().encode(input.idempotencyKey));
|
|
190
|
+
const existing = await findAppArtifact(db, documentId, provider, idempotencyDigest);
|
|
191
|
+
if (existing)
|
|
192
|
+
return replayArtifact(existing, checksum, input.fileName);
|
|
193
|
+
const storage = primitives?.storage.resolve(environment, "documents");
|
|
194
|
+
if (!storage)
|
|
195
|
+
return { status: "not_configured" };
|
|
196
|
+
const requestedStorageKey = await artifactStorageKey(documentId, provider, input.idempotencyKey, checksum);
|
|
197
|
+
let uploaded;
|
|
198
|
+
try {
|
|
199
|
+
uploaded = await storage.upload(input.bytes, {
|
|
200
|
+
key: requestedStorageKey,
|
|
201
|
+
contentType: input.contentType,
|
|
202
|
+
metadata: {
|
|
203
|
+
documentId,
|
|
204
|
+
provider,
|
|
205
|
+
checksum,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await bestEffortDelete(storage, requestedStorageKey);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
const storageKey = uploaded.key;
|
|
214
|
+
try {
|
|
215
|
+
const result = await financeService.bindInvoiceRendition(db, documentId, {
|
|
216
|
+
format: "pdf",
|
|
217
|
+
contentType: input.contentType,
|
|
218
|
+
storageKey,
|
|
219
|
+
fileSize: input.bytes.byteLength,
|
|
220
|
+
checksum,
|
|
221
|
+
generatedAt: new Date().toISOString(),
|
|
222
|
+
appProvider: provider,
|
|
223
|
+
appIdempotencyDigest: idempotencyDigest,
|
|
224
|
+
appFileName: input.fileName,
|
|
225
|
+
metadata: { source: "remote_app" },
|
|
226
|
+
});
|
|
227
|
+
if (result.status === "not_found") {
|
|
228
|
+
await bestEffortDelete(storage, storageKey);
|
|
229
|
+
return { status: "not_found" };
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
status: "ok",
|
|
233
|
+
outcome: "created",
|
|
234
|
+
artifact: mapPdfArtifact(result.rendition, provider),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const raced = await findAppArtifact(db, documentId, provider, idempotencyDigest);
|
|
239
|
+
if (raced) {
|
|
240
|
+
if (raced.storageKey !== storageKey)
|
|
241
|
+
await bestEffortDelete(storage, storageKey);
|
|
242
|
+
return replayArtifact(raced, checksum, input.fileName);
|
|
243
|
+
}
|
|
244
|
+
await bestEffortDelete(storage, storageKey);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
async updateExternalSyncState(db, documentId, provider, input) {
|
|
249
|
+
const locked = toRows(await db.execute(
|
|
250
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
251
|
+
sql `SELECT id FROM invoices WHERE id = ${documentId} FOR UPDATE`))[0];
|
|
252
|
+
if (!locked)
|
|
253
|
+
return { status: "not_found" };
|
|
254
|
+
const [replay] = await db
|
|
255
|
+
.select()
|
|
256
|
+
.from(invoiceExternalSyncObservations)
|
|
257
|
+
.where(and(eq(invoiceExternalSyncObservations.invoiceId, documentId), eq(invoiceExternalSyncObservations.provider, provider), eq(invoiceExternalSyncObservations.operationId, input.operationId)))
|
|
258
|
+
.limit(1);
|
|
259
|
+
if (replay) {
|
|
260
|
+
const replayState = mapExternalSyncObservation(replay);
|
|
261
|
+
return syncStateMatches(replayState, input)
|
|
262
|
+
? { status: "ok", outcome: "unchanged", sync: replayState }
|
|
263
|
+
: {
|
|
264
|
+
status: "conflict",
|
|
265
|
+
reason: "idempotency_key_reused",
|
|
266
|
+
current: replayState,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const [existing] = await db
|
|
270
|
+
.select()
|
|
271
|
+
.from(invoiceExternalRefs)
|
|
272
|
+
.where(and(eq(invoiceExternalRefs.invoiceId, documentId), eq(invoiceExternalRefs.provider, provider)))
|
|
273
|
+
.limit(1);
|
|
274
|
+
const current = existing ? mapExternalSyncState(existing) : null;
|
|
275
|
+
if (current &&
|
|
276
|
+
new Date(current.occurredAt).getTime() >= new Date(input.occurredAt).getTime()) {
|
|
277
|
+
return { status: "conflict", reason: "out_of_order", current };
|
|
278
|
+
}
|
|
279
|
+
await db.insert(invoiceExternalSyncObservations).values({
|
|
280
|
+
invoiceId: documentId,
|
|
281
|
+
provider,
|
|
282
|
+
operationId: input.operationId,
|
|
283
|
+
status: input.status,
|
|
284
|
+
occurredAt: new Date(input.occurredAt),
|
|
285
|
+
errorCode: input.error?.code ?? null,
|
|
286
|
+
errorMessage: input.error?.message ?? null,
|
|
287
|
+
metadata: input.metadata,
|
|
288
|
+
});
|
|
289
|
+
const values = syncStateValues(input);
|
|
290
|
+
const [row] = existing
|
|
291
|
+
? await db
|
|
292
|
+
.update(invoiceExternalRefs)
|
|
293
|
+
.set({ ...values, updatedAt: new Date() })
|
|
294
|
+
.where(eq(invoiceExternalRefs.id, existing.id))
|
|
295
|
+
.returning()
|
|
296
|
+
: await db
|
|
297
|
+
.insert(invoiceExternalRefs)
|
|
298
|
+
.values({ invoiceId: documentId, provider, ...values })
|
|
299
|
+
.returning();
|
|
300
|
+
if (!row)
|
|
301
|
+
return { status: "not_found" };
|
|
302
|
+
const sync = mapExternalSyncState(row);
|
|
303
|
+
if (!sync) {
|
|
304
|
+
throw new Error("Persisted external sync state is incomplete.");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
status: "ok",
|
|
308
|
+
outcome: existing ? "updated" : "created",
|
|
309
|
+
sync,
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
async updateExternalLifecycleState(db, documentId, provider, input) {
|
|
313
|
+
const document = toRows(await db.execute(
|
|
314
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
315
|
+
sql `SELECT id, invoice_type, status FROM invoices WHERE id = ${documentId} FOR UPDATE`))[0];
|
|
316
|
+
if (!document || document.invoice_type === "credit_note")
|
|
317
|
+
return { status: "not_found" };
|
|
318
|
+
const [replay] = await db
|
|
319
|
+
.select()
|
|
320
|
+
.from(invoiceExternalLifecycleOperations)
|
|
321
|
+
.where(and(eq(invoiceExternalLifecycleOperations.invoiceId, documentId), eq(invoiceExternalLifecycleOperations.provider, provider), eq(invoiceExternalLifecycleOperations.operationId, input.operationId)))
|
|
322
|
+
.limit(1);
|
|
323
|
+
if (replay) {
|
|
324
|
+
const replayState = mapExternalLifecycleOperation(replay);
|
|
325
|
+
return lifecycleStateMatches(replayState, input)
|
|
326
|
+
? { status: "ok", outcome: "unchanged", lifecycle: replayState }
|
|
327
|
+
: {
|
|
328
|
+
status: "conflict",
|
|
329
|
+
reason: "idempotency_key_reused",
|
|
330
|
+
current: replayState,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const nativeConflict = await validateNativeLifecycleState(db, document, documentId, input);
|
|
334
|
+
if (nativeConflict)
|
|
335
|
+
return nativeConflict;
|
|
336
|
+
const [latest] = await db
|
|
337
|
+
.select()
|
|
338
|
+
.from(invoiceExternalLifecycleOperations)
|
|
339
|
+
.where(and(eq(invoiceExternalLifecycleOperations.invoiceId, documentId), eq(invoiceExternalLifecycleOperations.provider, provider)))
|
|
340
|
+
.orderBy(desc(invoiceExternalLifecycleOperations.occurredAt))
|
|
341
|
+
.limit(1);
|
|
342
|
+
const current = latest ? mapExternalLifecycleOperation(latest) : null;
|
|
343
|
+
if (current) {
|
|
344
|
+
if (new Date(current.occurredAt).getTime() >= new Date(input.occurredAt).getTime()) {
|
|
345
|
+
return { status: "conflict", reason: "out_of_order", current };
|
|
346
|
+
}
|
|
347
|
+
return { status: "conflict", reason: "terminal_transition", current };
|
|
348
|
+
}
|
|
349
|
+
await db.insert(invoiceExternalLifecycleOperations).values({
|
|
350
|
+
invoiceId: documentId,
|
|
351
|
+
provider,
|
|
352
|
+
operationId: input.operationId,
|
|
353
|
+
state: input.state,
|
|
354
|
+
occurredAt: new Date(input.occurredAt),
|
|
355
|
+
successorInvoiceId: input.lineage?.successorDocumentId ?? null,
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
status: "ok",
|
|
359
|
+
outcome: "created",
|
|
360
|
+
lifecycle: {
|
|
361
|
+
provider,
|
|
362
|
+
documentId,
|
|
363
|
+
...input,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
async recordSettlementObservation(db, documentId, provider, input) {
|
|
368
|
+
const document = toRows(await db.execute(
|
|
369
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
370
|
+
sql `SELECT id, invoice_type, status, currency, total_cents FROM invoices WHERE id = ${documentId} FOR UPDATE`))[0];
|
|
371
|
+
if (!document || document.invoice_type === "credit_note")
|
|
372
|
+
return { status: "not_found" };
|
|
373
|
+
const [replay] = await db
|
|
374
|
+
.select()
|
|
375
|
+
.from(invoiceExternalSettlementObservations)
|
|
376
|
+
.where(and(eq(invoiceExternalSettlementObservations.invoiceId, documentId), eq(invoiceExternalSettlementObservations.provider, provider), eq(invoiceExternalSettlementObservations.operationId, input.operationId)))
|
|
377
|
+
.limit(1);
|
|
378
|
+
if (replay) {
|
|
379
|
+
const replayObservation = mapSettlementObservation(replay);
|
|
380
|
+
return settlementObservationMatches(replayObservation, input)
|
|
381
|
+
? { status: "ok", outcome: "unchanged", observation: replayObservation }
|
|
382
|
+
: {
|
|
383
|
+
status: "conflict",
|
|
384
|
+
reason: "idempotency_key_reused",
|
|
385
|
+
current: replayObservation,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (!["issued", "overdue", "partially_paid", "paid"].includes(document.status) ||
|
|
389
|
+
document.currency !== input.currency ||
|
|
390
|
+
document.total_cents !== input.totals.totalCents) {
|
|
391
|
+
return { status: "conflict", reason: "native_document_mismatch", current: null };
|
|
392
|
+
}
|
|
393
|
+
const [latest] = await db
|
|
394
|
+
.select()
|
|
395
|
+
.from(invoiceExternalSettlementObservations)
|
|
396
|
+
.where(and(eq(invoiceExternalSettlementObservations.invoiceId, documentId), eq(invoiceExternalSettlementObservations.provider, provider)))
|
|
397
|
+
.orderBy(desc(invoiceExternalSettlementObservations.occurredAt))
|
|
398
|
+
.limit(1);
|
|
399
|
+
const current = latest ? mapSettlementObservation(latest) : null;
|
|
400
|
+
if (current) {
|
|
401
|
+
if (new Date(current.occurredAt).getTime() >= new Date(input.occurredAt).getTime()) {
|
|
402
|
+
return { status: "conflict", reason: "out_of_order", current };
|
|
403
|
+
}
|
|
404
|
+
if (current.status === "paid") {
|
|
405
|
+
return { status: "conflict", reason: "terminal_transition", current };
|
|
406
|
+
}
|
|
407
|
+
if (input.totals.paidCents < current.totals.paidCents ||
|
|
408
|
+
input.totals.balanceDueCents > current.totals.balanceDueCents ||
|
|
409
|
+
current.paymentIdentifiers.some((paymentIdentifier) => !input.paymentIdentifiers.includes(paymentIdentifier))) {
|
|
410
|
+
return { status: "conflict", reason: "settlement_regression", current };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const paymentIdentifiers = [...new Set(input.paymentIdentifiers)].sort();
|
|
414
|
+
for (const paymentIdentifier of paymentIdentifiers) {
|
|
415
|
+
const lockKey = `finance:external-payment:${provider}:${paymentIdentifier}`;
|
|
416
|
+
await db.execute(
|
|
417
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
418
|
+
sql `SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
|
419
|
+
}
|
|
420
|
+
const identifierOwners = await db
|
|
421
|
+
.select()
|
|
422
|
+
.from(invoiceExternalPaymentIdentifiers)
|
|
423
|
+
.where(and(eq(invoiceExternalPaymentIdentifiers.provider, provider), inArray(invoiceExternalPaymentIdentifiers.paymentIdentifier, paymentIdentifiers)))
|
|
424
|
+
.limit(paymentIdentifiers.length);
|
|
425
|
+
const conflictingOwner = identifierOwners.find((owner) => owner.invoiceId !== documentId);
|
|
426
|
+
if (conflictingOwner) {
|
|
427
|
+
return {
|
|
428
|
+
status: "conflict",
|
|
429
|
+
reason: "payment_identifier_conflict",
|
|
430
|
+
current,
|
|
431
|
+
paymentIdentifier: conflictingOwner.paymentIdentifier,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const ownedIdentifiers = new Set(identifierOwners.map((owner) => owner.paymentIdentifier));
|
|
435
|
+
const identifiersToClaim = paymentIdentifiers.filter((paymentIdentifier) => !ownedIdentifiers.has(paymentIdentifier));
|
|
436
|
+
if (identifiersToClaim.length > 0) {
|
|
437
|
+
await db.insert(invoiceExternalPaymentIdentifiers).values(identifiersToClaim.map((paymentIdentifier) => ({
|
|
438
|
+
provider,
|
|
439
|
+
paymentIdentifier,
|
|
440
|
+
invoiceId: documentId,
|
|
441
|
+
firstOperationId: input.operationId,
|
|
442
|
+
})));
|
|
443
|
+
}
|
|
444
|
+
await db.insert(invoiceExternalSettlementObservations).values({
|
|
445
|
+
invoiceId: documentId,
|
|
446
|
+
provider,
|
|
447
|
+
operationId: input.operationId,
|
|
448
|
+
occurredAt: new Date(input.occurredAt),
|
|
449
|
+
status: input.status,
|
|
450
|
+
currency: input.currency,
|
|
451
|
+
totalCents: input.totals.totalCents,
|
|
452
|
+
paidCents: input.totals.paidCents,
|
|
453
|
+
balanceDueCents: input.totals.balanceDueCents,
|
|
454
|
+
paymentIdentifiers,
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
status: "ok",
|
|
458
|
+
outcome: "created",
|
|
459
|
+
observation: {
|
|
460
|
+
provider,
|
|
461
|
+
documentId,
|
|
462
|
+
...input,
|
|
463
|
+
paymentIdentifiers,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
async function validateNativeLifecycleState(db, document, documentId, input) {
|
|
470
|
+
if (input.state === "converted") {
|
|
471
|
+
if (input.lineage?.sourceDocumentId !== documentId) {
|
|
472
|
+
return { status: "conflict", reason: "lineage_mismatch", current: null };
|
|
473
|
+
}
|
|
474
|
+
if (document.invoice_type !== "proforma" || document.status !== "void") {
|
|
475
|
+
return { status: "conflict", reason: "native_state_mismatch", current: null };
|
|
476
|
+
}
|
|
477
|
+
const successor = toRows(await db.execute(
|
|
478
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
479
|
+
sql `SELECT id, invoice_type, converted_from_invoice_id FROM invoices WHERE id = ${input.lineage.successorDocumentId} FOR SHARE`))[0];
|
|
480
|
+
if (successor?.invoice_type !== "invoice" ||
|
|
481
|
+
successor.converted_from_invoice_id !== documentId) {
|
|
482
|
+
return { status: "conflict", reason: "lineage_mismatch", current: null };
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
if (input.lineage || document.status !== "void") {
|
|
487
|
+
return { status: "conflict", reason: "native_state_mismatch", current: null };
|
|
488
|
+
}
|
|
489
|
+
const successor = toRows(await db.execute(
|
|
490
|
+
// agent-quality: raw-sql reviewed -- identifiers are static and values are bound.
|
|
491
|
+
sql `SELECT id FROM invoices WHERE converted_from_invoice_id = ${documentId} LIMIT 1 FOR SHARE`))[0];
|
|
492
|
+
return successor ? { status: "conflict", reason: "native_state_mismatch", current: null } : null;
|
|
493
|
+
}
|
|
494
|
+
function mapExternalLifecycleOperation(row) {
|
|
495
|
+
if (row.state !== "converted" && row.state !== "voided") {
|
|
496
|
+
throw new Error("Persisted external lifecycle operation has an invalid state.");
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
provider: row.provider,
|
|
500
|
+
documentId: row.invoiceId,
|
|
501
|
+
operationId: row.operationId,
|
|
502
|
+
state: row.state,
|
|
503
|
+
occurredAt: row.occurredAt.toISOString(),
|
|
504
|
+
lineage: row.state === "converted" && row.successorInvoiceId
|
|
505
|
+
? {
|
|
506
|
+
sourceDocumentId: row.invoiceId,
|
|
507
|
+
successorDocumentId: row.successorInvoiceId,
|
|
508
|
+
}
|
|
509
|
+
: null,
|
|
187
510
|
};
|
|
188
511
|
}
|
|
512
|
+
function lifecycleStateMatches(current, input) {
|
|
513
|
+
return (current.state === input.state &&
|
|
514
|
+
new Date(current.occurredAt).getTime() === new Date(input.occurredAt).getTime() &&
|
|
515
|
+
canonicalJson(current.lineage) === canonicalJson(input.lineage));
|
|
516
|
+
}
|
|
517
|
+
function mapSettlementObservation(row) {
|
|
518
|
+
if (row.status !== "partial" && row.status !== "paid") {
|
|
519
|
+
throw new Error("Persisted settlement observation has an invalid status.");
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
provider: row.provider,
|
|
523
|
+
documentId: row.invoiceId,
|
|
524
|
+
operationId: row.operationId,
|
|
525
|
+
occurredAt: row.occurredAt.toISOString(),
|
|
526
|
+
status: row.status,
|
|
527
|
+
currency: row.currency,
|
|
528
|
+
totals: {
|
|
529
|
+
totalCents: row.totalCents,
|
|
530
|
+
paidCents: row.paidCents,
|
|
531
|
+
balanceDueCents: row.balanceDueCents,
|
|
532
|
+
},
|
|
533
|
+
paymentIdentifiers: [...row.paymentIdentifiers].sort(),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function settlementObservationMatches(current, input) {
|
|
537
|
+
return (current.status === input.status &&
|
|
538
|
+
current.currency === input.currency &&
|
|
539
|
+
new Date(current.occurredAt).getTime() === new Date(input.occurredAt).getTime() &&
|
|
540
|
+
canonicalJson(current.totals) === canonicalJson(input.totals) &&
|
|
541
|
+
canonicalJson(current.paymentIdentifiers) ===
|
|
542
|
+
canonicalJson([...new Set(input.paymentIdentifiers)].sort()));
|
|
543
|
+
}
|
|
189
544
|
function buildHydratedFx(invoice, issuedEvent) {
|
|
190
545
|
if (!invoice.baseCurrency || invoice.baseCurrency === invoice.currency)
|
|
191
546
|
return null;
|
|
@@ -224,19 +579,132 @@ function mapExternalReference(row) {
|
|
|
224
579
|
metadata: isRecord(row.metadata) ? row.metadata : null,
|
|
225
580
|
syncedAt: row.syncedAt?.toISOString() ?? null,
|
|
226
581
|
syncError: row.syncError,
|
|
582
|
+
sync: mapExternalSyncState(row),
|
|
227
583
|
createdAt: row.createdAt.toISOString(),
|
|
228
584
|
updatedAt: row.updatedAt.toISOString(),
|
|
229
585
|
};
|
|
230
586
|
}
|
|
587
|
+
function mapExternalSyncState(row) {
|
|
588
|
+
if (!isExternalSyncStatus(row.syncState) || !row.syncOperationId || !row.syncOccurredAt) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
provider: row.provider,
|
|
593
|
+
documentId: row.invoiceId,
|
|
594
|
+
operationId: row.syncOperationId,
|
|
595
|
+
status: row.syncState,
|
|
596
|
+
occurredAt: row.syncOccurredAt.toISOString(),
|
|
597
|
+
error: row.syncErrorCode && row.syncErrorMessage
|
|
598
|
+
? { code: row.syncErrorCode, message: row.syncErrorMessage }
|
|
599
|
+
: null,
|
|
600
|
+
metadata: isRecord(row.syncMetadata) ? row.syncMetadata : null,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function mapExternalSyncObservation(row) {
|
|
604
|
+
if (!isExternalSyncStatus(row.status)) {
|
|
605
|
+
throw new Error("Persisted external sync observation has an invalid status.");
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
provider: row.provider,
|
|
609
|
+
documentId: row.invoiceId,
|
|
610
|
+
operationId: row.operationId,
|
|
611
|
+
status: row.status,
|
|
612
|
+
occurredAt: row.occurredAt.toISOString(),
|
|
613
|
+
error: row.errorCode && row.errorMessage ? { code: row.errorCode, message: row.errorMessage } : null,
|
|
614
|
+
metadata: isRecord(row.metadata) ? row.metadata : null,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function syncStateValues(input) {
|
|
618
|
+
return {
|
|
619
|
+
syncState: input.status,
|
|
620
|
+
syncOperationId: input.operationId,
|
|
621
|
+
syncOccurredAt: new Date(input.occurredAt),
|
|
622
|
+
syncErrorCode: input.error?.code ?? null,
|
|
623
|
+
syncErrorMessage: input.error?.message ?? null,
|
|
624
|
+
syncMetadata: input.metadata,
|
|
625
|
+
syncedAt: input.status === "succeeded" ? new Date(input.occurredAt) : null,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function syncStateMatches(current, input) {
|
|
629
|
+
return (current.status === input.status &&
|
|
630
|
+
new Date(current.occurredAt).getTime() === new Date(input.occurredAt).getTime() &&
|
|
631
|
+
current.error?.code === input.error?.code &&
|
|
632
|
+
current.error?.message === input.error?.message &&
|
|
633
|
+
canonicalJson(current.metadata) === canonicalJson(input.metadata));
|
|
634
|
+
}
|
|
635
|
+
function isExternalSyncStatus(value) {
|
|
636
|
+
return ["succeeded", "retryable_failure", "terminal_failure"].includes(value ?? "");
|
|
637
|
+
}
|
|
638
|
+
async function findAppArtifact(db, documentId, provider, idempotencyDigest) {
|
|
639
|
+
const [row] = await db
|
|
640
|
+
.select()
|
|
641
|
+
.from(invoiceRenditions)
|
|
642
|
+
.where(and(eq(invoiceRenditions.invoiceId, documentId), eq(invoiceRenditions.appProvider, provider), eq(invoiceRenditions.appIdempotencyDigest, idempotencyDigest)))
|
|
643
|
+
.limit(1);
|
|
644
|
+
return row ?? null;
|
|
645
|
+
}
|
|
646
|
+
function replayArtifact(row, checksum, fileName) {
|
|
647
|
+
if (row.checksum !== checksum || row.appFileName !== fileName) {
|
|
648
|
+
return { status: "conflict", reason: "idempotency_key_reused" };
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
status: "ok",
|
|
652
|
+
outcome: "unchanged",
|
|
653
|
+
artifact: mapPdfArtifact(row, row.appProvider ?? ""),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function mapPdfArtifact(row, provider) {
|
|
657
|
+
return {
|
|
658
|
+
id: row.id,
|
|
659
|
+
documentId: row.invoiceId,
|
|
660
|
+
provider,
|
|
661
|
+
fileName: row.appFileName ?? "document.pdf",
|
|
662
|
+
byteSize: row.fileSize ?? 0,
|
|
663
|
+
checksum: row.checksum ?? "",
|
|
664
|
+
createdAt: row.createdAt.toISOString(),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function artifactStorageKey(documentId, provider, idempotencyKey, checksum) {
|
|
668
|
+
const providerHash = (await sha256(new TextEncoder().encode(provider))).slice(0, 24);
|
|
669
|
+
const documentHash = (await sha256(new TextEncoder().encode(documentId))).slice(0, 24);
|
|
670
|
+
const replayHash = (await sha256(new TextEncoder().encode(idempotencyKey))).slice(0, 24);
|
|
671
|
+
return `finance/app-artifacts/${providerHash}/${documentHash}/${replayHash}-${crypto.randomUUID()}-${checksum}.pdf`;
|
|
672
|
+
}
|
|
673
|
+
async function sha256(bytes) {
|
|
674
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
675
|
+
new Uint8Array(buffer).set(bytes);
|
|
676
|
+
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
677
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
678
|
+
}
|
|
679
|
+
async function bestEffortDelete(storage, key) {
|
|
680
|
+
try {
|
|
681
|
+
await storage.delete(key);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Compensation is best effort; an unbound key remains unreachable through
|
|
685
|
+
// every Voyant document route and must never replace the persistence error.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
231
688
|
function referenceMatches(existing, input) {
|
|
232
689
|
return (existing.externalId === (input.externalId ?? null) &&
|
|
233
690
|
existing.externalNumber === (input.externalNumber ?? null) &&
|
|
234
691
|
existing.externalUrl === (input.externalUrl ?? null) &&
|
|
235
692
|
existing.status === (input.status ?? null) &&
|
|
236
|
-
|
|
693
|
+
canonicalJson(existing.metadata) === canonicalJson(input.metadata ?? null) &&
|
|
237
694
|
(existing.syncedAt?.toISOString() ?? null) === (input.syncedAt ?? null) &&
|
|
238
695
|
existing.syncError === (input.syncError ?? null));
|
|
239
696
|
}
|
|
240
697
|
function isRecord(value) {
|
|
241
698
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
242
699
|
}
|
|
700
|
+
function canonicalJson(value) {
|
|
701
|
+
if (Array.isArray(value))
|
|
702
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
703
|
+
if (isRecord(value)) {
|
|
704
|
+
return `{${Object.keys(value)
|
|
705
|
+
.sort()
|
|
706
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
707
|
+
.join(",")}}`;
|
|
708
|
+
}
|
|
709
|
+
return JSON.stringify(value) ?? "undefined";
|
|
710
|
+
}
|
package/dist/routes-runtime.d.ts
CHANGED
|
@@ -12,7 +12,6 @@ export declare const routeIdempotencyKey: (scope: string, options?: {
|
|
|
12
12
|
VOYANT_CHECKOUT_CAPABILITY_TTL_SECONDS: string;
|
|
13
13
|
CHECKOUT_CAPABILITY_TTL_SECONDS: string;
|
|
14
14
|
SESSION_CLAIMS_SECRET: string;
|
|
15
|
-
BETTER_AUTH_SECRET: string;
|
|
16
15
|
}>;
|
|
17
16
|
Variables: {
|
|
18
17
|
db: import("drizzle-orm/postgres-js").PostgresJsDatabase;
|
package/dist/routes-shared.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { createFinanceStaleBookingHoldsRuntime } from "./stale-booking-holds-run
|
|
|
8
8
|
/** Provide Finance's generic host input and its narrow Bookings integration. */
|
|
9
9
|
export function createFinanceRuntimePortContribution(host) {
|
|
10
10
|
return {
|
|
11
|
-
[financeAppApiRuntimePort.id]: createFinanceAppApiRuntime(),
|
|
11
|
+
[financeAppApiRuntimePort.id]: createFinanceAppApiRuntime(host.primitives),
|
|
12
12
|
[actionLedgerFinanceDriftRuntimePort.id]: {
|
|
13
13
|
checkFinanceDrift: checkFinanceActionLedgerDrift,
|
|
14
14
|
},
|