From bbb736ba60aacd2a2c62561d3fc25300bc19c129 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:23:41 -0500 Subject: [PATCH 1/5] handle recurring donations --- .../src/donations/donations.service.spec.ts | 379 +++++++++++++++++- .../src/donations/donations.service.ts | 122 +++++- .../src/foodRequests/request.controller.ts | 6 - apps/backend/src/orders/order.service.spec.ts | 5 +- package.json | 2 +- 5 files changed, 487 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 16165536..20541753 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -1,30 +1,81 @@ -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; -import { mock } from 'jest-mock-extended'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { RecurrenceEnum } from './types'; -const mockDonationRepository = mock>(); -const mockFoodManufacturerRepository = mock>(); +jest.setTimeout(60000); + +const today = new Date(); +today.setHours(0, 0, 0, 0); + +const daysAgo = (numDays: number) => { + const date = new Date(today); + date.setDate(date.getDate() - numDays); + date.setHours(0, 0, 0, 0); + return date; +}; + +const daysFromNow = (numDays: number) => { + const date = new Date(today); + date.setDate(date.getDate() + numDays); + date.setHours(0, 0, 0, 0); + return date; +}; + +// insert a minimal donation and return its generated ID +async function insertDonation(overrides: { + recurrence: RecurrenceEnum; + recurrenceFreq: number; + nextDonationDates: Date[]; + occurrencesRemaining: number; +}): Promise { + // uses FoodCorp Industries manufacturer from test seed data + const result = await testDataSource.query( + `INSERT INTO donations + (food_manufacturer_id, status, recurrence, recurrence_freq, + next_donation_dates, occurrences_remaining) + VALUES + ( + (SELECT food_manufacturer_id FROM food_manufacturers + WHERE food_manufacturer_name = 'FoodCorp Industries' LIMIT 1), + 'available', + $1, $2, $3, $4 + ) + RETURNING donation_id`, + [ + overrides.recurrence, + overrides.recurrenceFreq, + overrides.nextDonationDates, + overrides.occurrencesRemaining, + ], + ); + return result[0].donation_id; +} describe('DonationService', () => { let service: DonationService; beforeAll(async () => { - mockDonationRepository.count.mockReset(); + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); - const module = await Test.createTestingModule({ + const module: TestingModule = await Test.createTestingModule({ providers: [ DonationService, { provide: getRepositoryToken(Donation), - useValue: mockDonationRepository, + useValue: testDataSource.getRepository(Donation), }, { provide: getRepositoryToken(FoodManufacturer), - useValue: mockFoodManufacturerRepository, + useValue: testDataSource.getRepository(FoodManufacturer), }, ], }).compile(); @@ -32,18 +83,318 @@ describe('DonationService', () => { service = module.get(DonationService); }); + beforeEach(async () => { + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + it('should be defined', () => { expect(service).toBeDefined(); }); describe('getDonationCount', () => { - it.each([[0], [5]])('should return %i of donations', async (count) => { - mockDonationRepository.count.mockResolvedValue(count); + it('returns total number of donations in the database', async () => { + const donationCount = await service.getNumberOfDonations(); + expect(donationCount).toEqual(4); + }); + }); + + describe('handleRecurringDonations', () => { + describe('no-op cases', () => { + it('skips donation with no nextDonationDates', async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toEqual([]); + expect(donation.occurrencesRemaining).toEqual(3); + }); + + it('skips donation whose nextDonationDates are all in the future', async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysFromNow(7)], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates.length).toEqual(1); + expect(donation.occurrencesRemaining).toEqual(3); + }); + }); + + describe('single expired date', () => { + it('removes expired date and adds next weekly occurrence', async () => { + const pastDate = daysAgo(5); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setDate(expectedNextDate.getDate() + 7); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it(`'removes expired date and adds next monthly occurrence'`, async () => { + const pastDate = daysAgo(30); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setMonth(expectedNextDate.getMonth() + 1); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it('removes expired date and adds next yearly occurrence', async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.YEARLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setFullYear(expectedNextDate.getFullYear() + 1); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + }); + + describe('expired and future dates coexisting', () => { + it('processes expired date and preserves the existing future date', async () => { + const pastDate = daysAgo(1); + const futureDate = daysFromNow(1); + + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate, futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + + const replacementDate = new Date(pastDate); + replacementDate.setDate(replacementDate.getDate() + 7); + + expect(donation.nextDonationDates).toHaveLength(2); + + const times = donation.nextDonationDates.map((d) => + new Date(d).getTime(), + ); + expect(times).toContain(futureDate.getTime()); + expect(times).toContain(replacementDate.getTime()); + + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it('processes only past dates and leaves future dates intact', async () => { + const pastDate = daysAgo(7); + const futureDate = daysFromNow(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate, futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + + const times = donation.nextDonationDates.map((d) => + new Date(d).getTime(), + ); + expect(times).toContain(futureDate.getTime()); + + expect(donation.occurrencesRemaining).toEqual(1); + }); + }); + + describe('occurrence exhaustion', () => { + it(`removes expired date without adding replacement when it was the last occurrence`, async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 1, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + + it(`doesn't add replacement for non-recurring donation`, async () => { + await service.handleRecurringDonations(); + + const donation = await service.findOne(1); + expect(donation.nextDonationDates).toBeNull(); + expect(donation.occurrencesRemaining).toBeNull(); + }); + + it('clears nextDonationDates when occurrencesRemaining is already 0', async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 0, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + }); + + describe('cascading recalculation', () => { + it('advances through multiple expired replacement dates until a future date is reached', async () => { + const pastDate1 = daysAgo(10); + const pastDate2 = daysAgo(3); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate1, pastDate2], + occurrencesRemaining: 4, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.occurrencesRemaining).toEqual(1); + expect( + new Date(donation.nextDonationDates[0]).getTime(), + ).toBeGreaterThan(new Date().getTime()); + }); + + it('stops advancing and schedules no replacement when occurrences run out mid-cascade', async () => { + const pastDate1 = daysAgo(14); + const pastDate2 = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate1, pastDate2], + occurrencesRemaining: 1, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + }); + + describe('multiple donations', () => { + it('processes each donation independently based on their recurrence rules', async () => { + const pastDate1 = daysAgo(14); + const pastDate2 = daysAgo(7); + const futureDate = daysFromNow(7); + + const donationId1 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 3, + nextDonationDates: [pastDate1], + occurrencesRemaining: 2, + }); + + const donationId2 = await insertDonation({ + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate2], + occurrencesRemaining: 1, + }); + + const donationId3 = await insertDonation({ + recurrence: RecurrenceEnum.YEARLY, + recurrenceFreq: 1, + nextDonationDates: [futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation1 = await service.findOne(donationId1); + const donation2 = await service.findOne(donationId2); + const donation3 = await service.findOne(donationId3); + + expect(donation1.nextDonationDates).toHaveLength(1); + expect( + new Date(donation1.nextDonationDates[0]).getTime(), + ).toBeGreaterThan(new Date().getTime()); + expect(donation1.occurrencesRemaining).toEqual(1); - const donationCount: number = await service.getNumberOfDonations(); + expect(donation2.nextDonationDates).toHaveLength(0); + expect(donation2.occurrencesRemaining).toEqual(0); - expect(donationCount).toEqual(count); - expect(mockDonationRepository.count).toHaveBeenCalled(); + expect(donation3.nextDonationDates).toHaveLength(1); + expect(new Date(donation3.nextDonationDates[0]).toDateString()).toEqual( + futureDate.toDateString(), + ); + expect(donation3.occurrencesRemaining).toEqual(3); + }); }); }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 1dd719d5..628061b8 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,14 +1,16 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; -import { DonationStatus } from './types'; +import { DonationStatus, RecurrenceEnum } from './types'; import { CreateDonationDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Injectable() export class DonationService { + private readonly logger = new Logger(DonationService.name); + constructor( @InjectRepository(Donation) private repo: Repository, @InjectRepository(FoodManufacturer) @@ -78,7 +80,119 @@ export class DonationService { } async handleRecurringDonations(): Promise { - console.log('Accessing donation service from cron job'); - // TODO: Implement logic for sending reminder emails + const donations = await this.getAll(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (const donation of donations) { + if ( + !donation.nextDonationDates || + donation.nextDonationDates.length === 0 + ) { + continue; + } + + let dates = [...donation.nextDonationDates].sort( + (a, b) => a.getTime() - b.getTime(), + ); + + let occurrences = donation.occurrencesRemaining; + let occurrencesUpdated = false; + + for (let i = 0; i < dates.length; i++) { + const currentDate = dates[i]; + + // all remaining dates are in future + if (currentDate.getTime() > today.getTime()) { + break; + } + + // recurrence has ended, clear nextDonationDates + if (occurrences <= 0) { + dates = []; + occurrencesUpdated = true; + break; + } + + this.logger.log(`Placeholder for sending automated email`); + + /** + * IMPORTANT: future logic below should only proceed if the email is successfully sent + */ + const emailSent = true; + if (!emailSent) continue; + + dates.splice(i, 1); + i--; + occurrences -= 1; + occurrencesUpdated = true; + + if (occurrences > 0 && donation.recurrence !== RecurrenceEnum.NONE) { + let nextDate = this.calculateNextDate( + currentDate, + donation.recurrence, + donation.recurrenceFreq, + ); + + // cascading recalculation of next dates when replacement dates are also expired + while (nextDate.getTime() <= today.getTime() && occurrences > 0) { + this.logger.log( + `Placeholder for sending automated email for replacement date`, + ); + const cascadeEmailSent = true; + if (!cascadeEmailSent) break; + + occurrences -= 1; + + if (occurrences > 0) { + nextDate = this.calculateNextDate( + nextDate, + donation.recurrence, + donation.recurrenceFreq, + ); + } + } + + if (occurrences > 0) { + const alreadyExists = dates.some( + (date) => date.getTime() === nextDate.getTime(), + ); + if (!alreadyExists) { + dates.push(nextDate); + dates.sort((a, b) => a.getTime() - b.getTime()); + } + } + } + } + + if (occurrencesUpdated) { + await this.repo.update(donation.donationId, { + nextDonationDates: dates, + occurrencesRemaining: occurrences, + }); + } + } + } + + private calculateNextDate( + currentDate: Date, + recurrence: RecurrenceEnum, + recurrenceFreq = 1, + ): Date { + const nextDate = new Date(currentDate); + switch (recurrence) { + case RecurrenceEnum.WEEKLY: + nextDate.setDate(nextDate.getDate() + 7 * recurrenceFreq); + break; + case RecurrenceEnum.MONTHLY: + nextDate.setMonth(nextDate.getMonth() + recurrenceFreq); + break; + case RecurrenceEnum.YEARLY: + nextDate.setFullYear(nextDate.getFullYear() + recurrenceFreq); + break; + default: + break; + } + return nextDate; } } diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 230eb0c4..9236ab74 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -163,12 +163,6 @@ export class RequestsController { const uploadedPhotoUrls = photos && photos.length > 0 ? await this.awsS3Service.upload(photos) : []; - console.log( - 'Received photo files:', - photos?.map((p) => p.originalname), - '| Count:', - photos?.length, - ); const updatedRequest = await this.requestsService.updateDeliveryDetails( requestId, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 808ef510..f57ba140 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -21,7 +21,7 @@ describe('OrdersService', () => { } // Clean database at the start - await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); const module: TestingModule = await Test.createTestingModule({ @@ -42,7 +42,8 @@ describe('OrdersService', () => { }); beforeEach(async () => { - // Run all migrations fresh for each test + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); }); diff --git a/package.json b/package.json index d5f75f46..2ea81af1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", - "test": "jest", + "test": "jest --runInBand", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install", From b3b4174e63c7f94928eb9759f2b28e8c2fb335c2 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:25:29 -0500 Subject: [PATCH 2/5] remove unnecessary import --- apps/backend/src/donations/donations.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index e3b0ffcb..9400d3c1 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -7,7 +7,6 @@ import { Param, NotFoundException, ParseIntPipe, - BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; From ab550e1fbe145a1cf82ad0f1fabe77669e340c5f Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:18:58 -0500 Subject: [PATCH 3/5] refactor generate next dates to take in from date, hopefully fix github actions --- .github/workflows/backend-tests.yml | 2 +- .../src/donations/donations.service.spec.ts | 69 +++++++++++-------- .../src/donations/donations.service.ts | 19 ++--- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 98c5a73e..26b4e0f4 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -42,4 +42,4 @@ jobs: node-version: 20 - run: yarn install --frozen-lockfile - run: yarn list strip-ansi string-width string-length - - run: npx jest \ No newline at end of file + - run: yarn test \ No newline at end of file diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 84b9f26b..8e687e2a 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -11,6 +11,7 @@ jest.setTimeout(60000); const today = new Date(); today.setHours(0, 0, 0, 0); +const MOCK_MONDAY = new Date(2025, 0, 6); const daysAgo = (numDays: number) => { const date = new Date(today); @@ -66,10 +67,6 @@ const allFalse: RepeatOnDaysDto = { Saturday: false, }; -// Pin "today" to a known day so tests are deterministic. -// 2025-01-06 is a Monday. -const MOCK_MONDAY = new Date('2025-01-06T12:00:00.000Z'); - const toDayOfWeek = (iso: string): DayOfWeek => { const days: DayOfWeek[] = [ 'Sunday', @@ -112,14 +109,11 @@ describe('DonationService', () => { }); afterEach(async () => { - jest.useRealTimers(); await testDataSource.query(`DROP SCHEMA public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); }); beforeEach(async () => { - jest.useFakeTimers(); - jest.setSystemTime(MOCK_MONDAY); await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); @@ -432,6 +426,7 @@ describe('DonationService', () => { describe('generateNextDonationDates', () => { it('WEEKLY - returns empty array when no days are selected', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, allFalse, @@ -442,6 +437,7 @@ describe('DonationService', () => { it('WEEKLY - returns one date when exactly one day is selected (freq = 1)', async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -458,6 +454,7 @@ describe('DonationService', () => { Friday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -470,9 +467,10 @@ describe('DonationService', () => { }); it('WEEKLY - offsets dates correctly when freq = 2', async () => { - // Today is Monday 2025-01-06, day 14 = Monday 2025-01-20, +2 for Wed = Jan 22. + // From date is Monday 2025-01-06, day 14 = Monday 2025-01-20, +2 for Wed = Jan 22. const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 2, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -482,8 +480,8 @@ describe('DonationService', () => { expect(toDayOfWeek(result[0])).toBe('Wednesday'); const resultDate = new Date(result[0]); - expect(resultDate.getUTCDate()).toBe(22); - expect(resultDate.getUTCMonth()).toBe(0); + expect(resultDate.getDate()).toBe(22); + expect(resultDate.getMonth()).toBe(0); }); it('WEEKLY - returns dates in ascending order', async () => { @@ -494,6 +492,7 @@ describe('DonationService', () => { Saturday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -506,6 +505,7 @@ describe('DonationService', () => { it("WEEKLY - does not include today's DOW if selected", async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Monday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -516,6 +516,7 @@ describe('DonationService', () => { it('MONTHLY - returns exactly one date', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -525,6 +526,7 @@ describe('DonationService', () => { it('MONTHLY - adds correct number of months for freq = 1', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -532,13 +534,14 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 1 month = 2025-02-06 - expect(resultDate.getUTCFullYear()).toBe(2025); - expect(resultDate.getUTCMonth()).toBe(1); // February - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getFullYear()).toBe(2025); + expect(resultDate.getMonth()).toBe(1); // February + expect(resultDate.getDate()).toBe(6); }); it('MONTHLY - adds correct number of months for freq = 3', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 3, RecurrenceEnum.MONTHLY, null, @@ -546,12 +549,13 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 3 months = 2025-04-06 - expect(resultDate.getUTCMonth()).toBe(3); // April - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getMonth()).toBe(3); // April + expect(resultDate.getDate()).toBe(6); }); it('MONTHLY - rolls over the year correctly', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 12, RecurrenceEnum.MONTHLY, null, @@ -559,8 +563,8 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 12 months = 2026-01-06 - expect(resultDate.getUTCFullYear()).toBe(2026); - expect(resultDate.getUTCMonth()).toBe(0); // January + expect(resultDate.getFullYear()).toBe(2026); + expect(resultDate.getMonth()).toBe(0); // January }); it('MONTHLY - ignores repeatOnDays', async () => { @@ -570,11 +574,13 @@ describe('DonationService', () => { Friday: true, }; const withDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, repeatOnDays, ); const withoutDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -584,19 +590,20 @@ describe('DonationService', () => { }); it('MONTHLY - clamps to 28th when today is the 29th', async () => { - jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); const result = await service.generateNextDonationDates( + new Date('2025-01-29T12:00:00.000Z'), 1, RecurrenceEnum.MONTHLY, null, ); - expect(new Date(result[0]).getUTCDate()).toBe(28); - expect(new Date(result[0]).getUTCMonth()).toBe(1); // February + expect(new Date(result[0]).getDate()).toBe(28); + expect(new Date(result[0]).getMonth()).toBe(1); // February }); it('YEARLY - returns exactly one date', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -606,6 +613,7 @@ describe('DonationService', () => { it('YEARLY - adds correct number of years for freq = 1', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -613,29 +621,32 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 1 year = 2026-01-06 - expect(resultDate.getUTCFullYear()).toBe(2026); - expect(resultDate.getUTCMonth()).toBe(0); // January - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getFullYear()).toBe(2026); + expect(resultDate.getMonth()).toBe(0); // January + expect(resultDate.getDate()).toBe(6); }); it('YEARLY - adds correct number of years for freq = 5', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 5, RecurrenceEnum.YEARLY, null, ); - expect(new Date(result[0]).getUTCFullYear()).toBe(2030); + expect(new Date(result[0]).getFullYear()).toBe(2030); }); it('YEARLY - ignores repeatOnDays', async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const withDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, repeatOnDays, ); const withoutDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -645,20 +656,21 @@ describe('DonationService', () => { }); it('YEARLY - clamps to 28th when today is the 29th', async () => { - jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); const result = await service.generateNextDonationDates( + new Date('2025-01-29T12:00:00.000Z'), 1, RecurrenceEnum.YEARLY, null, ); - expect(new Date(result[0]).getUTCFullYear()).toBe(2026); - expect(new Date(result[0]).getUTCDate()).toBe(28); - expect(new Date(result[0]).getUTCMonth()).toBe(0); // January + expect(new Date(result[0]).getFullYear()).toBe(2026); + expect(new Date(result[0]).getDate()).toBe(28); + expect(new Date(result[0]).getMonth()).toBe(0); // January }); it('NONE - returns empty array', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.NONE, null, @@ -673,6 +685,7 @@ describe('DonationService', () => { Friday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.NONE, repeatOnDays, diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 7ee310cc..a5b9ba1c 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -55,6 +55,7 @@ export class DonationService { const nextDonationDates = donationData.recurrence !== RecurrenceEnum.NONE ? await this.generateNextDonationDates( + new Date(), donationData.recurrenceFreq, donationData.recurrence, donationData.repeatOnDays ?? null, @@ -206,11 +207,11 @@ export class DonationService { } async generateNextDonationDates( + fromDate: Date, recurrenceFreq: number, recurrence: RecurrenceEnum, - repeatOnDays: RepeatOnDaysDto | null, + repeatOnDays: RepeatOnDaysDto | null = null, ): Promise { - const today = new Date(); const dates: string[] = []; if (recurrence === RecurrenceEnum.WEEKLY) { @@ -234,24 +235,24 @@ export class DonationService { const startDay = recurrenceFreq > 1 ? recurrenceFreq * 7 : 1; for (let i = startDay; i <= startDay + 6; i++) { - const nextDay = daysOfWeek[(today.getDay() + i) % 7]; + const nextDay = daysOfWeek[(fromDate.getDay() + i) % 7]; if (selectedDays.includes(nextDay)) { - const nextDate = new Date(today); - nextDate.setDate(today.getDate() + i); + const nextDate = new Date(fromDate); + nextDate.setDate(fromDate.getDate() + i); dates.push(nextDate.toISOString()); } } } else if (recurrence === RecurrenceEnum.MONTHLY) { - const nextDate = new Date(today); + const nextDate = new Date(fromDate); // Date clamp if the day is later than 28th if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setMonth(today.getMonth() + recurrenceFreq); + nextDate.setMonth(fromDate.getMonth() + recurrenceFreq); dates.push(nextDate.toISOString()); } else if (recurrence === RecurrenceEnum.YEARLY) { - const nextDate = new Date(today); + const nextDate = new Date(fromDate); // Date clamp if the day is later than 28th if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setFullYear(today.getFullYear() + recurrenceFreq); + nextDate.setFullYear(fromDate.getFullYear() + recurrenceFreq); dates.push(nextDate.toISOString()); } return dates; From 7920dec45dd764d1ef890e6f33d202360a541508 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:26:51 -0500 Subject: [PATCH 4/5] cleanup --- .../src/donations/donations.service.spec.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 8e687e2a..432270de 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -9,19 +9,19 @@ import { testDataSource } from '../config/typeormTestDataSource'; jest.setTimeout(60000); -const today = new Date(); -today.setHours(0, 0, 0, 0); +const TODAY = new Date(); +TODAY.setHours(0, 0, 0, 0); const MOCK_MONDAY = new Date(2025, 0, 6); const daysAgo = (numDays: number) => { - const date = new Date(today); + const date = new Date(TODAY); date.setDate(date.getDate() - numDays); date.setHours(0, 0, 0, 0); return date; }; const daysFromNow = (numDays: number) => { - const date = new Date(today); + const date = new Date(TODAY); date.setDate(date.getDate() + numDays); date.setHours(0, 0, 0, 0); return date; @@ -67,7 +67,7 @@ const allFalse: RepeatOnDaysDto = { Saturday: false, }; -const toDayOfWeek = (iso: string): DayOfWeek => { +const TODAYOfWeek = (iso: string): DayOfWeek => { const days: DayOfWeek[] = [ 'Sunday', 'Monday', @@ -192,7 +192,7 @@ describe('DonationService', () => { expect(donation.occurrencesRemaining).toEqual(2); }); - it(`'removes expired date and adds next monthly occurrence'`, async () => { + it('removes expired date and adds next monthly occurrence', async () => { const pastDate = daysAgo(30); const donationId = await insertDonation({ recurrence: RecurrenceEnum.MONTHLY, @@ -309,9 +309,15 @@ describe('DonationService', () => { }); it(`doesn't add replacement for non-recurring donation`, async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.NONE, + recurrenceFreq: null, + nextDonationDates: null, + occurrencesRemaining: null, + }); await service.handleRecurringDonations(); - const donation = await service.findOne(1); + const donation = await service.findOne(donationId); expect(donation.nextDonationDates).toBeNull(); expect(donation.occurrencesRemaining).toBeNull(); }); @@ -444,7 +450,7 @@ describe('DonationService', () => { ); expect(result).toHaveLength(1); - expect(toDayOfWeek(result[0])).toBe('Wednesday'); + expect(TODAYOfWeek(result[0])).toBe('Wednesday'); }); it('WEEKLY - returns dates only for selected days within the target week window', async () => { @@ -461,7 +467,7 @@ describe('DonationService', () => { ); expect(result).toHaveLength(2); - const resultDays = result.map(toDayOfWeek); + const resultDays = result.map(TODAYOfWeek); expect(resultDays).toContain('Wednesday'); expect(resultDays).toContain('Friday'); }); @@ -477,7 +483,7 @@ describe('DonationService', () => { ); expect(result).toHaveLength(1); - expect(toDayOfWeek(result[0])).toBe('Wednesday'); + expect(TODAYOfWeek(result[0])).toBe('Wednesday'); const resultDate = new Date(result[0]); expect(resultDate.getDate()).toBe(22); @@ -502,7 +508,7 @@ describe('DonationService', () => { expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); }); - it("WEEKLY - does not include today's DOW if selected", async () => { + it("WEEKLY - does not include TODAY's DOW if selected", async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Monday: true }; const result = await service.generateNextDonationDates( MOCK_MONDAY, @@ -589,9 +595,9 @@ describe('DonationService', () => { expect(withDays).toEqual(withoutDays); }); - it('MONTHLY - clamps to 28th when today is the 29th', async () => { + it('MONTHLY - clamps to 28th when TODAY is the 29th', async () => { const result = await service.generateNextDonationDates( - new Date('2025-01-29T12:00:00.000Z'), + new Date(2025, 0, 29), 1, RecurrenceEnum.MONTHLY, null, @@ -655,7 +661,7 @@ describe('DonationService', () => { expect(withDays).toEqual(withoutDays); }); - it('YEARLY - clamps to 28th when today is the 29th', async () => { + it('YEARLY - clamps to 28th when TODAY is the 29th', async () => { const result = await service.generateNextDonationDates( new Date('2025-01-29T12:00:00.000Z'), 1, From c54ddca3e1ba5de3cbc2f85871a17dc7b8e97611 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:55:06 -0500 Subject: [PATCH 5/5] review comments pt 1 --- .../backend/src/donations/donations.service.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index a5b9ba1c..d63bebd9 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -102,6 +102,19 @@ export class DonationService { continue; } + if (donation.recurrence === RecurrenceEnum.NONE) continue; + + if ( + !donation.occurrencesRemaining || + donation.occurrencesRemaining <= 0 + ) { + await this.repo.update(donation.donationId, { + nextDonationDates: [], + occurrencesRemaining: 0, + }); + continue; + } + let dates = [...donation.nextDonationDates].sort( (a, b) => a.getTime() - b.getTime(), ); @@ -137,7 +150,7 @@ export class DonationService { occurrences -= 1; occurrencesUpdated = true; - if (occurrences > 0 && donation.recurrence !== RecurrenceEnum.NONE) { + if (occurrences > 0) { let nextDate = this.calculateNextDate( currentDate, donation.recurrence, @@ -169,13 +182,14 @@ export class DonationService { ); if (!alreadyExists) { dates.push(nextDate); - dates.sort((a, b) => a.getTime() - b.getTime()); } } } } if (occurrencesUpdated) { + dates.sort((a, b) => a.getTime() - b.getTime()); + await this.repo.update(donation.donationId, { nextDonationDates: dates, occurrencesRemaining: occurrences,