import { z } from "zod"; export const expenseStatusSchema = z.enum(["unreimbursed", "reimbursed"]); export type ExpenseStatus = z.infer; export const attachmentKindSchema = z.enum(["payment_proof", "invoice"]); export type AttachmentKind = z.infer; 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; export const updateOperationSchema = z.enum(["download", "apply"]); export type UpdateOperation = z.infer; /** The browser never supplies release URLs or filesystem paths. */ export const updateApplySchema = z.object({ // Keep the browser contract aligned with server/update.ts' SemVer parser, // including optional prerelease and build metadata segments. version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), confirm: z.literal(true), jobId: z.string().uuid().optional(), }).strict(); export const updateDownloadSchema = z.object({ version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), confirm: z.boolean().default(true).optional(), }).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); }