@voyantjs/finance 0.3.1 → 0.4.1
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/index.d.ts +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/routes-documents.d.ts +159 -0
- package/dist/routes-documents.d.ts.map +1 -0
- package/dist/routes-documents.js +35 -0
- package/dist/routes-public.d.ts +80 -1
- package/dist/routes-public.d.ts.map +1 -1
- package/dist/routes-public.js +9 -1
- package/dist/routes-settlement.d.ts +63 -0
- package/dist/routes-settlement.d.ts.map +1 -0
- package/dist/routes-settlement.js +18 -0
- package/dist/service-documents.d.ts +67 -0
- package/dist/service-documents.d.ts.map +1 -0
- package/dist/service-documents.js +226 -0
- package/dist/service-public.d.ts +3 -1
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +114 -27
- package/dist/service-settlement.d.ts +36 -0
- package/dist/service-settlement.d.ts.map +1 -0
- package/dist/service-settlement.js +172 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +2 -57
- package/dist/validation-billing.d.ts +110 -0
- package/dist/validation-billing.d.ts.map +1 -1
- package/dist/validation-billing.js +54 -1
- package/dist/validation-public.d.ts +117 -0
- package/dist/validation-public.d.ts.map +1 -1
- package/dist/validation-public.js +23 -0
- package/package.json +7 -5
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { renderPdfDocument } from "@voyantjs/utils/pdf-renderer";
|
|
2
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
3
|
+
import { invoiceTemplates, } from "./schema.js";
|
|
4
|
+
import { financeService, renderInvoiceBody } from "./service.js";
|
|
5
|
+
function defaultInvoiceDocumentMimeType(format) {
|
|
6
|
+
switch (format) {
|
|
7
|
+
case "html":
|
|
8
|
+
return "text/html; charset=utf-8";
|
|
9
|
+
case "json":
|
|
10
|
+
return "application/json; charset=utf-8";
|
|
11
|
+
case "xml":
|
|
12
|
+
return "application/xml; charset=utf-8";
|
|
13
|
+
default:
|
|
14
|
+
return "application/pdf";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function encodeStringBody(value) {
|
|
18
|
+
return new TextEncoder().encode(value);
|
|
19
|
+
}
|
|
20
|
+
function getBodySize(body) {
|
|
21
|
+
if (body instanceof Uint8Array)
|
|
22
|
+
return body.byteLength;
|
|
23
|
+
if (body instanceof ArrayBuffer)
|
|
24
|
+
return body.byteLength;
|
|
25
|
+
return body.size;
|
|
26
|
+
}
|
|
27
|
+
function toUploadMetadata(metadata) {
|
|
28
|
+
const entries = Object.entries(metadata ?? {}).filter(([, value]) => ["string", "number", "boolean"].includes(typeof value));
|
|
29
|
+
return entries.length > 0
|
|
30
|
+
? Object.fromEntries(entries.map(([key, value]) => [key, String(value)]))
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
export function defaultStorageBackedInvoiceDocumentSerializer(context) {
|
|
34
|
+
switch (context.targetFormat) {
|
|
35
|
+
case "html":
|
|
36
|
+
return {
|
|
37
|
+
body: encodeStringBody(context.renderedBody),
|
|
38
|
+
format: "html",
|
|
39
|
+
language: context.language,
|
|
40
|
+
metadata: { renderedBodyFormat: context.renderedBodyFormat },
|
|
41
|
+
};
|
|
42
|
+
case "json":
|
|
43
|
+
return {
|
|
44
|
+
body: encodeStringBody(JSON.stringify(context.variables, null, 2)),
|
|
45
|
+
format: "json",
|
|
46
|
+
language: context.language,
|
|
47
|
+
metadata: { renderedBodyFormat: context.renderedBodyFormat },
|
|
48
|
+
};
|
|
49
|
+
case "xml":
|
|
50
|
+
return {
|
|
51
|
+
body: encodeStringBody(context.renderedBody),
|
|
52
|
+
format: "xml",
|
|
53
|
+
language: context.language,
|
|
54
|
+
metadata: { renderedBodyFormat: context.renderedBodyFormat },
|
|
55
|
+
};
|
|
56
|
+
default:
|
|
57
|
+
return defaultPdfInvoiceDocumentSerializer(context);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function defaultPdfInvoiceDocumentSerializer(context) {
|
|
61
|
+
const body = await renderPdfDocument({
|
|
62
|
+
title: `Invoice ${context.invoice.id}`,
|
|
63
|
+
content: context.renderedBody,
|
|
64
|
+
format: context.renderedBodyFormat === "lexical_json"
|
|
65
|
+
? "lexical_json"
|
|
66
|
+
: context.renderedBodyFormat === "html"
|
|
67
|
+
? "html"
|
|
68
|
+
: "markdown",
|
|
69
|
+
metadataLines: [
|
|
70
|
+
`Invoice ID: ${context.invoice.id}`,
|
|
71
|
+
...(context.language ? [`Language: ${context.language}`] : []),
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
body,
|
|
76
|
+
format: "pdf",
|
|
77
|
+
language: context.language,
|
|
78
|
+
metadata: {
|
|
79
|
+
renderedBodyFormat: context.renderedBodyFormat,
|
|
80
|
+
renderer: "voyant-basic-pdf",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function createStorageBackedInvoiceDocumentGenerator(options) {
|
|
85
|
+
const serializer = options.serializer ?? defaultStorageBackedInvoiceDocumentSerializer;
|
|
86
|
+
return async (context) => {
|
|
87
|
+
const upload = await serializer(context);
|
|
88
|
+
const format = upload.format ?? context.targetFormat;
|
|
89
|
+
const keyPrefix = typeof options.keyPrefix === "function"
|
|
90
|
+
? await options.keyPrefix(context)
|
|
91
|
+
: (options.keyPrefix ?? `invoices/${context.invoice.id}`);
|
|
92
|
+
const key = upload.key?.trim() || `${keyPrefix.replace(/\/$/, "")}/rendition.${format}`;
|
|
93
|
+
const uploaded = await options.storage.upload(upload.body, {
|
|
94
|
+
key,
|
|
95
|
+
contentType: defaultInvoiceDocumentMimeType(format),
|
|
96
|
+
metadata: toUploadMetadata(upload.metadata),
|
|
97
|
+
});
|
|
98
|
+
const downloadUrl = uploaded.url ||
|
|
99
|
+
(options.signedUrlExpiresIn
|
|
100
|
+
? await options.storage.signedUrl(uploaded.key, options.signedUrlExpiresIn)
|
|
101
|
+
: "");
|
|
102
|
+
return {
|
|
103
|
+
format,
|
|
104
|
+
storageKey: uploaded.key,
|
|
105
|
+
fileSize: getBodySize(upload.body),
|
|
106
|
+
language: upload.language ?? context.language,
|
|
107
|
+
metadata: {
|
|
108
|
+
...(upload.metadata ?? {}),
|
|
109
|
+
storageProvider: options.storage.name,
|
|
110
|
+
...(uploaded.url ? { url: uploaded.url } : {}),
|
|
111
|
+
...(downloadUrl ? { downloadUrl } : {}),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function createPdfInvoiceDocumentGenerator(options) {
|
|
117
|
+
return createStorageBackedInvoiceDocumentGenerator({
|
|
118
|
+
...options,
|
|
119
|
+
serializer: defaultPdfInvoiceDocumentSerializer,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function prepareInvoiceDocument(db, invoiceId, input) {
|
|
123
|
+
const invoice = await financeService.getInvoiceById(db, invoiceId);
|
|
124
|
+
if (!invoice) {
|
|
125
|
+
return { status: "not_found" };
|
|
126
|
+
}
|
|
127
|
+
let templateId = input.templateId ?? invoice.templateId ?? null;
|
|
128
|
+
if (!templateId) {
|
|
129
|
+
const [defaultTemplate] = await db
|
|
130
|
+
.select()
|
|
131
|
+
.from(invoiceTemplates)
|
|
132
|
+
.where(and(eq(invoiceTemplates.isDefault, true), eq(invoiceTemplates.active, true)))
|
|
133
|
+
.orderBy(desc(invoiceTemplates.updatedAt))
|
|
134
|
+
.limit(1);
|
|
135
|
+
templateId = defaultTemplate?.id ?? null;
|
|
136
|
+
}
|
|
137
|
+
const [template, lineItems, paymentRows] = await Promise.all([
|
|
138
|
+
templateId ? financeService.getInvoiceTemplateById(db, templateId) : Promise.resolve(null),
|
|
139
|
+
financeService.listInvoiceLineItems(db, invoiceId),
|
|
140
|
+
financeService.listPayments(db, invoiceId),
|
|
141
|
+
]);
|
|
142
|
+
const renderedBodyFormat = template?.bodyFormat ?? "html";
|
|
143
|
+
const variables = {
|
|
144
|
+
invoice,
|
|
145
|
+
lineItems,
|
|
146
|
+
payments: paymentRows,
|
|
147
|
+
};
|
|
148
|
+
const renderedBody = template
|
|
149
|
+
? renderInvoiceBody(template.body, template.bodyFormat, variables)
|
|
150
|
+
: JSON.stringify(variables);
|
|
151
|
+
return {
|
|
152
|
+
status: "ready",
|
|
153
|
+
invoice,
|
|
154
|
+
template,
|
|
155
|
+
lineItems,
|
|
156
|
+
payments: paymentRows,
|
|
157
|
+
renderedBody,
|
|
158
|
+
renderedBodyFormat,
|
|
159
|
+
variables,
|
|
160
|
+
targetFormat: input.format,
|
|
161
|
+
language: input.language ?? invoice.language ?? template?.language ?? null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export const financeDocumentsService = {
|
|
165
|
+
async generateInvoiceDocument(db, invoiceId, input, runtime) {
|
|
166
|
+
const prepared = await prepareInvoiceDocument(db, invoiceId, input);
|
|
167
|
+
if (prepared.status === "not_found") {
|
|
168
|
+
return { status: "not_found" };
|
|
169
|
+
}
|
|
170
|
+
let artifact;
|
|
171
|
+
try {
|
|
172
|
+
artifact = await runtime.generator({
|
|
173
|
+
db,
|
|
174
|
+
invoice: prepared.invoice,
|
|
175
|
+
template: prepared.template,
|
|
176
|
+
lineItems: prepared.lineItems,
|
|
177
|
+
payments: prepared.payments,
|
|
178
|
+
renderedBody: prepared.renderedBody,
|
|
179
|
+
renderedBodyFormat: prepared.renderedBodyFormat,
|
|
180
|
+
variables: prepared.variables,
|
|
181
|
+
bindings: runtime.bindings ?? {},
|
|
182
|
+
targetFormat: prepared.targetFormat,
|
|
183
|
+
language: prepared.language,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { status: "generator_failed" };
|
|
188
|
+
}
|
|
189
|
+
if (input.replaceExisting) {
|
|
190
|
+
const existing = await financeService.listInvoiceRenditions(db, invoiceId);
|
|
191
|
+
for (const rendition of existing) {
|
|
192
|
+
if (rendition.format === (artifact.format ?? prepared.targetFormat) &&
|
|
193
|
+
rendition.status !== "stale") {
|
|
194
|
+
await financeService.updateInvoiceRendition(db, rendition.id, { status: "stale" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const rendition = await financeService.createInvoiceRendition(db, invoiceId, {
|
|
199
|
+
templateId: prepared.template?.id ?? null,
|
|
200
|
+
format: artifact.format ?? prepared.targetFormat,
|
|
201
|
+
status: "ready",
|
|
202
|
+
storageKey: artifact.storageKey ?? null,
|
|
203
|
+
fileSize: artifact.fileSize ?? null,
|
|
204
|
+
checksum: artifact.checksum ?? null,
|
|
205
|
+
language: artifact.language ?? prepared.language ?? null,
|
|
206
|
+
generatedAt: new Date().toISOString(),
|
|
207
|
+
metadata: {
|
|
208
|
+
...(artifact.metadata ?? {}),
|
|
209
|
+
renderedBodyFormat: prepared.renderedBodyFormat,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (!rendition) {
|
|
213
|
+
return { status: "not_found" };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
status: "generated",
|
|
217
|
+
invoiceId: prepared.invoice.id,
|
|
218
|
+
renderedBodyFormat: prepared.renderedBodyFormat,
|
|
219
|
+
renderedBody: prepared.renderedBody,
|
|
220
|
+
rendition,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
async regenerateInvoiceDocument(db, invoiceId, input, runtime) {
|
|
224
|
+
return this.generateInvoiceDocument(db, invoiceId, input, runtime);
|
|
225
|
+
},
|
|
226
|
+
};
|
package/dist/service-public.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
-
import type { PublicBookingFinanceDocuments, PublicPaymentOptionsQuery, PublicStartPaymentSessionInput, PublicValidateVoucherInput } from "./validation-public.js";
|
|
2
|
+
import type { PublicBookingFinanceDocuments, PublicBookingFinancePayments, PublicFinanceDocumentLookup, PublicPaymentOptionsQuery, PublicStartPaymentSessionInput, PublicValidateVoucherInput } from "./validation-public.js";
|
|
3
3
|
export declare const publicFinanceService: {
|
|
4
4
|
getBookingDocuments(db: PostgresJsDatabase, bookingId: string): Promise<PublicBookingFinanceDocuments | null>;
|
|
5
|
+
getDocumentByReference(db: PostgresJsDatabase, reference: string): Promise<PublicFinanceDocumentLookup | null>;
|
|
5
6
|
getBookingPaymentOptions(db: PostgresJsDatabase, bookingId: string, query: PublicPaymentOptionsQuery): Promise<{
|
|
6
7
|
bookingId: string;
|
|
7
8
|
accounts: {
|
|
@@ -42,6 +43,7 @@ export declare const publicFinanceService: {
|
|
|
42
43
|
targetId: string | null;
|
|
43
44
|
} | null;
|
|
44
45
|
} | null>;
|
|
46
|
+
getBookingPayments(db: PostgresJsDatabase, bookingId: string): Promise<PublicBookingFinancePayments | null>;
|
|
45
47
|
getPaymentSession(db: PostgresJsDatabase, sessionId: string): Promise<{
|
|
46
48
|
id: string;
|
|
47
49
|
targetType: "other" | "booking" | "order" | "invoice" | "booking_payment_schedule" | "booking_guarantee";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-public.d.ts","sourceRoot":"","sources":["../src/service-public.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"service-public.d.ts","sourceRoot":"","sources":["../src/service-public.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAWjE,OAAO,KAAK,EACV,6BAA6B,EAC7B,4BAA4B,EAE5B,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,0BAA0B,EAC3B,MAAM,wBAAwB,CAAA;AA4J/B,eAAO,MAAM,oBAAoB;4BAEzB,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,6BAA6B,GAAG,IAAI,CAAC;+BA2C1C,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC;iCA0CxC,kBAAkB,aACX,MAAM,SACV,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAoH5B,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,4BAA4B,GAAG,IAAI,CAAC;0BA2DnB,kBAAkB,aAAa,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAM3D,kBAAkB,aACX,MAAM,cACL,MAAM,SACX,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA4BjC,kBAAkB,aACX,MAAM,eACJ,MAAM,SACZ,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAuBjC,kBAAkB,aACX,MAAM,SACV,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAMb,kBAAkB,SAAS,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8FhF,CAAA"}
|
package/dist/service-public.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { bookings } from "@voyantjs/bookings/schema";
|
|
2
2
|
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
|
|
3
|
-
import { bookingGuarantees, bookingPaymentSchedules, invoiceRenditions, invoices, paymentInstruments, } from "./schema.js";
|
|
3
|
+
import { bookingGuarantees, bookingPaymentSchedules, invoiceRenditions, invoices, paymentInstruments, payments, } from "./schema.js";
|
|
4
4
|
import { financeService } from "./service.js";
|
|
5
5
|
function normalizeDateTime(value) {
|
|
6
6
|
if (!value) {
|
|
@@ -105,6 +105,31 @@ function toPublicPaymentSession(session) {
|
|
|
105
105
|
failureMessage: session.failureMessage ?? null,
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
function mapInvoiceDocument(invoice, renditions) {
|
|
109
|
+
const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
|
|
110
|
+
const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
|
|
111
|
+
const downloadUrl = getMetadataDownloadUrl(metadata) ?? maybeUrl(selectedRendition?.storageKey ?? null);
|
|
112
|
+
return {
|
|
113
|
+
invoiceId: invoice.id,
|
|
114
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
115
|
+
invoiceType: invoice.invoiceType,
|
|
116
|
+
invoiceStatus: invoice.status,
|
|
117
|
+
currency: invoice.currency,
|
|
118
|
+
totalCents: invoice.totalCents,
|
|
119
|
+
paidCents: invoice.paidCents,
|
|
120
|
+
balanceDueCents: invoice.balanceDueCents,
|
|
121
|
+
issueDate: invoice.issueDate,
|
|
122
|
+
dueDate: invoice.dueDate,
|
|
123
|
+
renditionId: selectedRendition?.id ?? null,
|
|
124
|
+
documentStatus: selectedRendition?.status ?? "missing",
|
|
125
|
+
format: selectedRendition?.format ?? null,
|
|
126
|
+
language: selectedRendition?.language ?? null,
|
|
127
|
+
generatedAt: normalizeDateTime(selectedRendition?.generatedAt),
|
|
128
|
+
fileSize: selectedRendition?.fileSize ?? null,
|
|
129
|
+
checksum: selectedRendition?.checksum ?? null,
|
|
130
|
+
downloadUrl,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
108
133
|
export const publicFinanceService = {
|
|
109
134
|
async getBookingDocuments(db, bookingId) {
|
|
110
135
|
const [booking] = await db
|
|
@@ -136,32 +161,42 @@ export const publicFinanceService = {
|
|
|
136
161
|
}
|
|
137
162
|
return {
|
|
138
163
|
bookingId,
|
|
139
|
-
documents: invoiceRows.map((invoice) =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
documents: invoiceRows.map((invoice) => mapInvoiceDocument(invoice, renditionByInvoiceId.get(invoice.id) ?? [])),
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
async getDocumentByReference(db, reference) {
|
|
168
|
+
const [invoiceMatch, paymentMatch] = await Promise.all([
|
|
169
|
+
db
|
|
170
|
+
.select()
|
|
171
|
+
.from(invoices)
|
|
172
|
+
.where(eq(invoices.invoiceNumber, reference))
|
|
173
|
+
.orderBy(desc(invoices.createdAt))
|
|
174
|
+
.limit(1),
|
|
175
|
+
db
|
|
176
|
+
.select({
|
|
177
|
+
invoiceId: payments.invoiceId,
|
|
178
|
+
})
|
|
179
|
+
.from(payments)
|
|
180
|
+
.where(eq(payments.referenceNumber, reference))
|
|
181
|
+
.orderBy(desc(payments.createdAt))
|
|
182
|
+
.limit(1),
|
|
183
|
+
]);
|
|
184
|
+
const invoiceId = invoiceMatch[0]?.id ?? paymentMatch[0]?.invoiceId ?? null;
|
|
185
|
+
if (!invoiceId) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
|
189
|
+
if (!invoice?.bookingId) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const renditions = await db
|
|
193
|
+
.select()
|
|
194
|
+
.from(invoiceRenditions)
|
|
195
|
+
.where(eq(invoiceRenditions.invoiceId, invoice.id))
|
|
196
|
+
.orderBy(desc(invoiceRenditions.createdAt));
|
|
197
|
+
return {
|
|
198
|
+
bookingId: invoice.bookingId,
|
|
199
|
+
...mapInvoiceDocument(invoice, renditions),
|
|
165
200
|
};
|
|
166
201
|
},
|
|
167
202
|
async getBookingPaymentOptions(db, bookingId, query) {
|
|
@@ -254,6 +289,58 @@ export const publicFinanceService = {
|
|
|
254
289
|
: null,
|
|
255
290
|
};
|
|
256
291
|
},
|
|
292
|
+
async getBookingPayments(db, bookingId) {
|
|
293
|
+
const [booking] = await db
|
|
294
|
+
.select({ id: bookings.id })
|
|
295
|
+
.from(bookings)
|
|
296
|
+
.where(eq(bookings.id, bookingId))
|
|
297
|
+
.limit(1);
|
|
298
|
+
if (!booking) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const invoiceRows = await db
|
|
302
|
+
.select({
|
|
303
|
+
id: invoices.id,
|
|
304
|
+
invoiceNumber: invoices.invoiceNumber,
|
|
305
|
+
invoiceType: invoices.invoiceType,
|
|
306
|
+
})
|
|
307
|
+
.from(invoices)
|
|
308
|
+
.where(eq(invoices.bookingId, bookingId))
|
|
309
|
+
.orderBy(desc(invoices.createdAt));
|
|
310
|
+
if (invoiceRows.length === 0) {
|
|
311
|
+
return { bookingId, payments: [] };
|
|
312
|
+
}
|
|
313
|
+
const invoiceById = new Map(invoiceRows.map((invoice) => [invoice.id, invoice]));
|
|
314
|
+
const paymentRows = await db
|
|
315
|
+
.select()
|
|
316
|
+
.from(payments)
|
|
317
|
+
.where(or(...invoiceRows.map((invoice) => eq(payments.invoiceId, invoice.id))))
|
|
318
|
+
.orderBy(desc(payments.paymentDate), desc(payments.createdAt));
|
|
319
|
+
return {
|
|
320
|
+
bookingId,
|
|
321
|
+
payments: paymentRows.flatMap((payment) => {
|
|
322
|
+
const invoice = invoiceById.get(payment.invoiceId);
|
|
323
|
+
if (!invoice) {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
id: payment.id,
|
|
329
|
+
invoiceId: invoice.id,
|
|
330
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
331
|
+
invoiceType: invoice.invoiceType,
|
|
332
|
+
status: payment.status,
|
|
333
|
+
paymentMethod: payment.paymentMethod,
|
|
334
|
+
amountCents: payment.amountCents,
|
|
335
|
+
currency: payment.currency,
|
|
336
|
+
paymentDate: payment.paymentDate,
|
|
337
|
+
referenceNumber: payment.referenceNumber ?? null,
|
|
338
|
+
notes: payment.notes ?? null,
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
},
|
|
257
344
|
async getPaymentSession(db, sessionId) {
|
|
258
345
|
const session = await financeService.getPaymentSessionById(db, sessionId);
|
|
259
346
|
return session ? toPublicPaymentSession(session) : null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import type { Invoice as FinanceInvoice, InvoiceExternalRef } from "./schema.js";
|
|
3
|
+
import type { PolledInvoiceSettlementResult, PollInvoiceSettlementInput } from "./validation.js";
|
|
4
|
+
type SettlementInvoice = FinanceInvoice;
|
|
5
|
+
type SettlementExternalRef = InvoiceExternalRef;
|
|
6
|
+
export interface InvoiceSettlementPollerContext {
|
|
7
|
+
db: PostgresJsDatabase;
|
|
8
|
+
invoice: SettlementInvoice;
|
|
9
|
+
externalRef: SettlementExternalRef;
|
|
10
|
+
bindings: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface InvoiceSettlementPollerResult {
|
|
13
|
+
externalId?: string | null;
|
|
14
|
+
externalNumber?: string | null;
|
|
15
|
+
externalUrl?: string | null;
|
|
16
|
+
status?: string | null;
|
|
17
|
+
paidAmountCents?: number | null;
|
|
18
|
+
unpaidAmountCents?: number | null;
|
|
19
|
+
syncedAt?: string | Date | null;
|
|
20
|
+
settledAt?: string | Date | null;
|
|
21
|
+
referenceNumber?: string | null;
|
|
22
|
+
syncError?: string | null;
|
|
23
|
+
metadata?: Record<string, unknown> | null;
|
|
24
|
+
}
|
|
25
|
+
export type InvoiceSettlementPoller = (context: InvoiceSettlementPollerContext) => Promise<InvoiceSettlementPollerResult>;
|
|
26
|
+
export interface FinanceSettlementRuntimeOptions {
|
|
27
|
+
bindings?: Record<string, unknown>;
|
|
28
|
+
invoiceSettlementPollers?: Record<string, InvoiceSettlementPoller>;
|
|
29
|
+
}
|
|
30
|
+
export declare const financeSettlementService: {
|
|
31
|
+
pollInvoiceSettlement(db: PostgresJsDatabase, invoiceId: string, input: PollInvoiceSettlementInput, runtime?: FinanceSettlementRuntimeOptions): Promise<PolledInvoiceSettlementResult | {
|
|
32
|
+
status: "not_found";
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=service-settlement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-settlement.d.ts","sourceRoot":"","sources":["../src/service-settlement.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,KAAK,EAAE,OAAO,IAAI,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAEhF,OAAO,KAAK,EAAE,6BAA6B,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAA;AAEhG,KAAK,iBAAiB,GAAG,cAAc,CAAA;AACvC,KAAK,qBAAqB,GAAG,kBAAkB,CAAA;AAE/C,MAAM,WAAW,8BAA8B;IAC7C,EAAE,EAAE,kBAAkB,CAAA;IACtB,OAAO,EAAE,iBAAiB,CAAA;IAC1B,WAAW,EAAE,qBAAqB,CAAA;IAClC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClC;AAED,MAAM,WAAW,6BAA6B;IAC5C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAC/B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAChC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1C;AAED,MAAM,MAAM,uBAAuB,GAAG,CACpC,OAAO,EAAE,8BAA8B,KACpC,OAAO,CAAC,6BAA6B,CAAC,CAAA;AAE3C,MAAM,WAAW,+BAA+B;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAClC,wBAAwB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;CACnE;AAyED,eAAO,MAAM,wBAAwB;8BAE7B,kBAAkB,aACX,MAAM,SACV,0BAA0B,YACxB,+BAA+B,GACvC,OAAO,CAAC,6BAA6B,GAAG;QAAE,MAAM,EAAE,WAAW,CAAA;KAAE,CAAC;CAwIpE,CAAA"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { financeService } from "./service.js";
|
|
2
|
+
function coerceRecord(value) {
|
|
3
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
4
|
+
? value
|
|
5
|
+
: null;
|
|
6
|
+
}
|
|
7
|
+
function toIsoString(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return null;
|
|
10
|
+
if (value instanceof Date)
|
|
11
|
+
return value.toISOString();
|
|
12
|
+
const parsed = new Date(value);
|
|
13
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
14
|
+
}
|
|
15
|
+
function normalizeMoney(value) {
|
|
16
|
+
if (typeof value !== "number" || Number.isNaN(value))
|
|
17
|
+
return null;
|
|
18
|
+
return Math.round(value);
|
|
19
|
+
}
|
|
20
|
+
function resolveReferenceNumber(input, pollResult, externalRef, invoice) {
|
|
21
|
+
return (input.referenceNumber ??
|
|
22
|
+
pollResult.referenceNumber ??
|
|
23
|
+
pollResult.externalNumber ??
|
|
24
|
+
pollResult.externalId ??
|
|
25
|
+
externalRef.externalNumber ??
|
|
26
|
+
externalRef.externalId ??
|
|
27
|
+
invoice.invoiceNumber);
|
|
28
|
+
}
|
|
29
|
+
function resolvePaymentDate(input, pollResult) {
|
|
30
|
+
return (input.paymentDate ??
|
|
31
|
+
toIsoString(pollResult.settledAt) ??
|
|
32
|
+
toIsoString(pollResult.syncedAt) ??
|
|
33
|
+
new Date().toISOString());
|
|
34
|
+
}
|
|
35
|
+
async function synchronizeExternalRef(db, invoiceId, externalRef, pollResult) {
|
|
36
|
+
const syncedAt = toIsoString(pollResult.syncedAt) ?? new Date().toISOString();
|
|
37
|
+
const row = await financeService.registerInvoiceExternalRef(db, invoiceId, {
|
|
38
|
+
provider: externalRef.provider,
|
|
39
|
+
externalId: pollResult.externalId ?? externalRef.externalId ?? null,
|
|
40
|
+
externalNumber: pollResult.externalNumber ?? externalRef.externalNumber ?? null,
|
|
41
|
+
externalUrl: pollResult.externalUrl ?? externalRef.externalUrl ?? null,
|
|
42
|
+
status: pollResult.status ?? externalRef.status ?? null,
|
|
43
|
+
metadata: pollResult.metadata ?? coerceRecord(externalRef.metadata) ?? null,
|
|
44
|
+
syncedAt,
|
|
45
|
+
syncError: pollResult.syncError ?? null,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
row,
|
|
49
|
+
syncedAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export const financeSettlementService = {
|
|
53
|
+
async pollInvoiceSettlement(db, invoiceId, input, runtime = {}) {
|
|
54
|
+
let invoice = await financeService.getInvoiceById(db, invoiceId);
|
|
55
|
+
if (!invoice) {
|
|
56
|
+
return { status: "not_found" };
|
|
57
|
+
}
|
|
58
|
+
const externalRefs = await financeService.listInvoiceExternalRefs(db, invoiceId);
|
|
59
|
+
const refsToPoll = input.provider
|
|
60
|
+
? externalRefs.filter((externalRef) => externalRef.provider === input.provider)
|
|
61
|
+
: externalRefs;
|
|
62
|
+
const results = [];
|
|
63
|
+
for (const externalRef of refsToPoll) {
|
|
64
|
+
const poller = runtime.invoiceSettlementPollers?.[externalRef.provider];
|
|
65
|
+
if (!poller) {
|
|
66
|
+
const synced = await synchronizeExternalRef(db, invoice.id, externalRef, {
|
|
67
|
+
syncError: "No settlement poller configured",
|
|
68
|
+
});
|
|
69
|
+
results.push({
|
|
70
|
+
provider: externalRef.provider,
|
|
71
|
+
externalRefId: synced.row?.id ?? externalRef.id,
|
|
72
|
+
externalId: synced.row?.externalId ?? externalRef.externalId ?? null,
|
|
73
|
+
externalNumber: synced.row?.externalNumber ?? externalRef.externalNumber ?? null,
|
|
74
|
+
externalUrl: synced.row?.externalUrl ?? externalRef.externalUrl ?? null,
|
|
75
|
+
status: synced.row?.status ?? externalRef.status ?? null,
|
|
76
|
+
paidAmountCents: null,
|
|
77
|
+
unpaidAmountCents: null,
|
|
78
|
+
syncedAt: toIsoString(synced.row?.syncedAt) ?? synced.syncedAt,
|
|
79
|
+
settledAt: null,
|
|
80
|
+
createdPaymentId: null,
|
|
81
|
+
newlyAppliedAmountCents: 0,
|
|
82
|
+
syncError: synced.row?.syncError ?? "No settlement poller configured",
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const pollResult = await poller({
|
|
88
|
+
db,
|
|
89
|
+
invoice,
|
|
90
|
+
externalRef,
|
|
91
|
+
bindings: runtime.bindings ?? {},
|
|
92
|
+
});
|
|
93
|
+
const synced = await synchronizeExternalRef(db, invoice.id, externalRef, pollResult);
|
|
94
|
+
const paidAmountCents = normalizeMoney(pollResult.paidAmountCents);
|
|
95
|
+
const unpaidAmountCents = normalizeMoney(pollResult.unpaidAmountCents);
|
|
96
|
+
let newlyAppliedAmountCents = 0;
|
|
97
|
+
let createdPaymentId = null;
|
|
98
|
+
if (input.reconcilePayment && paidAmountCents !== null) {
|
|
99
|
+
const cappedPaidAmountCents = Math.min(invoice.totalCents, Math.max(0, paidAmountCents));
|
|
100
|
+
const outstandingAmountCents = Math.max(0, invoice.totalCents - invoice.paidCents);
|
|
101
|
+
newlyAppliedAmountCents = Math.min(outstandingAmountCents, Math.max(0, cappedPaidAmountCents - invoice.paidCents));
|
|
102
|
+
if (newlyAppliedAmountCents > 0) {
|
|
103
|
+
const payment = await financeService.createPayment(db, invoice.id, {
|
|
104
|
+
amountCents: newlyAppliedAmountCents,
|
|
105
|
+
currency: invoice.currency,
|
|
106
|
+
baseCurrency: invoice.baseCurrency ?? null,
|
|
107
|
+
baseAmountCents: invoice.baseCurrency && invoice.baseCurrency === invoice.currency
|
|
108
|
+
? newlyAppliedAmountCents
|
|
109
|
+
: null,
|
|
110
|
+
fxRateSetId: invoice.fxRateSetId ?? null,
|
|
111
|
+
paymentMethod: input.paymentMethod,
|
|
112
|
+
paymentInstrumentId: null,
|
|
113
|
+
paymentAuthorizationId: null,
|
|
114
|
+
paymentCaptureId: null,
|
|
115
|
+
status: "completed",
|
|
116
|
+
referenceNumber: resolveReferenceNumber(input, pollResult, externalRef, invoice),
|
|
117
|
+
paymentDate: resolvePaymentDate(input, pollResult),
|
|
118
|
+
notes: input.notes ??
|
|
119
|
+
`Settlement reconciled from ${externalRef.provider} external reference`,
|
|
120
|
+
});
|
|
121
|
+
createdPaymentId = payment?.id ?? null;
|
|
122
|
+
invoice = (await financeService.getInvoiceById(db, invoice.id)) ?? invoice;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
results.push({
|
|
126
|
+
provider: externalRef.provider,
|
|
127
|
+
externalRefId: synced.row?.id ?? externalRef.id,
|
|
128
|
+
externalId: synced.row?.externalId ?? externalRef.externalId ?? null,
|
|
129
|
+
externalNumber: synced.row?.externalNumber ?? externalRef.externalNumber ?? null,
|
|
130
|
+
externalUrl: synced.row?.externalUrl ?? externalRef.externalUrl ?? null,
|
|
131
|
+
status: synced.row?.status ?? externalRef.status ?? null,
|
|
132
|
+
paidAmountCents,
|
|
133
|
+
unpaidAmountCents,
|
|
134
|
+
syncedAt: toIsoString(synced.row?.syncedAt) ?? synced.syncedAt,
|
|
135
|
+
settledAt: toIsoString(pollResult.settledAt),
|
|
136
|
+
createdPaymentId,
|
|
137
|
+
newlyAppliedAmountCents,
|
|
138
|
+
syncError: synced.row?.syncError ?? pollResult.syncError ?? null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const message = error instanceof Error ? error.message : "Settlement polling failed";
|
|
143
|
+
const synced = await synchronizeExternalRef(db, invoice.id, externalRef, {
|
|
144
|
+
syncError: message,
|
|
145
|
+
});
|
|
146
|
+
results.push({
|
|
147
|
+
provider: externalRef.provider,
|
|
148
|
+
externalRefId: synced.row?.id ?? externalRef.id,
|
|
149
|
+
externalId: synced.row?.externalId ?? externalRef.externalId ?? null,
|
|
150
|
+
externalNumber: synced.row?.externalNumber ?? externalRef.externalNumber ?? null,
|
|
151
|
+
externalUrl: synced.row?.externalUrl ?? externalRef.externalUrl ?? null,
|
|
152
|
+
status: synced.row?.status ?? externalRef.status ?? null,
|
|
153
|
+
paidAmountCents: null,
|
|
154
|
+
unpaidAmountCents: null,
|
|
155
|
+
syncedAt: toIsoString(synced.row?.syncedAt) ?? synced.syncedAt,
|
|
156
|
+
settledAt: null,
|
|
157
|
+
createdPaymentId: null,
|
|
158
|
+
newlyAppliedAmountCents: 0,
|
|
159
|
+
syncError: synced.row?.syncError ?? message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
invoice = (await financeService.getInvoiceById(db, invoice.id)) ?? invoice;
|
|
164
|
+
return {
|
|
165
|
+
invoiceId: invoice.id,
|
|
166
|
+
invoiceStatus: invoice.status,
|
|
167
|
+
paidCents: invoice.paidCents,
|
|
168
|
+
balanceDueCents: invoice.balanceDueCents,
|
|
169
|
+
results,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
};
|