@@ -0,0 +1,41 @@
|
||||
<!-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 -->
|
||||
# Invoicing
|
||||
|
||||
An optional client of the platform's Invoicing service. It stores no invoices, numbering sequence
|
||||
or totals locally. It ships off. `pnpm kit module:on invoicing` includes it and requests invoice
|
||||
read/draft plus series-read scopes; it neither enables the service nor approves permissions.
|
||||
Issue, credit, void and series administration require deliberate additional platform scope approval.
|
||||
The module remains unsupported on Convex.
|
||||
|
||||
The page is mounted under a project at `/<org>/<workspace>/<project>/invoicing`; its records and
|
||||
permissions are explicitly scoped to the app organization, across that organization's projects.
|
||||
It does not claim project-private invoice storage. The gateway separately binds the app organization
|
||||
to the approved Cloud organization/project/environment connection. A request resolves current app
|
||||
membership before readiness or service dispatch. Browser slugs, party references and resource IDs
|
||||
confer no authority.
|
||||
|
||||
The contracts expose invoice/series reads, draft creation/editing, explicit issue/credit/void,
|
||||
series creation and UBL download. The common form supports multiple lines, postal/electronic party
|
||||
addresses, VAT identifiers, category/rate and exemption reason. It preserves unedited identifiers,
|
||||
payment details, notes, adjustments and extensions in an existing draft. Advanced terms remain
|
||||
available through the typed contract. CRM party links are metadata, not an implicit cross-service
|
||||
lookup or permission. App member subject references must match the prepared binding.
|
||||
|
||||
Draft writers need the editor role. Fiscal actions and series configuration require an administrator;
|
||||
the server checks these permissions independently of hidden UI controls. An issue/credit/void input
|
||||
requires explicit confirmation and the exact current revision. Identical retries retain their
|
||||
operation key; editing the submitted terms produces a different key. The service alone allocates
|
||||
numbers, calculates amounts, resolves retries and preserves issued snapshots. Neither app code nor
|
||||
binary rollback rewinds those facts. Credits create a new draft that must be reviewed and issued.
|
||||
Nothing automatically sends an invoice or invokes a payment provider.
|
||||
|
||||
Money and revisions cross the app boundary as validated strings. The generated SDK uses exact int64
|
||||
encoding; quantities become bigint only inside the adapter. The form never calculates fiscal totals
|
||||
with JavaScript floats. Missing series, configuration, grant, entitlement, incompatible API/profile,
|
||||
revoked partition and degraded dependencies remain distinct bounded readiness errors. Remote error
|
||||
details are not shown. UBL is an explicit bounded XML download.
|
||||
|
||||
Local tests use memory ports and synthetic HTTP; they establish app permissions, foreign-org denial,
|
||||
wire values and control behavior. Real runtime-role service, numbering/retry/restore and gateway/app
|
||||
journeys are separate release gates. Fiscal retention/export/erasure remain service-owned; see the
|
||||
platform compliance inventory. No customer data belongs in fixtures.
|
||||
@@ -0,0 +1,161 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Simulated ports cover contract/app rules; real service fiscal transactions are separate acceptance.
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { decimal, draft, invoiceTerms, issue, revision } from "../contract/index.js"
|
||||
import { transitionInvoice, updateDraft, validateTerms } from "../core/invoices.js"
|
||||
import { type InvoicingService, readsOf } from "../core/ports.js"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
import { draftData } from "../ui/form-data.js"
|
||||
import { id, invoiceId, row, terms } from "./fixture.js"
|
||||
|
||||
function service(): InvoicingService {
|
||||
return {
|
||||
list: vi.fn(async () => ({ rows: [row] })),
|
||||
get: vi.fn(async () => row),
|
||||
series: vi.fn(async () => ({ rows: [] })),
|
||||
ubl: vi.fn(async () => ({ filename: "fixture.xml", xml: "<Invoice/>" })),
|
||||
draft: vi.fn(async () => row),
|
||||
update: vi.fn(async () => row),
|
||||
transition: vi.fn(async () => row),
|
||||
createSeries: vi.fn(async () => {
|
||||
throw new Error("not in this fixture")
|
||||
}),
|
||||
}
|
||||
}
|
||||
describe("Invoicing contracts and safe app operations", () => {
|
||||
it("keeps amounts exact and rejects number/exponent and malformed integer inputs without throwing", () => {
|
||||
expect(invoiceTerms.parse(terms).lines[0]?.net_price.amount).toBe("9007199254740993.123456789012345678")
|
||||
for (const bad of [1, NaN, Infinity, "1e3", "1.2.3", ""]) expect(decimal.safeParse(bad).success).toBe(false)
|
||||
for (const bad of ["oops", "1.5", "9223372036854775808", ""]) expect(revision.safeParse(bad).success).toBe(false)
|
||||
expect(
|
||||
draft.input.safeParse({
|
||||
org: "fixture",
|
||||
data: { ...terms, lines: [{ ...terms.lines[0], quantity: "oops" }] },
|
||||
requestId: id,
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it("refuses cross-currency terms before an external mutation", () => {
|
||||
expect(() =>
|
||||
validateTerms({
|
||||
...terms,
|
||||
lines: [
|
||||
{
|
||||
...terms.lines[0],
|
||||
quantity: "2",
|
||||
item_name: "Synthetic",
|
||||
unit: "C62",
|
||||
vat_category: "S",
|
||||
net_price: { currency: "USD", amount: "1" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
it("requires an explicit confirmation and grants no write methods to a read view", async () => {
|
||||
expect(
|
||||
issue.input.safeParse({ org: "fixture", invoiceId, expectedVersion: "1", requestId: id, confirmed: false })
|
||||
.success,
|
||||
).toBe(false)
|
||||
const port = service()
|
||||
expect("draft" in readsOf(port)).toBe(false)
|
||||
await expect(
|
||||
transitionInvoice(port, "issue", { invoiceId, expectedVersion: "1", requestId: id, confirmed: false as never }),
|
||||
).rejects.toMatchObject({ code: "bad_request" })
|
||||
expect(port.transition).not.toHaveBeenCalled()
|
||||
})
|
||||
it("forwards the same exact revision and idempotency key on response-loss retries", async () => {
|
||||
const port = service()
|
||||
const input = { invoiceId, expectedVersion: "1", requestId: id, confirmed: true as const }
|
||||
await transitionInvoice(port, "issue", input)
|
||||
await transitionInvoice(port, "issue", input)
|
||||
expect(vi.mocked(port.transition).mock.calls[0]).toEqual(vi.mocked(port.transition).mock.calls[1])
|
||||
expect(port.transition).toHaveBeenCalledWith(
|
||||
"issue",
|
||||
expect.objectContaining({ expectedVersion: "1", key: `invoicing:issue:${id}` }),
|
||||
)
|
||||
})
|
||||
it("does not predict a future revision, and delegates stale receipt resolution to the service", async () => {
|
||||
const port = service()
|
||||
await expect(
|
||||
updateDraft(port, { invoiceId, data: terms, expectedVersion: "9007199254740994", requestId: id }),
|
||||
).rejects.toMatchObject({ code: "conflict" })
|
||||
expect(port.update).not.toHaveBeenCalled()
|
||||
await updateDraft(port, { invoiceId, data: terms, expectedVersion: "1", requestId: id })
|
||||
expect(port.update).toHaveBeenCalledWith(invoiceId, terms, "1", `invoicing:update:${id}`)
|
||||
})
|
||||
it("retains unedited invoice terms and exact digits while editing an existing draft", () => {
|
||||
const prior = {
|
||||
...terms,
|
||||
notes: ["Synthetic note"],
|
||||
ext: { fixture: "retain" },
|
||||
payment_means: { code: "30", account_id: "SYNTHETIC" },
|
||||
seller: { ...terms.seller, trading_name: "Synthetic trading" },
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
series: terms.series_id,
|
||||
currency: terms.currency,
|
||||
issueDate: terms.issue_date,
|
||||
buyerReference: terms.buyer_reference,
|
||||
}
|
||||
for (const side of ["seller", "buyer"] as const) {
|
||||
const party = terms[side]
|
||||
Object.assign(values, {
|
||||
[`${side}.reference`]: party.reference,
|
||||
[`${side}.name`]: party.name,
|
||||
[`${side}.country`]: party.address.country,
|
||||
[`${side}.scheme`]: party.endpoint.scheme,
|
||||
[`${side}.endpoint`]: party.endpoint.value,
|
||||
[`${side}.vatScheme`]: party.vat_identifier?.scheme ?? "vat",
|
||||
[`${side}.vatValue`]: party.vat_identifier?.value ?? "",
|
||||
})
|
||||
}
|
||||
const line = terms.lines[0]
|
||||
if (!line) throw new Error("fixture line absent")
|
||||
Object.assign(values, {
|
||||
"line.0.name": line.item_name,
|
||||
"line.0.quantity": line.quantity,
|
||||
"line.0.unit": line.unit,
|
||||
"line.0.price": line.net_price.amount,
|
||||
"line.0.category": line.vat_category,
|
||||
"line.0.rate": line.vat_rate ?? "",
|
||||
})
|
||||
const next = draftData(values, 1, "en", prior)
|
||||
expect(next.ext).toEqual(prior.ext)
|
||||
expect(next.payment_means).toEqual(prior.payment_means)
|
||||
expect(next.seller.trading_name).toBe(prior.seller.trading_name)
|
||||
expect(next.notes).toEqual(prior.notes)
|
||||
expect(next.lines[0]?.net_price.amount).toBe(line.net_price.amount)
|
||||
})
|
||||
it("reads a price and a VAT rate as a Lithuanian reader types them, exactly", () => {
|
||||
const values: Record<string, string> = {
|
||||
series: terms.series_id,
|
||||
currency: "EUR",
|
||||
issueDate: terms.issue_date,
|
||||
buyerReference: terms.buyer_reference,
|
||||
"line.0.name": "Varžtas",
|
||||
"line.0.quantity": "3",
|
||||
"line.0.unit": "H87",
|
||||
"line.0.price": "1\u00a0234,50",
|
||||
"line.0.category": "S",
|
||||
"line.0.rate": "21",
|
||||
}
|
||||
for (const side of ["seller", "buyer"] as const) {
|
||||
const party = terms[side]
|
||||
Object.assign(values, {
|
||||
[`${side}.reference`]: party.reference,
|
||||
[`${side}.name`]: party.name,
|
||||
[`${side}.scheme`]: party.endpoint.scheme,
|
||||
[`${side}.endpoint`]: party.endpoint.value,
|
||||
[`${side}.country`]: party.address.country,
|
||||
})
|
||||
}
|
||||
const next = draftData(values, 1, "lt", terms)
|
||||
expect(next.lines[0]?.net_price).toEqual({ currency: "EUR", amount: "1234.50" })
|
||||
expect(next.lines[0]?.vat_rate).toBe("21")
|
||||
expect(() => draftData({ ...values, "line.0.price": "12,5,0" }, 1, "lt", terms)).toThrow()
|
||||
})
|
||||
it("ships the same translated labels in both languages", () =>
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(lt).sort()))
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { InvoiceTerms, InvoiceView } from "../contract/index.js"
|
||||
export const id = "01900000-0000-7000-8000-000000000001"
|
||||
export const invoiceId = `inv_${id}`
|
||||
export const seriesId = `ser_${id}`
|
||||
export const terms: InvoiceTerms = {
|
||||
type_code: "380",
|
||||
series_id: seriesId,
|
||||
issue_date: "2026-11-03",
|
||||
currency: "EUR",
|
||||
buyer_reference: "Synthetic-order",
|
||||
seller: {
|
||||
reference: `conusai://crm/parties/pty_${id}`,
|
||||
name: "Synthetic Seller",
|
||||
vat_identifier: { scheme: "vat", value: "LT100000001" },
|
||||
endpoint: { scheme: "0088", value: "1234567890128" },
|
||||
address: { country: "LT", city: "Synthetic city" },
|
||||
},
|
||||
buyer: {
|
||||
reference: `conusai://crm/parties/pty_${id}`,
|
||||
name: "Synthetic Buyer",
|
||||
endpoint: { scheme: "0088", value: "1234567890128" },
|
||||
address: { country: "LT" },
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
item_name: "Synthetic item",
|
||||
quantity: "2",
|
||||
unit: "C62",
|
||||
net_price: { currency: "EUR", amount: "9007199254740993.123456789012345678" },
|
||||
vat_category: "S",
|
||||
vat_rate: "21",
|
||||
},
|
||||
],
|
||||
}
|
||||
export const row: InvoiceView = {
|
||||
invoiceId,
|
||||
revision: "9007199254740993",
|
||||
status: "draft",
|
||||
number: null,
|
||||
data: terms,
|
||||
payable: { currency: "EUR", amount: "18014398509481986.246913578024691356" },
|
||||
deleted: false,
|
||||
erased: false,
|
||||
}
|
||||
export const raw = () => ({
|
||||
id: invoiceId,
|
||||
revision: "RAW_REVISION",
|
||||
status: "draft",
|
||||
number: null,
|
||||
data: { ...terms, lines: terms.lines.map((line) => ({ ...line, quantity: 2 })) },
|
||||
calculated: {
|
||||
line_nets: [],
|
||||
totals: Object.fromEntries(
|
||||
[
|
||||
"allowance",
|
||||
"charge",
|
||||
"line_extension",
|
||||
"payable",
|
||||
"prepaid",
|
||||
"rounding",
|
||||
"tax",
|
||||
"tax_exclusive",
|
||||
"tax_inclusive",
|
||||
].map((key) => [key, row.payable]),
|
||||
),
|
||||
vat_breakdown: [],
|
||||
},
|
||||
deleted: false,
|
||||
erased: false,
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
import { id, invoiceId, seriesId, terms } from "./fixture.js"
|
||||
|
||||
const org = (t: IsolationTenant) => ({ org: t.orgSlug })
|
||||
const get = (t: IsolationTenant) => ({ ...org(t), invoiceId })
|
||||
const action = (t: IsolationTenant) => ({ ...get(t), expectedVersion: "1", requestId: id, confirmed: true })
|
||||
export const invoicingIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"invoicing.invoice.list": { input: org },
|
||||
"invoicing.series.list": { input: org },
|
||||
"invoicing.invoice.get": { input: get },
|
||||
"invoicing.invoice.ubl": { input: get },
|
||||
"invoicing.invoice.draft": { input: (t) => ({ ...org(t), data: terms, requestId: id }) },
|
||||
"invoicing.invoice.update": { input: (t) => ({ ...action(t), data: terms }) },
|
||||
"invoicing.invoice.issue": { input: action },
|
||||
"invoicing.invoice.credit": { input: (t) => ({ ...action(t), seriesId, reason: "Synthetic correction" }) },
|
||||
"invoicing.invoice.void": { input: (t) => ({ ...action(t), reason: "Synthetic correction" }) },
|
||||
"invoicing.series.create": {
|
||||
input: (t) => ({ ...org(t), code: "SYNTH", prefix: "SYNTH-", kind: "invoice", requestId: id }),
|
||||
},
|
||||
}
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = { seed: async () => ({}), fixtures: businessModulesOn.includes("invoicing") ? invoicingIsolationFixtures : {} }
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The invoice terms accept exactly what the Invoicing service accepts: its `DecimalAmount` grammar and ISO 4217
|
||||
// codes (business-services/types). The module's own regex let `007.50` and `XYZ` through, which the service then
|
||||
// refused; a person found out only after submitting.
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { decimal, line, money } from "../contract/index.js"
|
||||
|
||||
describe("the invoice's exact money", () => {
|
||||
it("refuses an amount with a leading zero and a currency the service does not know", () => {
|
||||
for (const amount of ["007.50", "00", "-01", "1.", ".5", "+1", "1e3", "1,50"]) {
|
||||
expect(decimal.safeParse(amount).success, amount).toBe(false)
|
||||
}
|
||||
expect(money.safeParse({ amount: "7.50", currency: "XYZ" }).success).toBe(false)
|
||||
expect(money.safeParse({ amount: "7.50", currency: "LTL" }).success).toBe(false)
|
||||
})
|
||||
|
||||
it("takes a line's unit only as a UN/CEFACT Rec 20 code the service accepts", () => {
|
||||
const base = {
|
||||
quantity: "3",
|
||||
item_name: "Bolt",
|
||||
net_price: { amount: "1.20", currency: "EUR" },
|
||||
vat_category: "S",
|
||||
vat_rate: "21",
|
||||
}
|
||||
expect(line.safeParse({ ...base, unit: "C62" }).success).toBe(true)
|
||||
for (const unit of ["pcs", "PCS", "XBX", "m2"]) expect(line.safeParse({ ...base, unit }).success, unit).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts every amount the service accepts, trailing zeros and its bounds included", () => {
|
||||
for (const amount of ["0", "-0.00", "7.50", "9999999999999999999999999999.999999999999999999"]) {
|
||||
expect(decimal.safeParse(amount).success, amount).toBe(true)
|
||||
}
|
||||
expect(decimal.safeParse("10000000000000000000000000000").success).toBe(false)
|
||||
expect(money.safeParse({ amount: "7.50", currency: "EUR" }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Memory app + injected service port. Proves app membership/roles, never external partition isolation.
|
||||
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
import { INVOICING_SERVICE, type InvoicingService } from "../core/ports.js"
|
||||
import { invoicingModule } from "../module.js"
|
||||
import { id, invoiceId, row, terms } from "./fixture.js"
|
||||
import { invoicingIsolationFixtures } from "./isolation.js"
|
||||
|
||||
async function fixture() {
|
||||
const registry = buildRegistry([tenancyModule, invoicingModule])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
const port: InvoicingService = {
|
||||
list: vi.fn(async () => ({ rows: [row] })),
|
||||
get: vi.fn(async () => row),
|
||||
series: vi.fn(async () => ({ rows: [] })),
|
||||
ubl: vi.fn(async () => ({ filename: "fixture.xml", xml: "<Invoice/>" })),
|
||||
draft: vi.fn(async () => row),
|
||||
update: vi.fn(async () => row),
|
||||
transition: vi.fn(async () => row),
|
||||
createSeries: vi.fn(async () => {
|
||||
throw new Error("not used")
|
||||
}),
|
||||
}
|
||||
root.value(INVOICING_SERVICE, port)
|
||||
const caller = (actor: Actor) => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
const user = async (name: string) => {
|
||||
const row = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: name,
|
||||
email: `${name}@example.test`,
|
||||
displayName: name,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: row.id }
|
||||
}
|
||||
const owner = caller(await user("owner"))
|
||||
await owner.callByName("tenancy.org.create", { slug: "alpha", name: "Synthetic Alpha" })
|
||||
const foreign = caller(await user("foreign"))
|
||||
await foreign.callByName("tenancy.org.create", { slug: "beta", name: "Synthetic Beta" })
|
||||
const invite = (await owner.callByName("tenancy.member.invite", {
|
||||
org: "alpha",
|
||||
email: "viewer@example.test",
|
||||
role: "viewer",
|
||||
})) as { token: string }
|
||||
const viewer = caller(await user("viewer"))
|
||||
await viewer.callByName("tenancy.invitation.accept", { org: "alpha", token: invite.token })
|
||||
return { owner, foreign, viewer, port }
|
||||
}
|
||||
describe("Invoicing app dispatch (simulated service)", () => {
|
||||
it("denies every foreign-organization operation before reaching a service port", async () => {
|
||||
const { owner, port } = await fixture()
|
||||
for (const [name, fixture] of Object.entries(invoicingIsolationFixtures)) {
|
||||
const input = fixture.input({ orgSlug: "beta" } as never)
|
||||
await expect(owner.callByName(name, input)).rejects.toMatchObject({ code: "not_found" })
|
||||
}
|
||||
for (const method of Object.values(port)) expect(method).not.toHaveBeenCalled()
|
||||
})
|
||||
it("allows viewer reads but refuses fiscal writes and series administration", async () => {
|
||||
const { viewer, port } = await fixture()
|
||||
expect(await viewer.callByName("invoicing.invoice.list", { org: "alpha" })).toMatchObject({ rows: [{ invoiceId }] })
|
||||
for (const name of [
|
||||
"invoicing.invoice.draft",
|
||||
"invoicing.invoice.update",
|
||||
"invoicing.invoice.issue",
|
||||
"invoicing.invoice.credit",
|
||||
"invoicing.invoice.void",
|
||||
"invoicing.series.create",
|
||||
]) {
|
||||
const fixture = invoicingIsolationFixtures[name]
|
||||
if (!fixture) throw new Error("fixture missing")
|
||||
await expect(viewer.callByName(name, fixture.input({ orgSlug: "alpha" } as never))).rejects.toMatchObject({
|
||||
code: "not_found",
|
||||
})
|
||||
}
|
||||
expect(port.draft).not.toHaveBeenCalled()
|
||||
expect(port.transition).not.toHaveBeenCalled()
|
||||
expect(port.createSeries).not.toHaveBeenCalled()
|
||||
})
|
||||
it("routes an authorized draft with exact strings and a stable operation key", async () => {
|
||||
const { owner, port } = await fixture()
|
||||
const result = await owner.callByName("invoicing.invoice.draft", { org: "alpha", data: terms, requestId: id })
|
||||
expect(result).toMatchObject({ invoiceId, revision: row.revision })
|
||||
expect(port.draft).toHaveBeenCalledWith(terms, `invoicing:draft:${id}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Actual generated SDK over synthetic HTTP; this is not live fiscal/provider acceptance.
|
||||
import { requestBusinessBinding } from "@conusai-kit/business"
|
||||
import { businessServiceHooks } from "@conusai-kit/business/server"
|
||||
import { Container, type ServiceAdmissionContext } from "@conusai-kit/core"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { sdkInvoicingService } from "../adapters/service.js"
|
||||
import { invoicingModule } from "../module.js"
|
||||
import { id, invoiceId, raw, terms } from "./fixture.js"
|
||||
|
||||
const bindingId = id
|
||||
function json(value: unknown) {
|
||||
return new Response(JSON.stringify(value).replaceAll('"RAW_REVISION"', "9007199254740993"), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
async function fixture(reply: (request: Request) => Response | Promise<Response>) {
|
||||
const requests: Request[] = []
|
||||
vi.stubGlobal("fetch", async (input: Request | string | URL) => {
|
||||
const request = new Request(input)
|
||||
requests.push(request.clone())
|
||||
return reply(request)
|
||||
})
|
||||
const container = new Container()
|
||||
const hooks = businessServiceHooks({
|
||||
env: { apiUrl: "https://platform.example.test/api", token: "synthetic-token", orgSlug: "fixture" },
|
||||
platformOrganizationId: 7,
|
||||
platformProjectId: 9,
|
||||
platformEnvironmentId: 11,
|
||||
profile: "postgres",
|
||||
controlPlane: {
|
||||
lookup: async (appTenantId) => ({
|
||||
partition_major: 1,
|
||||
binding_id: bindingId,
|
||||
organization_id: 7,
|
||||
project_id: 9,
|
||||
environment_id: 11,
|
||||
environment_uuid: id,
|
||||
app_tenant_id: appTenantId,
|
||||
}),
|
||||
observe: async () => {
|
||||
throw new Error("This fixture does not certify readiness")
|
||||
},
|
||||
},
|
||||
})
|
||||
const context: ServiceAdmissionContext = {
|
||||
container,
|
||||
module: "invoicing",
|
||||
procedure: "invoicing.invoice.list",
|
||||
kind: "read",
|
||||
scope: { level: "org", orgId: id, actor: { userId: id } },
|
||||
}
|
||||
await hooks.prepareServices?.(container, context, invoicingModule.serviceRequirements ?? [])
|
||||
return { service: sdkInvoicingService(requestBusinessBinding(container)), requests }
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe("Invoicing generated wire adapter", () => {
|
||||
it("preserves decimal and int64 digits through a real generated client", async () => {
|
||||
const { service, requests } = await fixture(() => json(raw()))
|
||||
const result = await service.get(invoiceId)
|
||||
expect(result.revision).toBe("9007199254740993")
|
||||
expect(result.data.lines[0]?.net_price.amount).toBe(terms.lines[0]?.net_price.amount)
|
||||
await service.transition("issue", { invoiceId, expectedVersion: result.revision, key: `fixture:${id}` })
|
||||
const body = await requests[1]?.text()
|
||||
expect(body).toContain('"revision":9007199254740993')
|
||||
expect(body).toContain(`fixture:${id}`)
|
||||
})
|
||||
it("denies a foreign app subject before issuing any network request", async () => {
|
||||
const { service, requests } = await fixture(() => json(raw()))
|
||||
await expect(
|
||||
service.draft(
|
||||
{
|
||||
...terms,
|
||||
buyer: {
|
||||
...terms.buyer,
|
||||
reference:
|
||||
"conusai://app-partitions/01900000-0000-7000-8000-000000000099/members/01900000-0000-7000-8000-000000000001",
|
||||
},
|
||||
},
|
||||
`fixture:${id}`,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "bad_request" })
|
||||
expect(requests).toHaveLength(0)
|
||||
})
|
||||
it("returns bounded XML for explicit download instead of parsing it as JSON", async () => {
|
||||
const { service } = await fixture(
|
||||
() => new Response("<Invoice>synthetic</Invoice>", { headers: { "content-type": "application/xml" } }),
|
||||
)
|
||||
expect(await service.ubl(invoiceId)).toEqual({
|
||||
filename: `invoice-${invoiceId}.xml`,
|
||||
xml: "<Invoice>synthetic</Invoice>",
|
||||
})
|
||||
})
|
||||
it("maps service failures without returning arbitrary private remote details", async () => {
|
||||
const { service } = await fixture(
|
||||
() =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
type: "https://conusai.com/problems/conflict",
|
||||
title: "Conflict",
|
||||
status: 409,
|
||||
detail: "private fixture value",
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/problem+json" } },
|
||||
),
|
||||
)
|
||||
const refused = await service.get(invoiceId).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(refused).toMatchObject({ code: "business_conflict", status: 409 })
|
||||
expect(JSON.stringify((refused as { problem: unknown }).problem)).not.toContain("private fixture value")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import * as c from "../contract/index.js"
|
||||
import { operationKey, transitionInvoice, updateDraft, validateTerms } from "../core/invoices.js"
|
||||
import { invoicingServiceOf } from "./service.js"
|
||||
export const INVOICING_PROCEDURES = [
|
||||
implement(c.invoices, ({ ctx, input }) => invoicingServiceOf(ctx.container, ctx.kind).list(input.after)),
|
||||
implement(c.invoice, ({ ctx, input }) => invoicingServiceOf(ctx.container, ctx.kind).get(input.invoiceId)),
|
||||
implement(c.series, ({ ctx, input }) => invoicingServiceOf(ctx.container, ctx.kind).series(input.after)),
|
||||
implement(c.ubl, ({ ctx, input }) => invoicingServiceOf(ctx.container, ctx.kind).ubl(input.invoiceId)),
|
||||
implement(c.draft, ({ ctx, input }) =>
|
||||
invoicingServiceOf(ctx.container, "effect").draft(
|
||||
validateTerms(input.data),
|
||||
operationKey("draft", input.requestId),
|
||||
),
|
||||
),
|
||||
implement(c.update, ({ ctx, input }) => updateDraft(invoicingServiceOf(ctx.container, "effect"), input)),
|
||||
implement(c.issue, ({ ctx, input }) =>
|
||||
transitionInvoice(invoicingServiceOf(ctx.container, "effect"), "issue", input),
|
||||
),
|
||||
implement(c.credit, ({ ctx, input }) =>
|
||||
transitionInvoice(invoicingServiceOf(ctx.container, "effect"), "credit", input),
|
||||
),
|
||||
implement(c.voidInvoice, ({ ctx, input }) =>
|
||||
transitionInvoice(invoicingServiceOf(ctx.container, "effect"), "void", input),
|
||||
),
|
||||
implement(c.createSeries, ({ ctx, input }) =>
|
||||
invoicingServiceOf(ctx.container, "effect").createSeries({
|
||||
...input,
|
||||
key: operationKey("series", input.requestId),
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Exact terms are service-owned. Party links are metadata and confer no CRM access.
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { Decimal, isCurrencyCode } from "@conusai-kit/money"
|
||||
import { isUnitCode } from "@conusai-kit/money/units"
|
||||
import { z } from "zod"
|
||||
|
||||
const suffix = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
export const invoiceId = z.string().regex(new RegExp(`^inv_${suffix}$`))
|
||||
export const seriesId = z.string().regex(new RegExp(`^ser_${suffix}$`))
|
||||
export const revision = z
|
||||
.string()
|
||||
.regex(/^[1-9][0-9]{0,18}$/)
|
||||
.refine((value) => {
|
||||
try {
|
||||
return BigInt(value) <= 9223372036854775807n
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
// Exactly the service's `DecimalAmount`, `CurrencyCode` and `UnitCode` (business-services/types), checked by the kit, so what
|
||||
// this form accepts the service accepts too. The wire values stay plain strings (`: boolean`, not a type guard).
|
||||
export const decimal = z.string().refine((value): boolean => Decimal.isAmount(value))
|
||||
const currency = z.string().refine((value): boolean => isCurrencyCode(value))
|
||||
const date = z.iso.date()
|
||||
const text = z.string().trim().min(1).max(200)
|
||||
export const money = z.object({ amount: decimal, currency }).strict()
|
||||
const identifier = z.object({ scheme: text, value: text }).strict()
|
||||
const address = z
|
||||
.object({
|
||||
country: z.string().regex(/^[A-Z]{2}$/),
|
||||
street: text.nullable().optional(),
|
||||
additional_street: text.nullable().optional(),
|
||||
address_line_3: text.nullable().optional(),
|
||||
city: text.nullable().optional(),
|
||||
postal_code: text.nullable().optional(),
|
||||
subdivision: text.nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
// A CRM link identifies a legal/business party. A scoped member link may identify
|
||||
// an actual person; the service checks that its binding matches the assertion.
|
||||
const reference = z
|
||||
.string()
|
||||
.max(256)
|
||||
.regex(
|
||||
new RegExp(
|
||||
`^(?:conusai://crm/parties/pty_${suffix}|conusai://app-partitions/[0-9a-f-]{36}/members/[0-9a-f-]{36})$`,
|
||||
),
|
||||
)
|
||||
export const party = z
|
||||
.object({
|
||||
reference,
|
||||
name: text,
|
||||
trading_name: text.nullable().optional(),
|
||||
identifiers: z.array(identifier).max(20).optional(),
|
||||
legal_registration: identifier.nullable().optional(),
|
||||
vat_identifier: identifier.nullable().optional(),
|
||||
endpoint: z.object({ scheme: text, value: text }).strict(),
|
||||
address,
|
||||
})
|
||||
.strict()
|
||||
const quantity = z
|
||||
.string()
|
||||
.regex(/^[1-9][0-9]{0,12}$/)
|
||||
.refine((value) => {
|
||||
try {
|
||||
return BigInt(value) <= 1000000000000n
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
export const line = z
|
||||
.object({
|
||||
quantity,
|
||||
unit: z.string().refine((value): boolean => isUnitCode(value)),
|
||||
item_name: text,
|
||||
item_identifiers: z.array(identifier).max(20).optional(),
|
||||
net_price: money,
|
||||
vat_category: z.string().min(1).max(8),
|
||||
vat_rate: decimal.nullable().optional(),
|
||||
exemption_reason: text.nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
const adjustment = z
|
||||
.object({
|
||||
amount: money,
|
||||
charge: z.boolean(),
|
||||
reason: text,
|
||||
vat_category: z.string().min(1).max(8),
|
||||
vat_rate: decimal.nullable().optional(),
|
||||
exemption_reason: text.nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
export const invoiceTerms = z
|
||||
.object({
|
||||
type_code: z.string().regex(/^[0-9]{3}$/),
|
||||
series_id: seriesId,
|
||||
issue_date: date,
|
||||
due_date: date.nullable().optional(),
|
||||
currency,
|
||||
seller: party,
|
||||
buyer: party,
|
||||
buyer_reference: text,
|
||||
preceding_invoice: invoiceId.nullable().optional(),
|
||||
lines: z.array(line).min(1).max(200),
|
||||
allowances_charges: z.array(adjustment).max(100).optional(),
|
||||
payment_means: z
|
||||
.object({ code: text, account_id: text.nullable().optional(), account_name: text.nullable().optional() })
|
||||
.strict()
|
||||
.nullable()
|
||||
.optional(),
|
||||
notes: z.array(z.string().trim().min(1).max(1000)).max(20).optional(),
|
||||
ext: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict()
|
||||
export type InvoiceTerms = z.output<typeof invoiceTerms>
|
||||
export const invoiceView = z.object({
|
||||
invoiceId,
|
||||
revision,
|
||||
status: z.enum(["draft", "issued", "voided"]),
|
||||
number: z.string().nullable(),
|
||||
data: invoiceTerms,
|
||||
payable: money,
|
||||
deleted: z.boolean(),
|
||||
erased: z.boolean(),
|
||||
})
|
||||
export type InvoiceView = z.output<typeof invoiceView>
|
||||
export const seriesView = z.object({
|
||||
seriesId,
|
||||
revision,
|
||||
code: text,
|
||||
prefix: text,
|
||||
kind: z.enum(["invoice", "credit_note"]),
|
||||
active: z.boolean(),
|
||||
nextNumber: revision,
|
||||
})
|
||||
export type SeriesView = z.output<typeof seriesView>
|
||||
const org = { org: z.string() }
|
||||
const cursor = z.string().min(1).max(4096).optional()
|
||||
const requestId = z.string().uuid()
|
||||
export const invoices = procedure("invoicing.invoice.list")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.read")
|
||||
.input(z.object({ ...org, after: cursor }))
|
||||
.output(z.object({ rows: z.array(invoiceView), after: cursor }))
|
||||
.contract()
|
||||
export const invoice = procedure("invoicing.invoice.get")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.read")
|
||||
.input(z.object({ ...org, invoiceId }))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
export const series = procedure("invoicing.series.list")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("invoicing.series.read")
|
||||
.input(z.object({ ...org, after: cursor }))
|
||||
.output(z.object({ rows: z.array(seriesView), after: cursor }))
|
||||
.contract()
|
||||
export const draft = procedure("invoicing.invoice.draft")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.write")
|
||||
.input(z.object({ ...org, data: invoiceTerms, requestId }))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
export const update = procedure("invoicing.invoice.update")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.write")
|
||||
.input(z.object({ ...org, invoiceId, expectedVersion: revision, data: invoiceTerms, requestId }))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
const transition = { ...org, invoiceId, expectedVersion: revision, requestId, confirmed: z.literal(true) }
|
||||
export const issue = procedure("invoicing.invoice.issue")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.issue")
|
||||
.input(z.object(transition))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
export const credit = procedure("invoicing.invoice.credit")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.credit")
|
||||
.input(z.object({ ...transition, seriesId, reason: text }))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
export const voidInvoice = procedure("invoicing.invoice.void")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.void")
|
||||
.input(z.object({ ...transition, reason: text }))
|
||||
.output(invoiceView)
|
||||
.contract()
|
||||
export const ubl = procedure("invoicing.invoice.ubl")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("invoicing.invoice.read")
|
||||
.input(z.object({ ...org, invoiceId }))
|
||||
.output(z.object({ filename: z.string(), xml: z.string().max(8000000) }))
|
||||
.contract()
|
||||
export const createSeries = procedure("invoicing.series.create")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("invoicing.series.manage")
|
||||
.input(
|
||||
z.object({
|
||||
...org,
|
||||
code: z.string().regex(/^[A-Za-z0-9._-]{1,32}$/),
|
||||
prefix: z.string().regex(/^[A-Za-z0-9._-]{0,31}[-._]$/),
|
||||
kind: z.enum(["invoice", "credit_note"]),
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(seriesView)
|
||||
.contract()
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { badRequest, conflict } from "@conusai-kit/core"
|
||||
import type { InvoiceTerms } from "../contract/index.js"
|
||||
import type { InvoicingService } from "./ports.js"
|
||||
export const operationKey = (operation: string, requestId: string) => `invoicing:${operation}:${requestId}`
|
||||
export function validateTerms(data: InvoiceTerms): InvoiceTerms {
|
||||
if (
|
||||
data.lines.some((line) => line.net_price.currency !== data.currency) ||
|
||||
data.allowances_charges?.some((charge) => charge.amount.currency !== data.currency)
|
||||
)
|
||||
throw badRequest("Invoice currencies must match.")
|
||||
return data
|
||||
}
|
||||
export async function updateDraft(
|
||||
service: InvoicingService,
|
||||
input: { invoiceId: string; data: InvoiceTerms; expectedVersion: string; requestId: string },
|
||||
) {
|
||||
const current = await service.get(input.invoiceId)
|
||||
// Prior revisions reach the service's idempotency ledger on response-loss retries.
|
||||
if (BigInt(input.expectedVersion) > BigInt(current.revision)) throw conflict("Reload the invoice before editing.")
|
||||
return service.update(
|
||||
input.invoiceId,
|
||||
validateTerms(input.data),
|
||||
input.expectedVersion,
|
||||
operationKey("update", input.requestId),
|
||||
)
|
||||
}
|
||||
export async function transitionInvoice(
|
||||
service: InvoicingService,
|
||||
operation: "issue" | "credit" | "void",
|
||||
input: {
|
||||
invoiceId: string
|
||||
expectedVersion: string
|
||||
requestId: string
|
||||
confirmed: true
|
||||
reason?: string
|
||||
seriesId?: string
|
||||
},
|
||||
) {
|
||||
if (input.confirmed !== true) throw badRequest("Explicit confirmation is required.")
|
||||
// The service owns state/revision, retries, immutable numbering and fiscal retention.
|
||||
// No local allocation or predicted revision may substitute for that transaction.
|
||||
return service.transition(operation, {
|
||||
invoiceId: input.invoiceId,
|
||||
expectedVersion: input.expectedVersion,
|
||||
key: operationKey(operation, input.requestId),
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
...(input.seriesId ? { seriesId: input.seriesId } : {}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { InvoiceTerms, InvoiceView, SeriesView } from "../contract/index.js"
|
||||
export interface Page<T> {
|
||||
rows: T[]
|
||||
after?: string
|
||||
}
|
||||
export interface InvoicingReads {
|
||||
list(after?: string): Promise<Page<InvoiceView>>
|
||||
get(id: string): Promise<InvoiceView>
|
||||
series(after?: string): Promise<Page<SeriesView>>
|
||||
ubl(id: string): Promise<{ filename: string; xml: string }>
|
||||
}
|
||||
export interface InvoicingService extends InvoicingReads {
|
||||
draft(data: InvoiceTerms, key: string): Promise<InvoiceView>
|
||||
update(id: string, data: InvoiceTerms, revision: string, key: string): Promise<InvoiceView>
|
||||
transition(
|
||||
operation: "issue" | "credit" | "void",
|
||||
input: { invoiceId: string; expectedVersion: string; key: string; seriesId?: string; reason?: string },
|
||||
): Promise<InvoiceView>
|
||||
createSeries(input: {
|
||||
code: string
|
||||
prefix: string
|
||||
kind: "invoice" | "credit_note"
|
||||
key: string
|
||||
}): Promise<SeriesView>
|
||||
}
|
||||
export const readsOf = (service: InvoicingReads): InvoicingReads => ({
|
||||
list: (after) => service.list(after),
|
||||
get: (id) => service.get(id),
|
||||
series: (after) => service.series(after),
|
||||
ubl: (id) => service.ubl(id),
|
||||
})
|
||||
export const INVOICING_SERVICE = token<InvoicingService>("invoicing.service", {
|
||||
views: { read: readsOf, write: readsOf },
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export const en = {
|
||||
exemption: "VAT exemption reason",
|
||||
vatLithuanian: "9937 (Lithuania)",
|
||||
vatDefault: "VAT",
|
||||
vatScheme: "VAT identifier scheme",
|
||||
vatIdentifier: "VAT identifier",
|
||||
revision: "Revision",
|
||||
saved: "Saved invoice terms",
|
||||
title: "Invoicing",
|
||||
intro: "Invoices belong to this app organization. Issuance allocates an immutable fiscal number.",
|
||||
draft: "Draft",
|
||||
issued: "Issued",
|
||||
voided: "Voided",
|
||||
create: "Save draft",
|
||||
save: "Save draft changes",
|
||||
busy: "Saving…",
|
||||
issue: "Issue invoice",
|
||||
credit: "Create credit draft",
|
||||
void: "Void invoice",
|
||||
confirm: "I have reviewed the saved invoice terms and confirm this fiscal action.",
|
||||
reason: "Reason",
|
||||
download: "Download UBL",
|
||||
series: "Numbering series",
|
||||
createSeries: "Create numbering series",
|
||||
seriesCode: "Series code",
|
||||
seriesPrefix: "Number prefix",
|
||||
kind: "Series kind",
|
||||
invoiceKind: "Invoice",
|
||||
creditKind: "Credit note",
|
||||
missingSeries: "No active invoice series. An administrator must configure numbering before drafting.",
|
||||
seller: "Seller",
|
||||
buyer: "Buyer",
|
||||
partyRef: "CRM party reference",
|
||||
name: "Legal name",
|
||||
country: "Country code",
|
||||
street: "Street",
|
||||
city: "City",
|
||||
postcode: "Postal code",
|
||||
endpointScheme: "Electronic address scheme",
|
||||
endpoint: "Electronic address",
|
||||
currency: "Currency",
|
||||
issueDate: "Issue date",
|
||||
dueDate: "Due date",
|
||||
buyerReference: "Buyer reference",
|
||||
item: "Item",
|
||||
quantity: "Whole quantity",
|
||||
unit: "Unit code",
|
||||
price: "Net unit price (decimal)",
|
||||
vatCategory: "VAT category",
|
||||
vatRate: "VAT rate (decimal)",
|
||||
addLine: "Add line",
|
||||
number: "Number",
|
||||
status: "Status",
|
||||
payable: "Payable",
|
||||
empty: "No invoices yet.",
|
||||
back: "Back to invoices",
|
||||
conflict: "The invoice changed. Reload before editing.",
|
||||
invalid: "Check the invoice terms and required configuration.",
|
||||
unavailable: "Invoicing is unavailable. Review platform diagnostics.",
|
||||
fiscalNotice:
|
||||
"Review the saved terms and save changes before issuing. Issued invoices are immutable; corrections use a credit draft or authorized void. No invoice is sent automatically.",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
export const lt = {
|
||||
exemption: "Neapmokestinimo PVM priežastis",
|
||||
vatLithuanian: "9937 (Lietuva)",
|
||||
vatDefault: "PVM",
|
||||
vatScheme: "PVM kodo schema",
|
||||
vatIdentifier: "PVM mokėtojo kodas",
|
||||
revision: "Versija",
|
||||
saved: "Išsaugoti sąskaitos duomenys",
|
||||
title: "Sąskaitos",
|
||||
intro: "Sąskaitos priklauso šios programos organizacijai. Išrašant suteikiamas nekeičiamas apskaitos numeris.",
|
||||
draft: "Juodraštis",
|
||||
issued: "Išrašyta",
|
||||
voided: "Anuliuota",
|
||||
create: "Išsaugoti juodraštį",
|
||||
save: "Išsaugoti juodraščio pakeitimus",
|
||||
busy: "Išsaugoma…",
|
||||
issue: "Išrašyti sąskaitą",
|
||||
credit: "Sukurti kreditinės juodraštį",
|
||||
void: "Anuliuoti sąskaitą",
|
||||
confirm: "Peržiūrėjau išsaugotus sąskaitos duomenis ir patvirtinu šį apskaitos veiksmą.",
|
||||
reason: "Priežastis",
|
||||
download: "Atsisiųsti UBL",
|
||||
series: "Numeravimo serijos",
|
||||
createSeries: "Sukurti numeravimo seriją",
|
||||
seriesCode: "Serijos kodas",
|
||||
seriesPrefix: "Numerio priešdėlis",
|
||||
kind: "Serijos tipas",
|
||||
invoiceKind: "Sąskaita",
|
||||
creditKind: "Kreditinė sąskaita",
|
||||
missingSeries: "Nėra aktyvios sąskaitų serijos. Prieš rengiant juodraštį administratorius turi nustatyti numeravimą.",
|
||||
seller: "Pardavėjas",
|
||||
buyer: "Pirkėjas",
|
||||
partyRef: "CRM šalies nuoroda",
|
||||
name: "Juridinis pavadinimas",
|
||||
country: "Šalies kodas",
|
||||
street: "Gatvė",
|
||||
city: "Miestas",
|
||||
postcode: "Pašto kodas",
|
||||
endpointScheme: "Elektroninio adreso schema",
|
||||
endpoint: "Elektroninis adresas",
|
||||
currency: "Valiuta",
|
||||
issueDate: "Išrašymo data",
|
||||
dueDate: "Apmokėjimo data",
|
||||
buyerReference: "Pirkėjo nuoroda",
|
||||
item: "Prekė ar paslauga",
|
||||
quantity: "Sveikasis kiekis",
|
||||
unit: "Vieneto kodas",
|
||||
price: "Neto vieneto kaina (dešimtainė)",
|
||||
vatCategory: "PVM kategorija",
|
||||
vatRate: "PVM tarifas (dešimtainis)",
|
||||
addLine: "Pridėti eilutę",
|
||||
number: "Numeris",
|
||||
status: "Būsena",
|
||||
payable: "Mokėtina suma",
|
||||
empty: "Sąskaitų dar nėra.",
|
||||
back: "Grįžti į sąskaitas",
|
||||
conflict: "Sąskaita pasikeitė. Prieš keisdami įkelkite iš naujo.",
|
||||
invalid: "Patikrinkite sąskaitos duomenis ir privalomus nustatymus.",
|
||||
unavailable: "Sąskaitų paslauga nepasiekiama. Peržiūrėkite platformos diagnostiką.",
|
||||
fiscalNotice:
|
||||
"Peržiūrėkite išsaugotus duomenis ir prieš išrašydami išsaugokite pakeitimus. Išrašytos sąskaitos nekeičiamos; taisykite kreditine ar įgaliotu anuliavimu. Sąskaitos automatiškai nesiunčiamos.",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
export { sdkInvoicingService } from "./adapters/service.js"
|
||||
export * from "./contract/index.js"
|
||||
export { INVOICING_SERVICE, type InvoicingReads, type InvoicingService } from "./core/ports.js"
|
||||
export { invoicingModule } from "./module.js"
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { INVOICING_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
export const invoicingModule = defineModule({
|
||||
id: "invoicing",
|
||||
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
"invoicing.invoice.read": "viewer",
|
||||
"invoicing.series.read": "viewer",
|
||||
"invoicing.invoice.write": "editor",
|
||||
"invoicing.invoice.issue": "admin",
|
||||
"invoicing.invoice.credit": "admin",
|
||||
"invoicing.invoice.void": "admin",
|
||||
"invoicing.series.manage": "admin",
|
||||
},
|
||||
procedures: INVOICING_PROCEDURES,
|
||||
serviceRequirements: [
|
||||
{
|
||||
transport: "business",
|
||||
service: "invoicing",
|
||||
apiRange: "^1.1.0",
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
operations: {
|
||||
"invoicing.invoice.list": ["invoicing.invoices:read"],
|
||||
"invoicing.invoice.get": ["invoicing.invoices:read"],
|
||||
"invoicing.invoice.ubl": ["invoicing.invoices:read"],
|
||||
"invoicing.series.list": ["invoicing.series:read"],
|
||||
"invoicing.invoice.draft": ["invoicing.invoices:write"],
|
||||
"invoicing.invoice.update": ["invoicing.invoices:read", "invoicing.invoices:write"],
|
||||
"invoicing.invoice.issue": ["invoicing.invoices:issue"],
|
||||
"invoicing.invoice.credit": ["invoicing.invoices:write"],
|
||||
"invoicing.invoice.void": ["invoicing.invoices:void"],
|
||||
"invoicing.series.create": ["invoicing.series:write"],
|
||||
},
|
||||
configuration: [],
|
||||
dependencies: [],
|
||||
dataClasses: ["personal", "financial"],
|
||||
partitionRequired: true,
|
||||
remediation: "/docs/business-services.md",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
nav: [{ scope: "project", label: "invoicing.title", href: "invoicing", permission: "invoicing.invoice.read" }],
|
||||
},
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
import { formatMoney } from "@conusai-kit/i18n/values"
|
||||
import { Money } from "@conusai-kit/money"
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import {
|
||||
CheckField,
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
ResourceList,
|
||||
SelectField,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
useResourceList,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { useRef, useState } from "react"
|
||||
import type { InvoiceView, SeriesView } from "../contract/index.js"
|
||||
import { DraftFields } from "./fields.js"
|
||||
import { draftData } from "./form-data.js"
|
||||
import { describeFailure, type Labels } from "./labels.js"
|
||||
|
||||
// A lost response retries the same input/key. Editing terms starts a different operation.
|
||||
function useSubmissionId() {
|
||||
const current = useRef<{ value: string; key: string } | null>(null)
|
||||
return (input: unknown) => {
|
||||
const value = JSON.stringify(input)
|
||||
if (current.current?.value !== value) current.current = { value, key: crypto.randomUUID() }
|
||||
return current.current.key
|
||||
}
|
||||
}
|
||||
export function Invoices({
|
||||
slugs,
|
||||
seed,
|
||||
href,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: { rows: InvoiceView[]; after?: string | undefined }
|
||||
href: string
|
||||
labels: Labels
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<InvoiceView>({
|
||||
seed,
|
||||
rowId: (row) => row.invoiceId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<{ rows: InvoiceView[]; after?: string }>("invoicing.invoice.list", {
|
||||
org: slugs.org,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.title}
|
||||
columns={[
|
||||
{ id: "number", header: labels.number, cell: (row) => row.number ?? labels.draft },
|
||||
{ id: "status", header: labels.status, cell: (row) => labels[row.status] },
|
||||
{ id: "payable", header: labels.payable, cell: (row) => formatMoney(Money.parse(row.payable), labels.locale) },
|
||||
]}
|
||||
rowHref={(row) => `${href}/${row.invoiceId}`}
|
||||
rowLabel={(row) => row.number ?? labels.draft}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function InvoiceForm({
|
||||
slugs,
|
||||
invoice,
|
||||
series,
|
||||
labels,
|
||||
href,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
invoice?: InvoiceView
|
||||
series: SeriesView[]
|
||||
labels: Labels
|
||||
href: string
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const [count, setCount] = useState(invoice?.data.lines.length ?? 1)
|
||||
const key = useSubmissionId()
|
||||
const write = useWrite<Record<string, unknown>, InvoiceView>(
|
||||
invoice ? "invoicing.invoice.update" : "invoicing.invoice.draft",
|
||||
)
|
||||
const submit = async (values: FormValues) => {
|
||||
const data = draftData(values, count, labels.locale, invoice?.data)
|
||||
const input = {
|
||||
org: slugs.org,
|
||||
data,
|
||||
...(invoice ? { invoiceId: invoice.invoiceId, expectedVersion: invoice.revision } : {}),
|
||||
}
|
||||
const saved = await write.mutate({ ...input, requestId: key(input) })
|
||||
navigation.navigate(`${href}/${saved.invoiceId}`)
|
||||
}
|
||||
if (!series.some((row) => row.active && row.kind === (invoice?.data.type_code === "381" ? "credit_note" : "invoice")))
|
||||
return <p>{labels.missingSeries}</p>
|
||||
return (
|
||||
<PageSection title={invoice ? labels.save : labels.create} variant="card">
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={invoice ? labels.save : labels.create}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<DraftFields labels={labels} {...(invoice ? { data: invoice.data } : {})} series={series} count={count} />
|
||||
<Button type="button" onClick={() => setCount((old) => Math.min(old + 1, 200))}>
|
||||
{labels.addLine}
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function FiscalAction({
|
||||
slugs,
|
||||
invoice,
|
||||
operation,
|
||||
series,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
invoice: InvoiceView
|
||||
operation: "issue" | "credit" | "void"
|
||||
series: SeriesView[]
|
||||
labels: Labels
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const key = useSubmissionId()
|
||||
const action = useWrite<Record<string, unknown>, InvoiceView>(`invoicing.invoice.${operation}`)
|
||||
const submit = async (values: FormValues) => {
|
||||
const input = {
|
||||
org: slugs.org,
|
||||
invoiceId: invoice.invoiceId,
|
||||
expectedVersion: invoice.revision,
|
||||
confirmed: values.confirmed === "on",
|
||||
...(operation === "credit" ? { seriesId: values.series } : {}),
|
||||
...(operation !== "issue" ? { reason: values.reason } : {}),
|
||||
}
|
||||
const result = await action.mutate({ ...input, requestId: key(input) })
|
||||
if (operation === "credit")
|
||||
navigation.navigate(
|
||||
`/${slugs.org}/${slugs.workspace}/${slugs.project}/invoicing/${encodeURIComponent(result.invoiceId)}`,
|
||||
)
|
||||
else await navigation.refresh()
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels[operation]}>
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels[operation]}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{operation === "credit" ? (
|
||||
<SelectField
|
||||
name="series"
|
||||
label={labels.series}
|
||||
required
|
||||
options={series
|
||||
.filter((row) => row.active && row.kind === "credit_note")
|
||||
.map((row) => ({ value: row.seriesId, label: row.code }))}
|
||||
/>
|
||||
) : null}
|
||||
{operation !== "issue" ? <TextField name="reason" label={labels.reason} required maxLength={500} /> : null}
|
||||
<CheckField name="confirmed" label={labels.confirm} required />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function SeriesForm({ slugs, labels }: { slugs: ProjectSlugs; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const key = useSubmissionId()
|
||||
const write = useWrite<Record<string, unknown>, SeriesView>("invoicing.series.create")
|
||||
const submit = async (values: FormValues) => {
|
||||
const input = { org: slugs.org, code: values.code, prefix: values.prefix, kind: values.kind }
|
||||
await write.mutate({ ...input, requestId: key(input) })
|
||||
await navigation.refresh()
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels.createSeries}>
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels.createSeries}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="code" label={labels.seriesCode} required maxLength={32} />
|
||||
<TextField name="prefix" label={labels.seriesPrefix} required maxLength={32} />
|
||||
<SelectField
|
||||
name="kind"
|
||||
label={labels.kind}
|
||||
required
|
||||
options={[
|
||||
{ value: "invoice", label: labels.invoiceKind },
|
||||
{ value: "credit_note", label: labels.creditKind },
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function UblDownload({ slugs, invoice, labels }: { slugs: ProjectSlugs; invoice: InvoiceView; labels: Labels }) {
|
||||
const client = useDataClient()
|
||||
const [problem, setProblem] = useState<string>()
|
||||
const download = async () => {
|
||||
try {
|
||||
const { value } = await client.call<{ xml: string; filename: string }>("invoicing.invoice.ubl", {
|
||||
org: slugs.org,
|
||||
invoiceId: invoice.invoiceId,
|
||||
})
|
||||
const url = URL.createObjectURL(new Blob([value.xml], { type: "application/xml" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = value.filename
|
||||
link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
setProblem(undefined)
|
||||
} catch (error) {
|
||||
setProblem(describeFailure(error, labels))
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void download()
|
||||
}}
|
||||
>
|
||||
{labels.download}
|
||||
</Button>
|
||||
{problem ? <p role="alert">{problem}</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { SelectField, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { FieldLegend, FieldSet } from "@conusai-kit/ui/primitives"
|
||||
import type { InvoiceTerms, SeriesView } from "../contract/index.js"
|
||||
import type { Labels } from "./labels.js"
|
||||
export function DraftFields({
|
||||
labels,
|
||||
data,
|
||||
series,
|
||||
count,
|
||||
}: {
|
||||
labels: Labels
|
||||
data?: InvoiceTerms
|
||||
series: SeriesView[]
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
name="series"
|
||||
label={labels.series}
|
||||
required
|
||||
defaultValue={data?.series_id}
|
||||
options={series
|
||||
.filter((row) => row.active && row.kind === (data?.type_code === "381" ? "credit_note" : "invoice"))
|
||||
.map((row) => ({ value: row.seriesId, label: row.code }))}
|
||||
/>
|
||||
<TextField name="currency" label={labels.currency} required maxLength={3} defaultValue={data?.currency} />
|
||||
<TextField name="issueDate" type="date" label={labels.issueDate} required defaultValue={data?.issue_date} />
|
||||
<TextField name="dueDate" type="date" label={labels.dueDate} defaultValue={data?.due_date ?? undefined} />
|
||||
<TextField name="buyerReference" label={labels.buyerReference} required defaultValue={data?.buyer_reference} />
|
||||
{(["seller", "buyer"] as const).map((side) => (
|
||||
<FieldSet key={side}>
|
||||
<FieldLegend>{labels[side]}</FieldLegend>
|
||||
<TextField
|
||||
name={`${side}.reference`}
|
||||
label={labels.partyRef}
|
||||
required
|
||||
defaultValue={data?.[side].reference}
|
||||
/>
|
||||
<TextField name={`${side}.name`} label={labels.name} required defaultValue={data?.[side].name} />
|
||||
<SelectField
|
||||
name={`${side}.vatScheme`}
|
||||
label={labels.vatScheme}
|
||||
defaultValue={data?.[side].vat_identifier?.scheme ?? "vat"}
|
||||
options={[
|
||||
{ value: "vat", label: labels.vatDefault },
|
||||
{ value: "9937", label: labels.vatLithuanian },
|
||||
]}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.vatValue`}
|
||||
label={labels.vatIdentifier}
|
||||
defaultValue={data?.[side].vat_identifier?.value ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.country`}
|
||||
label={labels.country}
|
||||
required
|
||||
maxLength={2}
|
||||
defaultValue={data?.[side].address.country}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.street`}
|
||||
label={labels.street}
|
||||
defaultValue={data?.[side].address.street ?? undefined}
|
||||
/>
|
||||
<TextField name={`${side}.city`} label={labels.city} defaultValue={data?.[side].address.city ?? undefined} />
|
||||
<TextField
|
||||
name={`${side}.postcode`}
|
||||
label={labels.postcode}
|
||||
defaultValue={data?.[side].address.postal_code ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.scheme`}
|
||||
label={labels.endpointScheme}
|
||||
required
|
||||
defaultValue={data?.[side].endpoint.scheme}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.endpoint`}
|
||||
label={labels.endpoint}
|
||||
required
|
||||
defaultValue={data?.[side].endpoint.value}
|
||||
/>
|
||||
</FieldSet>
|
||||
))}
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<FieldSet key={`line-${i}`}>
|
||||
<FieldLegend>
|
||||
{labels.item} {i + 1}
|
||||
</FieldLegend>
|
||||
<TextField name={`line.${i}.name`} label={labels.item} required defaultValue={data?.lines[i]?.item_name} />
|
||||
<TextField
|
||||
name={`line.${i}.quantity`}
|
||||
label={labels.quantity}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.quantity}
|
||||
/>
|
||||
<TextField name={`line.${i}.unit`} label={labels.unit} required defaultValue={data?.lines[i]?.unit} />
|
||||
<TextField
|
||||
name={`line.${i}.price`}
|
||||
label={labels.price}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.net_price.amount}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.category`}
|
||||
label={labels.vatCategory}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.vat_category}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.rate`}
|
||||
label={labels.vatRate}
|
||||
defaultValue={data?.lines[i]?.vat_rate ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.exemption`}
|
||||
label={labels.exemption}
|
||||
defaultValue={data?.lines[i]?.exemption_reason ?? undefined}
|
||||
/>
|
||||
</FieldSet>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { Locale } from "@conusai-kit/i18n"
|
||||
import { parseDecimalInput } from "@conusai-kit/i18n/values"
|
||||
import { type InvoiceTerms, invoiceTerms } from "../contract/index.js"
|
||||
/**
|
||||
* Merge edited fields, retaining all unedited terms/identifiers/notes/extensions. No float coercion: a price or a
|
||||
* rate is read as the person typed it in `locale` (`12,50` in LT) into the service's exact text; text that is not a
|
||||
* number is passed on as typed, so the contract refuses it by name.
|
||||
*/
|
||||
export function draftData(
|
||||
values: Record<string, string>,
|
||||
count: number,
|
||||
locale: Locale,
|
||||
prior?: InvoiceTerms,
|
||||
): InvoiceTerms {
|
||||
const exact = (text: string | undefined) => (text ? (parseDecimalInput(text, locale)?.toString() ?? text) : text)
|
||||
const party = (side: "seller" | "buyer") => ({
|
||||
...prior?.[side],
|
||||
reference: values[`${side}.reference`],
|
||||
name: values[`${side}.name`],
|
||||
vat_identifier: values[`${side}.vatValue`]
|
||||
? { scheme: values[`${side}.vatScheme`], value: values[`${side}.vatValue`] }
|
||||
: null,
|
||||
endpoint: { scheme: values[`${side}.scheme`], value: values[`${side}.endpoint`] },
|
||||
address: {
|
||||
...prior?.[side].address,
|
||||
country: values[`${side}.country`],
|
||||
street: values[`${side}.street`] || null,
|
||||
city: values[`${side}.city`] || null,
|
||||
postal_code: values[`${side}.postcode`] || null,
|
||||
},
|
||||
})
|
||||
return invoiceTerms.parse({
|
||||
...prior,
|
||||
type_code: prior?.type_code ?? "380",
|
||||
series_id: values.series,
|
||||
currency: values.currency,
|
||||
issue_date: values.issueDate,
|
||||
due_date: values.dueDate || null,
|
||||
buyer_reference: values.buyerReference,
|
||||
seller: party("seller"),
|
||||
buyer: party("buyer"),
|
||||
lines: Array.from({ length: count }, (_, i) => ({
|
||||
...prior?.lines[i],
|
||||
item_name: values[`line.${i}.name`],
|
||||
quantity: values[`line.${i}.quantity`],
|
||||
unit: values[`line.${i}.unit`],
|
||||
net_price: { currency: values.currency, amount: exact(values[`line.${i}.price`]) },
|
||||
vat_category: values[`line.${i}.category`],
|
||||
vat_rate: exact(values[`line.${i}.rate`]) || null,
|
||||
exemption_reason: values[`line.${i}.exemption`] || null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import type { Locale } from "@conusai-kit/i18n"
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import { ZodError } from "zod"
|
||||
import type { en } from "../i18n/en.js"
|
||||
export type Messages = { [K in keyof typeof en]: string }
|
||||
/**
|
||||
* What the loader hands the views: the module's messages, the reader's locale (money is formatted with it) and
|
||||
* the kit's refusal sentences by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`).
|
||||
*/
|
||||
export type Labels = Messages & { readonly locale: Locale; readonly refusals: Readonly<Record<string, string>> }
|
||||
export function describeFailure(error: unknown, labels: Labels): string {
|
||||
if (error instanceof ZodError) return labels.invalid
|
||||
return serviceNotice(error, labels.refusals)?.text ?? labels.unavailable
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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(" · "),
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user