Files
TallyNote/shared/contracts.ts
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

122 lines
3.8 KiB
TypeScript

import { z } from "zod";
export const expenseStatusSchema = z.enum(["unreimbursed", "reimbursed"]);
export type ExpenseStatus = z.infer<typeof expenseStatusSchema>;
export const attachmentKindSchema = z.enum(["payment_proof", "invoice"]);
export type AttachmentKind = z.infer<typeof attachmentKindSchema>;
export const MAX_AMOUNT_CENTS = 999_999_999_999;
const expenseFieldsSchema = z.object({
paidAt: z.string().datetime({ offset: true }),
amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/).refine((value) => {
try { amountToCents(value); return true; } catch { return false; }
}, "金额超出允许范围"),
note: z.string().trim().max(2000).default(""),
}).strict();
const invoiceMissingReasonField = z.string().trim().max(500).nullable().optional();
export const expenseInputSchema = expenseFieldsSchema.extend({
invoiceMissingReason: invoiceMissingReasonField.default(null),
}).strict();
export const expenseUpdateSchema = expenseFieldsSchema.extend({
invoiceMissingReason: invoiceMissingReasonField,
version: z.number().int().positive(),
}).strict();
export const statusUpdateSchema = z.object({
status: expenseStatusSchema,
version: z.number().int().positive(),
}).strict();
export const versionSchema = z.object({
version: z.number().int().positive(),
}).strict();
export const attachmentDeleteSchema = versionSchema.extend({
// Only needed when removing the final invoice. The server preserves an
// existing reason when this field is omitted.
invoiceMissingReason: invoiceMissingReasonField,
}).strict();
export const permanentDeleteSchema = versionSchema.extend({
password: z.string().min(1).max(512),
});
export const loginSchema = z.object({
username: z.string().trim().min(1).max(128),
password: z.string().min(1).max(512),
}).strict();
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(512),
newPassword: z.string().min(12).max(128),
}).strict();
export const createAdminSchema = z.object({
username: z.string().trim().min(3).max(64),
displayName: z.string().trim().min(1).max(80),
}).strict();
export const adminStatusSchema = z.object({
status: z.enum(["active", "disabled"]),
version: z.number().int().positive(),
}).strict();
export const exportRequestSchema = z.union([
z.object({
ids: z.array(z.string().uuid()).min(1).max(5000).refine((ids) => new Set(ids).size === ids.length, "记录不能重复"),
includeManifest: z.boolean().default(false),
}).strict(),
z.object({
month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/),
status: expenseStatusSchema,
query: z.string().max(200).default(""),
missingInvoice: z.boolean().default(false),
includeManifest: z.boolean().default(false),
}).strict(),
]);
export const updateJobStatusSchema = z.enum([
"queued",
"downloading",
"verifying",
"staged",
"backing_up",
"applying",
"completed",
"failed",
"cancelled",
]);
export type UpdateJobStatus = z.infer<typeof updateJobStatusSchema>;
/** The browser never supplies release URLs or filesystem paths. */
export const updateApplySchema = z.object({
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/),
confirm: z.literal(true),
}).strict();
export type ApiError = {
error: {
code: string;
message: string;
requestId: string;
details?: unknown;
};
};
export function amountToCents(value: string): number {
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value);
if (!match) throw new Error("INVALID_AMOUNT");
const cents = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
if (!Number.isSafeInteger(cents) || cents <= 0 || cents > MAX_AMOUNT_CENTS) throw new Error("INVALID_AMOUNT");
return cents;
}
export function centsToAmount(cents: number): string {
return (cents / 100).toFixed(2);
}