Feature - Add support for a fiscal year period defined in user settings.

* Add "This fiscal year", "Last fiscal year" as date range options in Transaction Details to filter transactions to those periods
* Add fiscal year ranges to Statistics & Trend Analysis
* Add "fiscal year start date" to user profile settings, allowing the user to select any date of the calendar year as the start of the fiscal year
* Add "fiscal year format" to user profile settings, allowing the user to specify how financial year date labels should appear

Implementation notes:
* The default fiscal year start is January 1 and the default fiscal year display format is "FY 2025"
* Fiscal year start date (month number & day number) are stored together in db as a uint16, high byte & low byte respectively
* February 29 is disallowed as a fiscal year start date, since it is never used as a convention in any country
* Jest is added to the project as a dev dependency, for unit tests in frontend

Signed-off-by: Sebastian Reategui <seb.reategui@gmail.com>
This commit is contained in:
Sebastian Reategui
2025-06-05 12:36:46 +10:00
committed by mayswind
parent 70eea8ff33
commit b94dc8eb83
42 changed files with 3417 additions and 105 deletions
+14
View File
@@ -19,6 +19,7 @@
"default": {
"currency": "USD",
"firstDayOfWeek": "Sunday",
"fiscalYearFormat": "EndYYYY",
"longDateFormat": "MMDDYYYY",
"shortDateFormat": "MMDDYYYY",
"longTimeFormat": "HHMMSSA",
@@ -85,6 +86,13 @@
"q3": "{year}Q3",
"q4": "{year}Q4"
},
"fiscalYear": {
"StartYYYY_EndYYYY": "FY {StartYYYY}-{EndYYYY}",
"StartYYYY_EndYY": "FY {StartYYYY}-{EndYY}",
"StartYY_EndYY": "FY {StartYY}-{EndYY}",
"EndYYYY": "FY {EndYYYY}",
"EndYY": "FY {EndYY}"
},
"misc": {
"multiTextJoinSeparator": ", ",
"hoursBehindDefaultTimezone": "{hours} hour(s) behind default timezone",
@@ -1196,6 +1204,7 @@
"oldPassword": "Current Password",
"defaultCurrency": "Default Currency",
"firstDayOfWeek": "First Day of Week",
"fiscalYearStart": "Fiscal Year Start Date",
"transactionEditScope": "Editable Transaction Range",
"name": "Name",
"category": "Category",
@@ -1366,6 +1375,8 @@
"Last month": "Last month",
"This year": "This year",
"Last year": "Last year",
"This fiscal year": "This fiscal year",
"Last fiscal year": "Last fiscal year",
"Recent 12 months": "Recent 12 months",
"Recent 24 months": "Recent 24 months",
"Recent 36 months": "Recent 36 months",
@@ -1447,10 +1458,12 @@
"Default Currency": "Default Currency",
"Default Account": "Default Account",
"First Day of Week": "First Day of Week",
"Fiscal Year Start Date": "Fiscal Year Start Date",
"Long Date Format": "Long Date Format",
"Short Date Format": "Short Date Format",
"Long Time Format": "Long Time Format",
"Short Time Format": "Short Time Format",
"Fiscal Year Format": "Fiscal Year Format",
"Decimal Separator": "Decimal Separator",
"Digit Grouping Symbol": "Digit Grouping Symbol",
"Digit Grouping": "Digit Grouping",
@@ -1807,6 +1820,7 @@
"Aggregate by Month": "Aggregate by Month",
"Aggregate by Quarter": "Aggregate by Quarter",
"Aggregate by Year": "Aggregate by Year",
"Aggregate by Fiscal Year": "Aggregate by Fiscal Year",
"Filter Accounts": "Filter Accounts",
"Filter Transaction Categories": "Filter Transaction Categories",
"Filter Transaction Tags": "Filter Transaction Tags",
+138 -11
View File
@@ -12,6 +12,7 @@ import {
type LocalizedDateTimeFormat,
type LocalizedDateRange,
type LocalizedRecentMonthDateRange,
type UnixTimeRange,
Month,
WeekDay,
MeridiemIndicator,
@@ -46,6 +47,13 @@ import {
CurrencySortingType
} from '@/core/currency.ts';
import {
FiscalYearStart,
FiscalYearFormat,
FiscalYearUnixTime,
LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE,
} from '@/core/fiscalyear.ts';
import {
CoordinateDisplayType
} from '@/core/coordinate.ts';
@@ -120,21 +128,25 @@ import {
} from '@/lib/common.ts';
import {
isPM,
formatUnixTime,
formatCurrentTime,
formatDate,
parseDateFromUnixTime,
getYear,
getTimezoneOffset,
getTimezoneOffsetMinutes,
formatMonthDay,
formatUnixTime,
getBrowserTimezoneOffset,
getBrowserTimezoneOffsetMinutes,
getTimeDifferenceHoursAndMinutes,
getCurrentUnixTime,
getDateTimeFormatType,
getFiscalYearTimeRangeFromUnixTime,
getFiscalYearTimeRangeFromYear,
getRecentMonthDateRanges,
getTimeDifferenceHoursAndMinutes,
getTimezoneOffset,
getTimezoneOffsetMinutes,
getYear,
isDateRangeMatchFullMonths,
isDateRangeMatchFullYears,
isDateRangeMatchFullMonths
isPM,
parseDateFromUnixTime,
} from '@/lib/datetime.ts';
import {
@@ -629,6 +641,14 @@ export function useI18n() {
return t('default.firstDayOfWeek');
}
function getDefaultFiscalYearStart(): string {
return t('default.fiscalYearStart');
}
function getDefaultFiscalYearFormat(): string {
return t('default.fiscalYearFormat');
}
function getAllLanguageOptions(includeSystemDefault: boolean): LanguageOption[] {
const ret: LanguageOption[] = [];
@@ -766,7 +786,7 @@ export function useI18n() {
function getLocalizedDateTimeFormats<T extends DateFormat | TimeFormat>(type: string, allFormatMap: Record<string, T>, allFormatArray: T[], languageDefaultTypeNameKey: string, systemDefaultFormatType: T): LocalizedDateTimeFormat[] {
const defaultFormat = getLocalizedDateTimeFormat<T>(type, allFormatMap, allFormatArray, LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE, languageDefaultTypeNameKey, systemDefaultFormatType);
const ret: LocalizedDateTimeFormat[] = [];
ret.push({
type: LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE,
format: defaultFormat,
@@ -786,7 +806,7 @@ export function useI18n() {
return ret;
}
function getAllDateRanges(scene: DateRangeScene, includeCustom?: boolean, includeBillingCycle?: boolean): LocalizedDateRange[] {
const ret: LocalizedDateRange[] = [];
const allDateRanges = DateRange.values();
@@ -919,6 +939,37 @@ export function useI18n() {
];
}
function getAllFiscalYearFormats(): FiscalYearFormat[] {
const now = getCurrentUnixTime();
let fiscalYearStart = userStore.currentUserFiscalYearStart;
if (!fiscalYearStart) {
fiscalYearStart = FiscalYearStart.Default.value;
}
let nowFiscalYearRange = getFiscalYearTimeRangeFromUnixTime(now, userStore.currentUserFiscalYearStart);
const ret: FiscalYearFormat[] = [];
let defaultFiscalYearFormatType = FiscalYearFormat.parse(t('default.fiscalYearFormat'));
if (!defaultFiscalYearFormatType) {
defaultFiscalYearFormatType = FiscalYearFormat.Default;
}
ret.push({
type: LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE,
displayName: `${t('Language Default')} (${formatTimeRangeToFiscalYearFormat(defaultFiscalYearFormatType, nowFiscalYearRange)})`
});
const allFiscalYearFormats = FiscalYearFormat.values();
for (let i = 0; i < allFiscalYearFormats.length; i++) {
const type = allFiscalYearFormats[i];
ret.push({
type: type.type,
displayName: formatTimeRangeToFiscalYearFormat(type, nowFiscalYearRange),
});
}
return ret;
}
function getAllDigitGroupingTypes(): LocalizedDigitGroupingType[] {
const defaultDigitGroupingTypeName = t('default.digitGrouping');
let defaultDigitGroupingType = DigitGroupingType.parse(defaultDigitGroupingTypeName);
@@ -1315,6 +1366,21 @@ export function useI18n() {
return digitGroupingType.type;
}
function getCurrentFiscalYearFormatType(): number {
let fiscalYearFormat = FiscalYearFormat.valueOf(userStore.currentUserFiscalYearFormat);
if (!fiscalYearFormat) {
const defaultFiscalYearFormatTypeName = t('default.fiscalYearFormat');
fiscalYearFormat = FiscalYearFormat.parse(defaultFiscalYearFormatTypeName);
if (!fiscalYearFormat) {
fiscalYearFormat = FiscalYearFormat.Default;
}
}
return fiscalYearFormat.type;
}
function getCurrencyName(currencyCode: string): string {
return t(`currency.name.${currencyCode}`);
}
@@ -1347,6 +1413,10 @@ export function useI18n() {
return formatDate(date, getLocalizedLongDateFormat());
}
function formatMonthDayToLongDay(monthDay: string): string {
return formatMonthDay(monthDay, getLocalizedLongMonthDayFormat());
}
function formatYearQuarter(year: number, quarter: number): string {
if (1 <= quarter && quarter <= 4) {
return t('format.yearQuarter.q' + quarter, {
@@ -1357,7 +1427,7 @@ export function useI18n() {
return '';
}
}
function formatDateRange(dateType: number, startTime: number, endTime: number): string {
if (dateType === DateRange.All.type) {
return t(DateRange.All.name);
@@ -1406,6 +1476,53 @@ export function useI18n() {
return `${displayStartTime} ~ ${displayEndTime}`;
}
function formatTimeRangeToFiscalYearFormat(format: FiscalYearFormat, timeRange: FiscalYearUnixTime | UnixTimeRange): string {
if (!format) {
format = FiscalYearFormat.Default;
}
return t('format.fiscalYear.' + format.displayName, {
StartYYYY: formatUnixTime(timeRange.minUnixTime, 'YYYY'),
StartYY: formatUnixTime(timeRange.minUnixTime, 'YY'),
EndYYYY: formatUnixTime(timeRange.maxUnixTime, 'YYYY'),
EndYY: formatUnixTime(timeRange.maxUnixTime, 'YY'),
});
}
function formatUnixTimeToFiscalYear(unixTime: number): string {
let fiscalYearFormat = FiscalYearFormat.valueOf(getCurrentFiscalYearFormatType());
if (!fiscalYearFormat) {
fiscalYearFormat = FiscalYearFormat.Default;
}
let timeRange = getFiscalYearTimeRangeFromUnixTime(unixTime, userStore.currentUserFiscalYearStart);
return formatTimeRangeToFiscalYearFormat(fiscalYearFormat, timeRange);
}
function formatYearToFiscalYear(year: number) {
let fiscalYearFormat = FiscalYearFormat.valueOf(getCurrentFiscalYearFormatType());
if (!fiscalYearFormat) {
fiscalYearFormat = FiscalYearFormat.Default;
}
let timeRange = getFiscalYearTimeRangeFromYear(year, userStore.currentUserFiscalYearStart);
return formatTimeRangeToFiscalYearFormat(fiscalYearFormat, timeRange);
}
function formatFiscalYearStart(fiscalYearStart: number) {
let fy = FiscalYearStart.fromNumber(fiscalYearStart);
if ( !fy ) {
fy = FiscalYearStart.Default;
}
return formatMonthDayToLongDay(fy.toMonthDashDayString());
}
function getTimezoneDifferenceDisplayText(utcOffset: number): string {
const defaultTimezoneOffset = getTimezoneOffsetMinutes();
const offsetTime = getTimeDifferenceHoursAndMinutes(utcOffset - defaultTimezoneOffset);
@@ -1699,6 +1816,8 @@ export function useI18n() {
// get localization default type
getDefaultCurrency,
getDefaultFirstDayOfWeek,
getDefaultFiscalYearStart,
getDefaultFiscalYearFormat,
// get all localized info of specified type
getAllLanguageOptions,
getAllEnableDisableOptions,
@@ -1714,6 +1833,7 @@ export function useI18n() {
getAllShortDateFormats: () => getLocalizedDateTimeFormats<ShortDateFormat>('shortDate', ShortDateFormat.all(), ShortDateFormat.values(), 'shortDateFormat', ShortDateFormat.Default),
getAllLongTimeFormats: () => getLocalizedDateTimeFormats<LongTimeFormat>('longTime', LongTimeFormat.all(), LongTimeFormat.values(), 'longTimeFormat', LongTimeFormat.Default),
getAllShortTimeFormats: () => getLocalizedDateTimeFormats<ShortTimeFormat>('shortTime', ShortTimeFormat.all(), ShortTimeFormat.values(), 'shortTimeFormat', ShortTimeFormat.Default),
getAllFiscalYearFormats,
getAllDateRanges,
getAllRecentMonthDateRanges,
getAllTimezones,
@@ -1750,6 +1870,8 @@ export function useI18n() {
getWeekdayLongName,
getMultiMonthdayShortNames,
getMultiWeekdayLongNames,
getCurrentFiscalYearStartFormatted: () => formatMonthDayToLongDay(FiscalYearStart.strictFromNumber(userStore.currentUserFiscalYearStart).toMonthDashDayString()),
getCurrentFiscalYearFormatType,
getCurrentDecimalSeparator,
getCurrentDigitGroupingSymbol,
getCurrentDigitGroupingType,
@@ -1776,8 +1898,13 @@ export function useI18n() {
formatUnixTimeToLongTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedLongTimeFormat(), utcOffset, currentUtcOffset),
formatUnixTimeToShortTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedShortTimeFormat(), utcOffset, currentUtcOffset),
formatDateToLongDate,
formatMonthDayToLongDay,
formatYearQuarter,
formatDateRange,
formatFiscalYearStart,
formatTimeRangeToFiscalYearFormat,
formatUnixTimeToFiscalYear,
formatYearToFiscalYear,
getTimezoneDifferenceDisplayText,
appendDigitGroupingSymbol: getNumberWithDigitGroupingSymbol,
parseAmount: getParsedAmountNumber,