345 lines
9.3 KiB
TypeScript
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,
|
|
};
|