172 lines
7.0 KiB
TypeScript
172 lines
7.0 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
|
||
import { type Container, notFound, serviceRefusal, type TransportCaller } from "@conusai-kit/core"
|
||
import type { I18n } from "@conusai-kit/i18n"
|
||
import { Decimal, Money } from "@conusai-kit/money"
|
||
import { Quantity } from "@conusai-kit/money/units"
|
||
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
|
||
import { Can } from "@conusai-kit/ui/data"
|
||
import {
|
||
FactList,
|
||
Page,
|
||
PageHeader,
|
||
PageSection,
|
||
type ServiceNotice,
|
||
ServiceStatePage,
|
||
serviceNotice,
|
||
} from "@conusai-kit/ui/patterns"
|
||
import * as contracts from "../contract/index.js"
|
||
import { en } from "../i18n/en.js"
|
||
import { FiscalAction, InvoiceForm, Invoices, SeriesForm, UblDownload } from "./controls.js"
|
||
import type { Labels, Messages } from "./labels.js"
|
||
|
||
type Data = { labels: Labels } & (
|
||
| {
|
||
kind: "list"
|
||
invoices: { rows: contracts.InvoiceView[]; after?: string | undefined }
|
||
series: contracts.SeriesView[]
|
||
}
|
||
| {
|
||
kind: "invoice"
|
||
invoice: contracts.InvoiceView
|
||
series: contracts.SeriesView[]
|
||
/** The amounts, quantities and rates as the reader writes them, formatted on the server. */
|
||
shown: { payable: string; lines: string[] }
|
||
}
|
||
| { kind: "state"; notice: ServiceNotice }
|
||
)
|
||
export const invoicingPage: ProjectPage<TransportCaller, I18n, Container, Data> = projectPage({
|
||
async load({ slugs, segments, caller, i18n }) {
|
||
const messages = Object.fromEntries(
|
||
Object.keys(en).map((key) => [key, i18n.message(`invoicing.${key}`)]),
|
||
) as Messages
|
||
const labels: Labels = {
|
||
...messages,
|
||
locale: i18n.locale,
|
||
refusals: businessRefusalNotices(i18n.locale, {
|
||
conflict: messages.conflict,
|
||
revision_conflict: messages.conflict,
|
||
invalid: messages.invalid,
|
||
}),
|
||
}
|
||
const [id, ...rest] = segments
|
||
if (rest.length || (id !== undefined && !contracts.invoiceId.safeParse(id).success)) throw notFound()
|
||
try {
|
||
const allSeries: contracts.SeriesView[] = []
|
||
let after: string | undefined
|
||
const seen = new Set<string>()
|
||
let pages = 0
|
||
// Numbering choices are bounded; refusal is preferable to silently missing a series.
|
||
do {
|
||
if (pages++ >= 20) throw serviceRefusal("degraded")
|
||
const page = await caller.call(contracts.series, { org: slugs.org, ...(after ? { after } : {}) })
|
||
allSeries.push(...page.rows)
|
||
after = page.after
|
||
if (allSeries.length > 1000 || (after && seen.has(after))) throw serviceRefusal("degraded")
|
||
if (after) seen.add(after)
|
||
} while (after)
|
||
if (id === undefined)
|
||
return {
|
||
kind: "list",
|
||
labels,
|
||
invoices: await caller.call(contracts.invoices, { org: slugs.org }),
|
||
series: allSeries,
|
||
}
|
||
const invoice = await caller.call(contracts.invoice, { org: slugs.org, invoiceId: id })
|
||
return { kind: "invoice", labels, invoice, series: allSeries, shown: shownOf(invoice, labels, i18n) }
|
||
} catch (error) {
|
||
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
|
||
if (!notice) throw error
|
||
return { kind: "state", labels, notice }
|
||
}
|
||
},
|
||
View({ slugs, data }) {
|
||
const labels = data.labels
|
||
const href = `/${slugs.org}/${slugs.workspace}/${slugs.project}/invoicing`
|
||
if (data.kind === "state") return <ServiceStatePage title={labels.title} notice={data.notice} />
|
||
if (data.kind === "list")
|
||
return (
|
||
<Page>
|
||
<PageHeader title={labels.title} description={labels.intro} />
|
||
<p>{labels.fiscalNotice}</p>
|
||
<Invoices slugs={slugs} seed={data.invoices} href={href} labels={labels} />
|
||
<Can permission="invoicing.invoice.write">
|
||
<InvoiceForm slugs={slugs} labels={labels} series={data.series} href={href} />
|
||
</Can>
|
||
<Can permission="invoicing.series.manage">
|
||
<SeriesForm slugs={slugs} labels={labels} />
|
||
</Can>
|
||
</Page>
|
||
)
|
||
const invoice = data.invoice
|
||
return (
|
||
<Page>
|
||
<PageHeader
|
||
title={invoice.number ?? labels.draft}
|
||
description={`${labels[invoice.status]} · ${data.shown.payable}`}
|
||
/>
|
||
<a href={href}>{labels.back}</a>
|
||
<p>{labels.fiscalNotice}</p>
|
||
<PageSection title={labels.saved}>
|
||
<FactList
|
||
facts={[
|
||
{ label: labels.revision, value: invoice.revision },
|
||
{ label: labels.seller, value: invoice.data.seller.name },
|
||
{ label: labels.buyer, value: invoice.data.buyer.name },
|
||
{ label: labels.issueDate, value: invoice.data.issue_date },
|
||
{ label: labels.dueDate, value: invoice.data.due_date ?? "—" },
|
||
{ label: labels.currency, value: invoice.data.currency },
|
||
{ label: labels.buyerReference, value: invoice.data.buyer_reference },
|
||
...invoice.data.lines.map((line, index) => ({
|
||
label: line.item_name,
|
||
value: data.shown.lines[index] ?? "",
|
||
})),
|
||
{ label: labels.payable, value: data.shown.payable },
|
||
]}
|
||
/>
|
||
</PageSection>
|
||
{invoice.status === "draft" ? (
|
||
<>
|
||
<Can permission="invoicing.invoice.write">
|
||
<InvoiceForm slugs={slugs} invoice={invoice} labels={labels} series={data.series} href={href} />
|
||
</Can>
|
||
<Can permission="invoicing.invoice.issue">
|
||
<FiscalAction slugs={slugs} invoice={invoice} operation="issue" series={data.series} labels={labels} />
|
||
</Can>
|
||
</>
|
||
) : null}
|
||
{invoice.status === "issued" ? (
|
||
<>
|
||
<Can permission="invoicing.invoice.credit">
|
||
<FiscalAction slugs={slugs} invoice={invoice} operation="credit" series={data.series} labels={labels} />
|
||
</Can>
|
||
<Can permission="invoicing.invoice.void">
|
||
<FiscalAction slugs={slugs} invoice={invoice} operation="void" series={data.series} labels={labels} />
|
||
</Can>
|
||
</>
|
||
) : null}
|
||
{invoice.status !== "draft" ? (
|
||
<PageSection title={labels.download}>
|
||
<UblDownload slugs={slugs} invoice={invoice} labels={labels} />
|
||
</PageSection>
|
||
) : null}
|
||
</Page>
|
||
)
|
||
},
|
||
})
|
||
|
||
/** Exact values in the reader's form (`12,50 €`, `3 vnt.`, `21 %`), never through a float. */
|
||
function shownOf(invoice: contracts.InvoiceView, labels: Labels, i18n: I18n): { payable: string; lines: string[] } {
|
||
return {
|
||
payable: i18n.money(Money.parse(invoice.payable)),
|
||
lines: invoice.data.lines.map((line) =>
|
||
[
|
||
`${i18n.quantity(Quantity.of(line.quantity, line.unit))} × ${i18n.money(Money.parse(line.net_price))}`,
|
||
`${labels.vatCategory}: ${line.vat_category}`,
|
||
`${labels.vatRate}: ${line.vat_rate ? i18n.percent(Decimal.parse(line.vat_rate)) : "—"}`,
|
||
...(line.exemption_reason ? [line.exemption_reason] : []),
|
||
].join(" · "),
|
||
),
|
||
}
|
||
}
|