@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.
@@ -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
+ };
@@ -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;AAUjE,OAAO,KAAK,EACV,6BAA6B,EAC7B,yBAAyB,EACzB,8BAA8B,EAC9B,0BAA0B,EAC3B,MAAM,wBAAwB,CAAA;AA4H/B,eAAO,MAAM,oBAAoB;4BAEzB,kBAAkB,aACX,MAAM,GAChB,OAAO,CAAC,6BAA6B,GAAG,IAAI,CAAC;iCAqE1C,kBAAkB,aACX,MAAM,SACV,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BAmHN,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"}
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"}
@@ -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
- const renditions = renditionByInvoiceId.get(invoice.id) ?? [];
141
- const selectedRendition = renditions.find((rendition) => rendition.status === "ready") ?? renditions[0] ?? null;
142
- const metadata = getMetadataRecord(selectedRendition?.metadata ?? null);
143
- const downloadUrl = getMetadataDownloadUrl(metadata) ?? maybeUrl(selectedRendition?.storageKey ?? null);
144
- return {
145
- invoiceId: invoice.id,
146
- invoiceNumber: invoice.invoiceNumber,
147
- invoiceType: invoice.invoiceType,
148
- invoiceStatus: invoice.status,
149
- currency: invoice.currency,
150
- totalCents: invoice.totalCents,
151
- paidCents: invoice.paidCents,
152
- balanceDueCents: invoice.balanceDueCents,
153
- issueDate: invoice.issueDate,
154
- dueDate: invoice.dueDate,
155
- renditionId: selectedRendition?.id ?? null,
156
- documentStatus: selectedRendition?.status ?? "missing",
157
- format: selectedRendition?.format ?? null,
158
- language: selectedRendition?.language ?? null,
159
- generatedAt: normalizeDateTime(selectedRendition?.generatedAt),
160
- fileSize: selectedRendition?.fileSize ?? null,
161
- checksum: selectedRendition?.checksum ?? null,
162
- downloadUrl,
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
+ };