@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 : {} }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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}`
|
||||
@@ -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 } })
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user