Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/entities/app-settings/types/currency-code.type.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type CurrencyCode = 'UAH' | 'USD' | 'EUR';
export type CurrencyCode = string;
3 changes: 3 additions & 0 deletions app/src/entities/currency/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { getDefaultCurrencies } from './lib/get-default-currencies';
export type { CurrencyId } from './types/currency-id.type';
export type { Currency } from './types/currency.type';
101 changes: 101 additions & 0 deletions app/src/entities/currency/lib/get-default-currencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Currency } from '../types/currency.type';

/**
* Seed list used only to initialize the `Currencies` sheet and as an offline
* fallback when Google Sheets is unavailable. The sheet remains the source of
* truth: new currencies can be added there without touching this list.
*/
export function getDefaultCurrencies(): Currency[] {
return [
{
id: 'uah',
code: 'UAH',
name: 'Ukrainian Hryvnia',
symbol: '₴',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'usd',
code: 'USD',
name: 'US Dollar',
symbol: '$',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'eur',
code: 'EUR',
name: 'Euro',
symbol: '€',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'rub',
code: 'RUB',
name: 'Russian Ruble',
symbol: '₽',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'pln',
code: 'PLN',
name: 'Polish Zloty',
symbol: 'zł',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'gbp',
code: 'GBP',
name: 'British Pound',
symbol: '£',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'chf',
code: 'CHF',
name: 'Swiss Franc',
symbol: 'CHF',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'cad',
code: 'CAD',
name: 'Canadian Dollar',
symbol: 'C$',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'try',
code: 'TRY',
name: 'Turkish Lira',
symbol: '₺',
decimalDigits: 2,
isDefault: true,
isEnabled: true,
},
{
id: 'jpy',
code: 'JPY',
name: 'Japanese Yen',
symbol: '¥',
decimalDigits: 0,
isDefault: true,
isEnabled: true,
},
];
}
1 change: 1 addition & 0 deletions app/src/entities/currency/types/currency-id.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type CurrencyId = string;
11 changes: 11 additions & 0 deletions app/src/entities/currency/types/currency.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CurrencyId } from './currency-id.type';

export type Currency = {
id: CurrencyId;
code: string;
name: string;
symbol: string;
decimalDigits: number;
isDefault: boolean;
isEnabled: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { mapRowToCategoryStats, mapRowToMonthlyStats } from './analytics-row.map

describe('analytics row mapper', () => {
it('maps monthly stats rows', () => {
expect(mapRowToMonthlyStats(['2026-05', '1000', '250', '750'])).toEqual({
expect(mapRowToMonthlyStats(['2026-05', 'UAH', '1000', '250', '750'])).toEqual({
balance: 750,
currency: 'UAH',
expense: 250,
income: 1000,
label: 'May 2026',
Expand All @@ -14,18 +15,38 @@ describe('analytics row mapper', () => {
});

it('maps category stats rows', () => {
expect(mapRowToCategoryStats(['Food', 'expense', '350', '2'])).toEqual({
expect(mapRowToCategoryStats(['Food', 'expense', 'UAH', '350', '2'])).toEqual({
categoryName: 'Food',
count: 2,
currency: 'UAH',
expense: 350,
income: 0,
kind: 'expense',
total: 350,
});
});

it('maps income category stats rows', () => {
expect(mapRowToCategoryStats(['Salary', 'income', 'USD', '3000', '1'])).toEqual({
categoryName: 'Salary',
count: 1,
currency: 'USD',
expense: 0,
income: 3000,
kind: 'income',
total: 3000,
});
});

it('tolerates missing currency in monthly stats rows', () => {
const result = mapRowToMonthlyStats(['2026-05', '', '1000', '250', '750']);

expect(result).not.toBeNull();
expect(result?.currency).toBe('');
});

it('skips invalid rows', () => {
expect(mapRowToMonthlyStats(['2026-05', 'bad', '250', '750'])).toBeNull();
expect(mapRowToCategoryStats(['Food', 'other', '350', '2'])).toBeNull();
expect(mapRowToMonthlyStats(['2026-05', 'UAH', 'bad', '250', '750'])).toBeNull();
expect(mapRowToCategoryStats(['Food', 'other', 'UAH', '350', '2'])).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function isTransactionKind(value: string | undefined): value is TransactionKind
}

export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null {
const [month, incomeValue, expenseValue, balanceValue] = row;
const [month, currency, incomeValue, expenseValue, balanceValue] = row;
const income = parseAmount(incomeValue);
const expense = parseAmount(expenseValue);
const balance = parseAmount(balanceValue);
Expand All @@ -41,6 +41,7 @@ export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null {

return {
balance,
currency: currency?.trim() || '',
expense,
income,
label: formatMonthLabel(month),
Expand All @@ -49,7 +50,7 @@ export function mapRowToMonthlyStats(row: string[]): MonthlyStats | null {
}

export function mapRowToCategoryStats(row: string[]): CategoryStats | null {
const [categoryName, kind, totalValue, countValue] = row;
const [categoryName, kind, currency, totalValue, countValue] = row;
const total = parseAmount(totalValue);
const count = parseAmount(countValue);

Expand All @@ -60,6 +61,7 @@ export function mapRowToCategoryStats(row: string[]): CategoryStats | null {
return {
categoryName,
count,
currency: currency?.trim() || '',
expense: kind === 'expense' ? total : 0,
income: kind === 'income' ? total : 0,
kind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ describe('readAnalyticsStats', () => {
it('reads analytics from Google Sheets when stats are available', async () => {
const ranges = new Map([
[
'MonthlyStats!A2:D',
{ range: 'MonthlyStats!A2:D', values: [['2026-05', '1000', '250', '750']] },
'MonthlyStats!A2:E',
{ range: 'MonthlyStats!A2:E', values: [['2026-05', 'UAH', '1000', '250', '750']] },
],
[
'CategoryStats!A2:D',
{ range: 'CategoryStats!A2:D', values: [['Food', 'expense', '250', '1']] },
'CategoryStats!A2:E',
{ range: 'CategoryStats!A2:E', values: [['Food', 'expense', 'UAH', '250', '1']] },
],
]);

Expand All @@ -45,8 +45,79 @@ describe('readAnalyticsStats', () => {
}),
).resolves.toMatchObject({
source: 'google-sheets',
monthlyStats: [{ month: '2026-05' }],
categoryStats: [{ categoryName: 'Food', kind: 'expense' }],
monthlyStats: [{ month: '2026-05', currency: 'UAH' }],
categoryStats: [{ categoryName: 'Food', kind: 'expense', currency: 'UAH' }],
});
});

it('forces local stats when forceLocal is true even with valid credentials', async () => {
const ranges = new Map([
[
'MonthlyStats!A2:E',
{ range: 'MonthlyStats!A2:E', values: [['2026-05', 'UAH', '999', '999', '0']] },
],
[
'CategoryStats!A2:E',
{ range: 'CategoryStats!A2:E', values: [['Remote', 'expense', 'UAH', '999', '1']] },
],
]);

await expect(
readAnalyticsStats({
accessToken: 'token',
forceLocal: true,
googleSheetsClient: {
readRange: ({ range }) => Promise.resolve(ranges.get(range) ?? { range, values: [] }),
},
spreadsheetId: 'sheet-id',
transactions: [
createTransaction({
amount: 120,
categoryName: 'Food',
id: 'food',
kind: 'expense',
signedAmount: -120,
}),
],
}),
).resolves.toMatchObject({
source: 'local',
categoryStats: [{ categoryName: 'Food' }],
});
});

it('local analytics keeps UAH and USD as separate monthly and category entries', async () => {
await expect(
readAnalyticsStats({
accessToken: null,
spreadsheetId: null,
transactions: [
createTransaction({
id: 'uah',
amount: 100,
currency: 'UAH',
kind: 'expense',
signedAmount: -100,
}),
createTransaction({
id: 'usd',
amount: 50,
currency: 'USD',
kind: 'expense',
signedAmount: -50,
}),
],
}),
).resolves.toMatchObject({
source: 'local',
monthlyStats: expect.arrayContaining([
expect.objectContaining({ currency: 'UAH', expense: 100 }),
expect.objectContaining({ currency: 'USD', expense: 50 }),
]),
categoryStats: expect.arrayContaining([
expect.objectContaining({ currency: 'UAH', categoryName: 'Category', expense: 100 }),
expect.objectContaining({ currency: 'USD', categoryName: 'Category', expense: 50 }),
]),
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { CategoryStats, MonthlyStats } from '../types/analytics-stat.types'

type ReadAnalyticsStatsParams = {
accessToken: string | null;
forceLocal?: boolean;
googleSheetsClient?: Pick<GoogleSheetsClient, 'readRange'>;
spreadsheetId: string | null;
transactions: Transaction[];
Expand All @@ -33,13 +34,14 @@ function isCategoryStats(value: CategoryStats | null): value is CategoryStats {

export async function readAnalyticsStats({
accessToken,
forceLocal = false,
googleSheetsClient = createGoogleSheetsClient(),
spreadsheetId,
transactions,
}: ReadAnalyticsStatsParams): Promise<AnalyticsStats> {
const localStats = buildLocalAnalyticsStats(transactions);

if (!accessToken || !spreadsheetId) {
if (!accessToken || !spreadsheetId || forceLocal) {
return localStats;
}

Expand All @@ -56,8 +58,12 @@ export async function readAnalyticsStats({
spreadsheetId,
}),
]);
const monthlyStats = monthlyRange.values.map(mapRowToMonthlyStats).filter(isMonthlyStats);
const categoryStats = categoryRange.values.map(mapRowToCategoryStats).filter(isCategoryStats);
const monthlyStats = (monthlyRange.values ?? [])
.map(mapRowToMonthlyStats)
.filter(isMonthlyStats);
const categoryStats = (categoryRange.values ?? [])
.map(mapRowToCategoryStats)
.filter(isCategoryStats);

if (!monthlyStats.length && !categoryStats.length) {
return localStats;
Expand Down
Loading
Loading