diff --git a/src/app/recurringtransactions/recurringtransaction.ts b/src/app/recurringtransactions/recurringtransaction.ts new file mode 100644 index 0000000..bf6aea6 --- /dev/null +++ b/src/app/recurringtransactions/recurringtransaction.ts @@ -0,0 +1,221 @@ +export class RecurringTransaction { + id: string = ''; + title: string; + description?: string = null; + frequency: Frequency; + start: Date = new Date(); + end?: Date; + amount: number; + expense = true; + categoryId: string; + budgetId: string; + createdBy: string; +} + +export class Frequency { + unit: FrequencyUnit; + count: number; + time: Time; + amount?: (void | Set | DayOfMonth | DayOfYear); + + private constructor(unit: FrequencyUnit, count: number, time: Time, amount?: (void | Set | DayOfMonth | DayOfYear)) { + this.unit = unit; + this.count = count; + this.time = time; + this.amount = amount; + } + + static Daily(count: number, time: Time): Frequency { + return new Frequency(FrequencyUnit.DAILY, count, time); + } + + static Weekly(count: number, time: Time, daysOfWeek: Set): Frequency { + return new Frequency(FrequencyUnit.WEEKLY, count, time, daysOfWeek) + } + + static Monthly(count: number, time: Time, dayOfMonth: DayOfMonth): Frequency { + return new Frequency(FrequencyUnit.MONTHLY, count, time, dayOfMonth) + } + + static Yearly(count: number, time: Time, dayOfYear: DayOfYear): Frequency { + return new Frequency(FrequencyUnit.YEARLY, count, time, dayOfYear) + } + + static parse(s: string): Frequency { + const parts = s.split(';'); + let count: number, time: Time; + switch (parts[0]) { + case 'D': + count = Number.parseInt(parts[1]); + time = Time.parse(parts[2]); + return this.Daily(count, time); + case 'W': + count = Number.parseInt(parts[1]); + time = Time.parse(parts[3]); + const daysOfWeek = new Set(parts[2].split(',').map(day => DayOfWeek[day])); + return this.Weekly(count, time, daysOfWeek); + case 'M': + count = Number.parseInt(parts[1]); + time = Time.parse(parts[3]); + const dayOfMonth = DayOfMonth.parse(parts[2]); + return this.Monthly(count, time, dayOfMonth); + case 'Y': + count = Number.parseInt(parts[1]); + time = Time.parse(parts[3]); + const dayOfYear = DayOfYear.parse(parts[2]); + return this.Yearly(count, time, dayOfYear); + default: + throw new Error(`Invalid Frequency format: ${s}`); + } + } + + toString(): string { + let parts = [this.unit.toString()] + parts.push(this.count.toString()) + if (this.amount) { + if (this.unit === FrequencyUnit.WEEKLY) { + parts.push(Array.from(this.amount as Set).join(',')) + } else { + parts.push(this.amount.toString()) + } + } + parts.push(this.time.toString()) + return parts.join(';') + } +} + +export enum FrequencyUnit { + DAILY = 'D', + WEEKLY = 'W', + MONTHLY = 'M', + YEARLY = 'Y', +} + +export class Time { + hours: number; + minutes: number; + seconds: number; + + constructor(hours: number, minutes: number, seconds: number) { + this.hours = hours; + this.minutes = minutes; + this.seconds = seconds; + } + + toString(): string { + return [ + String(this.hours).padStart(2, '0'), + String(this.minutes).padStart(2, '0'), + String(this.seconds).padStart(2, '0'), + ].join(':') + } + + static parse(s: string): Time { + if (!s.match(/[0-9]{2}:[0-9]{2}:[0-9]{2}/)) { + throw new Error('Invalid time format. Time must be formatted as HH:mm:ss'); + } + const parts = s.split(':').map(part => Number.parseInt(part)); + return new Time(parts[0], parts[1], parts[2]); + } +} + +export enum Position { + DAY = 'DAY', + FIRST = 'FIRST', + SECOND = 'SECOND', + THIRD = 'THIRD', + FOURTH = 'FOURTH', + LAST = 'LAST', +} + +export enum DayOfWeek { + MONDAY = 'MONDAY', + TUESDAY = 'TUESDAY', + WEDNESDAY = 'WEDNESDAY', + THURSDAY = 'THURSDAY', + FRIDAY = 'FRIDAY', + SATURDAY = 'SATURDAY', + SUNDAY = 'SUNDAY', +} + +export class DayOfMonth { + position: Position; + day: (number | DayOfWeek); + + private constructor(position: Position, day: (number | DayOfWeek)) { + this.position = position; + this.day = day; + } + + static Each(day: number): DayOfMonth { + if (day < 1 || day > 31) { + throw new Error('Day must be between 1 and 31'); + } + return new DayOfMonth(Position.DAY, day); + } + + static PositionalDayOfWeek(position: Position, day: DayOfWeek): DayOfMonth { + if (position === Position.DAY) { + throw new Error('Use DayOfMonth.Each() to create a monthly recurring transaction on the same calendar day'); + } + return new DayOfMonth(position, day) + } + + static parse(s: string): DayOfMonth { + const parts = s.split('-'); + const position = Position[parts[0]]; + if (position === Position.DAY) { + return DayOfMonth.Each(Number.parseInt(parts[1])); + } else { + return DayOfMonth.PositionalDayOfWeek(position, DayOfWeek[parts[1]]); + } + } + + toString(): string { + return `${this.position}-${this.day}` + } +} + +export class DayOfYear { + month: number; + day: number; + + constructor(month: number, day: number) { + this.month = month; + this.day = day; + } + + static parse(s: string): DayOfYear { + if (!s.match(/[0-9]{2}-[0-9]{2}/)) { + throw new Error(`Invalid format for DayOfYear: ${s}`) + } + const parts = s.split('-').map(part => Number.parseInt(part)); + if (parts[0] < 1 || parts[0] > 12) { + throw new Error(`Invalid month for DayOfYear: ${parts[0]}`); + } + let maxDay: number; + switch (parts[0]) { + case 2: + maxDay = 29; + break; + case 4: + case 6: + case 9: + case 11: + maxDay = 30; + break; + default: + maxDay = 31; + } + if (parts[1] < 1 || parts[1] > maxDay) { + throw new Error(`Invalid day for DayOfYear: ${parts[0]}`); + } + return new DayOfYear(parts[0], parts[1]); + } + + toString(): string { + const monthString = this.month.toString().padStart(2, '0') + const dayString = this.day.toString().padStart(2, '0') + return `${monthString}-${dayString}` + } +} \ No newline at end of file