@@ -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()))
|
||||
})
|
||||
Reference in New Issue
Block a user