From 22dc5c20469ed73c85c27da7b9dd6e6a336ea46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=BCndig?= Date: Fri, 24 Jan 2025 18:57:07 +0100 Subject: [PATCH] Functions: Fix first payout email function (#1023) --- admin/src/views/NextSurveysView.tsx | 4 +- .../cron/first-payout-email/index.ts | 22 ++-- .../firestore-auditor/FirestoreAuditor.ts | 2 + .../webhooks/admin/scripts/SurveyManager.ts | 6 +- .../src/lib/PostfinancePaymentsFileHandler.ts | 2 +- shared/src/stripe/StripeEventHandler.ts | 4 +- shared/src/utils/date.test.ts | 113 ++++++++++++++++++ .../stats/ContributionStatsCalculator.test.ts | 2 +- 8 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 shared/src/utils/date.test.ts diff --git a/admin/src/views/NextSurveysView.tsx b/admin/src/views/NextSurveysView.tsx index cfd1cf925..f62725cea 100644 --- a/admin/src/views/NextSurveysView.tsx +++ b/admin/src/views/NextSurveysView.tsx @@ -1,10 +1,9 @@ import { Box, Button, Link, Popover, Typography } from '@mui/material'; import { DataGrid } from '@mui/x-data-grid'; import { Recipient } from '@socialincome/shared/src/types/recipient'; -import { Survey, SurveyStatus, getSurveyUrl } from '@socialincome/shared/src/types/survey'; +import { getSurveyUrl, Survey, SurveyStatus } from '@socialincome/shared/src/types/survey'; import { toDate, toDateTime } from '@socialincome/shared/src/utils/date'; import { - QueryDocumentSnapshot, and, collectionGroup, getDoc, @@ -13,6 +12,7 @@ import { or, orderBy, query, + QueryDocumentSnapshot, where, } from 'firebase/firestore'; import { StringPropertyPreview } from 'firecms'; diff --git a/functions/src/functions/cron/first-payout-email/index.ts b/functions/src/functions/cron/first-payout-email/index.ts index 21e491d58..a27d14d21 100644 --- a/functions/src/functions/cron/first-payout-email/index.ts +++ b/functions/src/functions/cron/first-payout-email/index.ts @@ -7,12 +7,12 @@ import { FirstPayoutEmailTemplateData, SendgridMailClient, } from '../../../../../shared/src/sendgrid/SendgridMailClient'; -import { CONTRIBUTION_FIRESTORE_PATH, Contribution } from '../../../../../shared/src/types/contribution'; +import { Contribution, CONTRIBUTION_FIRESTORE_PATH } from '../../../../../shared/src/types/contribution'; import { LanguageCode } from '../../../../../shared/src/types/language'; -import { USER_FIRESTORE_PATH, User } from '../../../../../shared/src/types/user'; +import { User, USER_FIRESTORE_PATH } from '../../../../../shared/src/types/user'; import { toDateTime } from '../../../../../shared/src/utils/date'; -export const getFirstPayoutEmailReceivers = async ( +const getFirstPayoutEmailReceivers = async ( firestoreAdmin: FirestoreAdmin, from: DateTime, to: DateTime, @@ -65,15 +65,14 @@ export const getFirstPayoutEmailReceivers = async ( ).flat(); }; -// Run on the 16th of every month at 15:00 UTC -export default onSchedule({ schedule: '0 15 16 * *', memory: '2GiB' }, async () => { - let message: string = ''; +export async function sendFirstPayoutEmail() { + let message = ''; const sendgridClient = new SendgridMailClient(process.env.SENDGRID_API_KEY!); try { const firestoreAdmin = new FirestoreAdmin(); const now = DateTime.now(); - const fromDate = DateTime.fromObject({ year: now.year, month: now.month - 1, day: 16, hour: 0 }, { zone: 'utc' }); - const toDate = DateTime.fromObject({ year: now.year, month: now.month, day: 16, hour: 0 }, { zone: 'utc' }); + const fromDate = now.minus({ months: 1 }).set({ day: 16, hour: 0, minute: 0, second: 0, millisecond: 0 }); + const toDate = now.set({ day: 16, hour: 0, minute: 0, second: 0, millisecond: 0 }); const firstPayoutEmailReceivers = await getFirstPayoutEmailReceivers(firestoreAdmin, fromDate, toDate); await Promise.all( @@ -81,7 +80,6 @@ export default onSchedule({ schedule: '0 15 16 * *', memory: '2GiB' }, async () await sendgridClient.sendFirstPayoutEmail(email, language, templateData); }), ); - message = `Successfully sent first payout emails to ${firstPayoutEmailReceivers.length} users`; logger.info(message); } catch (error) { @@ -95,4 +93,10 @@ export default onSchedule({ schedule: '0 15 16 * *', memory: '2GiB' }, async () text: message, }); } +} + +// Run on the 16th of every month at 15:00 UTC +export default onSchedule({ schedule: '0 15 16 * *', memory: '2GiB' }, async () => { + await sendFirstPayoutEmail(); + logger.info('First payout email cron job finished'); }); diff --git a/functions/src/functions/firestore/firestore-auditor/FirestoreAuditor.ts b/functions/src/functions/firestore/firestore-auditor/FirestoreAuditor.ts index 379114159..a48869df4 100644 --- a/functions/src/functions/firestore/firestore-auditor/FirestoreAuditor.ts +++ b/functions/src/functions/firestore/firestore-auditor/FirestoreAuditor.ts @@ -3,6 +3,7 @@ import { Change } from 'firebase-functions'; import { isEqual } from 'lodash'; import { DateTime } from 'luxon'; import { FirestoreAdmin } from '../../../../../shared/src/firebase/admin/FirestoreAdmin'; + import { toFirebaseAdminTimestamp } from '../../../../../shared/src/firebase/admin/utils'; /** @@ -16,6 +17,7 @@ export class FirestoreAuditor { constructor() { this.firestoreAdmin = new FirestoreAdmin(); } + /** * Takes care of * - updating the last_updated_at to now field in the document which got changed diff --git a/functions/src/functions/webhooks/admin/scripts/SurveyManager.ts b/functions/src/functions/webhooks/admin/scripts/SurveyManager.ts index a20e09a2e..7eb6eddc2 100644 --- a/functions/src/functions/webhooks/admin/scripts/SurveyManager.ts +++ b/functions/src/functions/webhooks/admin/scripts/SurveyManager.ts @@ -3,17 +3,17 @@ import { AuthAdmin } from '../../../../../../shared/src/firebase/admin/AuthAdmin import { FirestoreAdmin } from '../../../../../../shared/src/firebase/admin/FirestoreAdmin'; import { toFirebaseAdminTimestamp } from '../../../../../../shared/src/firebase/admin/utils'; import { - RECIPIENT_FIRESTORE_PATH, Recipient, + RECIPIENT_FIRESTORE_PATH, RecipientMainLanguage, RecipientProgramStatus, } from '../../../../../../shared/src/types/recipient'; import { - SURVEY_FIRETORE_PATH, + recipientSurveys, Survey, + SURVEY_FIRETORE_PATH, SurveyQuestionnaire, SurveyStatus, - recipientSurveys, } from '../../../../../../shared/src/types/survey'; import { rndString } from '../../../../../../shared/src/utils/crypto'; import { toDateTime } from '../../../../../../shared/src/utils/date'; diff --git a/functions/src/lib/PostfinancePaymentsFileHandler.ts b/functions/src/lib/PostfinancePaymentsFileHandler.ts index 2acccc7a5..f9862a005 100644 --- a/functions/src/lib/PostfinancePaymentsFileHandler.ts +++ b/functions/src/lib/PostfinancePaymentsFileHandler.ts @@ -13,7 +13,7 @@ import { StatusKey, } from '../../../shared/src/types/contribution'; import { Currency } from '../../../shared/src/types/currency'; -import { USER_FIRESTORE_PATH, User } from '../../../shared/src/types/user'; +import { User, USER_FIRESTORE_PATH } from '../../../shared/src/types/user'; import { POSTFINANCE_FTP_HOST, POSTFINANCE_FTP_PORT, diff --git a/shared/src/stripe/StripeEventHandler.ts b/shared/src/stripe/StripeEventHandler.ts index 33c3a97da..2ca7f9851 100644 --- a/shared/src/stripe/StripeEventHandler.ts +++ b/shared/src/stripe/StripeEventHandler.ts @@ -11,8 +11,8 @@ import { StripeContribution, } from '../types/contribution'; import { CountryCode } from '../types/country'; -import { Currency, bestGuessCurrency } from '../types/currency'; -import { USER_FIRESTORE_PATH, User, splitName } from '../types/user'; +import { bestGuessCurrency, Currency } from '../types/currency'; +import { splitName, User, USER_FIRESTORE_PATH } from '../types/user'; export class StripeEventHandler { readonly stripe: Stripe; diff --git a/shared/src/utils/date.test.ts b/shared/src/utils/date.test.ts new file mode 100644 index 000000000..d8417af32 --- /dev/null +++ b/shared/src/utils/date.test.ts @@ -0,0 +1,113 @@ +import { Timestamp } from 'firebase/firestore'; +import { DateTime } from 'luxon'; +import { toFirebaseAdminTimestamp } from '../firebase/admin/utils'; +import { daysUntilTs, getMonthId, getMonthIDs, toDate, toDateTime } from './date'; + +describe('getMonthId', () => { + test('formats single digit month with leading zero', () => { + expect(getMonthId(2023, 5)).toBe('2023-05'); + }); + + test('formats double digit month correctly', () => { + expect(getMonthId(2023, 12)).toBe('2023-12'); + }); +}); + +describe('getMonthIDs', () => { + test('returns correct number of months', () => { + const date = new Date('2023-05-15'); + const result = getMonthIDs(date, 3); + expect(result).toHaveLength(3); + }); + + test('handles year rollover correctly', () => { + const date = new Date('2023-02-15'); + const result = getMonthIDs(date, 3); + expect(result).toEqual(['2023-02', '2023-01', '2022-12']); + }); + + test('returns empty array for last_n = 0', () => { + const date = new Date('2023-05-15'); + const result = getMonthIDs(date, 0); + expect(result).toEqual([]); + }); +}); + +describe('toDateTime', () => { + test('converts Date to DateTime', () => { + const date = new Date('2023-05-15T12:00:00Z'); + const result = toDateTime(date); + expect(result.isValid).toBe(true); + expect(result.toISO()).toBe('2023-05-15T12:00:00.000Z'); + }); + + test('converts number (milliseconds) to DateTime', () => { + const timestamp = 1684147200000; // 2023-05-15T10:40:00Z + const result = toDateTime(timestamp); + expect(result.isValid).toBe(true); + expect(result.toISO()).toBe('2023-05-15T10:40:00.000Z'); + }); + + test('converts Timestamp to DateTime', () => { + const timestamp = Timestamp.fromDate(new Date('2023-05-15T10:40:00Z')); + const result = toDateTime(timestamp); + expect(result.isValid).toBe(true); + expect(result.toISO()).toBe('2023-05-15T10:40:00.000Z'); + }); + + test('respects timezone parameter', () => { + const date = new Date('2023-05-15T12:00:00Z'); + const result = toDateTime(date, 'America/New_York'); + expect(result.zoneName).toBe('America/New_York'); + }); +}); + +describe('toDate', () => { + test('converts DateTime to Date', () => { + const dateTime = DateTime.fromISO('2023-05-15T12:00:00Z'); + const result = toDate(dateTime); + expect(result instanceof Date).toBe(true); + expect(result.toISOString()).toBe('2023-05-15T12:00:00.000Z'); + }); +}); + +describe('daysUntilTs', () => { + beforeEach(() => { + // Mock current date to make tests deterministic + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-05-15T12:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('calculates positive days for future date', () => { + const futureDate = new Date('2023-05-20T12:00:00Z'); + expect(daysUntilTs(futureDate)).toBe(5); + }); + + test('calculates negative days for past date', () => { + const pastDate = new Date('2023-05-10T12:00:00Z'); + expect(daysUntilTs(pastDate)).toBe(-5); + }); + + test('returns 0 for same day', () => { + const sameDate = new Date('2023-05-15T12:00:00Z'); + expect(daysUntilTs(sameDate)).toBe(0); + }); +}); + +describe('toFirebaseAdminTimestamp', () => { + test('converts Date to Timestamp', () => { + const date = new Date('2023-05-15T12:00:00Z'); + const result = toFirebaseAdminTimestamp(date); + expect(result.toDate().toISOString()).toBe('2023-05-15T12:00:00.000Z'); + }); + + test('converts DateTime to Timestamp', () => { + const dateTime = DateTime.fromISO('2023-05-15T12:00:00Z'); + const result = toFirebaseAdminTimestamp(dateTime); + expect(result.toDate().toISOString()).toBe('2023-05-15T12:00:00.000Z'); + }); +}); diff --git a/shared/src/utils/stats/ContributionStatsCalculator.test.ts b/shared/src/utils/stats/ContributionStatsCalculator.test.ts index 99dab50df..7715f9162 100644 --- a/shared/src/utils/stats/ContributionStatsCalculator.test.ts +++ b/shared/src/utils/stats/ContributionStatsCalculator.test.ts @@ -5,7 +5,7 @@ import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin'; import { getOrInitializeFirebaseAdmin } from '../../firebase/admin/app'; import { toFirebaseAdminTimestamp } from '../../firebase/admin/utils'; import { CONTRIBUTION_FIRESTORE_PATH, ContributionSourceKey, StatusKey } from '../../types/contribution'; -import { USER_FIRESTORE_PATH, User } from '../../types/user'; +import { User, USER_FIRESTORE_PATH } from '../../types/user'; import { ContributionStatsCalculator } from './ContributionStatsCalculator'; const projectId = 'contribution-stats-calculator-test';