224 lines
7.8 KiB
TypeScript
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)
|
|
}
|