mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-16 07:57:33 +08:00
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:
committed by
mayswind
parent
70eea8ff33
commit
b94dc8eb83
@@ -512,6 +512,8 @@ export class DateRange implements TypeAndName {
|
||||
// Date ranges for normal and trend analysis scene
|
||||
public static readonly ThisYear = new DateRange(9, 'This year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis);
|
||||
public static readonly LastYear = new DateRange(10, 'Last year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis);
|
||||
public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis);
|
||||
public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis);
|
||||
|
||||
// Billing cycle date ranges for normal scene only
|
||||
public static readonly PreviousBillingCycle = new DateRange(51, 'Previous Billing Cycle', true, DateRangeScene.Normal);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { TypeAndDisplayName } from '@/core/base.ts';
|
||||
import type { UnixTimeRange } from './datetime';
|
||||
|
||||
export class FiscalYearStart {
|
||||
public static readonly Default = new FiscalYearStart(1, 1);
|
||||
|
||||
public readonly day: number;
|
||||
public readonly month: number;
|
||||
public readonly value: number;
|
||||
|
||||
private constructor(month: number, day: number) {
|
||||
const [validMonth, validDay] = validateMonthDay(month, day);
|
||||
this.day = validDay;
|
||||
this.month = validMonth;
|
||||
this.value = (validMonth << 8) | validDay;
|
||||
}
|
||||
|
||||
public static of(month: number, day: number): FiscalYearStart {
|
||||
return new FiscalYearStart(month, day);
|
||||
}
|
||||
|
||||
public static valueOf(value: number): FiscalYearStart {
|
||||
return FiscalYearStart.strictFromNumber(value);
|
||||
}
|
||||
|
||||
public static valuesFromNumber(value: number): number[] {
|
||||
return FiscalYearStart.strictFromNumber(value).values();
|
||||
}
|
||||
|
||||
public values(): number[] {
|
||||
return [
|
||||
this.month,
|
||||
this.day
|
||||
];
|
||||
}
|
||||
|
||||
public static parse(valueString: string): FiscalYearStart | undefined {
|
||||
return FiscalYearStart.strictFromMonthDashDayString(valueString);
|
||||
}
|
||||
|
||||
public static isValidType(value: number): boolean {
|
||||
if (value < 0x0101 || value > 0x0C1F) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const month = (value >> 8) & 0xFF;
|
||||
const day = value & 0xFF;
|
||||
|
||||
try {
|
||||
validateMonthDay(month, day);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
try {
|
||||
FiscalYearStart.validateMonthDay(this.month, this.day);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public isDefault(): boolean {
|
||||
return this.month === 1 && this.day === 1;
|
||||
}
|
||||
|
||||
public static validateMonthDay(month: number, day: number): [number, number] {
|
||||
return validateMonthDay(month, day);
|
||||
}
|
||||
|
||||
public static strictFromMonthDayValues(month: number, day: number): FiscalYearStart {
|
||||
return FiscalYearStart.of(month, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FiscalYearStart from a uint16 value (two bytes - month high, day low)
|
||||
* @param value uint16 value (month in high byte, day in low byte)
|
||||
* @returns FiscalYearStart instance
|
||||
*/
|
||||
public static strictFromNumber(value: number): FiscalYearStart {
|
||||
if (value < 0 || value > 0xFFFF) {
|
||||
throw new Error('Invalid uint16 value');
|
||||
}
|
||||
|
||||
const month = (value >> 8) & 0xFF; // high byte
|
||||
const day = value & 0xFF; // low byte
|
||||
|
||||
try {
|
||||
const [validMonth, validDay] = validateMonthDay(month, day);
|
||||
return FiscalYearStart.of(validMonth, validDay);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid uint16 value');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FiscalYearStart from a month/day string
|
||||
* @param input MM-dd string (e.g. "04-01" = 1 April)
|
||||
* @returns FiscalYearStart instance
|
||||
*/
|
||||
public static strictFromMonthDashDayString(input: string): FiscalYearStart {
|
||||
if (!input || !input.includes('-')) {
|
||||
throw new Error('Invalid input string');
|
||||
}
|
||||
|
||||
const parts = input.split('-');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid input string');
|
||||
}
|
||||
|
||||
const month = parseInt(parts[0], 10);
|
||||
const day = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(month) || isNaN(day)) {
|
||||
throw new Error('Invalid input string');
|
||||
}
|
||||
|
||||
try {
|
||||
const [validMonth, validDay] = validateMonthDay(month, day);
|
||||
return FiscalYearStart.of(validMonth, validDay);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid input string');
|
||||
}
|
||||
}
|
||||
|
||||
public static fromMonthDashDayString(input: string): FiscalYearStart | null {
|
||||
try {
|
||||
return FiscalYearStart.strictFromMonthDashDayString(input);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static fromNumber(value: number): FiscalYearStart | null {
|
||||
try {
|
||||
return FiscalYearStart.strictFromNumber(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static fromMonthDayValues(month: number, day: number): FiscalYearStart | null {
|
||||
try {
|
||||
return FiscalYearStart.strictFromMonthDayValues(month, day);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public toMonthDashDayString(): string {
|
||||
return `${this.month.toString().padStart(2, '0')}-${this.day.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
public toMonthDayValues(): [string, string] {
|
||||
return [
|
||||
`${this.month.toString().padStart(2, '0')}`,
|
||||
`${this.day.toString().padStart(2, '0')}`
|
||||
]
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.toMonthDashDayString();
|
||||
}
|
||||
}
|
||||
|
||||
function validateMonthDay(month: number, day: number): [number, number] {
|
||||
if (month < 1 || month > 12 || day < 1) {
|
||||
throw new Error('Invalid month or day');
|
||||
}
|
||||
|
||||
let maxDays = 31;
|
||||
switch (month) {
|
||||
// January, March, May, July, August, October, December
|
||||
case 1: case 3: case 5: case 7: case 8: case 10: case 12:
|
||||
maxDays = 31;
|
||||
break;
|
||||
// April, June, September, November
|
||||
case 4: case 6: case 9: case 11:
|
||||
maxDays = 30;
|
||||
break;
|
||||
// February
|
||||
case 2:
|
||||
maxDays = 28; // Disallow fiscal year start on leap day
|
||||
break;
|
||||
}
|
||||
|
||||
if (day > maxDays) {
|
||||
throw new Error('Invalid day for given month');
|
||||
}
|
||||
|
||||
return [month, day];
|
||||
}
|
||||
|
||||
export class FiscalYearUnixTime implements UnixTimeRange {
|
||||
public readonly year: number;
|
||||
public readonly minUnixTime: number;
|
||||
public readonly maxUnixTime: number;
|
||||
|
||||
private constructor(fiscalYear: number, minUnixTime: number, maxUnixTime: number) {
|
||||
this.year = fiscalYear;
|
||||
this.minUnixTime = minUnixTime;
|
||||
this.maxUnixTime = maxUnixTime;
|
||||
}
|
||||
|
||||
public static of(fiscalYear: number, minUnixTime: number, maxUnixTime: number): FiscalYearUnixTime {
|
||||
return new FiscalYearUnixTime(fiscalYear, minUnixTime, maxUnixTime);
|
||||
}
|
||||
}
|
||||
|
||||
export const LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE: number = 0;
|
||||
|
||||
export class FiscalYearFormat implements TypeAndDisplayName {
|
||||
private static readonly allInstances: FiscalYearFormat[] = [];
|
||||
private static readonly allInstancesByType: Record<number, FiscalYearFormat> = {};
|
||||
private static readonly allInstancesByTypeName: Record<string, FiscalYearFormat> = {};
|
||||
|
||||
public static readonly StartYYYY_EndYYYY = new FiscalYearFormat(1, 'StartYYYY_EndYYYY');
|
||||
public static readonly StartYYYY_EndYY = new FiscalYearFormat(2, 'StartYYYY_EndYY');
|
||||
public static readonly StartYY_EndYY = new FiscalYearFormat(3, 'StartYY_EndYY');
|
||||
public static readonly EndYYYY = new FiscalYearFormat(4, 'EndYYYY');
|
||||
public static readonly EndYY = new FiscalYearFormat(5, 'EndYY');
|
||||
|
||||
public static readonly Default = FiscalYearFormat.EndYYYY;
|
||||
|
||||
public readonly type: number;
|
||||
public readonly displayName: string;
|
||||
|
||||
private constructor(type: number, displayName: string) {
|
||||
this.type = type;
|
||||
this.displayName = displayName;
|
||||
|
||||
FiscalYearFormat.allInstances.push(this);
|
||||
FiscalYearFormat.allInstancesByType[type] = this;
|
||||
FiscalYearFormat.allInstancesByTypeName[displayName] = this;
|
||||
}
|
||||
|
||||
public static all(): Record<string, FiscalYearFormat> {
|
||||
return FiscalYearFormat.allInstancesByTypeName;
|
||||
}
|
||||
|
||||
public static values(): FiscalYearFormat[] {
|
||||
return FiscalYearFormat.allInstances;
|
||||
}
|
||||
|
||||
public static valueOf(type: number): FiscalYearFormat | undefined {
|
||||
return FiscalYearFormat.allInstancesByType[type];
|
||||
}
|
||||
|
||||
public static parse(displayName: string): FiscalYearFormat | undefined {
|
||||
return FiscalYearFormat.allInstancesByTypeName[displayName];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MapPosition {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
@@ -158,6 +158,7 @@ export class ChartDateAggregationType implements TypeAndName {
|
||||
public static readonly Month = new ChartDateAggregationType(0, 'Aggregate by Month');
|
||||
public static readonly Quarter = new ChartDateAggregationType(1, 'Aggregate by Quarter');
|
||||
public static readonly Year = new ChartDateAggregationType(2, 'Aggregate by Year');
|
||||
public static readonly FiscalYear = new ChartDateAggregationType(3, 'Aggregate by Fiscal Year');
|
||||
|
||||
public static readonly Default = ChartDateAggregationType.Month;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user