Files
Novault-Frontend-web/src/services/accountService.ts

417 lines
12 KiB
TypeScript

/**
* Account Service - API calls for account management
*/
import api from './api';
import type { Account, AccountFormInput, TransferFormInput, ApiResponse } from '../types';
/**
* Map account data from API (snake_case) to frontend (camelCase)
*/
function mapAccountFromApi(data: Record<string, unknown>): Account {
return {
id: data.id as number,
name: data.name as string,
type: data.type as Account['type'],
balance: data.balance as number,
currency: data.currency as Account['currency'],
icon: data.icon as string,
isCredit: data.is_credit as boolean,
billingDate: data.billing_date as number | undefined,
paymentDate: data.payment_date as number | undefined,
createdAt: data.created_at as string,
updatedAt: data.updated_at as string,
sortOrder: data.sort_order as number,
warningThreshold: data.warning_threshold as number | undefined,
lastSyncTime: data.last_sync_time as string | undefined,
accountCode: data.account_code as string | undefined,
accountType: data.account_type as 'asset' | 'liability' | undefined,
tags: (data.tags as { id: number; name: string; color: string; created_at: string }[] | undefined)?.map(t => ({
id: t.id,
name: t.name,
color: t.color,
createdAt: t.created_at,
})),
};
}
/**
* Get all accounts
*/
export async function getAccounts(): Promise<Account[]> {
const response = await api.get<ApiResponse<Record<string, unknown>[]>>('/accounts');
const rawData = response.data || [];
return rawData.map(mapAccountFromApi);
}
/**
* Get a single account by ID
*/
export async function getAccount(id: number): Promise<Account> {
const response = await api.get<ApiResponse<Record<string, unknown>>>(`/accounts/${id}`);
if (!response.data) {
throw new Error('Account not found');
}
return mapAccountFromApi(response.data);
}
/**
* Create a new account
*/
export async function createAccount(data: AccountFormInput): Promise<Account> {
// Convert camelCase to snake_case for backend
const payload = {
name: data.name,
type: data.type,
balance: data.balance,
currency: data.currency,
icon: data.icon,
billing_date: data.billingDate,
payment_date: data.paymentDate,
warning_threshold: data.warningThreshold,
account_code: data.accountCode,
tag_ids: data.tagIds,
};
const response = await api.post<ApiResponse<Account>>('/accounts', payload);
if (!response.data) {
throw new Error(response.error || 'Failed to create account');
}
return response.data;
}
/**
* Update an existing account
*/
export async function updateAccount(id: number, data: Partial<AccountFormInput>): Promise<Account> {
// Convert camelCase to snake_case for backend
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.type !== undefined) payload.type = data.type;
if (data.balance !== undefined) payload.balance = data.balance;
if (data.currency !== undefined) payload.currency = data.currency;
if (data.icon !== undefined) payload.icon = data.icon;
if (data.billingDate !== undefined) payload.billing_date = data.billingDate;
if (data.paymentDate !== undefined) payload.payment_date = data.paymentDate;
if (data.sortOrder !== undefined) payload.sort_order = data.sortOrder;
if (data.warningThreshold !== undefined) payload.warning_threshold = data.warningThreshold;
if (data.accountCode !== undefined) payload.account_code = data.accountCode;
if (data.accountType !== undefined) payload.account_type = data.accountType;
if (data.tagIds !== undefined) payload.tag_ids = data.tagIds;
const response = await api.put<ApiResponse<Account>>(`/accounts/${id}`, payload);
if (!response.data) {
throw new Error(response.error || 'Failed to update account');
}
return response.data;
}
/**
* Delete an account
*/
export async function deleteAccount(id: number): Promise<void> {
await api.delete<ApiResponse<void>>(`/accounts/${id}`);
}
/**
* Transfer between accounts
*/
export async function transferBetweenAccounts(data: TransferFormInput): Promise<void> {
// Convert camelCase to snake_case for backend
const payload = {
from_account_id: data.fromAccountId,
to_account_id: data.toAccountId,
amount: data.amount,
note: data.note,
};
const response = await api.post<ApiResponse<void>>('/accounts/transfer', payload);
if (!response.success && response.error) {
throw new Error(response.error);
}
}
/**
* Get accounts grouped by type
*/
export function groupAccountsByType(accounts: Account[] | undefined): Record<string, Account[]> {
if (!accounts || !Array.isArray(accounts)) {
return {};
}
return accounts.reduce(
(groups, account) => {
const type = account.type;
if (!groups[type]) {
groups[type] = [];
}
groups[type].push(account);
return groups;
},
{} as Record<string, Account[]>
);
}
/**
* Calculate total balance for accounts
*/
export function calculateTotalBalance(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts.reduce((total, account) => total + account.balance, 0);
}
/**
* Calculate total assets (positive balances)
*/
export function calculateTotalAssets(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts
.filter((account) => account.balance > 0)
.reduce((total, account) => total + account.balance, 0);
}
/**
* Calculate total liabilities (negative balances)
*/
export function calculateTotalLiabilities(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts
.filter((account) => account.balance < 0)
.reduce((total, account) => total + Math.abs(account.balance), 0);
}
/**
* Reorder accounts by updating their sort_order
* Requirements: 1.3, 1.4
*/
export async function reorderAccounts(accountIds: number[]): Promise<void> {
// Update each account's sort order
const updates = accountIds.map((id, index) =>
updateAccount(id, { sortOrder: index })
);
await Promise.all(updates);
}
/**
* Calculate total assets (only asset-type accounts)
* Requirements: 1.2
*/
export function calculateAssetTypeTotal(accounts: Account[] | undefined): number {
if (!accounts || !Array.isArray(accounts)) {
return 0;
}
return accounts
.filter((account) => account.accountType === 'asset' || (!account.accountType && account.balance > 0))
.reduce((total, account) => total + account.balance, 0);
}
export default {
getAccounts,
getAccount,
createAccount,
updateAccount,
deleteAccount,
transferBetweenAccounts,
groupAccountsByType,
calculateTotalBalance,
calculateTotalAssets,
calculateTotalLiabilities,
reorderAccounts,
calculateAssetTypeTotal,
};
// ============================================
// Sub-Account and Savings Pot Functions
// Feature: financial-core-upgrade
// ============================================
import type {
AccountWithSubAccounts,
CreateSubAccountInput,
UpdateSubAccountInput,
SavingsPotOperationResult,
SavingsPotDepositInput,
SavingsPotWithdrawInput,
SavingsPotProgress,
} from '../types';
import { calculateTotalBalance as calcTotalBalance, calculateSavingsPotProgress } from '../utils/balanceCalculator';
/**
* Get sub-accounts for a parent account
* Validates: Requirements 9.1, 9.5
*/
export async function getSubAccounts(parentAccountId: number): Promise<AccountWithSubAccounts[]> {
const response = await api.get<ApiResponse<AccountWithSubAccounts[]>>(
`/accounts/${parentAccountId}/sub-accounts`
);
return response.data || [];
}
/**
* Create a new sub-account
* Validates: Requirements 9.2, 9.6
*/
export async function createSubAccount(
parentAccountId: number,
data: CreateSubAccountInput
): Promise<AccountWithSubAccounts> {
const payload = {
name: data.name,
sub_account_type: data.subAccountType,
balance: data.balance || 0,
target_amount: data.targetAmount,
target_date: data.targetDate,
annual_rate: data.annualRate,
interest_enabled: data.interestEnabled,
icon: data.icon,
currency: data.currency,
};
const response = await api.post<ApiResponse<AccountWithSubAccounts>>(
`/accounts/${parentAccountId}/sub-accounts`,
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to create sub-account');
}
return response.data;
}
/**
* Update a sub-account
* Validates: Requirements 9.3, 9.7
*/
export async function updateSubAccount(
parentAccountId: number,
subAccountId: number,
data: UpdateSubAccountInput
): Promise<AccountWithSubAccounts> {
const payload: Record<string, unknown> = {};
if (data.name !== undefined) payload.name = data.name;
if (data.targetAmount !== undefined) payload.target_amount = data.targetAmount;
if (data.targetDate !== undefined) payload.target_date = data.targetDate;
if (data.annualRate !== undefined) payload.annual_rate = data.annualRate;
if (data.interestEnabled !== undefined) payload.interest_enabled = data.interestEnabled;
if (data.icon !== undefined) payload.icon = data.icon;
const response = await api.put<ApiResponse<AccountWithSubAccounts>>(
`/accounts/${parentAccountId}/sub-accounts/${subAccountId}`,
payload
);
if (!response.data) {
throw new Error(response.error || 'Failed to update sub-account');
}
return response.data;
}
/**
* Delete a sub-account
* Validates: Requirements 9.4, 9.8
*/
export async function deleteSubAccount(
parentAccountId: number,
subAccountId: number
): Promise<void> {
await api.delete<ApiResponse<void>>(
`/accounts/${parentAccountId}/sub-accounts/${subAccountId}`
);
}
/**
* Deposit to a savings pot
* Validates: Requirements 10.1, 10.3, 10.5
*/
export async function depositToSavingsPot(
savingsPotId: number,
data: SavingsPotDepositInput
): Promise<SavingsPotOperationResult> {
const response = await api.post<ApiResponse<SavingsPotOperationResult>>(
`/savings-pot/${savingsPotId}/deposit`,
{ amount: data.amount }
);
if (!response.data) {
throw new Error(response.error || 'Failed to deposit to savings pot');
}
return response.data;
}
/**
* Withdraw from a savings pot
* Validates: Requirements 10.2, 10.4, 10.6
*/
export async function withdrawFromSavingsPot(
savingsPotId: number,
data: SavingsPotWithdrawInput
): Promise<SavingsPotOperationResult> {
const response = await api.post<ApiResponse<SavingsPotOperationResult>>(
`/savings-pot/${savingsPotId}/withdraw`,
{ amount: data.amount }
);
if (!response.data) {
throw new Error(response.error || 'Failed to withdraw from savings pot');
}
return response.data;
}
/**
* Get savings pot details with progress
* Validates: Requirements 10.7, 10.8
*/
export async function getSavingsPot(savingsPotId: number): Promise<AccountWithSubAccounts & { progress: SavingsPotProgress }> {
const response = await api.get<ApiResponse<AccountWithSubAccounts>>(
`/savings-pot/${savingsPotId}`
);
if (!response.data) {
throw new Error(response.error || 'Savings pot not found');
}
// Calculate progress
const progress = calculateSavingsPotProgress(response.data);
return {
...response.data,
progress,
};
}
/**
* Calculate total balance for an account with sub-accounts
* Uses the formula: 总余额 = 可用余额 + 冻结余额 + Σ(非存钱罐子账户余额)
* Validates: Requirements 1.3, 14.1
*/
export function calculateAccountTotalBalance(account: AccountWithSubAccounts): number {
return calcTotalBalance(account);
}
/**
* Get all accounts with their sub-accounts
*/
export async function getAccountsWithSubAccounts(): Promise<AccountWithSubAccounts[]> {
const accounts = await getAccounts();
// Fetch sub-accounts for each parent account
const accountsWithSubs = await Promise.all(
accounts.map(async (account) => {
try {
const subAccounts = await getSubAccounts(account.id);
return {
...account,
subAccounts,
} as AccountWithSubAccounts;
} catch {
return {
...account,
subAccounts: [],
} as AccountWithSubAccounts;
}
})
);
return accountsWithSubs;
}