diff --git a/server/package.json b/server/package.json index e52c9339..13dccb89 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@types/axios": "^0.14.0", "@types/cors": "^2.8.6", + "@types/cron": "^1.7.2", "@types/express": "^4.17.3", "@types/express-rate-limit": "^5.0.0", "@types/hapi__joi": "^17.1.0", @@ -33,6 +34,7 @@ "axios": "^0.19.2", "commander": "^6.1.0", "cors": "^2.8.5", + "cron": "^1.8.2", "dotenv": "^8.2.0", "express": "^4.17.1", "express-rate-limit": "^5.1.3", diff --git a/server/src/core/app.ts b/server/src/core/app.ts index d633cdf9..f2291d59 100644 --- a/server/src/core/app.ts +++ b/server/src/core/app.ts @@ -94,15 +94,15 @@ export interface AppConfig { */ distributionPeriodLength: number; /** - * Interval delay in minutes between two distribution + * Interval delay in CronTime syntax/format between two distribution * processes */ - distributionInterval: number; + distributionInterval: string; /** - * Interval delay in minutes between two distribution + * Interval delay in CronTime syntax/format between two distribution * processes for vetted beneficiaires */ - vettedDistributionInterval: number; + vettedDistributionInterval: string; /** * Interval delay in minutes between two * processes of computing statistics @@ -138,8 +138,8 @@ export function loadAppConfigFromEnv(env: { [key: string]: string }): AppConfig flutterwaveWebhooksRoot: env.FLUTTERWAVE_WEBHOOKS || '/webhooks/flutterwave', distributionPeriodLimit: (env.DISTRIBUTION_PERIOD_LIMIT && Number(env.DISTRIBUTION_PERIOD_LIMIT)) || 2000, distributionPeriodLength: (env.DISTRIBUTION_PERIOD_LENGTH && Number(env.DISTRIBUTION_PERIOD_LENGTH)) || 30, - distributionInterval: (env.DISTRIBUTION_INTERVAL && Number(env.DISTRIBUTION_INTERVAL)) || 2, - vettedDistributionInterval: (env.VETTED_DISTRIBUTION_INTERVAL && Number(env.VETTED_DISTRIBUTION_INTERVAL)) || 5, + distributionInterval: (env.DISTRIBUTION_INTERVAL) || `0 */2 * * * *`, + vettedDistributionInterval: (env.VETTED_DISTRIBUTION_INTERVAL) || `0 */5 * * * *`, statsComputationInterval: (env.STATS_COMPUTATION_INTERVAL && Number(env.STATS_COMPUTATION_INTERVAL)) || 1, googleClientId: env.GOOGLE_CLIENT_ID, sendgridApiKey: env.SENDGRID_API_KEY || '', diff --git a/server/src/core/bootstrap.ts b/server/src/core/bootstrap.ts index 73e1acda..f00f1254 100644 --- a/server/src/core/bootstrap.ts +++ b/server/src/core/bootstrap.ts @@ -39,7 +39,8 @@ export async function bootstrap(config: AppConfig): Promise { paymentProviders.register(flwPaymentProvider); paymentProviders.register(manualPayProvider); paymentProviders.setPreferredForReceiving(flwPaymentProvider.name()); - paymentProviders.setPreferredForSending(manualPayProvider.name()); + paymentProviders.setPreferredForSending(flwPaymentProvider.name()); + paymentProviders.setPreferredForRefunds(manualPayProvider.name()); const systemLocks = new SystemLocks(db); const transactions = new Transactions(db, { paymentProviders, eventBus }); diff --git a/server/src/core/payment/flutterwave-payment-provider.ts b/server/src/core/payment/flutterwave-payment-provider.ts index 97abebc8..fb4232c7 100644 --- a/server/src/core/payment/flutterwave-payment-provider.ts +++ b/server/src/core/payment/flutterwave-payment-provider.ts @@ -1,9 +1,8 @@ -import * as axios from 'axios'; +import * as axios from 'axios'; import { PaymentProvider, PaymentRequestResult, ProviderTransactionInfo, SendFundsResult, TransactionStatus, Transaction } from './types'; import { User } from '../user'; import { generateId } from '../util'; import { createFlutterwaveApiError } from '../error'; -import { not } from '@hapi/joi'; const API_BASE_URL = 'https://api.flutterwave.com/v3'; @@ -28,6 +27,32 @@ interface FlutterwaveInitiatePaymentResponse { } } +interface FlutterwaveTransferInfo { + id: number; + account_number: string; + bank_code: string; + full_name: string; + created_at: string; + currency: string; + debit_currency: string; + amount: number; + fee: number; + status: string; + reference: string; + meta: any; + narration: string; + complete_message: string; + requires_approval: number; + is_approved: number; + bank_name: string; +}; + +interface FlutterwaveInitiateTransferResponse { + status: string; + message: string; + data: FlutterwaveTransferInfo +} + interface FlutterwaveTransactionInfo { id: string; tx_ref: string; @@ -51,7 +76,7 @@ interface FlutterwaveTransactionInfo { interface FlutterwaveNotification { event: string; 'event.type': string; - data: FlutterwaveTransactionInfo; + data: FlutterwaveTransactionInfo | FlutterwaveTransferInfo; } interface FlutterwaveTransactionResponse { @@ -61,12 +86,17 @@ interface FlutterwaveTransactionResponse { } function extractTransactionInfo(data: FlutterwaveTransactionInfo): ProviderTransactionInfo { - const status: TransactionStatus = data.status === 'successful' ? 'success' : - data.status === 'failed' ? 'failed' : 'pending'; + const flwStatus = data.status.toLowerCase(); + const status: TransactionStatus = flwStatus === 'successful' ? 'success' : + flwStatus === 'failed' ? 'failed' : 'pending'; + + // phone number comes in in 07... format, strip the 0 + const flwPhone = data.customer?.phone_number?.substring(1); + const phone = flwPhone ? `254${flwPhone}` : ''; // convert to internal 254 format return { userData: { - phone: `254${data.customer.phone_number.substring(1)}` // use internal instead of local format + phone, }, status, amount: data.amount, @@ -76,6 +106,31 @@ function extractTransactionInfo(data: FlutterwaveTransactionInfo): ProviderTrans }; } +function extractTransferInfo(data: FlutterwaveTransferInfo): ProviderTransactionInfo { + const flwStatus = data.status.toLowerCase(); + const status: TransactionStatus = flwStatus === 'successful' ? 'success' : + flwStatus === 'failed' ? 'failed' : 'pending'; + + let phone = ''; + // if it's an M-PESA transfer, then account_number is the phone number + if (data.bank_code === 'MPS') { + // phone number comes in in 07... format, strip the 0 + const flwPhone = data.account_number.substring(1); + phone = flwPhone ? `254${flwPhone}` : ''; // convert to internal 254 format + } + + return { + userData: { + phone + }, + status, + amount: data.amount, + providerTransactionId: data.reference, + metadata: data, + failureReason: status === 'failed' ? data.complete_message : '' + }; +} + export class FlutterwavePaymentProvider implements PaymentProvider { constructor(private args: FlutterwavePaymentProviderArgs) { @@ -131,7 +186,12 @@ export class FlutterwavePaymentProvider implements PaymentProvider { async handlePaymentNotification(payload: any): Promise { const notification: FlutterwaveNotification = payload; const { data } = notification; - return extractTransactionInfo(data); + + if (notification['event.type'] === 'Transfer') { + return extractTransferInfo(data as FlutterwaveTransferInfo); + } + + return extractTransactionInfo(data as FlutterwaveTransactionInfo); } async getTransaction(localTransaction: Transaction): Promise { @@ -161,8 +221,32 @@ export class FlutterwavePaymentProvider implements PaymentProvider { throw createFlutterwaveApiError(e.response.data && e.response.data.message || e.message); } } - sendFundsToUser(user: User, amount: number, metadata: any): Promise { - throw new Error('Method not implemented.'); + async sendFundsToUser(user: User, amount: number, metadata: any): Promise { + const transferArgs = { + account_bank: 'MPS', + account_number: `0${user.phone.substring(3)}`, + amount, + narration: 'Social Relief transfer', + currency: 'KES', + reference: generateId(), + beneficiary_name: user.name + }; + + try { + const url = getUrl(`/transfers`); + const res = await axios.default.post(url, transferArgs, { headers: { Authorization: `Bearer ${this.args.secretKey}`}}); + + const { data, status } = res.data; + + return { + providerTransactionId: data.reference, + // success from the API means that the transaction was queued successfully, not that it's completed + status: status === 'success' ? 'pending' : 'failed' + }; + } + catch(e) { + throw createFlutterwaveApiError(e.response.data && e.response.data.message || e.message); + } } } diff --git a/server/src/core/payment/provider-registry.ts b/server/src/core/payment/provider-registry.ts index ca787127..304f414f 100644 --- a/server/src/core/payment/provider-registry.ts +++ b/server/src/core/payment/provider-registry.ts @@ -7,6 +7,7 @@ export class PaymentProviders implements PaymentProviderRegistry { } = {}; private preferredSending: string; + private preferredRefunds: string; private preferredReceiving: string; register(provider: PaymentProvider): void { @@ -36,4 +37,12 @@ export class PaymentProviders implements PaymentProviderRegistry { this.preferredSending = name; } + getPreferredForRefunds(): PaymentProvider { + return this.getProvider(this.preferredRefunds); + } + + setPreferredForRefunds(name: string): void { + this.preferredRefunds = name; + } + } \ No newline at end of file diff --git a/server/src/core/payment/tests/transaction-service.test.ts b/server/src/core/payment/tests/transaction-service.test.ts index d1722ebb..795978a5 100644 --- a/server/src/core/payment/tests/transaction-service.test.ts +++ b/server/src/core/payment/tests/transaction-service.test.ts @@ -11,7 +11,8 @@ const COLLECTION = 'transactions'; describe('TransactionService tests', () => { const dbUtils = createDbUtils(DB, COLLECTION); let paymentProviders: PaymentProviderRegistry; - let testPaymentProvider: PaymentProvider; + let testPaymentProvider1: PaymentProvider; + let testPaymentProvider2: PaymentProvider; beforeAll(async () => { await dbUtils.setupDb(); @@ -27,17 +28,27 @@ describe('TransactionService tests', () => { beforeEach(() => { paymentProviders = new PaymentProviders(); - testPaymentProvider = { - name: () => 'testPaymentProvider', + testPaymentProvider1 = { + name: () => 'testPaymentProvider1', + requestPaymentFromUser: jest.fn(), + handlePaymentNotification: jest.fn(), + getTransaction: jest.fn(), + sendFundsToUser: jest.fn() + }; + + testPaymentProvider2 = { + name: () => 'testPaymentProvider2', requestPaymentFromUser: jest.fn(), handlePaymentNotification: jest.fn(), getTransaction: jest.fn(), sendFundsToUser: jest.fn() }; - paymentProviders.register(testPaymentProvider); - paymentProviders.setPreferredForSending(testPaymentProvider.name()); - paymentProviders.setPreferredForReceiving(testPaymentProvider.name()); + paymentProviders.register(testPaymentProvider1); + paymentProviders.register(testPaymentProvider2); + paymentProviders.setPreferredForSending(testPaymentProvider1.name()); + paymentProviders.setPreferredForReceiving(testPaymentProvider2.name()); + paymentProviders.setPreferredForRefunds(testPaymentProvider1.name()); }); function createService() { @@ -68,7 +79,7 @@ describe('TransactionService tests', () => { describe('initiateRefund', () => { test('creates transaction and sends balance to user', async () => { const providerTransactionId = 'providerTx1' - testPaymentProvider.sendFundsToUser = jest.fn().mockResolvedValue({ providerTransactionId, status: 'pending' }); + testPaymentProvider1.sendFundsToUser = jest.fn().mockResolvedValue({ providerTransactionId, status: 'pending' }); const service = createService(); const res = await service.initiateRefund({ @@ -90,7 +101,7 @@ describe('TransactionService tests', () => { expect(res.toExternal).toBe(true); expect(res.to).toBe(''); expect(res.providerTransactionId).toBe(providerTransactionId); - expect(res.provider).toBe('testPaymentProvider'); + expect(res.provider).toBe('testPaymentProvider1'); expect(res.status).toBe('pending'); expect(res.type).toBe('refund'); diff --git a/server/src/core/payment/transaction-service.ts b/server/src/core/payment/transaction-service.ts index e6e02797..0ca48d23 100644 --- a/server/src/core/payment/transaction-service.ts +++ b/server/src/core/payment/transaction-service.ts @@ -138,7 +138,7 @@ export class Transactions implements TransactionService { fromExternal: false, from: user._id, type: 'refund', - provider: this.sendingProvider().name() + provider: this.refundsProvider().name() }; const trx = await this.sendFundsToUser(user, args); @@ -293,7 +293,7 @@ export class Transactions implements TransactionService { private async sendFundsToUser(user: User, args: TransactionCreateArgs): Promise { let trx: Transaction; - const provider = this.sendingProvider(); + const provider = this.provider(args.provider); const modifiedArgs = { ...args }; modifiedArgs.provider = provider.name(); modifiedArgs.status = 'pending'; @@ -352,6 +352,10 @@ export class Transactions implements TransactionService { return this.providers.getPreferredForReceiving(); } + private refundsProvider(): PaymentProvider { + return this.providers.getPreferredForRefunds(); + } + private sendingProvider(): PaymentProvider { return this.providers.getPreferredForSending(); } diff --git a/server/src/core/payment/types.ts b/server/src/core/payment/types.ts index d6b420fa..3fb6b1db 100644 --- a/server/src/core/payment/types.ts +++ b/server/src/core/payment/types.ts @@ -166,13 +166,23 @@ export interface PaymentProviderRegistry { */ setPreferredForReceiving(name: string): void; /** - * sets preferred payment provider for + * gets preferred payment provider for * sending money to users */ getPreferredForSending(): PaymentProvider; /** - * gets preferred payment provider for sending money to users + * sets preferred payment provider for sending money to users * @param name */ setPreferredForSending(name: string): void; + /** + * gets preferred payment provider for + * refunding donated money + */ + getPreferredForRefunds(): PaymentProvider; + /** + * sets preferred payment provider for refunding donated money + * @param name + */ + setPreferredForRefunds(name: string): void; } \ No newline at end of file diff --git a/server/src/start-worker.ts b/server/src/start-worker.ts index a58f09e4..7c56b360 100644 --- a/server/src/start-worker.ts +++ b/server/src/start-worker.ts @@ -7,8 +7,8 @@ export async function startWorker() { try { const config = loadAppConfigFromEnv(process.env); const app = await bootstrap(config); - runDistributionWorker(app, config.distributionInterval * MILLISECONDS_PER_MINUTE); - runVettedBeneficiaryDistributionWorker(app, config.vettedDistributionInterval * MILLISECONDS_PER_MINUTE); + runDistributionWorker(app, config.distributionInterval); + runVettedBeneficiaryDistributionWorker(app, config.vettedDistributionInterval); runStatsComputationWorker(app, config.statsComputationInterval * MILLISECONDS_PER_MINUTE); } catch (e) { diff --git a/server/src/webhooks/flutterwave.ts b/server/src/webhooks/flutterwave.ts index 7e419330..02dbc75d 100644 --- a/server/src/webhooks/flutterwave.ts +++ b/server/src/webhooks/flutterwave.ts @@ -6,7 +6,7 @@ export const flutterwaveRoutes = express.Router(); flutterwaveRoutes.post('/', (req: AppRequest, res) => { const notification = req.body; - console.log('Flutterwave payment notification'); + console.log('Flutterwave payment notification', JSON.stringify(req.body, null, 2)); req.core.transactions.handleProviderNotification(FLUTTERWAVE_PAYMENT_PROVIDER_NAME, notification) .then(_ => res.status(200).send()) .catch(e => { diff --git a/server/src/worker/distribution-worker.ts b/server/src/worker/distribution-worker.ts index 5a3b8bec..e14feb06 100644 --- a/server/src/worker/distribution-worker.ts +++ b/server/src/worker/distribution-worker.ts @@ -1,20 +1,19 @@ import { App } from '../core'; +import { CronJob } from 'cron'; -export function runDistributionWorker(app: App, intervalMilliseconds: number) { - async function workLoop() { - try { - console.log(`Starting distribution process at ${new Date()}...`); - const result = await app.donationDistributions.distributeDonations(); - console.log(`Completed distribution process at ${new Date()}`); - console.log(result); - console.log(); - setTimeout(workLoop, intervalMilliseconds); - } - catch(e) { - console.error(e); - setTimeout(workLoop, intervalMilliseconds); - } - } +export function runDistributionWorker(app: App, interval: string) { + const job = new CronJob(interval, async () => { + const result = await app.donationDistributions.distributeDonations(); + console.log(`Completed distribution process at ${new Date()}`); + console.log(result); + console.log(); + }, null, true, 'Africa/Nairobi'); - workLoop(); + try { + job.start(); + } + catch(e) { + console.error(e); + job.start(); + } } \ No newline at end of file diff --git a/server/src/worker/vetted-beneficiary-distribution-worker.ts b/server/src/worker/vetted-beneficiary-distribution-worker.ts index 203f3e1e..189a8684 100644 --- a/server/src/worker/vetted-beneficiary-distribution-worker.ts +++ b/server/src/worker/vetted-beneficiary-distribution-worker.ts @@ -1,20 +1,20 @@ import { App } from '../core'; +import { CronJob } from 'cron'; -export function runVettedBeneficiaryDistributionWorker(app: App, intervalMilliseconds: number) { - async function workLoop() { - try { - console.log(`Starting distribution process for vetted beneficiaries at ${new Date()}...`); - const result = await app.donationDistributions.distributeDonations(true); - console.log(`Completed distribution process for vetted beneficiaries at ${new Date()}`); - console.log(result); - console.log(); - setTimeout(workLoop, intervalMilliseconds); - } - catch(e) { - console.error(e); - setTimeout(workLoop, intervalMilliseconds); - } - } +export function runVettedBeneficiaryDistributionWorker(app: App, interval: string) { + const job = new CronJob(interval, async () => { + console.log(`Starting distribution process for vetted beneficiaries at ${new Date()}...`); + const result = await app.donationDistributions.distributeDonations(true); + console.log(`Completed distribution process for vetted beneficiaries at ${new Date()}`); + console.log(result); + console.log(); + }, null, true, 'Africa/Nairobi'); - workLoop(); + try { + job.start(); + } + catch(e) { + console.error(e); + job.start(); + } } \ No newline at end of file diff --git a/server/yarn.lock b/server/yarn.lock index a03a1f97..23513fb7 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -661,6 +661,14 @@ dependencies: "@types/express" "*" +"@types/cron@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@types/cron/-/cron-1.7.2.tgz#e9fb420da616920dae82d13adfca53282ffaab6e" + integrity sha512-AEpNLRcsVSc5AdseJKNHpz0d4e8+ow+abTaC0fKDbAU86rF1evoFF0oC2fV9FdqtfVXkG2LKshpLTJCFOpyvTg== + dependencies: + "@types/node" "*" + moment ">=2.14.0" + "@types/express-rate-limit@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@types/express-rate-limit/-/express-rate-limit-5.0.0.tgz#46b0dbae748a53347a5e1c3bdbb5a54e3f5c34f9" @@ -1583,6 +1591,13 @@ cors@^2.8.5: object-assign "^4" vary "^1" +cron@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/cron/-/cron-1.8.2.tgz#4ac5e3c55ba8c163d84f3407bde94632da8370ce" + integrity sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg== + dependencies: + moment-timezone "^0.5.x" + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -3667,6 +3682,18 @@ mkdirp@^0.5.0, mkdirp@^0.5.1: dependencies: minimist "^1.2.5" +moment-timezone@^0.5.x: + version "0.5.31" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.31.tgz#9c40d8c5026f0c7ab46eda3d63e49c155148de05" + integrity sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@>=2.14.0: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + mongodb@^3.5.5: version "3.5.5" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.5.5.tgz#1334c3e5a384469ac7ef0dea69d59acc829a496a" diff --git a/webapp/public/index.html b/webapp/public/index.html index 58b97fa6..35a0be87 100644 --- a/webapp/public/index.html +++ b/webapp/public/index.html @@ -48,5 +48,6 @@ })(); + diff --git a/webapp/src/components/donate-anonymously-modal.vue b/webapp/src/components/donate-anonymously-modal.vue index 0098a188..71700f4b 100644 --- a/webapp/src/components/donate-anonymously-modal.vue +++ b/webapp/src/components/donate-anonymously-modal.vue @@ -8,6 +8,7 @@ hide-footer no-stacking @hidden="hideDialog()" + @shown="showDialog()" content-class="rounded" >