Skip to content

Commit

Permalink
Merge pull request #172 from alphamanuscript/master
Browse files Browse the repository at this point in the history
v0.7.0
  • Loading branch information
habbes authored Oct 28, 2020
2 parents 87e792e + 79e7799 commit bfdd451
Show file tree
Hide file tree
Showing 23 changed files with 269 additions and 73 deletions.
2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions server/src/core/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || '',
Expand Down
3 changes: 2 additions & 1 deletion server/src/core/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export async function bootstrap(config: AppConfig): Promise<App> {
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 });
Expand Down
102 changes: 93 additions & 9 deletions server/src/core/payment/flutterwave-payment-provider.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -51,7 +76,7 @@ interface FlutterwaveTransactionInfo {
interface FlutterwaveNotification {
event: string;
'event.type': string;
data: FlutterwaveTransactionInfo;
data: FlutterwaveTransactionInfo | FlutterwaveTransferInfo;
}

interface FlutterwaveTransactionResponse {
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -131,7 +186,12 @@ export class FlutterwavePaymentProvider implements PaymentProvider {
async handlePaymentNotification(payload: any): Promise<ProviderTransactionInfo> {
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<ProviderTransactionInfo> {
Expand Down Expand Up @@ -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<SendFundsResult> {
throw new Error('Method not implemented.');
async sendFundsToUser(user: User, amount: number, metadata: any): Promise<SendFundsResult> {
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<FlutterwaveInitiateTransferResponse>(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);
}
}

}
9 changes: 9 additions & 0 deletions server/src/core/payment/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class PaymentProviders implements PaymentProviderRegistry {
} = {};

private preferredSending: string;
private preferredRefunds: string;
private preferredReceiving: string;

register(provider: PaymentProvider): void {
Expand Down Expand Up @@ -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;
}

}
27 changes: 19 additions & 8 deletions server/src/core/payment/tests/transaction-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() {
Expand Down Expand Up @@ -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({
Expand All @@ -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');

Expand Down
8 changes: 6 additions & 2 deletions server/src/core/payment/transaction-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -293,7 +293,7 @@ export class Transactions implements TransactionService {

private async sendFundsToUser(user: User, args: TransactionCreateArgs): Promise<Transaction> {
let trx: Transaction;
const provider = this.sendingProvider();
const provider = this.provider(args.provider);
const modifiedArgs = { ...args };
modifiedArgs.provider = provider.name();
modifiedArgs.status = 'pending';
Expand Down Expand Up @@ -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();
}
Expand Down
14 changes: 12 additions & 2 deletions server/src/core/payment/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions server/src/start-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion server/src/webhooks/flutterwave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading

0 comments on commit bfdd451

Please sign in to comment.