417 lines
12 KiB
TypeScript
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;
|
|
}
|