/**
* @fileoverview Reconciliation analysis output schemas for YNAB MCP server.
* Defines Zod validation schemas for account reconciliation including transaction matching,
* balance verification, insights, actionable recommendations, and optional execution results.
*
* @see src/tools/reconciliation/index.ts - Main reconciliation handler (lines 147-362)
* @see src/tools/reconciliation/types.ts - Type definitions for reconciliation entities
* @see src/tools/reconciliation/executor.ts - Execution engine for auto-applying recommendations
* @see src/tools/reconciliation/outputBuilder.ts - Adapter for building reconciliation payloads
* @see src/utils/money.ts - Money value formatting utilities
*
* @example
* // Human-readable narrative only (default)
* {
* human: "Successfully matched 45 of 50 bank transactions. Found 3 suggested matches..."
* }
*
* @example
* // With structured data (include_structured_data=true)
* {
* human: "Successfully matched 45 of 50 bank transactions...",
* structured: {
* success: true,
* phase: "analysis",
* summary: {
* statement_date_range: "2025-10-01 to 2025-10-31",
* bank_transactions_count: 50,
* ynab_transactions_count: 52,
* auto_matched: 45,
* suggested_matches: 3,
* unmatched_bank: 2,
* unmatched_ynab: 4,
* discrepancy: { amount: -25.50, currency: "USD", formatted: "-$25.50" }
* },
* balance_info: {
* current_cleared: { amount: 1250.00, currency: "USD", formatted: "$1,250.00" },
* target_statement: { amount: 1275.50, currency: "USD", formatted: "$1,275.50" },
* discrepancy: { amount: -25.50, currency: "USD", formatted: "-$25.50" },
* on_track: false
* },
* recommendations: [...],
* audit_metadata: { data_freshness: "real-time", ... }
* }
* }
*/
import { z } from "zod";
// ============================================================================
// NESTED SCHEMAS FOR COMPOSITION
// ============================================================================
/**
* Structured monetary value with formatting.
* Used throughout reconciliation for balances and discrepancies.
*
* @see src/utils/money.ts - MoneyValue type definition
*/
export const MoneyValueSchema = z.object({
amount: z.number().finite(),
currency: z.string(),
formatted: z.string(),
memo: z.string().optional(),
});
export type MoneyValue = z.infer<typeof MoneyValueSchema>;
/**
* ISO 8601 date string with calendar validation.
* Ensures dates are in YYYY-MM-DD format and represent valid calendar dates.
*
* @remarks
* This schema validates both format (regex) and calendar validity (refinement).
* It catches invalid dates like "2025-02-31" which Date.parse might coerce to "2025-03-03".
*
* @example
* ```typescript
* IsoDateWithCalendarValidationSchema.parse("2025-01-15"); // ✓ Valid
* IsoDateWithCalendarValidationSchema.parse("2025-02-31"); // ✗ Invalid (no Feb 31st)
* IsoDateWithCalendarValidationSchema.parse("2025-13-01"); // ✗ Invalid (month > 12)
* IsoDateWithCalendarValidationSchema.parse("2025-1-15"); // ✗ Invalid (must be zero-padded)
* ```
*/
export const IsoDateWithCalendarValidationSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in ISO format (YYYY-MM-DD)")
.refine(
(dateStr) => {
const parsed = Date.parse(dateStr);
if (Number.isNaN(parsed)) {
return false;
}
// Verify that the parsed date components match the original string
// This catches cases like "2025-02-31" which Date.parse might coerce to "2025-03-03"
const date = new Date(parsed);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const reconstructed = `${year}-${month}-${day}`;
return reconstructed === dateStr;
},
{
message:
"Invalid calendar date (e.g., month must be 01-12, day must be valid for the month)",
},
);
/**
* Bank transaction from CSV import (output format with money formatting).
* Represents a single transaction from the user's bank statement.
*
* @remarks
* The adapter adds `amount_money` field with formatted money value.
* Date field is always serialized as an ISO 8601 date string (YYYY-MM-DD) in tool output,
* even though the internal type may use Date objects during analysis.
*
* @see src/tools/reconciliation/types.ts - BankTransaction interface (internal type)
* @see src/tools/reconciliation/outputBuilder.ts:77-80 - toBankTransactionView function
*/
export const BankTransactionSchema = z.object({
id: z.string().uuid(),
date: IsoDateWithCalendarValidationSchema,
amount: z.number(),
payee: z.string(),
memo: z.string().optional(),
original_csv_row: z.number(),
amount_money: MoneyValueSchema, // Added by adapter
});
export type BankTransaction = z.infer<typeof BankTransactionSchema>;
/**
* Simplified YNAB transaction for reconciliation matching (output format with money formatting).
* Contains essential fields for transaction comparison.
*
* @remarks
* The adapter adds `amount_money` field with formatted money value.
* Date field is always serialized as an ISO 8601 date string (YYYY-MM-DD) in tool output,
* even though the internal type may use Date objects during analysis.
*
* @see src/tools/reconciliation/types.ts - YNABTransaction interface (internal type)
* @see src/tools/reconciliation/outputBuilder.ts:82-85 - toYNABTransactionView function
*/
export const YNABTransactionSimpleSchema = z.object({
id: z.string(),
date: IsoDateWithCalendarValidationSchema,
amount: z.number(),
payee_name: z.string().nullable(),
category_name: z.string().nullable(),
cleared: z.enum(["cleared", "uncleared", "reconciled"]),
approved: z.boolean(),
memo: z.string().nullable().optional(),
amount_money: MoneyValueSchema, // Added by adapter
});
export type YNABTransactionSimple = z.infer<typeof YNABTransactionSimpleSchema>;
/**
* Potential match candidate with confidence scoring.
* Represents a possible match between bank and YNAB transactions.
*
* @see src/tools/reconciliation/types.ts - MatchCandidate interface
*/
export const MatchCandidateSchema = z.object({
ynab_transaction: YNABTransactionSimpleSchema,
confidence: z.number().min(0).max(100),
match_reason: z.string(),
explanation: z.string(),
});
export type MatchCandidate = z.infer<typeof MatchCandidateSchema>;
/**
* Derives the confidence enum value from a numeric confidence score.
* Used to enforce consistency between confidence and confidence_score fields.
*
* @param score - Numeric confidence score (0-100)
* @returns Corresponding confidence level enum value
*
* @remarks
* Export this function to use in application logic when constructing
* TransactionMatch objects to ensure consistency between the two fields.
*
* Thresholds:
* - 'high': score >= 90
* - 'medium': score >= 60
* - 'low': score >= 1
* - 'none': score === 0
*
* @example
* ```typescript
* const confidenceScore = 85;
* const transactionMatch = {
* // ... other fields
* confidence: deriveConfidenceFromScore(confidenceScore),
* confidence_score: confidenceScore,
* };
* ```
*/
export function deriveConfidenceFromScore(
score: number,
): "high" | "medium" | "low" | "none" {
if (score >= 90) return "high";
if (score >= 60) return "medium";
if (score >= 1) return "low";
return "none";
}
/**
* Transaction match result with confidence and candidates.
* Links a bank transaction to a YNAB transaction or suggests candidates.
*
* @see src/tools/reconciliation/types.ts - TransactionMatch interface
*
* @remarks
* This schema contains both `confidence` (enum) and `confidence_score` (0-100)
* for backwards compatibility. A validation rule enforces consistency between
* the two fields by deriving the expected enum value from the numeric score
* and rejecting mismatches.
*
* Confidence thresholds:
* - 'high': confidence_score >= 90
* - 'medium': confidence_score >= 60
* - 'low': confidence_score >= 1
* - 'none': confidence_score === 0
*/
export const TransactionMatchSchema = z
.object({
bank_transaction: BankTransactionSchema,
ynab_transaction: YNABTransactionSimpleSchema.optional(),
candidates: z.array(MatchCandidateSchema).optional(),
confidence: z.enum(["high", "medium", "low", "none"]),
confidence_score: z.number().min(0).max(100),
match_reason: z.string(),
top_confidence: z.number().optional(),
action_hint: z.string().optional(),
recommendation: z.string().optional(),
})
.refine(
(data) => {
const expectedConfidence = deriveConfidenceFromScore(
data.confidence_score,
);
return data.confidence === expectedConfidence;
},
{
message:
"Confidence mismatch: confidence enum does not match confidence_score",
path: ["confidence"],
},
);
export type TransactionMatch = z.infer<typeof TransactionMatchSchema>;
/**
* Balance reconciliation status.
* Compares current account balances to target statement balance.
*
* @see src/tools/reconciliation/types.ts - BalanceInfo interface
*/
export const BalanceInfoSchema = z.object({
current_cleared: MoneyValueSchema,
current_uncleared: MoneyValueSchema,
current_total: MoneyValueSchema,
target_statement: MoneyValueSchema,
discrepancy: MoneyValueSchema,
on_track: z.boolean(),
});
export type BalanceInfo = z.infer<typeof BalanceInfoSchema>;
/**
* Reconciliation summary statistics.
* High-level overview of matching results and balance status.
*
* @see src/tools/reconciliation/types.ts - ReconciliationSummary interface
*/
export const ReconciliationSummarySchema = z.object({
statement_date_range: z.string(),
bank_transactions_count: z.number(),
ynab_transactions_count: z.number(),
auto_matched: z.number(),
suggested_matches: z.number(),
unmatched_bank: z.number(),
unmatched_ynab: z.number(),
current_cleared_balance: MoneyValueSchema,
target_statement_balance: MoneyValueSchema,
discrepancy: MoneyValueSchema,
discrepancy_explanation: z.string(),
});
export type ReconciliationSummary = z.infer<typeof ReconciliationSummarySchema>;
/**
* Reconciliation analysis insight.
* Highlights patterns, anomalies, or issues discovered during analysis.
*
* @see src/tools/reconciliation/types.ts - ReconciliationInsight interface
*/
export const ReconciliationInsightSchema = z.object({
id: z.string(),
type: z.enum(["repeat_amount", "near_match", "anomaly"]),
severity: z.enum(["info", "warning", "critical"]),
title: z.string(),
description: z.string(),
evidence: z.record(z.string(), z.unknown()).optional(),
});
export type ReconciliationInsight = z.infer<typeof ReconciliationInsightSchema>;
/**
* Actionable recommendation discriminated union.
* Suggests specific actions to resolve discrepancies (create, update, review).
*
* @see src/tools/reconciliation/types.ts - ActionableRecommendation union type
*/
export const ActionableRecommendationSchema = z.discriminatedUnion(
"action_type",
[
// Create transaction recommendation
z.object({
id: z.string(),
action_type: z.literal("create_transaction"),
priority: z.enum(["high", "medium", "low"]),
confidence: z.number().min(0).max(1),
message: z.string(),
reason: z.string(),
estimated_impact: MoneyValueSchema,
account_id: z.string(),
source_insight_id: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
parameters: z.object({
account_id: z.string(),
date: IsoDateWithCalendarValidationSchema,
amount: z.number(),
payee_name: z.string(),
memo: z.string().optional(),
cleared: z.enum(["cleared", "uncleared"]),
approved: z.boolean(),
category_id: z.string().optional(),
}),
}),
// Update cleared status recommendation
z.object({
id: z.string(),
action_type: z.literal("update_cleared"),
priority: z.enum(["high", "medium", "low"]),
confidence: z.number().min(0).max(1),
message: z.string(),
reason: z.string(),
estimated_impact: MoneyValueSchema,
account_id: z.string(),
source_insight_id: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
parameters: z.object({
transaction_id: z.string(),
cleared: z.enum(["cleared", "uncleared", "reconciled"]),
}),
}),
// Review duplicate recommendation
z.object({
id: z.string(),
action_type: z.literal("review_duplicate"),
priority: z.enum(["high", "medium", "low"]),
confidence: z.number().min(0).max(1),
message: z.string(),
reason: z.string(),
estimated_impact: MoneyValueSchema,
account_id: z.string(),
source_insight_id: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
parameters: z.object({
candidate_ids: z.array(z.string()),
bank_transaction: BankTransactionSchema,
suggested_match_id: z.string().optional(),
}),
}),
// Manual review recommendation
z.object({
id: z.string(),
action_type: z.literal("manual_review"),
priority: z.enum(["high", "medium", "low"]),
confidence: z.number().min(0).max(1),
message: z.string(),
reason: z.string(),
estimated_impact: MoneyValueSchema,
account_id: z.string(),
source_insight_id: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
parameters: z.object({
issue_type: z.string(),
related_transactions: z.array(z.string()),
}),
}),
],
);
export type ActionableRecommendation = z.infer<
typeof ActionableRecommendationSchema
>;
/**
* Account balance snapshot with money formatting.
* Used in execution result to show before/after balance states.
*
* @see src/tools/reconciliation/outputBuilder.ts:138-142 - convertAccountSnapshot function
*/
export const AccountSnapshotSchema = z.object({
balance: MoneyValueSchema,
cleared_balance: MoneyValueSchema,
uncleared_balance: MoneyValueSchema,
});
export type AccountSnapshot = z.infer<typeof AccountSnapshotSchema>;
/**
* Transaction data shapes for different execution action types.
* Provides stronger typing than generic Record<string, unknown>.
*/
/**
* Created transaction from YNAB API response.
* Used when a transaction is successfully created.
*/
export const CreatedTransactionSchema = z
.object({
id: z.string(),
date: z.string(),
amount: z.number(),
memo: z.string().nullable().optional(),
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional(),
approved: z.boolean().optional(),
payee_name: z.string().nullable().optional(),
category_name: z.string().nullable().optional(),
import_id: z.string().nullable().optional(),
})
.passthrough(); // Allow additional YNAB API fields
/**
* Transaction creation payload.
* Used when documenting what would be created (dry run) or what failed to create.
*/
export const TransactionCreationPayloadSchema = z.object({
account_id: z.string(),
date: z.string(),
amount: z.number(),
payee_name: z.string().optional(),
memo: z.string().optional(),
cleared: z.enum(["cleared", "uncleared"]).optional(),
approved: z.boolean().optional(),
import_id: z.string().optional(),
});
/**
* Transaction update payload.
* Used when documenting status or date changes.
*/
export const TransactionUpdatePayloadSchema = z.object({
transaction_id: z.string(),
new_date: z.string().optional(),
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional(),
});
/**
* Duplicate detection payload.
* Used when a transaction creation is skipped due to duplicate detection.
*/
export const DuplicateDetectionPayloadSchema = z.object({
transaction_id: z.string().nullable(),
import_id: z.string().optional(),
});
/**
* Execution action record with correlation tracking.
* Documents individual actions taken during reconciliation execution.
*
* Uses discriminated union based on action type for stronger type safety.
* Each action type has its own transaction payload schema.
*
* @see src/tools/reconciliation/executor.ts:29-36 - ExecutionActionRecord interface
* @see src/tools/reconciliation/executor.ts:226-238 - create_transaction action
* @see src/tools/reconciliation/executor.ts:278-290 - create_transaction_failed action
* @see src/tools/reconciliation/executor.ts:339-351 - create_transaction_duplicate action
* @see src/tools/reconciliation/executor.ts:472-480 - update_transaction action (dry run)
* @see src/tools/reconciliation/executor.ts:515-520 - update_transaction action (real)
*/
export const ExecutionActionRecordSchema = z.discriminatedUnion("type", [
// Successful transaction creation
z.object({
type: z.literal("create_transaction"),
transaction: CreatedTransactionSchema.nullable(),
reason: z.string(),
bulk_chunk_index: z.number().optional(),
correlation_key: z.string().optional(),
}),
// Failed transaction creation
z.object({
type: z.literal("create_transaction_failed"),
transaction: TransactionCreationPayloadSchema,
reason: z.string(),
bulk_chunk_index: z.number().optional(),
correlation_key: z.string().optional(),
}),
// Duplicate transaction detected
z.object({
type: z.literal("create_transaction_duplicate"),
transaction: DuplicateDetectionPayloadSchema,
reason: z.string(),
bulk_chunk_index: z.number(),
correlation_key: z.string().optional(),
duplicate: z.literal(true),
}),
// Transaction update (status/date change)
z.object({
type: z.literal("update_transaction"),
transaction: z.union([
CreatedTransactionSchema.nullable(), // Real execution
TransactionUpdatePayloadSchema, // Dry run
]),
reason: z.string(),
}),
// Balance alignment checkpoint
z.object({
type: z.literal("balance_checkpoint"),
transaction: z.null(),
reason: z.string(),
}),
// Bulk create fallback to sequential
z.object({
type: z.literal("bulk_create_fallback"),
transaction: z.null(),
reason: z.string(),
bulk_chunk_index: z.number(),
}),
]);
export type ExecutionActionRecord = z.infer<typeof ExecutionActionRecordSchema>;
/**
* Execution summary statistics.
* High-level overview of reconciliation execution results.
*
* @see src/tools/reconciliation/executor.ts:38-48 - ExecutionSummary interface
*/
export const ExecutionSummarySchema = z.object({
bank_transactions_count: z.number(),
ynab_transactions_count: z.number(),
matches_found: z.number(),
missing_in_ynab: z.number(),
missing_in_bank: z.number(),
transactions_created: z.number(),
transactions_updated: z.number(),
dates_adjusted: z.number(),
dry_run: z.boolean(),
});
export type ExecutionSummary = z.infer<typeof ExecutionSummarySchema>;
/**
* Bulk operation metrics for reconciliation transaction creation.
* Tracks performance and correlation behavior during bulk transaction operations.
*
* @remarks
* Note on failure counters:
* - `transaction_failures` is the canonical counter for per-transaction failures
* - `failed_transactions` is maintained for backward compatibility and should always
* mirror `transaction_failures` rather than represent an independent count
*
* @see src/tools/reconciliation/executor.ts:50-68 - BulkOperationDetails interface
*/
export const BulkOperationDetailsSchema = z
.object({
chunks_processed: z.number(),
bulk_successes: z.number(),
sequential_fallbacks: z.number(),
duplicates_detected: z.number(),
failed_transactions: z.number(),
bulk_chunk_failures: z.number(),
transaction_failures: z.number(),
sequential_attempts: z.number().optional(),
})
.refine((data) => data.failed_transactions === data.transaction_failures, {
message: "failed_transactions must equal transaction_failures",
});
export type BulkOperationDetails = z.infer<typeof BulkOperationDetailsSchema>;
/**
* Execution result (present if auto-execution enabled).
* Documents actions taken and resulting balance changes.
*
* @remarks
* This schema matches the actual payload shape built by convertExecution()
* in outputBuilder.ts, which differs from the legacy ExecutionResult interface.
* The adapter converts the ExecutionResult to a structured payload with formatted
* money values and nested balance snapshots.
*
* @see src/tools/reconciliation/outputBuilder.ts:199-232 - convertExecution function
* @see src/tools/reconciliation/executor.ts:69-79 - ExecutionResult interface
*/
export const ExecutionResultSchema = z.object({
summary: ExecutionSummarySchema,
account_balance: z.object({
before: AccountSnapshotSchema,
after: AccountSnapshotSchema,
}),
actions_taken: z.array(ExecutionActionRecordSchema),
recommendations: z.array(z.string()),
balance_reconciliation: z.unknown().optional(),
bulk_operation_details: BulkOperationDetailsSchema.optional(),
});
export type ExecutionResult = z.infer<typeof ExecutionResultSchema>;
/**
* Data freshness audit metadata.
* Documents data sources, cache status, and staleness.
*
* @remarks
* This schema uses `.catchall(z.unknown())` to support forward-compatible extensions
* through the AdapterOptions.auditMetadata pattern. Known keys are typed explicitly,
* but additional fields can be passed through without validation errors.
*
* @see src/tools/reconciliation/index.ts:272-284 - Audit metadata construction
* @see src/tools/reconciliation/outputBuilder.ts:19-25 - AdapterOptions interface with extensible auditMetadata
*/
export const AuditMetadataSchema = z
.object({
data_freshness: z.string(),
data_source: z.string(),
server_knowledge: z.number().optional(),
fetched_at: z.string(),
accounts_count: z.number().optional(),
transactions_count: z.number().optional(),
cache_status: z.object({
accounts_cached: z.boolean(),
transactions_cached: z.boolean(),
delta_merge_applied: z.boolean(),
}),
})
.catchall(z.unknown());
export type AuditMetadata = z.infer<typeof AuditMetadataSchema>;
// ============================================================================
// MAIN OUTPUT SCHEMA
// ============================================================================
/**
* Complete reconciliation analysis output.
* Discriminated union for human-only vs human+structured response modes.
*
* @see src/tools/reconciliation/index.ts:147-362 - Main handler
* @see src/tools/reconciliation/outputBuilder.ts - buildReconciliationPayload function
*
* @example
* // Human-readable narrative only (default)
* {
* human: "Successfully matched 45 of 50 bank transactions. Current cleared balance..."
* }
*
* @example
* // With structured data (include_structured_data=true)
* {
* human: "Successfully matched 45 of 50 bank transactions...",
* structured: {
* success: true,
* phase: "analysis",
* summary: {
* statement_date_range: "2025-10-01 to 2025-10-31",
* bank_transactions_count: 50,
* auto_matched: 45,
* suggested_matches: 3,
* unmatched_bank: 2,
* discrepancy: { amount: -25.50, currency: "USD", formatted: "-$25.50" }
* },
* auto_matches: [...],
* suggested_matches: [...],
* unmatched_bank: [...],
* unmatched_ynab: [...],
* balance_info: {
* current_cleared: { amount: 1250.00, currency: "USD", formatted: "$1,250.00" },
* target_statement: { amount: 1275.50, currency: "USD", formatted: "$1,275.50" },
* on_track: false
* },
* insights: [
* { id: "ins-1", type: "repeat_amount", severity: "warning", title: "Duplicate amount detected" }
* ],
* recommendations: [
* {
* id: "rec-1",
* action_type: "create_transaction",
* priority: "high",
* confidence: 0.95,
* message: "Create missing transaction for bank entry",
* parameters: { account_id: "acct-1", date: "2025-10-15", amount: -25500, ... }
* }
* ],
* execution_result: {
* executed: true,
* actions_taken: [{ action_type: "create_transaction", status: "success", transaction_id: "txn-new" }],
* reconciliation_complete: false,
* remaining_discrepancy: { amount: 0, currency: "USD", formatted: "$0.00" }
* },
* audit_metadata: {
* data_freshness: "real-time",
* data_source: "YNAB API + CSV",
* fetched_at: "2025-11-18T10:30:00Z",
* cache_status: { accounts_cached: false, transactions_cached: false }
* }
* }
* }
*/
/**
* CSV format configuration metadata.
* Documents the CSV format detected or specified by the user.
*
* @see src/tools/reconciliation/index.ts:364-402 - mapCsvFormatForPayload function
*/
export const CsvFormatMetadataSchema = z.object({
delimiter: z.string(),
decimal_separator: z.string(),
thousands_separator: z.string().nullable(),
date_format: z.string(),
header_row: z.boolean(),
date_column: z.string().nullable(),
amount_column: z.string().nullable(),
payee_column: z.string().nullable(),
});
export type CsvFormatMetadata = z.infer<typeof CsvFormatMetadataSchema>;
// Define the structured data schema without refinement first
const StructuredReconciliationDataBaseSchema = z.object({
version: z.string(),
schema_url: z.string(),
generated_at: z.string(),
account: z.object({
id: z.string().optional(),
name: z.string().optional(),
}),
summary: ReconciliationSummarySchema,
balance: BalanceInfoSchema.extend({
discrepancy_direction: z.enum(["balanced", "ynab_higher", "bank_higher"]),
}),
insights: z.array(ReconciliationInsightSchema),
next_steps: z.array(z.string()),
matches: z.object({
auto: z.array(TransactionMatchSchema),
suggested: z.array(TransactionMatchSchema),
}),
unmatched: z.object({
bank: z.array(BankTransactionSchema),
ynab: z.array(YNABTransactionSimpleSchema),
}),
recommendations: z.array(ActionableRecommendationSchema).optional(),
csv_format: CsvFormatMetadataSchema.optional(),
execution: ExecutionResultSchema.optional(),
audit: AuditMetadataSchema.optional(),
});
export const ReconcileAccountOutputSchema = z
.union([
// Human + structured data (when include_structured_data=true) - check this FIRST
z.object({
human: z.string(),
structured: StructuredReconciliationDataBaseSchema,
}),
// Human narrative only (default mode) - check this SECOND
z.object({
human: z.string(),
}),
])
.refine(
(data) => {
// Only validate if this is the structured variant (has 'structured' property)
if ("structured" in data && data.structured) {
const discrepancyAmount = data.structured.balance.discrepancy.amount;
const direction = data.structured.balance.discrepancy_direction;
// If absolute discrepancy < 0.01, direction must be 'balanced'
if (Math.abs(discrepancyAmount) < 0.01) {
return direction === "balanced";
}
// If discrepancy > 0, direction must be 'ynab_higher'
if (discrepancyAmount > 0) {
return direction === "ynab_higher";
}
// If discrepancy < 0, direction must be 'bank_higher'
if (discrepancyAmount < 0) {
return direction === "bank_higher";
}
}
// Human-only variant always passes validation
return true;
},
{
message:
"Discrepancy direction mismatch: direction must match the numeric discrepancy amount",
path: ["balance", "discrepancy_direction"],
},
);
export type ReconcileAccountOutput = z.infer<
typeof ReconcileAccountOutputSchema
>;