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
+19
View File
@@ -0,0 +1,19 @@
# Ledger
Status: optional source implementation; local synthetic verification only. Installed Ledger acceptance remains separate.
This module mounts service-owned accounts, exact balances, immutable entries and transactions in a project tab. Data belongs to the **app organization**, not the mounting project. Canonical caller tenancy hooks resolve current app membership; the request-scoped business binding selects the platform organization and opaque app partition. Procedure input cannot select that authority. No module database, migration, balance projection, financial sequence, cookie or persistent browser store exists.
Ledger stays out of `businessModulesOn`. `kit module:on ledger` requests read scopes only. Approval of account administration (`ledger.accounts:write`) and financial effects (`ledger.transactions:write`) is separate. Viewer reads, editor financial writes, and admin account configuration/reversal permissions are distinct. UI permissions reflect the caller; server permissions and readiness remain authoritative. Native and Convex remain refused until their shared service readiness/binding contracts support them; inclusion does not provision or enable a service.
The generated `@conusai-sdk/ledger` client handles exact raw JSON i64 tokens. Amounts and revisions cross the app boundary as canonical decimal strings and the SDK adapter encodes them as exact BigInt-backed integer tokens. Pure previews also use BigInt. Enter positive **minor units**, without decimal points. No currency precision, conversion, tax, personal chart of accounts or default financial numbering is invented.
Accounts support create, detail, configuration and archive; archive preserves history. Transactions support balanced multi-line pending holds or posted recording, hold post/void and posted reversal. The service owns locks, currency validation, bounds, funds and state transitions. Posted rows cannot be edited or deleted; reversal creates a new compensating transaction. List cursors are user-driven reads, never local financial storage.
All financial instructions are reviewed in a confirmation dialog. A component retains the request key for an identical retry and rotates it when the submitted payload changes. Keys do not survive navigation/reload; after an uncertain response, inspect service history before intentionally starting another operation. The server avoids mutable account/state pre-reads ahead of the service's replay check. Revision/idempotency conflicts expose a redacted reload instruction; remote detail is discarded.
The page shows the actual projection verifier result. The server core fails closed on drift or verifier failure before record/post/reverse; the UI also fences those actions. Void remains available for pending-hold recovery. A drift appearing after an earlier commit can refuse an otherwise identical replay until recovery; retain its original key. The verifier preflight is not an atomic substitute for service enforcement. Projection repair and coherent restores belong to operators; this module never repairs balances.
Operational labels, descriptions and references must contain no personal identifiers. See [Ledger data policy](../../../../docs/reference/compliance/lifecycle-ledger.md). Do not use real financial or customer data as fixtures.
Verification and unexecuted acceptance are recorded in the [S12 brief](../../../../docs/plans/cloud-lifecycle/briefs/s12.md). Source SDK workspace links and synthetic HTTP/browser fixtures do not establish packed-template, gateway, PostgreSQL persistence, concurrency, restore or live acceptance.
+109
View File
@@ -0,0 +1,109 @@
// Synthetic injected port; canonical caller/tenancy checks, not live gateway or persistence.
import {
type Actor,
buildRegistry,
Container,
createCaller,
type ServiceReadiness,
type TransportCaller,
} 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 { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
import { LEDGER_SERVICE, type LedgerService } from "../core/ports.js"
import { ledgerModule } from "../module.js"
/** The platform project of the fake deployment. */
export const BINDING_ID = "01900000-0000-7000-8000-000000000001"
export interface Tenant {
readonly owner: Actor
readonly call: TransportCaller
readonly orgSlug: string
readonly orgId: string
readonly workspaceSlug: string
readonly workspaceId: string
readonly projectSlug: string
readonly projectId: string
readonly slugs: { org: string; workspace: string; project: string }
}
export function ledgerApp(service: LedgerService, enabled = true, ready: boolean | ServiceReadiness = true) {
const registry = buildRegistry([tenancyModule, ...(enabled ? [ledgerModule] : [])])
const entities = entitiesOf(registry, "postgres")
const data = createMemoryDataSource({ entities })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(LEDGER_SERVICE, service)
const caller = (actor: Actor): TransportCaller => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
return createCaller(
{
registry,
container,
tenancy,
...(ready ? { serviceReadiness: ready === true ? simulatedBusinessAdmission : ready } : {}),
},
{ actor },
)
}
const signUp = async (email: string): Promise<Actor> => {
const user = await data.global(users).insert({
issuer: "https://id.example.test",
subject: email,
email,
displayName: email.split("@")[0] ?? email,
locale: "en",
})
return { userId: user.id }
}
/** An organization with one workspace and one project, owned by a new user. */
const tenant = async (slug: string): Promise<Tenant> => {
const owner = await signUp(`owner@${slug}.test`)
const call = caller(owner)
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
const workspace = (await call.callByName("tenancy.workspace.create", {
org: slug,
slug: `ws-${slug}`,
name: `Workspace ${slug}`,
visibility: "org",
})) as { workspaceId: string; slug: string }
const project = (await call.callByName("tenancy.project.create", {
org: slug,
workspace: workspace.slug,
slug: `p-${slug}`,
name: `Project ${slug}`,
moduleKey: "ledger",
})) as { projectId: string; slug: string }
return {
owner,
call,
orgSlug: org.slug,
orgId: org.orgId,
workspaceSlug: workspace.slug,
workspaceId: workspace.workspaceId,
projectSlug: project.slug,
projectId: project.projectId,
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
}
}
/** A second user who joins `t`'s organization with the org role `role`. */
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
token: string
}
const actor = await signUp(email)
const call = caller(actor)
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
return call
}
return { registry, data, caller, signUp, tenant, member }
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest"
import { positiveMinor, type RecordTerms, recordTerms, revision } from "../contract/index.js"
import { recordBalanced, transition, validateBalanced } from "../core/index.js"
import type { LedgerService } from "../core/ports.js"
import { retryIdentity } from "../ui/retry.js"
export const A = "acc_00000000-0000-4000-8000-000000000001"
export const B = "acc_00000000-0000-4000-8000-000000000002"
export const T = "txn_00000000-0000-4000-8000-000000000001"
export const terms: RecordTerms = {
effectiveAt: "2026-11-03T00:00:00Z",
description: "Synthetic transfer",
status: "pending",
entries: [
{ accountId: A, direction: "debit", amountMinor: "9007199254740993" },
{ accountId: B, direction: "credit", amountMinor: "9007199254740993" },
],
}
export const service = () =>
({
verify: vi.fn(async () => ({ checkedAccounts: 2, drift: [] as string[] })),
account: vi.fn(async (id: string) => ({ accountId: id, currency: "EUR", status: "active" })),
record: vi.fn(async () => ({ transactionId: T })),
transition: vi.fn(async () => ({ transactionId: T })),
}) as unknown as LedgerService
describe("exact ledger core and retry policy (port tier)", () => {
it("accepts i64 maximum and refuses overflow, zero, floats and unsafe numeric inputs", () => {
for (const schema of [positiveMinor, revision]) {
expect(schema.parse("9223372036854775807")).toBe("9223372036854775807")
for (const invalid of ["9223372036854775808", "0", "01", "1.5", 9007199254740992])
expect(schema.safeParse(invalid).success).toBe(false)
}
expect(recordTerms.parse(terms).entries[0]?.amountMinor).toBe("9007199254740993")
})
it("balances exact BigInt totals by currency and detects one-unit and cross-currency negatives", async () => {
const s = service()
await validateBalanced(s, terms)
const [debit, credit] = terms.entries
if (!debit || !credit) throw new Error("missing synthetic entries")
await expect(
validateBalanced(s, {
...terms,
entries: [debit, { ...credit, amountMinor: "9007199254740992" }],
}),
).rejects.toThrow()
vi.mocked(s.account).mockImplementation(
async (id) => ({ accountId: id, currency: id === A ? "EUR" : "USD", status: "active" }) as never,
)
await expect(validateBalanced(s, terms)).rejects.toThrow()
})
it("does not pre-read mutable account or transaction state before service replay", async () => {
const s = service()
vi.mocked(s.account).mockRejectedValue(new Error("archived after first commit"))
await recordBalanced(s, terms, "retry")
await recordBalanced(s, terms, "retry")
expect(s.account).not.toHaveBeenCalled()
expect(vi.mocked(s.record).mock.calls.map((call) => call[1])).toEqual([
"app-ledger:record:retry",
"app-ledger:record:retry",
])
await transition(s, "post", T, "9007199254740993", "retry")
expect(s.transition).toHaveBeenCalledWith("post", T, "9007199254740993", "app-ledger:post:retry")
})
it("fences new holds, posts and reversals on drift, fails closed on verifier outage, permits void recovery", async () => {
const s = service()
vi.mocked(s.verify).mockResolvedValue({ checkedAccounts: 2, drift: [A] })
await expect(recordBalanced(s, terms, "r")).rejects.toThrow()
for (const action of ["post", "reverse"] as const)
await expect(transition(s, action, T, "1", "r")).rejects.toThrow()
expect(s.record).not.toHaveBeenCalled()
expect(s.transition).not.toHaveBeenCalled()
await transition(s, "void", T, "1", "r")
vi.mocked(s.verify).mockRejectedValue(new Error("offline"))
await expect(recordBalanced(s, terms, "r")).rejects.toThrow("offline")
})
it("preserves service state/funds/overflow refusals without replacing them with local storage", async () => {
const s = service()
for (const reason of ["transaction-state", "insufficient-funds", "overflow", "revision-conflict"]) {
const error = new Error(reason)
vi.mocked(s.transition).mockRejectedValueOnce(error)
await expect(transition(s, "post", T, "1", "r")).rejects.toBe(error)
}
})
it("reuses identical payload keys, rotates for changed input and for returning to an earlier payload", () => {
const key = retryIdentity()
const first = key(terms)
expect(key(structuredClone(terms))).toBe(first)
expect(key({ ...terms, description: "Changed operational label" })).not.toBe(first)
expect(key(terms)).not.toBe(first)
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { IsolationFixture } from "@conusai-kit/testing"
import { businessModulesOn } from "../../index.js"
const id = "00000000-0000-4000-8000-000000000001"
const acc = `acc_${id}`
const txn = `txn_${id}`
const fixtures: Record<string, IsolationFixture> = {}
for (const name of ["accounts.list", "balances.list", "transactions.list", "verify"])
fixtures[`ledger.${name}`] = { input: (t) => ({ org: t.orgSlug }) }
for (const name of ["accounts.get", "entries.list"])
fixtures[`ledger.${name}`] = { input: (t) => ({ org: t.orgSlug, accountId: acc }) }
fixtures["ledger.transactions.get"] = { input: (t) => ({ org: t.orgSlug, transactionId: txn }) }
fixtures["ledger.accounts.create"] = {
input: (t) => ({
org: t.orgSlug,
requestId: id,
code: "SYNTHETIC",
name: "Operational",
currency: "EUR",
type: "asset",
normalBalance: "debit",
allowNegative: false,
}),
}
fixtures["ledger.accounts.update"] = {
input: (t) => ({
org: t.orgSlug,
accountId: acc,
requestId: id,
expectedVersion: "1",
name: "Operational",
status: "archived",
allowNegative: false,
}),
}
fixtures["ledger.transactions.record"] = {
input: (t) => ({
org: t.orgSlug,
requestId: id,
confirmed: true,
terms: {
effectiveAt: "2026-11-03T00:00:00Z",
description: "Synthetic",
status: "pending",
entries: [
{ accountId: acc, direction: "debit", amountMinor: "1" },
{ accountId: acc, direction: "credit", amountMinor: "1" },
],
},
}),
}
for (const name of ["post", "void", "reverse"])
fixtures[`ledger.transactions.${name}`] = {
input: (t) => ({ org: t.orgSlug, transactionId: txn, expectedVersion: "1", requestId: id, confirmed: true }),
}
export const ledgerIsolationFixtures = fixtures
export const isolation = { seed: async () => ({}), fixtures: businessModulesOn.includes("ledger") ? fixtures : {} }
+18
View File
@@ -0,0 +1,18 @@
import { buildRegistry } from "@conusai-kit/core"
import { tenancyModule } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { ledgerModule } from "../index.js"
const registry = buildRegistry([tenancyModule, ledgerModule])
describe("the ledger module", () => {
it("registers beside the kernel", () => {
expect(registry.modules.map((module) => module.id)).toContain("ledger")
})
it("has the same messages in English and in Lithuanian", () => {
const { en, lt } = registry.catalogues
expect(Object.keys(lt?.ledger ?? {}).sort()).toEqual(Object.keys(en?.ledger ?? {}).sort())
expect(Object.keys(en?.ledger ?? {}).length).toBeGreaterThan(0)
})
})
+151
View File
@@ -0,0 +1,151 @@
import type { IsolationTenant } from "@conusai-kit/testing"
import { describe, expect, it, vi } from "vitest"
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
import type { LedgerService } from "../core/ports.js"
import { ledgerApp } from "./app.js"
import { ledgerIsolationFixtures } from "./isolation.js"
const fixtureInput = (name: string, tenant: IsolationTenant) => {
const fixture = ledgerIsolationFixtures[name]
if (!fixture) throw new Error("missing isolation fixture")
return fixture.input(tenant)
}
const resultAccount = {
accountId: "acc_00000000-0000-4000-8000-000000000001",
code: "SYNTHETIC",
name: "Operational",
currency: "EUR",
type: "asset",
normalBalance: "debit",
allowNegative: false,
status: "active",
revision: "1",
} as const
const resultTransaction = {
transactionId: "txn_00000000-0000-4000-8000-000000000001",
revision: "1",
effectiveAt: "2026-11-03T00:00:00Z",
description: "Synthetic",
reference: null,
reverses: null,
status: "pending",
entries: [],
} as const
const fake = () =>
({
accounts: vi.fn(async () => ({ rows: [] })),
verify: vi.fn(async () => ({ checkedAccounts: 0, drift: [] })),
createAccount: vi.fn(async () => resultAccount),
updateAccount: vi.fn(async () => resultAccount),
record: vi.fn(async () => ({ ...resultTransaction, entries: [] })),
transition: vi.fn(async () => ({ ...resultTransaction, entries: [] })),
}) as unknown as LedgerService
describe("Ledger actual caller with synthetic admission/port", () => {
it("refuses all 13 procedures with wrong-tenant slugs before service access", async () => {
const s = fake()
const app = ledgerApp(s)
const a = await app.tenant("alpha")
const b = await app.tenant("beta")
const tenant = { orgSlug: b.orgSlug } as IsolationTenant
expect(Object.keys(ledgerIsolationFixtures)).toHaveLength(13)
for (const [name, fixture] of Object.entries(ledgerIsolationFixtures)) {
await expect(a.call.callByName(name, fixture.input(tenant))).rejects.toHaveProperty("problem.status", 404)
}
expect(s.accounts).not.toHaveBeenCalled()
})
it("allows viewer reads, refuses viewer writes and separates member writer from account/reversal admin", async () => {
const app = ledgerApp(fake())
const t = await app.tenant("alpha")
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
const member = await app.member(t, "writer@alpha.test", "member")
expect(await viewer.callByName("ledger.accounts.list", { org: t.orgSlug })).toEqual({ rows: [] })
const tenant = { orgSlug: t.orgSlug } as IsolationTenant
for (const name of [
"ledger.accounts.create",
"ledger.accounts.update",
"ledger.transactions.record",
"ledger.transactions.post",
"ledger.transactions.void",
"ledger.transactions.reverse",
]) {
await expect(viewer.callByName(name, fixtureInput(name, tenant))).rejects.toHaveProperty("problem.status", 404)
}
for (const name of ["ledger.accounts.create", "ledger.accounts.update", "ledger.transactions.reverse"])
await expect(member.callByName(name, fixtureInput(name, tenant))).rejects.toHaveProperty("problem.status", 404)
})
it("refuses unregistered default-off module and missing readiness/binding", async () => {
for (const [enabled, ready] of [
[false, true],
[true, false],
]) {
const s = fake()
const app = ledgerApp(s, enabled, ready)
const t = await app.tenant("alpha")
await expect(t.call.callByName("ledger.accounts.list", { org: t.orgSlug })).rejects.toThrow()
expect(s.accounts).not.toHaveBeenCalled()
}
})
it("refuses missing approved service scopes and Convex before any adapter call", async () => {
for (const readiness of [
{
...simulatedBusinessAdmission,
observe: async () => ({
...(await simulatedBusinessAdmission.observe({} as never, {} as never)),
authorized: false,
}),
},
{ ...simulatedBusinessAdmission, profile: "convex" as const },
]) {
const s = fake()
const app = ledgerApp(s, true, readiness)
const t = await app.tenant("alpha")
await expect(t.call.callByName("ledger.accounts.list", { org: t.orgSlug })).rejects.toHaveProperty(
"problem.code",
readiness.profile === "convex" ? "service_profile_unsupported" : "service_permission_missing",
)
expect(s.accounts).not.toHaveBeenCalled()
}
})
it("allows member financial writes and owner account/reversal administration through the real caller", async () => {
const service = fake()
const app = ledgerApp(service)
const t = await app.tenant("alpha")
const member = await app.member(t, "writer@alpha.test", "member")
const tenant = { orgSlug: t.orgSlug } as IsolationTenant
for (const name of ["ledger.transactions.record", "ledger.transactions.post", "ledger.transactions.void"]) {
expect(await member.callByName(name, fixtureInput(name, tenant))).toHaveProperty(
"transactionId",
resultTransaction.transactionId,
)
}
for (const name of ["ledger.accounts.create", "ledger.accounts.update"]) {
expect(await t.call.callByName(name, fixtureInput(name, tenant))).toHaveProperty(
"accountId",
resultAccount.accountId,
)
}
expect(
await t.call.callByName("ledger.transactions.reverse", fixtureInput("ledger.transactions.reverse", tenant)),
).toHaveProperty("transactionId", resultTransaction.transactionId)
})
it("requires explicit financial confirmation and refuses extra body-selected authority", async () => {
const service = fake()
const app = ledgerApp(service)
const t = await app.tenant("alpha")
const input = fixtureInput("ledger.transactions.record", { orgSlug: t.orgSlug } as IsolationTenant) as Record<
string,
unknown
>
for (const invalid of [
{ ...input, confirmed: false },
{ ...input, appTenantId: "forged", platformOrg: "beta" },
]) {
await expect(t.call.callByName("ledger.transactions.record", invalid)).rejects.toHaveProperty(
"problem.status",
422,
)
}
expect(service.record).not.toHaveBeenCalled()
})
})
+83
View File
@@ -0,0 +1,83 @@
// A refusal of the Ledger service reaches the person as the kit's sentence for it, in their language.
// Before the kit helper, the module mapped refusals itself and compared a `BusinessErrorCode` with Ledger's
// reasons (`unbalanced`, `insufficient-funds`), which it never is: an unbalanced posting was answered
// "reload the ledger record". Generated HTTP client with a synthetic binding; no gateway/live acceptance.
import type { RequestBusinessBinding } from "@conusai-kit/business"
import { describeBusinessRefusal } from "@conusai-kit/business/refusals"
import { describe, expect, it } from "vitest"
import { sdkLedgerService } from "../adapters/service.js"
import { LEDGER_ERRORS } from "../contract/index.js"
import type { LedgerService } from "../core/ports.js"
import { en } from "../i18n/en.js"
import { lt } from "../i18n/lt.js"
import { ledgerFailure } from "../ui/errors.js"
import { ledgerLabels } from "../ui/labels.js"
const id = "acc_00000000-0000-4000-8000-000000000001"
function refusing(reason: string, status: number) {
const binding = {
orgSlug: "verified-org",
client: (factory: Parameters<RequestBusinessBinding["client"]>[0]) => {
const client = factory({ baseUrl: "https://ledger.example.test", auth: "synthetic", throwOnError: false })
client.setConfig({
fetch: async () =>
new Response(
JSON.stringify({
type: `urn:conusai:ledger:${reason}`,
status,
code: `LEDGER_${reason.replaceAll("-", "_").toUpperCase()}`,
title: "Ledger request refused",
}),
{ status, headers: { "content-type": "application/json" } },
),
})
return client
},
} as RequestBusinessBinding
return sdkLedgerService(binding)
}
const terms: Parameters<LedgerService["record"]>[0] = {
effectiveAt: "2026-11-03T00:00:00Z",
description: "Synthetic",
status: "posted",
entries: [
{ accountId: id, direction: "debit", amountMinor: "100" },
{ accountId: id, direction: "credit", amountMinor: "99" },
],
}
async function refusalOf(reason: string, status: number): Promise<unknown> {
try {
await refusing(reason, status).record(terms, "stable-key")
} catch (error) {
return error
}
throw new Error("expected a refusal")
}
describe("Ledger refusals through the kit", () => {
it("tells the person an unbalanced posting does not balance, in English and Lithuanian", async () => {
const error = await refusalOf("unbalanced", 409)
expect(error).toHaveProperty("problem.code", "business_unbalanced")
expect(error).toHaveProperty("problem.status", 409)
expect(describeBusinessRefusal(error, "en")).toBe("Debits and credits do not balance.")
expect(describeBusinessRefusal(error, "lt")).toBe("Debetas ir kreditas nesutampa.")
})
it("names insufficient funds and a transaction's state as such", async () => {
expect(await refusalOf("insufficient-funds", 409)).toHaveProperty("problem.code", "business_insufficient_funds")
expect(await refusalOf("transaction-state", 409)).toHaveProperty("problem.code", "business_transaction_state")
})
it("shows a form the kit's sentence, a conflict in this module's words, and the module's own drift fence", async () => {
const labelsOf = (messages: Record<string, string>, locale: "en" | "lt") =>
ledgerLabels((key) => messages[key] ?? key, locale)
const english = labelsOf(en, "en")
const lithuanian = labelsOf(lt, "lt")
expect(ledgerFailure(await refusalOf("unbalanced", 409), english)).toBe("Debits and credits do not balance.")
expect(ledgerFailure(await refusalOf("unbalanced", 409), lithuanian)).toBe("Debetas ir kreditas nesutampa.")
expect(ledgerFailure(await refusalOf("revision-conflict", 409), english)).toBe(en.revisionConflict)
expect(ledgerFailure(await refusalOf("idempotency-conflict", 409), lithuanian)).toBe(lt.idempotencyConflict)
expect(ledgerFailure({ problem: { code: LEDGER_ERRORS.drift } }, english)).toBe(en.drift)
})
})
+106
View File
@@ -0,0 +1,106 @@
// Generated HTTP client with a synthetic RequestBusinessBinding. No gateway/live acceptance.
import type { RequestBusinessBinding } from "@conusai-kit/business"
import { Container } from "@conusai-kit/core"
import { describe, expect, it } from "vitest"
import { ledgerServiceOf, sdkLedgerService } from "../adapters/service.js"
const id = "acc_00000000-0000-4000-8000-000000000001"
const txn = "txn_00000000-0000-4000-8000-000000000001"
function adapter(response: (request: Request) => Promise<Response>) {
const calls: Request[] = []
const binding = {
orgSlug: "verified-org",
client: (factory: Parameters<RequestBusinessBinding["client"]>[0]) => {
const client = factory({ baseUrl: "https://ledger.example.test", auth: "synthetic", throwOnError: false })
client.setConfig({
fetch: async (input) => {
const request = new Request(input)
calls.push(request.clone())
return response(request)
},
})
return client
},
} as RequestBusinessBinding
return { service: sdkLedgerService(binding), calls }
}
const json = (text: string, status = 200) =>
new Response(text, { status, headers: { "content-type": "application/json" } })
describe("generated Ledger HTTP adapter (synthetic fetch tier)", () => {
it("retains raw JSON integers above 2^53 in balances and revisions", async () => {
const a = adapter(async (request) =>
request.url.includes("balances")
? json(
`{"data":[{"account_id":"${id}","currency":"EUR","posted":9007199254740993,"pending":-9007199254740993,"available":9223372036854775807}]}`,
)
: json(
`{"ext":{},"id":"${id}","code":"TEST","name":"Synthetic","currency":"EUR","type":"asset","normal_balance":"debit","allow_negative":false,"status":"active","revision":9007199254740993}`,
),
)
expect((await a.service.balances()).rows[0]).toMatchObject({
posted: "9007199254740993",
pending: "-9007199254740993",
available: "9223372036854775807",
})
expect((await a.service.account(id)).revision).toBe("9007199254740993")
expect(new URL(a.calls[0]?.url ?? "").pathname).toContain("/orgs/verified-org/ledger/balances")
})
it("sends exact unquoted i64 amount and revision tokens through generated SDK serialization", async () => {
const bodies: string[] = []
const a = adapter(async (request) => {
bodies.push(await request.text())
return json(
`{"ext":{},"id":"${txn}","revision":9007199254740993,"effective_at":"2026-11-03T00:00:00Z","description":"Synthetic","status":"pending","entries":[]}`,
)
})
await a.service.record(
{
effectiveAt: "2026-11-03T00:00:00Z",
description: "Synthetic",
status: "pending",
entries: [
{ accountId: id, direction: "debit", amountMinor: "9007199254740993" },
{ accountId: id, direction: "credit", amountMinor: "9007199254740993" },
],
},
"stable-key",
)
await a.service.transition("post", txn, "9007199254740993", "stable-post")
expect(bodies[0]).toContain('"amount_minor":9007199254740993')
expect(bodies[0]).toContain('"idempotency_key":"stable-key"')
expect(bodies[1]).toContain('"revision":9007199254740993')
})
it("redacts domain conflicts, invalid inputs and upstream failures", async () => {
for (const [status, reason, code] of [
[409, "revision-conflict", "business_revision_conflict"],
[409, "idempotency-conflict", "business_idempotency_conflict"],
[422, "unbalanced", "business_unbalanced"],
[422, "invalid-request", "business_invalid"],
[503, "unavailable", "business_unavailable"],
] as const) {
const a = adapter(async () =>
json(
JSON.stringify({
type: `urn:conusai:ledger:${reason}`,
status,
code: `LEDGER_${reason.replaceAll("-", "_").toUpperCase()}`,
title: "SENSITIVE",
detail: "SECRET_PERSON_TOKEN",
}),
status,
),
)
try {
await a.service.account(id)
throw new Error("expected refusal")
} catch (error) {
expect(JSON.stringify(error)).not.toMatch(/SENSITIVE|SECRET_PERSON_TOKEN/)
expect(error).toHaveProperty("problem.status", status)
expect(error).toHaveProperty("problem.code", code)
}
}
})
it("refuses missing or unverified request binding without deployment credential fallback", () => {
expect(() => ledgerServiceOf(new Container(), "effect")).toThrow()
})
})
+60
View File
@@ -0,0 +1,60 @@
import { implement } from "@conusai-kit/core"
import * as c from "../contract/index.js"
import { operationKey, recordBalanced, transition } from "../core/index.js"
import { ledgerServiceOf } from "./service.js"
export const LEDGER_PROCEDURES = [
implement(c.accounts, ({ ctx, input }) => ledgerServiceOf(ctx.container, ctx.kind).accounts(input.after)),
implement(c.account, ({ ctx, input }) => ledgerServiceOf(ctx.container, ctx.kind).account(input.accountId)),
implement(c.balances, ({ ctx, input }) =>
ledgerServiceOf(ctx.container, ctx.kind).balances(input.accountId, input.after),
),
implement(c.entries, ({ ctx, input }) =>
ledgerServiceOf(ctx.container, ctx.kind).entries(input.accountId, input.after),
),
implement(c.transactions, ({ ctx, input }) => ledgerServiceOf(ctx.container, ctx.kind).transactions(input.after)),
implement(c.transaction, ({ ctx, input }) =>
ledgerServiceOf(ctx.container, ctx.kind).transaction(input.transactionId),
),
implement(c.verify, ({ ctx }) => ledgerServiceOf(ctx.container, ctx.kind).verify()),
implement(c.createAccount, ({ ctx, input }) =>
ledgerServiceOf(ctx.container, "effect").createAccount(input, operationKey("account", input.requestId)),
),
implement(c.updateAccount, ({ ctx, input }) =>
ledgerServiceOf(ctx.container, "effect").updateAccount(
input.accountId,
input,
input.expectedVersion,
operationKey("account-update", input.requestId),
),
),
implement(c.record, ({ ctx, input }) =>
recordBalanced(ledgerServiceOf(ctx.container, "effect"), input.terms, input.requestId),
),
implement(c.post, ({ ctx, input }) =>
transition(
ledgerServiceOf(ctx.container, "effect"),
"post",
input.transactionId,
input.expectedVersion,
input.requestId,
),
),
implement(c.voidTransaction, ({ ctx, input }) =>
transition(
ledgerServiceOf(ctx.container, "effect"),
"void",
input.transactionId,
input.expectedVersion,
input.requestId,
),
),
implement(c.reverse, ({ ctx, input }) =>
transition(
ledgerServiceOf(ctx.container, "effect"),
"reverse",
input.transactionId,
input.expectedVersion,
input.requestId,
),
),
]
+203
View File
@@ -0,0 +1,203 @@
import {
businessAppError,
type RequestBusinessBinding,
requestBusinessBinding,
toBusinessError,
} from "@conusai-kit/business"
import type { Container, ProcedureKind } from "@conusai-kit/core"
import {
type Account,
type Balance,
type Entry,
ledgerCreateAccount,
ledgerGetAccount,
ledgerGetBalances,
ledgerGetTransaction,
ledgerListAccounts,
ledgerListEntries,
ledgerListTransactions,
ledgerPostTransaction,
ledgerRecordTransaction,
ledgerReverseTransaction,
ledgerUpdateAccount,
ledgerVerify,
ledgerVoidTransaction,
type Transaction,
} from "@conusai-sdk/ledger"
import { createClient } from "@conusai-sdk/ledger/client"
import { accountView, balanceView, entryView, transactionView, verificationView } from "../contract/index.js"
import { LEDGER_SERVICE, type LedgerReads, type LedgerService, type Page, 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 account = (row: Account) =>
accountView.parse({
accountId: row.id,
code: row.code,
name: row.name,
currency: row.currency,
type: row.type,
normalBalance: row.normal_balance,
allowNegative: row.allow_negative,
status: row.status,
revision: row.revision.toString(),
})
const balance = (row: Balance) =>
balanceView.parse({
accountId: row.account_id,
currency: row.currency,
posted: row.posted.toString(),
pending: row.pending.toString(),
available: row.available.toString(),
})
const entry = (row: Entry) =>
entryView.parse({
entryId: row.id,
transactionId: row.transaction_id,
accountId: row.account_id,
currency: row.currency,
direction: row.direction,
amountMinor: row.amount_minor.toString(),
})
const transaction = (row: Transaction) =>
transactionView.parse({
transactionId: row.id,
revision: row.revision.toString(),
effectiveAt: row.effective_at,
description: row.description,
reference: row.reference ?? null,
reverses: row.reverses ?? null,
status: row.status,
entries: row.entries.map(entry),
})
const page = <A, B>(result: { data: A[]; next_after?: string | null }, view: (row: A) => B): Page<B> => ({
rows: result.data.map(view),
...(result.next_after ? { after: result.next_after } : {}),
})
const query = (after?: string) => ({ limit: 50, ...(after ? { after } : {}) })
export function sdkLedgerService(binding: RequestBusinessBinding): LedgerService {
const client = binding.client(createClient)
const org = { org_slug: binding.orgSlug }
return {
async accounts(after) {
return page(await answer(ledgerListAccounts({ client, path: org, query: query(after) })), account)
},
async account(id) {
return account(await answer(ledgerGetAccount({ client, path: { ...org, account_id: id } })))
},
async balances(id, after) {
return page(
await answer(
ledgerGetBalances({ client, path: org, query: { ...query(after), ...(id ? { account_id: id } : {}) } }),
),
balance,
)
},
async entries(id, after) {
return page(
await answer(ledgerListEntries({ client, path: { ...org, account_id: id }, query: query(after) })),
entry,
)
},
async transactions(after) {
return page(await answer(ledgerListTransactions({ client, path: org, query: query(after) })), transaction)
},
async transaction(id) {
return transaction(await answer(ledgerGetTransaction({ client, path: { ...org, transaction_id: id } })))
},
async verify() {
const result = await answer(ledgerVerify({ client, path: org }))
return verificationView.parse({ checkedAccounts: result.checked_accounts, drift: result.drift })
},
async createAccount(input, key) {
return account(
await answer(
ledgerCreateAccount({
client,
path: org,
body: {
code: input.code,
name: input.name,
currency: input.currency,
type: input.type,
normal_balance: input.normalBalance,
allow_negative: input.allowNegative,
idempotency_key: key,
},
}),
),
)
},
async updateAccount(id, input, revision, key) {
return account(
await answer(
ledgerUpdateAccount({
client,
path: { ...org, account_id: id },
body: {
name: input.name,
status: input.status,
allow_negative: input.allowNegative,
revision: BigInt(revision),
idempotency_key: key,
},
}),
),
)
},
async record(terms, key) {
return transaction(
await answer(
ledgerRecordTransaction({
client,
path: org,
body: {
effective_at: terms.effectiveAt,
description: terms.description,
reference: terms.reference ?? null,
status: terms.status,
entries: terms.entries.map((row) => ({
account_id: row.accountId,
direction: row.direction,
amount_minor: BigInt(row.amountMinor),
})),
idempotency_key: key,
},
}),
),
)
},
async transition(action, id, revision, key) {
const method =
action === "post" ? ledgerPostTransaction : action === "void" ? ledgerVoidTransaction : ledgerReverseTransaction
return transaction(
await answer(
method({
client,
path: { ...org, transaction_id: id },
body: { revision: BigInt(revision), idempotency_key: key },
}),
),
)
},
}
}
export function ledgerServiceOf(container: Container, kind: "effect"): LedgerService
export function ledgerServiceOf(container: Container, kind: ProcedureKind): LedgerReads
export function ledgerServiceOf(container: Container, kind: ProcedureKind): LedgerReads {
const service = container.has(LEDGER_SERVICE)
? container.resolve(LEDGER_SERVICE)
: sdkLedgerService(requestBusinessBinding(container))
return kind === "effect" ? service : readsOf(service)
}
+228
View File
@@ -0,0 +1,228 @@
// The public API of the ledger module: the zod schemas, procedure contracts, permissions and events
// another module or a browser may import. Nothing here reaches core/, adapters/ or a driver.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
/**
* The module's own error: its verification found projection drift, so it fences posting. A refusal of the
* Ledger service is the kit's (`business_<refusal>`, `@conusai-kit/business`), not a code of this module.
*/
export const LEDGER_ERRORS = {
drift: "ledger_projection_drift",
} as const
const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
export const accountId = z.string().regex(new RegExp(`^acc_${uuid}$`))
export const transactionId = z.string().regex(new RegExp(`^txn_${uuid}$`))
const entryId = z.string().regex(new RegExp(`^ent_${uuid}$`))
const maximum = 9223372036854775807n
/** Integer decimal strings cross JSON; conversion to BigInt happens only in the adapter. */
export const positiveMinor = z
.string()
.regex(/^[1-9][0-9]{0,18}$/)
.refine((value) => {
try {
return BigInt(value) <= maximum
} catch {
return false
}
})
export const revision = positiveMinor
const signedMinor = z
.string()
.regex(/^(?:0|-?[1-9][0-9]{0,18})$/)
.refine((value) => {
try {
const amount = BigInt(value)
return amount >= -9223372036854775808n && amount <= maximum
} catch {
return false
}
})
const currency = z.string().regex(/^[A-Z]{3}$/)
const text = z
.string()
.trim()
.min(1)
.max(200)
.refine((value) => ![...value].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127))
export const direction = z.enum(["debit", "credit"])
export const accountType = z.enum(["asset", "liability", "equity", "income", "expense"])
export const accountView = z.object({
accountId,
code: z.string(),
name: z.string(),
currency,
type: accountType,
normalBalance: direction,
allowNegative: z.boolean(),
status: z.enum(["active", "archived"]),
revision,
})
export type AccountView = z.output<typeof accountView>
export const balanceView = z.object({
accountId,
currency,
posted: signedMinor,
pending: signedMinor,
available: signedMinor,
})
export type BalanceView = z.output<typeof balanceView>
export const entryView = z.object({
entryId,
transactionId,
accountId,
currency,
direction,
amountMinor: positiveMinor,
})
export type EntryView = z.output<typeof entryView>
export const transactionView = z.object({
transactionId,
revision,
effectiveAt: z.iso.datetime({ offset: true }),
description: z.string(),
reference: z.string().nullable(),
reverses: transactionId.nullable(),
status: z.enum(["pending", "posted", "voided"]),
entries: z.array(entryView).max(200),
})
export type TransactionView = z.output<typeof transactionView>
export const verificationView = z.object({ checkedAccounts: z.number().int().nonnegative(), drift: z.array(accountId) })
export type VerificationView = z.output<typeof verificationView>
export const recordTerms = z
.object({
effectiveAt: z.iso.datetime({ offset: true }),
description: text,
reference: text.optional(),
status: z.enum(["pending", "posted"]),
entries: z
.array(z.object({ accountId, direction, amountMinor: positiveMinor }).strict())
.min(2)
.max(200),
})
.strict()
export type RecordTerms = z.output<typeof recordTerms>
const org = { org: z.string() }
const cursor = z.string().min(1).max(4096).optional()
const requestId = z.string().uuid()
export const accounts = procedure("ledger.accounts.list")
.kind("read")
.scope("org")
.permission("ledger.accounts.read")
.input(z.object({ ...org, after: cursor }))
.output(z.object({ rows: z.array(accountView), after: cursor }))
.contract()
export const account = procedure("ledger.accounts.get")
.kind("read")
.scope("org")
.permission("ledger.accounts.read")
.input(z.object({ ...org, accountId }))
.output(accountView)
.contract()
export const balances = procedure("ledger.balances.list")
.kind("read")
.scope("org")
.permission("ledger.balances.read")
.input(z.object({ ...org, accountId: accountId.optional(), after: cursor }))
.output(z.object({ rows: z.array(balanceView), after: cursor }))
.contract()
export const entries = procedure("ledger.entries.list")
.kind("read")
.scope("org")
.permission("ledger.transactions.read")
.input(z.object({ ...org, accountId, after: cursor }))
.output(z.object({ rows: z.array(entryView), after: cursor }))
.contract()
export const transactions = procedure("ledger.transactions.list")
.kind("read")
.scope("org")
.permission("ledger.transactions.read")
.input(z.object({ ...org, after: cursor }))
.output(z.object({ rows: z.array(transactionView), after: cursor }))
.contract()
export const transaction = procedure("ledger.transactions.get")
.kind("read")
.scope("org")
.permission("ledger.transactions.read")
.input(z.object({ ...org, transactionId }))
.output(transactionView)
.contract()
export const verify = procedure("ledger.verify")
.kind("read")
.scope("org")
.permission("ledger.balances.read")
.input(z.object(org))
.output(verificationView)
.contract()
export const createAccount = procedure("ledger.accounts.create")
.kind("effect")
.scope("org")
.permission("ledger.accounts.manage")
.input(
z
.object({
...org,
requestId,
code: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/),
name: text,
currency,
type: accountType,
normalBalance: direction,
allowNegative: z.boolean(),
})
.strict(),
)
.output(accountView)
.contract()
export const updateAccount = procedure("ledger.accounts.update")
.kind("effect")
.scope("org")
.permission("ledger.accounts.manage")
.input(
z
.object({
...org,
accountId,
requestId,
expectedVersion: revision,
name: text,
status: z.enum(["active", "archived"]),
allowNegative: z.boolean(),
})
.strict(),
)
.output(accountView)
.contract()
export const record = procedure("ledger.transactions.record")
.kind("effect")
.scope("org")
.permission("ledger.transactions.write")
.input(z.object({ ...org, requestId, terms: recordTerms, confirmed: z.literal(true) }).strict())
.output(transactionView)
.contract()
const transitionInput = z
.object({ ...org, transactionId, expectedVersion: revision, requestId, confirmed: z.literal(true) })
.strict()
export const post = procedure("ledger.transactions.post")
.kind("effect")
.scope("org")
.permission("ledger.transactions.write")
.input(transitionInput)
.output(transactionView)
.contract()
export const voidTransaction = procedure("ledger.transactions.void")
.kind("effect")
.scope("org")
.permission("ledger.transactions.write")
.input(transitionInput)
.output(transactionView)
.contract()
export const reverse = procedure("ledger.transactions.reverse")
.kind("effect")
.scope("org")
.permission("ledger.transactions.reverse")
.input(transitionInput)
.output(transactionView)
.contract()
+52
View File
@@ -0,0 +1,52 @@
import { AppError, badRequest } from "@conusai-kit/core"
import { LEDGER_ERRORS, type RecordTerms } from "../contract/index.js"
import type { LedgerReads, LedgerService } from "./ports.js"
/** Optional preview only. Never run mutable account reads ahead of service replay. */
export async function validateBalanced(service: LedgerReads, terms: RecordTerms): Promise<void> {
const accounts = new Map(
await Promise.all(
[...new Set(terms.entries.map((entry) => entry.accountId))].map(
async (id) => [id, await service.account(id)] as const,
),
),
)
const totals = new Map<string, bigint>()
for (const entry of terms.entries) {
const account = accounts.get(entry.accountId)
if (account?.status !== "active") throw badRequest("Choose active ledger accounts.")
totals.set(
account.currency,
(totals.get(account.currency) ?? 0n) + BigInt(entry.amountMinor) * (entry.direction === "debit" ? 1n : -1n),
)
}
if ([...totals.values()].some((amount) => amount !== 0n))
throw badRequest("Balance debits and credits separately in every currency.")
}
export async function recordBalanced(service: LedgerService, terms: RecordTerms, requestId: string) {
await assertProjection(service)
// Service replay precedes account status, locking, funds, currency and overflow checks.
return service.record(terms, operationKey("record", requestId))
}
export async function assertProjection(service: LedgerReads): Promise<void> {
const health = await service.verify()
if (health.drift.length)
throw new AppError({
type: `/problems/${LEDGER_ERRORS.drift}`,
code: LEDGER_ERRORS.drift,
status: 409,
title: "Ledger posting fenced",
detail: "Ledger projections require operator recovery before posting.",
})
}
export async function transition(
service: LedgerService,
action: "post" | "void" | "reverse",
id: string,
revision: string,
requestId: string,
) {
if (action !== "void") await assertProjection(service)
return service.transition(action, id, revision, operationKey(action, requestId))
}
export const operationKey = (action: string, requestId: string): string => `app-ledger:${action}:${requestId}`
+43
View File
@@ -0,0 +1,43 @@
import { token } from "@conusai-kit/core"
import type {
AccountView,
BalanceView,
EntryView,
RecordTerms,
TransactionView,
VerificationView,
} from "../contract/index.js"
export interface Page<T> {
rows: T[]
after?: string
}
export interface LedgerReads {
accounts(after?: string): Promise<Page<AccountView>>
account(id: string): Promise<AccountView>
balances(accountId?: string, after?: string): Promise<Page<BalanceView>>
entries(accountId: string, after?: string): Promise<Page<EntryView>>
transactions(after?: string): Promise<Page<TransactionView>>
transaction(id: string): Promise<TransactionView>
verify(): Promise<VerificationView>
}
export interface LedgerService extends LedgerReads {
createAccount(input: Omit<AccountView, "accountId" | "revision" | "status">, key: string): Promise<AccountView>
updateAccount(
id: string,
input: Pick<AccountView, "name" | "status" | "allowNegative">,
revision: string,
key: string,
): Promise<AccountView>
record(terms: RecordTerms, key: string): Promise<TransactionView>
transition(action: "post" | "void" | "reverse", id: string, revision: string, key: string): Promise<TransactionView>
}
export const readsOf = (service: LedgerReads): LedgerReads => ({
accounts: (after) => service.accounts(after),
account: (id) => service.account(id),
balances: (id, after) => service.balances(id, after),
entries: (id, after) => service.entries(id, after),
transactions: (after) => service.transactions(after),
transaction: (id) => service.transaction(id),
verify: () => service.verify(),
})
export const LEDGER_SERVICE = token<LedgerService>("ledger.service", { views: { read: readsOf, write: readsOf } })
+72
View File
@@ -0,0 +1,72 @@
export const en = {
revisionConflict: "Reload the current ledger revision and review your instructions before submitting again.",
idempotencyConflict:
"Inspect the prior operation. Retry unchanged instructions with the original key, or review changed instructions as a new operation.",
yes: "Yes",
no: "No",
title: "Ledger",
intro:
"App organization-wide financial records. A project tab does not make these records project-private. Use operational labels only; never enter personal identifiers.",
accounts: "Accounts",
transactions: "Transactions",
entries: "Immutable entries",
balances: "Balances (exact minor units)",
empty: "No records.",
more: "Show more",
open: "Open record",
back: "Back to ledger",
name: "Operational label",
code: "Code",
currency: "Currency",
type: "Account type",
normalBalance: "Normal balance",
allowNegative: "Allow negative balance",
status: "Status",
revision: "Revision",
active: "Active",
archived: "Archived",
pending: "Pending hold",
posted: "Posted",
voided: "Voided",
debit: "Debit",
credit: "Credit",
asset: "Asset",
liability: "Liability",
equity: "Equity",
income: "Income",
expense: "Expense",
create: "Create account",
configure: "Configure account",
save: "Save",
busy: "Working…",
record: "Record transaction",
description: "Operational description",
reference: "Operational reference (optional)",
effectiveAt: "Effective time (ISO 8601 with timezone)",
account: "Account",
amountMinor: "Positive exact minor units",
addLine: "Add line",
removeLine: "Remove last line",
confirm: "Confirm financial effect",
review: "Review financial effect",
confirmDescription:
"Confirm these exact instructions. Posted records remain immutable; corrections create compensating records.",
cancel: "Cancel",
post: "Post hold",
void: "Void hold",
reverse: "Create reversal",
reverses: "Reverses",
transaction: "Transaction",
postedBalance: "Posted",
pendingBalance: "Pending",
availableBalance: "Available",
verifier: "Projection verifier",
healthy: "Consistent",
drift: "Drift: posting refused; contact an operator",
checked: "Checked accounts",
unavailable: "Ledger unavailable",
invalid: "Check the ledger instructions",
readOnly: "Read-only ledger",
preview: "Instructions",
archiveWarning: "Archiving retains all financial history. No posted rows are edited or deleted.",
} as const
+4
View File
@@ -0,0 +1,4 @@
// The ledger module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
export { en } from "./en.js"
export { lt } from "./lt.js"
+73
View File
@@ -0,0 +1,73 @@
import type { en } from "./en.js"
export const lt: { [K in keyof typeof en]: string } = {
revisionConflict: "Įkelkite dabartinę knygos versiją ir prieš siųsdami dar kartą peržiūrėkite nurodymus.",
idempotencyConflict:
"Patikrinkite ankstesnę operaciją. Nekeičiamus nurodymus kartokite su pradiniu raktu, o pakeistus peržiūrėkite kaip naują operaciją.",
yes: "Taip",
no: "Ne",
title: "Didžioji knyga",
intro:
"Finansiniai įrašai bendri programos organizacijai. Projekto skirtukas nesuteikia projekto privatumo. Naudokite tik operacines žymas, be asmens identifikatorių.",
accounts: "Sąskaitos",
transactions: "Operacijos",
entries: "Nekeičiami įrašai",
balances: "Likučiai (tikslūs mažieji vienetai)",
empty: "Įrašų nėra.",
more: "Rodyti daugiau",
open: "Atverti įrašą",
back: "Grįžti į knygą",
name: "Operacinė žyma",
code: "Kodas",
currency: "Valiuta",
type: "Sąskaitos tipas",
normalBalance: "Įprastinė pusė",
allowNegative: "Leisti neigiamą likutį",
status: "Būsena",
revision: "Versija",
active: "Aktyvi",
archived: "Archyvuota",
pending: "Rezervuota",
posted: "Įskaityta",
voided: "Atšaukta",
debit: "Debetas",
credit: "Kreditas",
asset: "Turtas",
liability: "Įsipareigojimai",
equity: "Nuosavas kapitalas",
income: "Pajamos",
expense: "Sąnaudos",
create: "Sukurti sąskaitą",
configure: "Konfigūruoti sąskaitą",
save: "Išsaugoti",
busy: "Vykdoma…",
record: "Įrašyti operaciją",
description: "Operacinis aprašas",
reference: "Operacinė nuoroda (nebūtina)",
effectiveAt: "Laikas (ISO 8601 su laiko juosta)",
account: "Sąskaita",
amountMinor: "Tikslūs teigiami mažieji vienetai",
addLine: "Pridėti eilutę",
removeLine: "Pašalinti paskutinę eilutę",
confirm: "Patvirtinti finansinį poveikį",
review: "Peržiūrėti finansinį poveikį",
confirmDescription:
"Patvirtinkite tikslius nurodymus. Įskaityti įrašai nekeičiami; pataisos sukuria kompensuojančius įrašus.",
cancel: "Atšaukti",
post: "Įskaityti rezervaciją",
void: "Atšaukti rezervaciją",
reverse: "Sukurti kompensaciją",
reverses: "Kompensuoja",
transaction: "Operacija",
postedBalance: "Įskaityta",
pendingBalance: "Rezervuota",
availableBalance: "Prieinama",
verifier: "Projekcijų tikrintuvas",
healthy: "Suderinta",
drift: "Neatitikimas: įskaitymas uždraustas, kreipkitės į operatorių",
checked: "Patikrintos sąskaitos",
unavailable: "Knyga nepasiekiama",
invalid: "Patikrinkite knygos nurodymus",
readOnly: "Tik skaitymas",
preview: "Nurodymai",
archiveWarning: "Archyvavimas išsaugo finansinę istoriją. Įskaityti įrašai nekeičiami ir netrinami.",
}
+3
View File
@@ -0,0 +1,3 @@
// The ledger module's public entry: its manifest. A page imports `./ui`; another module imports `./contract`.
export { ledgerModule } from "./module.js"
+57
View File
@@ -0,0 +1,57 @@
// The ledger module's manifest (spec §4.1): the one place it registers anything. Procedures, jobs,
// events, tables and permissions are added here as the module grows; `modules/index.ts` lists the
// manifest, and nothing in apps/ changes.
import { defineModule } from "@conusai-kit/core"
import { LEDGER_PROCEDURES } from "./adapters/procedures.js"
import { en, lt } from "./i18n/index.js"
export const ledgerModule = defineModule({
id: "ledger",
// 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: {
"ledger.accounts.read": "viewer",
"ledger.balances.read": "viewer",
"ledger.transactions.read": "viewer",
"ledger.accounts.manage": "admin",
"ledger.transactions.write": "editor",
"ledger.transactions.reverse": "admin",
},
procedures: LEDGER_PROCEDURES,
serviceRequirements: [
{
transport: "business",
service: "ledger",
apiRange: "^1.1.0",
profiles: ["postgres", "mariadb", "mongodb"],
operations: {
"ledger.accounts.list": ["ledger.accounts:read"],
"ledger.accounts.get": ["ledger.accounts:read"],
"ledger.balances.list": ["ledger.balances:read"],
"ledger.verify": ["ledger.balances:read"],
"ledger.entries.list": ["ledger.transactions:read"],
"ledger.transactions.list": ["ledger.transactions:read"],
"ledger.transactions.get": ["ledger.transactions:read"],
"ledger.accounts.create": ["ledger.accounts:write"],
"ledger.accounts.update": ["ledger.accounts:write"],
"ledger.transactions.record": ["ledger.balances:read", "ledger.transactions:write"],
"ledger.transactions.post": ["ledger.balances:read", "ledger.transactions:write"],
"ledger.transactions.void": ["ledger.transactions:write"],
"ledger.transactions.reverse": ["ledger.balances:read", "ledger.transactions:write"],
},
configuration: [],
dependencies: [],
dataClasses: ["financial"],
partitionRequired: true,
remediation: "/docs/business-services.md",
},
],
// A project's tab. `modules/pages.ts` mounts the page under this segment.
ui: { nav: [{ scope: "project", label: "ledger.title", href: "ledger", permission: "ledger.accounts.read" }] },
// Both languages, as TypeScript objects: kit-i18n merges them from the registry (AT-D23).
i18n: { en, lt },
})
+11
View File
@@ -0,0 +1,11 @@
import { describeError } from "@conusai-kit/ui/data"
import { serviceNotice } from "@conusai-kit/ui/patterns"
import { LEDGER_ERRORS } from "../contract/index.js"
import type { LedgerLabels } from "./labels.js"
/** What a form shows for a refused write: the module's drift fence, a Ledger refusal in the reader's words, or the kit's text. */
export function ledgerFailure(error: unknown, labels: LedgerLabels): string {
const code = (error as { problem?: { code?: unknown } } | null)?.problem?.code
if (code === LEDGER_ERRORS.drift) return labels.drift
return serviceNotice(error, labels.refusals)?.text ?? describeError(error)
}
+305
View File
@@ -0,0 +1,305 @@
"use client"
import { useWrite } from "@conusai-kit/ui/data"
import {
CheckField,
ConfirmDialog,
FactList,
FormLayout,
type FormValues,
Page,
PageSection,
ResourceList,
SelectField,
TextField,
usePageNavigation,
} from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import { useRef, useState } from "react"
import { type AccountView, recordTerms, type TransactionView } from "../contract/index.js"
import { ledgerFailure } from "./errors.js"
import type { LedgerLabels, LedgerMessages } from "./labels.js"
import { retryIdentity } from "./retry.js"
const options = (labels: LedgerLabels, values: (keyof LedgerMessages)[]) =>
values.map((value) => ({ value, label: labels[value] }))
export function AccountForm({
org,
account,
labels,
base,
}: {
org: string
account?: AccountView
labels: LedgerLabels
base: string
}) {
const navigation = usePageNavigation()
const write = useWrite<Record<string, unknown>, AccountView>(
account ? "ledger.accounts.update" : "ledger.accounts.create",
)
const identity = useRef(retryIdentity())
const [pending, setPending] = useState<Record<string, unknown>>()
return (
<PageSection title={account ? labels.configure : labels.create}>
<FormLayout
describeError={(error) => ledgerFailure(error, labels)}
submitLabel={labels.review}
busyLabel={labels.busy}
onSubmit={async (values: FormValues) => {
setPending(
account
? {
org,
accountId: account.accountId,
expectedVersion: account.revision,
name: values.name,
status: values.status,
allowNegative: values.allowNegative === "on",
}
: { org, ...values, allowNegative: values.allowNegative === "on" },
)
}}
>
{!account ? (
<>
<TextField name="code" label={labels.code} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" />
<TextField name="currency" label={labels.currency} required pattern="[A-Z]{3}" />
<SelectField
name="type"
label={labels.type}
defaultValue="asset"
options={options(labels, ["asset", "liability", "equity", "income", "expense"])}
/>
<SelectField
name="normalBalance"
label={labels.normalBalance}
defaultValue="debit"
options={options(labels, ["debit", "credit"])}
/>
</>
) : (
<SelectField
name="status"
label={labels.status}
defaultValue={account.status}
options={options(labels, ["active", "archived"])}
/>
)}
<TextField name="name" label={labels.name} defaultValue={account?.name} required maxLength={200} />
<CheckField name="allowNegative" label={labels.allowNegative} defaultChecked={account?.allowNegative} />
</FormLayout>
<ConfirmDialog
describeError={(error) => ledgerFailure(error, labels)}
open={pending !== undefined}
onOpenChange={(open) => {
if (!open) setPending(undefined)
}}
title={labels.confirm}
description={labels.archiveWarning}
confirmLabel={labels.save}
cancelLabel={labels.cancel}
onConfirm={async () => {
if (!pending) return
const saved = await write.mutate({ ...pending, requestId: identity.current(pending) })
navigation.navigate(`${base}/accounts/${saved.accountId}`)
}}
>
<Page>
{pending ? (
<FactList
facts={[
{ label: labels.name, value: String(pending.name) },
{ label: labels.code, value: account?.code ?? String(pending.code) },
{ label: labels.currency, value: account?.currency ?? String(pending.currency) },
{ label: labels.type, value: labels[(account?.type ?? String(pending.type)) as keyof LedgerMessages] },
{
label: labels.normalBalance,
value: labels[(account?.normalBalance ?? String(pending.normalBalance)) as keyof LedgerMessages],
},
{ label: labels.status, value: pending.status === "archived" ? labels.archived : labels.active },
{ label: labels.allowNegative, value: pending.allowNegative ? labels.yes : labels.no },
]}
/>
) : null}
</Page>
</ConfirmDialog>
</PageSection>
)
}
export function RecordForm({
org,
labels,
base,
fenced,
}: {
org: string
labels: LedgerLabels
base: string
fenced: boolean
}) {
const navigation = usePageNavigation()
const write = useWrite<Record<string, unknown>, TransactionView>("ledger.transactions.record")
const identity = useRef(retryIdentity())
const [lines, setLines] = useState(2)
const [pending, setPending] = useState<ReturnType<typeof recordTerms.parse>>()
return (
<PageSection title={labels.record}>
<FormLayout
describeError={(error) => ledgerFailure(error, labels)}
submitLabel={labels.review}
busyLabel={labels.busy}
submitDisabled={fenced}
onSubmit={async (values: FormValues) => {
if (fenced) throw new Error(labels.drift)
const result = recordTerms.safeParse({
effectiveAt: values.effectiveAt,
description: values.description,
...(values.reference ? { reference: values.reference } : {}),
status: values.status,
entries: Array.from({ length: lines }, (_, i) => ({
accountId: values[`account-${i}`],
direction: values[`direction-${i}`],
amountMinor: values[`amount-${i}`],
})),
})
if (!result.success) throw new Error(labels.invalid)
setPending(result.data)
}}
>
<TextField name="effectiveAt" label={labels.effectiveAt} required />
<TextField name="description" label={labels.description} required maxLength={200} />
<TextField name="reference" label={labels.reference} maxLength={200} />
<SelectField
name="status"
label={labels.status}
defaultValue="pending"
options={options(labels, ["pending", "posted"])}
/>
{Array.from({ length: lines }, (_, i) => (
<PageSection key={i} title={`${labels.entries} ${i + 1}`}>
<TextField name={`account-${i}`} label={labels.account} required />
<SelectField
name={`direction-${i}`}
label={labels.normalBalance}
defaultValue={i === 0 ? "debit" : "credit"}
options={options(labels, ["debit", "credit"])}
/>
<TextField
name={`amount-${i}`}
label={labels.amountMinor}
required
pattern="[1-9][0-9]{0,18}"
inputMode="numeric"
/>
</PageSection>
))}
<Button type="button" disabled={lines >= 200 || fenced} onClick={() => setLines((n) => n + 1)}>
{labels.addLine}
</Button>
<Button type="button" disabled={lines <= 2} onClick={() => setLines((n) => n - 1)}>
{labels.removeLine}
</Button>
</FormLayout>
<ConfirmDialog
describeError={(error) => ledgerFailure(error, labels)}
open={pending !== undefined}
onOpenChange={(open) => {
if (!open) setPending(undefined)
}}
title={labels.confirm}
description={labels.confirmDescription}
confirmLabel={labels.record}
cancelLabel={labels.cancel}
onConfirm={async () => {
if (!pending || fenced) throw new Error(labels.drift)
const saved = await write.mutate({
org,
terms: pending,
confirmed: true,
requestId: identity.current({ org, terms: pending }),
})
navigation.navigate(`${base}/transactions/${saved.transactionId}`)
}}
>
<Page>
{pending ? (
<>
<FactList
facts={[
{ label: labels.effectiveAt, value: pending.effectiveAt },
{ label: labels.description, value: pending.description },
{ label: labels.reference, value: pending.reference },
{ label: labels.status, value: labels[pending.status] },
]}
/>
<ResourceList
label={labels.entries}
data={pending.entries.map((entry, index) => ({ ...entry, id: String(index) }))}
columns={[
{ id: "accountId", header: labels.account },
{ id: "direction", header: labels.normalBalance, cell: (row) => labels[row.direction] },
{ id: "amountMinor", header: labels.amountMinor },
]}
empty={{ title: labels.empty }}
/>
</>
) : null}
</Page>
</ConfirmDialog>
</PageSection>
)
}
export function Transition({
org,
transaction,
action,
labels,
base,
fenced,
}: {
org: string
transaction: TransactionView
action: "post" | "void" | "reverse"
labels: LedgerLabels
base: string
fenced: boolean
}) {
const navigation = usePageNavigation()
const write = useWrite<Record<string, unknown>, TransactionView>(`ledger.transactions.${action}`)
const identity = useRef(retryIdentity())
const payload = {
org,
transactionId: transaction.transactionId,
expectedVersion: transaction.revision,
confirmed: true,
}
return (
<ConfirmDialog
describeError={(error) => ledgerFailure(error, labels)}
title={labels.confirm}
description={labels.confirmDescription}
confirmLabel={labels[action]}
cancelLabel={labels.cancel}
trigger={<Button disabled={fenced && action !== "void"}>{labels[action]}</Button>}
onConfirm={async () => {
if (fenced && action !== "void") throw new Error(labels.drift)
const saved = await write.mutate({ ...payload, requestId: identity.current(payload) })
navigation.navigate(`${base}/transactions/${saved.transactionId}`)
}}
>
<Page>
<FactList
facts={[
{ label: labels.transaction, value: transaction.transactionId },
{ label: labels.revision, value: transaction.revision },
{ label: labels.status, value: labels[transaction.status] },
]}
/>
</Page>
</ConfirmDialog>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { businessRefusalNotices } from "@conusai-kit/business/refusals"
import type { Locale } from "@conusai-kit/i18n"
import { en as keys } from "../i18n/en.js"
export type LedgerMessages = { [K in keyof typeof keys]: string }
/**
* The module's messages, and every Ledger refusal's problem code with the sentence a person reads for it
* (the kit's, in the loader's locale; a revision or idempotency conflict in this module's own words). Plain
* strings, so the loader hands them to the forms, which read a refusal with `serviceNotice`.
*/
export type LedgerLabels = LedgerMessages & { readonly refusals: Readonly<Record<string, string>> }
export function ledgerLabels(t: (key: string) => string, locale: Locale): LedgerLabels {
const messages = Object.fromEntries(Object.keys(keys).map((key) => [key, t(key)])) as LedgerMessages
return {
...messages,
refusals: businessRefusalNotices(locale, {
revision_conflict: messages.revisionConflict,
idempotency_conflict: messages.idempotencyConflict,
}),
}
}
+243
View File
@@ -0,0 +1,243 @@
import { businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
FactList,
Page,
PageHeader,
PageSection,
type ResourceColumn,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import * as c from "../contract/index.js"
import { AccountForm, RecordForm, Transition } from "./forms.js"
import { type LedgerLabels, ledgerLabels } from "./labels.js"
import { LedgerList } from "./resource.js"
type Loaded = { labels: LedgerLabels; verification: c.VerificationView } & (
| {
kind: "list"
accounts: { rows: c.AccountView[]; after?: string | undefined }
transactions: { rows: c.TransactionView[]; after?: string | undefined }
}
| {
kind: "account"
account: c.AccountView
balances: { rows: c.BalanceView[]; after?: string | undefined }
entries: { rows: c.EntryView[]; after?: string | undefined }
}
| { kind: "transaction"; transaction: c.TransactionView }
)
type Data = Loaded | { kind: "state"; labels: LedgerLabels; notice: ServiceNotice }
export type LedgerPage = ProjectPage<TransportCaller, I18n, Container, Data>
export const ledgerPage: LedgerPage = projectPage({
async load({ caller, slugs, segments, i18n }): Promise<Data> {
const labels = ledgerLabels((key) => i18n.message(`ledger.${key}`), i18n.locale)
const input = { org: slugs.org }
const [kind, id, ...rest] = segments
if (rest.length || (kind !== undefined && ((kind !== "accounts" && kind !== "transactions") || !id)))
throw notFound()
try {
const verification = await caller.call(c.verify, input)
if (kind === "accounts" && id) {
const [account, balances, entries] = await Promise.all([
caller.call(c.account, { ...input, accountId: id }),
caller.call(c.balances, { ...input, accountId: id }),
caller.call(c.entries, { ...input, accountId: id }),
])
return { kind: "account", labels, verification, account, balances, entries }
}
if (kind === "transactions" && id)
return {
kind: "transaction",
labels,
verification,
transaction: await caller.call(c.transaction, { ...input, transactionId: id }),
}
const [accounts, transactions] = await Promise.all([
caller.call(c.accounts, input),
caller.call(c.transactions, input),
])
return { kind: "list", labels, verification, accounts, transactions }
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", labels, notice }
}
},
View({ slugs, data }) {
if (data.kind === "state") return <ServiceStatePage title={data.labels.title} notice={data.notice} />
const { labels: l, verification } = data
const base = `/${slugs.org}/${slugs.workspace}/${slugs.project}/ledger`
const org = slugs.org
const fenced = verification.drift.length > 0
const entryColumns: ResourceColumn<c.EntryView>[] = [
{ id: "accountId", header: l.account },
{ id: "direction", header: l.normalBalance, cell: (row: c.EntryView) => l[row.direction] },
{ id: "amountMinor", header: l.amountMinor },
{ id: "currency", header: l.currency },
{ id: "transactionId", header: l.transaction },
]
return (
<Page>
<PageHeader
title={l.title}
description={l.intro}
breadcrumb={data.kind === "list" ? undefined : <a href={base}>{l.back}</a>}
/>
<PageSection title={l.verifier}>
<FactList
facts={[
{ label: l.status, value: fenced ? l.drift : l.healthy },
{ label: l.checked, value: String(verification.checkedAccounts) },
]}
/>
</PageSection>
{data.kind === "list" ? (
<>
<PageSection title={l.accounts}>
<LedgerList
seed={data.accounts}
procedure="ledger.accounts.list"
input={{ org }}
rowId={(row) => row.accountId}
href={(row) => `${base}/accounts/${row.accountId}`}
labels={l}
title={l.accounts}
columns={[
{ id: "code", header: l.code },
{ id: "name", header: l.name },
{ id: "currency", header: l.currency },
{ id: "status", header: l.status, cell: (row) => l[row.status] },
]}
/>
</PageSection>
<Can permission="ledger.accounts.manage">
<AccountForm org={org} labels={l} base={base} />
</Can>
<PageSection title={l.transactions}>
<LedgerList
seed={data.transactions}
procedure="ledger.transactions.list"
input={{ org }}
rowId={(row) => row.transactionId}
href={(row) => `${base}/transactions/${row.transactionId}`}
labels={l}
title={l.transactions}
columns={[
{ id: "transactionId", header: l.transaction },
{ id: "description", header: l.description },
{ id: "status", header: l.status, cell: (row) => l[row.status] },
{ id: "effectiveAt", header: l.effectiveAt },
]}
/>
</PageSection>
<Can permission="ledger.transactions.write">
<RecordForm org={org} labels={l} base={base} fenced={fenced} />
</Can>
</>
) : data.kind === "account" ? (
<>
<FactList
facts={[
{ label: l.account, value: data.account.accountId },
{ label: l.code, value: data.account.code },
{ label: l.name, value: data.account.name },
{ label: l.currency, value: data.account.currency },
{ label: l.type, value: l[data.account.type] },
{ label: l.normalBalance, value: l[data.account.normalBalance] },
{ label: l.allowNegative, value: data.account.allowNegative ? l.yes : l.no },
{ label: l.status, value: l[data.account.status] },
{ label: l.revision, value: data.account.revision },
]}
/>
<PageSection title={l.balances}>
<LedgerList
seed={data.balances}
procedure="ledger.balances.list"
input={{ org, accountId: data.account.accountId }}
rowId={(row) => row.accountId}
labels={l}
title={l.balances}
columns={[
{ id: "currency", header: l.currency },
{ id: "posted", header: l.postedBalance },
{ id: "pending", header: l.pendingBalance },
{ id: "available", header: l.availableBalance },
]}
/>
</PageSection>
<PageSection title={l.entries}>
<LedgerList
seed={data.entries}
procedure="ledger.entries.list"
input={{ org, accountId: data.account.accountId }}
rowId={(row) => row.entryId}
href={(row) => `${base}/transactions/${row.transactionId}`}
labels={l}
title={l.entries}
columns={entryColumns}
/>
</PageSection>
<Can permission="ledger.accounts.manage">
<AccountForm org={org} account={data.account} labels={l} base={base} />
</Can>
</>
) : (
<>
<FactList
facts={[
{ label: l.transaction, value: data.transaction.transactionId },
{ label: l.description, value: data.transaction.description },
{ label: l.reference, value: data.transaction.reference },
{ label: l.effectiveAt, value: data.transaction.effectiveAt },
{ label: l.status, value: l[data.transaction.status] },
{ label: l.revision, value: data.transaction.revision },
{ label: l.reverses, value: data.transaction.reverses },
]}
/>
<LedgerList
seed={{ rows: data.transaction.entries }}
procedure="ledger.transactions.get"
input={{ org, transactionId: data.transaction.transactionId }}
rowId={(row) => row.entryId}
labels={l}
title={l.entries}
columns={entryColumns}
/>
{data.transaction.status === "pending" ? (
<Can permission="ledger.transactions.write">
{(["post", "void"] as const).map((action) => (
<Transition
key={action}
org={org}
transaction={data.transaction}
action={action}
labels={l}
base={base}
fenced={fenced}
/>
))}
</Can>
) : data.transaction.status === "posted" ? (
<Can permission="ledger.transactions.reverse">
<Transition
org={org}
transaction={data.transaction}
action="reverse"
labels={l}
base={base}
fenced={fenced}
/>
</Can>
) : null}
</>
)}
</Page>
)
},
})
+67
View File
@@ -0,0 +1,67 @@
"use client"
import { useDataClient } from "@conusai-kit/ui/data"
import { type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import { useState } from "react"
import type { LedgerLabels } from "./labels.js"
/** Cursor reads happen only on user action; no local financial persistence. */
export function LedgerList<T extends object>({
seed,
procedure,
input,
columns,
rowId,
href,
labels,
title,
}: {
seed: { rows: T[]; after?: string | undefined }
procedure: string
input: Record<string, unknown>
columns: ResourceColumn<T>[]
rowId: (row: T) => string
href?: ((row: T) => string) | undefined
labels: LedgerLabels
title: string
}) {
const client = useDataClient()
const [page, setPage] = useState(seed)
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(false)
return (
<>
<ResourceList
label={title}
data={page.rows}
columns={columns}
rowId={rowId}
rowHref={href}
rowLabel={rowId}
rowLinkLabel={() => labels.open}
empty={{ title: labels.empty }}
/>
{failed ? <p role="alert">{labels.unavailable}</p> : null}
{page.after ? (
<Button
disabled={busy}
onClick={async () => {
setBusy(true)
setFailed(false)
try {
const next = (await client.call<typeof seed>(procedure, { ...input, after: page.after })).value
setPage({ ...next, rows: [...page.rows, ...next.rows] })
} catch {
setFailed(true)
} finally {
setBusy(false)
}
}}
>
{labels.more}
</Button>
) : null}
</>
)
}
+13
View File
@@ -0,0 +1,13 @@
/** Component-memory only. Identical retries retain their key; every changed submission gets a new one. */
export function retryIdentity() {
let previous: string | undefined
let key: string
return (payload: unknown): string => {
const next = JSON.stringify(payload)
if (next !== previous) {
previous = next
key = crypto.randomUUID()
}
return key
}
}