ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+41
View File
@@ -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.
+161
View File
@@ -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()))
})
+71
View File
@@ -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,
})
+30
View File
@@ -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 : {} }
+36
View File
@@ -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}`)
})
})
+114
View File
@@ -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")
})
})
+33
View File
@@ -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),
}),
),
]
+223
View File
@@ -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)
}
+219
View File
@@ -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()
+50
View File
@@ -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 } : {}),
})
}
+36
View File
@@ -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 },
})
+63
View File
@@ -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.",
}
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
export { en } from "./en.js"
export { lt } from "./lt.js"
+63
View File
@@ -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.",
}
+6
View File
@@ -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"
+51
View File
@@ -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 },
})
+240
View File
@@ -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}
</>
)
}
+127
View File
@@ -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>
))}
</>
)
}
+54
View File
@@ -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,
})),
})
}
+16
View File
@@ -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
}
+171
View File
@@ -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(" · "),
),
}
}