162 lines
6.8 KiB
TypeScript
162 lines
6.8 KiB
TypeScript
// 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()))
|
|
})
|