Skip to content

Commit

Permalink
feature(admin): payout forecast (#855)
Browse files Browse the repository at this point in the history
  • Loading branch information
brennerthomas authored Aug 10, 2024
1 parent 1d56859 commit 162a96c
Show file tree
Hide file tree
Showing 15 changed files with 394 additions and 20 deletions.
2 changes: 2 additions & 0 deletions admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { campaignsCollection } from './collections/Campaigns';
import { buildContributionsCollection } from './collections/Contributions';
import { expensesCollection } from './collections/Expenses';
import { buildPartnerOrganisationsCollection } from './collections/PartnerOrganisations';
import { buildPaymentForecastCollection } from './collections/PaymentForecast';
import { usersCollection } from './collections/Users';
import { buildRecipientsCollection } from './collections/recipients/Recipients';
import { buildRecipientsPaymentsCollection } from './collections/recipients/RecipientsPayments';
Expand Down Expand Up @@ -52,6 +53,7 @@ export default function App() {
buildSurveysCollection({ collectionGroup: true }),
adminsCollection,
expensesCollection,
buildPaymentForecastCollection(),
usersCollection,
campaignsCollection,
buildContributionsCollection({ collectionGroup: true }),
Expand Down
31 changes: 31 additions & 0 deletions admin/src/actions/CreatePaymentForecastAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Button } from '@mui/material';
import { DEFAULT_REGION } from '@socialincome/shared/src/firebase';
import { getFunctions, httpsCallable } from 'firebase/functions';
import { useSnackbarController } from 'firecms';

export function CreatePaymentForecastAction() {
const snackbarController = useSnackbarController();

const createPaymentForecast = () => {
const runPaymentForecastTask = httpsCallable(getFunctions(undefined, DEFAULT_REGION), 'runPaymentForecastTask');
runPaymentForecastTask()
.then((result) => {
snackbarController.open({ type: 'success', message: 'Payment forecast updated successfully' });
})
.catch((reason: Error) => {
snackbarController.open({ type: 'error', message: reason.message });
});
};

return (
<div>
<Button onClick={() => createPaymentForecast()} color="primary">
Refresh Forecast
</Button>
</div>
);
}

function setIsFunctionRunning(arg0: boolean) {
throw new Error('Function not implemented.');
}
2 changes: 1 addition & 1 deletion admin/src/actions/PaymentProcessAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function PaymentProcessAction() {
<DatePicker
label="Payment month"
views={['month', 'year']}
value={paymentDate.toJSDate()}
value={paymentDate.toJSDate() as any}
onChange={(value) => {
if (value) setPaymentDate(toPaymentDate(DateTime.fromJSDate(value)));
}}
Expand Down
53 changes: 53 additions & 0 deletions admin/src/collections/PaymentForecast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { PAYMENT_FORECAST_FIRESTORE_PATH, PaymentForecastEntry } from '@socialincome/shared/src/types/payment-forecast';
import { buildProperties, useSnackbarController } from 'firecms';
import { EntityCollection } from 'firecms/dist/types/collections';
import { CreatePaymentForecastAction } from '../actions/CreatePaymentForecastAction';
import { buildAuditedCollection } from './shared';

export const buildPaymentForecastCollection = () => {
const snackbarController = useSnackbarController();

const collection: EntityCollection<PaymentForecastEntry> = {
name: 'Payout Forecast',
group: 'Finances',
path: PAYMENT_FORECAST_FIRESTORE_PATH,
textSearchEnabled: false,
initialSort: ['order', 'asc'],
icon: 'LocalConvenienceStore',
description: 'Projected payout forecast for the next six months',
Actions: CreatePaymentForecastAction,
permissions: {
edit: false,
create: false,
delete: false,
},
properties: buildProperties<PaymentForecastEntry>({
order: {
dataType: 'number',
name: 'Order',
validation: { required: true },
},
month: {
dataType: 'string',
name: 'Month',
validation: { required: true },
},
numberOfRecipients: {
dataType: 'number',
name: 'Number of Recipients',
validation: { required: true },
},
amount_usd: {
dataType: 'number',
name: 'Total Amount USD',
validation: { required: true },
},
amount_sle: {
dataType: 'number',
name: 'Total Amount SLE',
validation: { required: true },
},
}),
};
return buildAuditedCollection<PaymentForecastEntry>(collection);
};
1 change: 1 addition & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"rules": "firestore.rules"
},
"emulators": {
"singleProjectMode": false,
"auth": {
"port": 9099,
"host": "0.0.0.0"
Expand Down
101 changes: 101 additions & 0 deletions functions/src/webhooks/admin/payment-forecast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { onCall } from 'firebase-functions/v2/https';
import { DateTime } from 'luxon';
import { FirestoreAdmin } from '../../../../../shared/src/firebase/admin/FirestoreAdmin';
import { PAYMENT_AMOUNT_SLE } from '../../../../../shared/src/types/payment';
import { PAYMENT_FORECAST_FIRESTORE_PATH } from '../../../../../shared/src/types/payment-forecast';
import {
calcFinalPaymentDate,
calcPaymentsLeft,
RECIPIENT_FIRESTORE_PATH,
RecipientProgramStatus,
} from '../../../../../shared/src/types/recipient';
import { getLatestExchangeRate } from '../../../../../shared/src/utils/exchangeRates';

function prepareNextSixMonths(): Map<string, number> {
const nextSixMonths: Map<string, number> = new Map();
const now: DateTime = DateTime.now();
for (let i = 1; i < 7; ++i) {
const nextMonthDateTime = now.plus({ months: i });
nextSixMonths.set(nextMonthDateTime.toFormat('LLLL yyyy'), 0);
}
return nextSixMonths;
}

function addRecipient(nextSixMonths: Map<string, number>, paymentsLeft: number) {
nextSixMonths.forEach((value, key) => {
if (paymentsLeft > 0) {
nextSixMonths.set(key, ++value);
paymentsLeft -= 1;
}
});
}

async function calculateUSDAmount(firestoreAdmin: FirestoreAdmin): Promise<number> {
const exchangeRateUSD = await getLatestExchangeRate(firestoreAdmin, 'USD');
const exchangeRateSLE = await getLatestExchangeRate(firestoreAdmin, 'SLE');
const monthlyAllowanceInUSD = (PAYMENT_AMOUNT_SLE / exchangeRateSLE) * exchangeRateUSD;
return parseFloat(monthlyAllowanceInUSD.toFixed(2));
}

async function deleteAllDocuments(firestoreAdmin: FirestoreAdmin): Promise<void> {
const batch = firestoreAdmin.firestore.batch();
const snapshot = await firestoreAdmin.firestore.collection(PAYMENT_FORECAST_FIRESTORE_PATH).get();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
}

async function fillNextSixMonths(
firestoreAdmin: FirestoreAdmin,
nextSixMonthsList: Map<string, number>,
): Promise<void> {
const batch = firestoreAdmin.firestore.batch();
const monthlyAllowanceInUSD = await calculateUSDAmount(firestoreAdmin);
let count = 1;
nextSixMonthsList.forEach((value, key) => {
const newDocRef = firestoreAdmin.firestore.collection(PAYMENT_FORECAST_FIRESTORE_PATH).doc();
batch.set(newDocRef, {
order: count,
month: key,
numberOfRecipients: value,
amount_usd: value * monthlyAllowanceInUSD,
amount_sle: value * PAYMENT_AMOUNT_SLE,
});
++count;
});
await batch.commit();
}

export default onCall<undefined, Promise<string>>({ memory: '2GiB' }, async (request) => {
const firestoreAdmin = new FirestoreAdmin();
try {
await firestoreAdmin.assertGlobalAdmin(request.auth?.token?.email);
const nextSixMonthsList = prepareNextSixMonths();
const recipientsSnapshot = await firestoreAdmin
.collection(RECIPIENT_FIRESTORE_PATH)
.where('progr_status', 'in', [RecipientProgramStatus.Active, RecipientProgramStatus.Designated])
.get();
recipientsSnapshot.docs.map((doc) => {
const recipient = doc.data();
if (recipient.si_start_date && recipient.progr_status === RecipientProgramStatus.Active) {
addRecipient(
nextSixMonthsList,
calcPaymentsLeft(
calcFinalPaymentDate(DateTime.fromSeconds(recipient.si_start_date._seconds, { zone: 'utc' })),
),
);
} else if (recipient.progr_status === RecipientProgramStatus.Designated) {
addRecipient(nextSixMonthsList, 6);
}
});

await deleteAllDocuments(firestoreAdmin);
await fillNextSixMonths(firestoreAdmin, nextSixMonthsList);

return 'Function executed successfully.';
} catch (error) {
console.error('Error during function execution:', error);
throw new Error('An error occurred while processing your request.');
}
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DateTime } from 'luxon';
import { PAYMENT_AMOUNT } from '../../../../../../shared/src/types/payment';
import { PAYMENT_AMOUNT_SLE } from '../../../../../../shared/src/types/payment';
import { PaymentTask } from './PaymentTask';

export class PaymentCSVTask extends PaymentTask {
Expand All @@ -12,7 +12,7 @@ export class PaymentCSVTask extends PaymentTask {
recipients.map(async (recipient) => {
csvRows.push([
recipient.get('mobile_money_phone').phone.toString().slice(-8),
PAYMENT_AMOUNT.toString(),
PAYMENT_AMOUNT_SLE.toString(),
recipient.get('first_name'),
recipient.get('last_name'),
recipient.get('om_uid').toString(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { DateTime } from 'luxon';
import { toFirebaseAdminTimestamp } from '../../../../../../shared/src/firebase/admin/utils';
import {
PAYMENTS_COUNT,
PAYMENT_AMOUNT,
PAYMENT_CURRENCY,
PAYMENT_FIRESTORE_PATH,
Payment,
PAYMENT_AMOUNT_SLE,
PAYMENT_FIRESTORE_PATH,
PAYMENTS_COUNT,
PaymentStatus,
} from '../../../../../../shared/src/types/payment';
import { RECIPIENT_FIRESTORE_PATH, RecipientProgramStatus } from '../../../../../../shared/src/types/recipient';
Expand All @@ -17,7 +16,7 @@ export class UpdateDatabaseEntriesTask extends PaymentTask {
let [paymentsPaid, paymentsCreated, setToActiveCount, setToFormerCount] = [0, 0, 0, 0];
const nextMonthPaymentDate = paymentDate.plus({ months: 1 });
const exchangeRates = await new ExchangeRateImporter().getExchangeRates(paymentDate);
const amountChf = Math.round((PAYMENT_AMOUNT / exchangeRates[PAYMENT_CURRENCY]) * 100) / 100;
const amountChf = Math.round((PAYMENT_AMOUNT_SLE / exchangeRates!['SLE']!) * 100) / 100;
const recipients = await this.getRecipients();

await Promise.all(
Expand All @@ -31,9 +30,9 @@ export class UpdateDatabaseEntriesTask extends PaymentTask {
if (!currentMonthPaymentDoc.exists || currentMonthPaymentDoc.get('status') === PaymentStatus.Created) {
// Payments are set to paid if they have status set to created or if the document doesn't exist yet
await currentMonthPaymentRef.set({
amount: PAYMENT_AMOUNT,
amount: PAYMENT_AMOUNT_SLE,
amount_chf: amountChf,
currency: PAYMENT_CURRENCY,
currency: 'SLE',
payment_at: toFirebaseAdminTimestamp(paymentDate),
status: PaymentStatus.Paid,
phone_number: recipient.get('mobile_money_phone').phone,
Expand Down Expand Up @@ -61,8 +60,8 @@ export class UpdateDatabaseEntriesTask extends PaymentTask {
nextMonthPaymentDate.toFormat('yyyy-MM'),
)
.set({
amount: PAYMENT_AMOUNT,
currency: PAYMENT_CURRENCY,
amount: PAYMENT_AMOUNT_SLE,
currency: 'SLE',
payment_at: toFirebaseAdminTimestamp(nextMonthPaymentDate),
status: PaymentStatus.Created,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test('BatchAddCHFToPayments', async () => {
const exchangeRatesWithoutSLEAndSLL: Map<number, ExchangeRates> = new Map([
[
1682640000, // 2023-04-28 00:00:00
{ XYZ: 25000 },
{ BTC: 25000 },
],
]);
expect(PaymentsManager.calcAmountChf(exchangeRatesWithoutSLEAndSLL, paymentSLE)).toBe(null);
Expand Down
2 changes: 2 additions & 0 deletions functions/src/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import createDonationCertificatesFunction from './admin/donation-certificates';
import paymentForecastFunction from './admin/payment-forecast';
import paymentProcessFunction from './admin/payment-process';
import {
addMissingAmountChfFunction,
Expand All @@ -10,6 +11,7 @@ import surveyLoginFunction from './website/survey-login';

export const createDonationCertificates = createDonationCertificatesFunction;
export const runPaymentProcessTask = paymentProcessFunction;
export const runPaymentForecastTask = paymentForecastFunction;

export const batchImportStripeCharges = batchImportStripeChargesFunction;
export const addMissingAmountChf = addMissingAmountChfFunction;
Expand Down
Loading

0 comments on commit 162a96c

Please sign in to comment.