Files
template-app/modules/invoicing/adapters/service.ts
T
2026-11-14 09:56:45 +00:00

224 lines
7.8 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import {
businessAppError,
type RequestBusinessBinding,
requestBusinessBinding,
toBusinessError,
} from "@conusai-kit/business"
import { badRequest, type Container, notFound, type ProcedureKind, serviceRefusal } from "@conusai-kit/core"
import {
type Invoice,
type InvoiceData,
invoicingCreateSeries,
invoicingCreditInvoice,
invoicingDraftInvoice,
invoicingGetInvoice,
invoicingIssueInvoice,
invoicingListInvoices,
invoicingListSeries,
invoicingRenderUbl,
invoicingUpdateInvoice,
invoicingVoidInvoice,
type Series,
} from "@conusai-sdk/invoicing"
import { createClient } from "@conusai-sdk/invoicing/client"
import { type InvoiceTerms, type InvoiceView, invoiceView, type SeriesView, seriesView } from "../contract/index.js"
import { INVOICING_SERVICE, type InvoicingReads, type InvoicingService, readsOf } from "../core/ports.js"
async function answer<T>(
operation: Promise<{ data?: T; error?: unknown; response?: Response }>,
): Promise<Exclude<T, undefined>> {
let result: Awaited<typeof operation>
try {
result = await operation
} catch (error) {
throw businessAppError(toBusinessError(error, undefined))
}
if (result.error !== undefined || result.data === undefined)
throw businessAppError(toBusinessError(result.error, result.response))
return result.data as Exclude<T, undefined>
}
const partyOf = (party: InvoiceTerms["seller"]): InvoiceData["seller"] => ({
reference: party.reference,
name: party.name,
trading_name: party.trading_name ?? null,
identifiers: party.identifiers ?? [],
legal_registration: party.legal_registration ?? null,
vat_identifier: party.vat_identifier ?? null,
endpoint: party.endpoint,
address: {
country: party.address.country,
street: party.address.street ?? null,
additional_street: party.address.additional_street ?? null,
address_line_3: party.address.address_line_3 ?? null,
city: party.address.city ?? null,
postal_code: party.address.postal_code ?? null,
subdivision: party.address.subdivision ?? null,
},
})
const terms = (data: InvoiceTerms): InvoiceData => ({
type_code: data.type_code,
series_id: data.series_id,
issue_date: data.issue_date,
due_date: data.due_date ?? null,
currency: data.currency,
seller: partyOf(data.seller),
buyer: partyOf(data.buyer),
buyer_reference: data.buyer_reference,
preceding_invoice: data.preceding_invoice ?? null,
lines: data.lines.map((line) => ({
item_name: line.item_name,
quantity: BigInt(line.quantity),
unit: line.unit,
item_identifiers: line.item_identifiers ?? [],
net_price: line.net_price,
vat_category: line.vat_category,
vat_rate: line.vat_rate ?? null,
exemption_reason: line.exemption_reason ?? null,
})),
allowances_charges: (data.allowances_charges ?? []).map((entry) => ({
amount: entry.amount,
charge: entry.charge,
reason: entry.reason,
vat_category: entry.vat_category,
vat_rate: entry.vat_rate ?? null,
exemption_reason: entry.exemption_reason ?? null,
})),
notes: data.notes ?? [],
ext: data.ext ?? {},
payment_means: data.payment_means
? {
code: data.payment_means.code,
account_id: data.payment_means.account_id ?? null,
account_name: data.payment_means.account_name ?? null,
}
: null,
})
const view = (row: Invoice): InvoiceView => {
if (row.deleted || row.erased) throw notFound()
return invoiceView.parse({
invoiceId: row.id,
revision: row.revision.toString(),
status: row.status,
number: row.number ?? null,
data: { ...row.data, lines: row.data.lines.map((line) => ({ ...line, quantity: line.quantity.toString() })) },
payable: row.calculated.totals.payable,
deleted: row.deleted,
erased: row.erased,
})
}
const series = (row: Series): SeriesView =>
seriesView.parse({
seriesId: row.id,
revision: row.revision.toString(),
code: row.code,
prefix: row.prefix,
active: row.active,
kind: row.kind,
nextNumber: row.next_number.toString(),
})
export function sdkInvoicingService(binding: RequestBusinessBinding): InvoicingService {
const client = binding.client(createClient)
const org = { org_slug: binding.orgSlug }
const scopedTerms = (data: InvoiceTerms): InvoiceData => {
for (const party of [data.seller, data.buyer])
if (
party.reference.startsWith("conusai://app-partitions/") &&
!party.reference.startsWith(`conusai://app-partitions/${binding.bindingId}/members/`)
)
throw badRequest("The party subject belongs to another app connection.")
return terms(data)
}
return {
async list(after) {
const result = await answer(
invoicingListInvoices({ client, path: org, query: { limit: 50n, ...(after ? { after } : {}) } }),
)
return {
rows: result.data.filter((row) => !row.deleted && !row.erased).map(view),
...(result.next_after ? { after: result.next_after } : {}),
}
},
async get(id) {
return view(await answer(invoicingGetInvoice({ client, path: { ...org, invoice_id: id } })))
},
async series(after) {
const result = await answer(
invoicingListSeries({ client, path: org, query: { limit: 50n, ...(after ? { after } : {}) } }),
)
return { rows: result.data.map(series), ...(result.next_after ? { after: result.next_after } : {}) }
},
async draft(data, key) {
return view(
await answer(
invoicingDraftInvoice({ client, path: org, body: { data: scopedTerms(data), idempotency_key: key } }),
),
)
},
async update(id, data, revision, key) {
return view(
await answer(
invoicingUpdateInvoice({
client,
path: { ...org, invoice_id: id },
body: { data: scopedTerms(data), revision: BigInt(revision), idempotency_key: key },
}),
),
)
},
async transition(operation, input) {
const method =
operation === "issue"
? invoicingIssueInvoice
: operation === "credit"
? invoicingCreditInvoice
: invoicingVoidInvoice
return view(
await answer(
method({
client,
path: { ...org, invoice_id: input.invoiceId },
body: {
revision: BigInt(input.expectedVersion),
idempotency_key: input.key,
...(input.seriesId ? { series_id: input.seriesId } : {}),
...(input.reason ? { reason: input.reason } : {}),
},
}),
),
)
},
async createSeries(input) {
return series(
await answer(
invoicingCreateSeries({
client,
path: org,
body: {
code: input.code,
prefix: input.prefix,
kind: input.kind,
active: true,
idempotency_key: input.key,
},
}),
),
)
},
async ubl(id) {
// Force the generated wire client to preserve XML bytes as a Blob.
const blob = await answer(invoicingRenderUbl({ client, path: { ...org, invoice_id: id }, parseAs: "blob" }))
if (!(blob instanceof Blob) || blob.size > 8000000) throw serviceRefusal("degraded")
return { filename: `invoice-${id}.xml`, xml: await blob.text() }
},
}
}
export function invoicingServiceOf(container: Container, kind: "effect"): InvoicingService
export function invoicingServiceOf(container: Container, kind: ProcedureKind): InvoicingReads
export function invoicingServiceOf(container: Container, kind: ProcedureKind): InvoicingReads {
const service = container.has(INVOICING_SERVICE)
? container.resolve(INVOICING_SERVICE)
: sdkInvoicingService(requestBusinessBinding(container))
return kind === "effect" ? service : readsOf(service)
}