Files
Novault-Frontend-web/src/services/recurringTransactionService.ts
2026-01-25 20:12:33 +08:00

345 lines
9.3 KiB
TypeScript

/**
* Recurring Transaction Service - API calls for recurring transaction management
* Implements requirements 1.2.1 (create recurring transactions), 1.2.3 (edit recurring transactions)
*/
import api from './api';
import type {
RecurringTransaction,
ApiResponse,
TransactionType,
FrequencyType,
CurrencyCode,
Transaction,
} from '../types';
/**
* Recurring transaction form input
*/
export interface RecurringTransactionFormInput {
amount: number;
type: TransactionType;
categoryId: number;
accountId: number;
currency: CurrencyCode;
note?: string;
frequency: FrequencyType;
startDate: string;
endDate?: string;
}
/**
* Process recurring transactions response (from backend, snake_case)
*/
interface ProcessRecurringTransactionsBackendResponse {
processed_count: number;
transactions: Transaction[];
allocations?: AllocationResultBackend[];
}
/**
* Allocation result from backend (snake_case)
*/
interface AllocationResultBackend {
rule_id: number;
rule_name: string;
total_amount: number;
allocated_amount: number;
remaining: number;
allocations?: AllocationDetailBackend[];
}
/**
* Allocation detail from backend (snake_case)
*/
interface AllocationDetailBackend {
target_type: string;
target_id: number;
target_name: string;
amount: number;
percentage?: number;
fixed_amount?: number;
}
/**
* Allocation result (frontend, camelCase)
*/
export interface AllocationResult {
ruleId: number;
ruleName: string;
totalAmount: number;
allocatedAmount: number;
remaining: number;
allocations?: AllocationDetail[];
}
/**
* Allocation detail (frontend, camelCase)
*/
export interface AllocationDetail {
targetType: string;
targetId: number;
targetName: string;
amount: number;
percentage?: number;
fixedAmount?: number;
}
/**
* Process recurring transactions response (frontend, camelCase)
*/
export interface ProcessRecurringTransactionsResponse {
processedCount: number;
transactions: Transaction[];
allocations?: AllocationResult[];
}
/**
* Get all recurring transactions
* @param activeOnly - If true, only return active recurring transactions
*/
export async function getRecurringTransactions(
activeOnly?: boolean
): Promise<RecurringTransaction[]> {
const params: Record<string, string | number | boolean | undefined> = {};
if (activeOnly !== undefined) {
params.active = activeOnly;
}
const response = await api.get<ApiResponse<RecurringTransactionBackend[]>>(
'/recurring-transactions',
params
);
// Convert snake_case backend response to camelCase frontend format
return (response.data || []).map(convertRecurringTransaction);
}
/**
* Backend recurring transaction format (snake_case)
*/
interface RecurringTransactionBackend {
id: number;
amount: number;
type: TransactionType;
category_id: number;
account_id: number;
currency: CurrencyCode;
note?: string;
frequency: FrequencyType;
start_date: string;
end_date?: string;
next_occurrence: string;
is_active: boolean;
created_at: string;
}
/**
* Convert backend format to frontend format
*/
function convertRecurringTransaction(backend: RecurringTransactionBackend): RecurringTransaction {
return {
id: backend.id,
amount: backend.amount,
type: backend.type,
categoryId: backend.category_id,
accountId: backend.account_id,
currency: backend.currency,
note: backend.note,
frequency: backend.frequency,
startDate: backend.start_date,
endDate: backend.end_date,
nextOccurrence: backend.next_occurrence,
isActive: backend.is_active,
createdAt: backend.created_at,
};
}
/**
* Get a single recurring transaction by ID
*/
export async function getRecurringTransaction(id: number): Promise<RecurringTransaction> {
const response = await api.get<ApiResponse<RecurringTransactionBackend>>(
`/recurring-transactions/${id}`
);
if (!response.data) {
throw new Error('Recurring transaction not found');
}
return convertRecurringTransaction(response.data);
}
/**
* Create a new recurring transaction
* Validates: Requirements 1.2.1 (创建周期性交易并保存周期规则)
*/
export async function createRecurringTransaction(
data: RecurringTransactionFormInput
): Promise<RecurringTransaction> {
// Convert camelCase to snake_case for backend
// Only include end_date if it has a value (empty string should not be sent)
const payload = {
amount: data.amount,
type: data.type,
category_id: data.categoryId,
account_id: data.accountId,
currency: data.currency,
note: data.note,
frequency: data.frequency,
start_date: data.startDate,
end_date: data.endDate || undefined,
};
const response = await api.post<ApiResponse<RecurringTransactionBackend>>(
'/recurring-transactions',
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to create recurring transaction');
}
return convertRecurringTransaction(response.data);
}
/**
* Update an existing recurring transaction
* Validates: Requirements 1.2.3 (编辑周期性交易模板)
*/
export async function updateRecurringTransaction(
id: number,
data: Partial<RecurringTransactionFormInput> & { isActive?: boolean }
): Promise<RecurringTransaction> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.amount !== undefined) payload.amount = data.amount;
if (data.type !== undefined) payload.type = data.type;
if (data.categoryId !== undefined) payload.category_id = data.categoryId;
if (data.accountId !== undefined) payload.account_id = data.accountId;
if (data.currency !== undefined) payload.currency = data.currency;
if (data.note !== undefined) payload.note = data.note;
if (data.frequency !== undefined) payload.frequency = data.frequency;
if (data.startDate !== undefined) payload.start_date = data.startDate;
// Handle endDate: empty string means clear the end date (send clear_end_date flag), valid date means set it
if (data.endDate !== undefined) {
if (data.endDate === '' || data.endDate === null) {
payload.clear_end_date = true;
} else {
payload.end_date = data.endDate;
}
}
if (data.isActive !== undefined) payload.is_active = data.isActive;
const response = await api.put<ApiResponse<RecurringTransactionBackend>>(
`/recurring-transactions/${id}`,
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to update recurring transaction');
}
return convertRecurringTransaction(response.data);
}
/**
* Delete a recurring transaction
* Validates: Requirements 1.2.4 (删除周期性交易)
*/
export async function deleteRecurringTransaction(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/recurring-transactions/${id}`);
}
/**
* Process due recurring transactions
* Validates: Requirements 1.2.2 (到达周期触发时间自动生成交易记录)
* For income transactions, it also triggers matching allocation rules
* @param time - Optional time parameter for testing (YYYY-MM-DD format)
*/
export async function processRecurringTransactions(
time?: string
): Promise<ProcessRecurringTransactionsResponse> {
const params: Record<string, string | number | boolean | undefined> = {};
if (time) {
params.time = time;
}
const response = await api.post<ApiResponse<ProcessRecurringTransactionsBackendResponse>>(
'/recurring-transactions/process',
undefined
);
// Convert snake_case to camelCase
const data = response.data;
// Convert allocations from backend format
const allocations: AllocationResult[] = (data?.allocations || []).map((a) => ({
ruleId: a.rule_id,
ruleName: a.rule_name,
totalAmount: a.total_amount,
allocatedAmount: a.allocated_amount,
remaining: a.remaining,
allocations: (a.allocations || []).map((d) => ({
targetType: d.target_type,
targetId: d.target_id,
targetName: d.target_name,
amount: d.amount,
percentage: d.percentage,
fixedAmount: d.fixed_amount,
})),
}));
return {
processedCount: data?.processed_count || 0,
transactions: data?.transactions || [],
allocations: allocations.length > 0 ? allocations : undefined,
};
}
/**
* Get frequency display name in Chinese
*/
export function getFrequencyDisplayName(frequency: FrequencyType): string {
const names: Record<FrequencyType, string> = {
daily: '每日',
weekly: '每周',
monthly: '每月',
yearly: '每年',
};
return names[frequency] || frequency;
}
/**
* Calculate next occurrence date based on frequency
* This is a client-side helper for preview purposes
*/
export function calculateNextOccurrence(
startDate: string,
frequency: FrequencyType,
occurrences: number = 1
): string {
const date = new Date(startDate);
switch (frequency) {
case 'daily':
date.setDate(date.getDate() + occurrences);
break;
case 'weekly':
date.setDate(date.getDate() + 7 * occurrences);
break;
case 'monthly':
date.setMonth(date.getMonth() + occurrences);
break;
case 'yearly':
date.setFullYear(date.getFullYear() + occurrences);
break;
}
return date.toISOString().split('T')[0];
}
export default {
getRecurringTransactions,
getRecurringTransaction,
createRecurringTransaction,
updateRecurringTransaction,
deleteRecurringTransaction,
processRecurringTransactions,
getFrequencyDisplayName,
calculateNextOccurrence,
};