Skip to content

Commit

Permalink
Merge pull request #5726 from opencollective/refact/transferwise-quot…
Browse files Browse the repository at this point in the history
…es-v2

(Transfer)Wise QuoteV2 Refactor
  • Loading branch information
kewitz committed Mar 22, 2021
2 parents 1e435cb + d2001ec commit 8ae51c0
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 45 deletions.
6 changes: 5 additions & 1 deletion server/graphql/common/expenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,8 +985,12 @@ export async function payExpense(req: express.Request, args: Record<string, unkn
throw new Error('Host is not connected to Transferwise');
}
const quote = await paymentProviders.transferwise.getTemporaryQuote(connectedAccount, payoutMethod, expense);
const paymentOption = quote.paymentOptions.find(p => p.payIn === 'BALANCE' && p.payOut === quote.payOut);
if (!paymentOption) {
throw new BadRequest(`Could not find available payment option for this transaction.`, null, quote);
}
// Notice this is the FX rate between Host and Collective, that's why we use `fxrate`.
fees.paymentProcessorFeeInCollectiveCurrency = floatAmountToCents(quote.fee / fxrate);
fees.paymentProcessorFeeInCollectiveCurrency = floatAmountToCents(paymentOption.fee.total / fxrate);
} else if (payoutMethodType === PayoutMethodTypes.PAYPAL && !args.forceManual) {
fees.paymentProcessorFeeInCollectiveCurrency = await paymentProviders.paypal.types['adaptive'].fees({
amount: expense.amount,
Expand Down
4 changes: 2 additions & 2 deletions server/lib/transactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ export async function createFromPaidExpense(
} else if (payoutMethodType === PayoutMethodTypes.BANK_ACCOUNT) {
// Notice this is the FX rate between Host and Collective, the user is not involved here and that's why TransferWise quote rate is irrelevant here.
hostCurrencyFxRate = await getFxRate(expense.currency, host.currency);
paymentProcessorFeeInHostCurrency = transactionData?.quote
? Math.round(transactionData.quote.fee * 100)
paymentProcessorFeeInHostCurrency = transactionData?.paymentOption?.fee?.total
? Math.round(transactionData.paymentOption.fee.total * 100)
: paymentProcessorFeeInHostCurrency;
paymentProcessorFeeInCollectiveCurrency = Math.round((1 / hostCurrencyFxRate) * paymentProcessorFeeInHostCurrency);
hostFeeInCollectiveCurrency = Math.round((1 / hostCurrencyFxRate) * hostFeeInHostCurrency);
Expand Down
53 changes: 34 additions & 19 deletions server/lib/transferwise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
BorderlessAccount,
CurrencyPair,
Profile,
Quote,
QuoteV2,
RecipientAccount,
Transfer,
Webhook,
Expand Down Expand Up @@ -59,6 +59,11 @@ const compactRecipientDetails = <T>(object: T): Partial<T> => <Partial<T>>omitBy
const getData = <T extends { data?: Record<string, unknown> }>(obj: T | undefined): T['data'] | undefined =>
obj && obj.data;

const tap = fn => data => {
fn(data);
return data;
};

const parseError = (
error: AxiosError<{ errorCode?: TransferwiseErrorCodes; errors?: Record<string, unknown>[] }>,
defaultMessage?: string,
Expand Down Expand Up @@ -95,10 +100,11 @@ export const requestDataAndThrowParsedError = (
},
defaultErrorMessage?: string,
): Promise<any> => {
debug(`calling ${url}`);
debug(`calling ${url}: ${JSON.stringify({ data, params: options.params }, null, 2)}`);
const pRequest = data ? fn(url, data, options) : fn(url, options);
return pRequest
.then(getData)
.then(tap(data => debug(JSON.stringify(data, null, 2))))
.catch(e => {
// Implements Strong Customer Authentication
// https://api-docs.transferwise.com/#payouts-guide-strong-customer-authentication
Expand Down Expand Up @@ -126,23 +132,33 @@ interface CreateQuote {
profileId: number;
sourceCurrency: string;
targetCurrency: string;
targetAccount?: number;
targetAmount?: number;
sourceAmount?: number;
payOut?: 'BANK_TRANSFER' | 'BALANCE' | 'SWIFT' | 'INTERAC' | null;
}
export const createQuote = async (
token: string,
{ profileId: profile, sourceCurrency, targetCurrency, targetAmount, sourceAmount }: CreateQuote,
): Promise<Quote> => {
{
profileId: profile,
sourceCurrency,
targetCurrency,
targetAmount,
sourceAmount,
payOut,
targetAccount,
}: CreateQuote,
): Promise<QuoteV2> => {
const data = {
payOut,
profile,
source: sourceCurrency,
target: targetCurrency,
rateType: 'FIXED',
type: 'BALANCE_PAYOUT',
targetAmount,
sourceAmount,
sourceCurrency,
targetAccount,
targetAmount,
targetCurrency,
};
return requestDataAndThrowParsedError(axios.post, `/v1/quotes`, {
return requestDataAndThrowParsedError(axios.post, `/v2/quotes`, {
headers: { Authorization: `Bearer ${token}` },
data,
});
Expand All @@ -168,8 +184,8 @@ export const createRecipientAccount = async (

export interface CreateTransfer {
accountId: number;
quoteId: number;
uuid: string;
quoteUuid: string;
customerTransactionId: string;
details?: {
reference?: string;
transferPurpose?: string;
Expand All @@ -178,9 +194,9 @@ export interface CreateTransfer {
}
export const createTransfer = async (
token: string,
{ accountId: targetAccount, quoteId: quote, uuid: customerTransactionId, details }: CreateTransfer,
{ accountId: targetAccount, quoteUuid, customerTransactionId, details }: CreateTransfer,
): Promise<Transfer> => {
const data = { targetAccount, quote, customerTransactionId, details };
const data = { targetAccount, quoteUuid, customerTransactionId, details };
return requestDataAndThrowParsedError(axios.post, `/v1/transfers`, {
data,
headers: { Authorization: `Bearer ${token}` },
Expand Down Expand Up @@ -233,14 +249,13 @@ interface GetTemporaryQuote {
export const getTemporaryQuote = async (
token: string,
{ sourceCurrency, targetCurrency, ...amount }: GetTemporaryQuote,
): Promise<Quote> => {
): Promise<QuoteV2> => {
const params = {
source: sourceCurrency,
target: targetCurrency,
rateType: 'FIXED',
sourceCurrency,
targetCurrency,
...amount,
};
return requestDataAndThrowParsedError(axios.get, `/v1/quotes`, {
return requestDataAndThrowParsedError(axios.post, `/v2/quotes`, {
headers: { Authorization: `Bearer ${token}` },
params,
});
Expand Down
38 changes: 22 additions & 16 deletions server/paymentProviders/transferwise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as transferwise from '../../lib/transferwise';
import models from '../../models';
import PayoutMethod from '../../models/PayoutMethod';
import { ConnectedAccount } from '../../types/ConnectedAccount';
import { Balance, Quote, RecipientAccount, Transfer } from '../../types/transferwise';
import { Balance, QuoteV2, QuoteV2PaymentOption, RecipientAccount, Transfer } from '../../types/transferwise';

const hashObject = obj => crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex').slice(0, 7);

Expand All @@ -40,7 +40,7 @@ async function getTemporaryQuote(
connectedAccount: typeof models.ConnectedAccount,
payoutMethod: PayoutMethod,
expense: typeof models.Expense,
): Promise<Quote> {
): Promise<QuoteV2> {
const token = await getToken(connectedAccount);
return await transferwise.getTemporaryQuote(token, {
sourceCurrency: expense.currency,
Expand All @@ -53,7 +53,8 @@ async function quoteExpense(
connectedAccount: typeof models.ConnectedAccount,
payoutMethod: PayoutMethod,
expense: typeof models.Expense,
): Promise<Quote> {
targetAccount?: number,
): Promise<QuoteV2> {
await populateProfileId(connectedAccount);

const token = await getToken(connectedAccount);
Expand All @@ -66,6 +67,7 @@ async function quoteExpense(
sourceCurrency: expense.currency,
targetCurrency: <string>payoutMethod.unfilteredData.currency,
targetAmount,
targetAccount,
});

return quote;
Expand All @@ -76,13 +78,22 @@ async function payExpense(
payoutMethod: PayoutMethod,
expense: typeof models.Expense,
): Promise<{
quote: Quote;
quote: QuoteV2;
recipient: RecipientAccount;
fund: { status: string; errorCode: string };
transfer: Transfer;
paymentOption: QuoteV2PaymentOption;
}> {
const token = await getToken(connectedAccount);
const quote = await quoteExpense(connectedAccount, payoutMethod, expense);
const recipient = await transferwise.createRecipientAccount(token, {
profileId: connectedAccount.data.id,
...(<RecipientAccount>payoutMethod.data),
});

const quote = await quoteExpense(connectedAccount, payoutMethod, expense, recipient.id);
const paymentOption = quote.paymentOptions.find(
p => p.disabled === false && p.payIn === 'BALANCE' && p.payOut === quote.payOut,
);

const account = await transferwise.getBorderlessAccount(token, <number>connectedAccount.data.id);
if (!account) {
Expand All @@ -91,24 +102,19 @@ async function payExpense(
'transferwise.error.accountnotfound',
);
}
const balance = account.balances.find(b => b.currency === quote.source);
const balance = account.balances.find(b => b.currency === quote.sourceCurrency);
if (!balance || balance.amount.value < quote.sourceAmount) {
throw new TransferwiseError(
`You don't have enough funds in your ${quote.source} balance. Please top up your account considering the source amount of ${quote.sourceAmount} (includes the fee ${quote.fee}) and try again.`,
`You don't have enough funds in your ${quote.sourceCurrency} balance. Please top up your account considering the source amount of ${quote.sourceAmount} (includes the fee ${paymentOption.fee.total}) and try again.`,
'transferwise.error.insufficientFunds',
{ currency: quote.source },
{ currency: quote.sourceCurrency },
);
}

const recipient = await transferwise.createRecipientAccount(token, {
profileId: connectedAccount.data.id,
...(<RecipientAccount>payoutMethod.data),
});

const transferOptions: transferwise.CreateTransfer = {
accountId: recipient.id,
quoteId: quote.id,
uuid: uuid(),
quoteUuid: quote.id,
customerTransactionId: uuid(),
};
// Append reference to currencies that require it.
if (currenciesThatRequireReference.includes(<string>payoutMethod.unfilteredData.currency)) {
Expand All @@ -127,7 +133,7 @@ async function payExpense(
throw e;
}

return { quote, recipient, transfer, fund };
return { quote, recipient, transfer, fund, paymentOption };
}

async function getAvailableCurrencies(
Expand Down
48 changes: 48 additions & 0 deletions server/types/transferwise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,54 @@ export type Quote = {
ofSourceAmount: boolean;
};

export type QuoteV2PaymentOption = {
disabled: boolean;
estimatedDelivery: string;
formattedEstimatedDelivery: string;
estimatedDeliveryDelays: [];
fee: {
transferwise: number;
payIn: number;
discount: number;
total: number;
};
sourceAmount: number;
targetAmount: number;
sourceCurrency: string;
targetCurrency: string;
payIn: 'BANK_TRANSFER' | 'BALANCE';
payOut: 'BANK_TRANSFER';
allowedProfileTypes: Array<'PERSONAL' | 'BUSINESS'>;
payInProduct: 'CHEAP' | 'BALANCE';
feePercentage: number;
};

export type QuoteV2 = {
id: string;
sourceCurrency: string;
targetCurrency: string;
sourceAmount: number;
payOut: 'BANK_TRANSFER';
rate: number;
createdTime: string;
user: number;
profile: number;
rateType: 'FIXED';
rateExpirationTime: string;
guaranteedTargetAmountAllowed: boolean;
targetAmountAllowed: boolean;
guaranteedTargetAmount: boolean;
providedAmountType: 'SOURCE' | 'TARGET';
paymentOptions: Array<QuoteV2PaymentOption>;
status: 'PENDING' | 'ACCEPTED' | 'FUNDED' | 'EXPIRED';
expirationTime: string;
notices: Array<{
text: string;
link: string | null;
type: 'WARNING' | 'INFO' | 'BLOCKED';
}>;
};

export type RecipientAccount = {
id?: number;
currency: string;
Expand Down
36 changes: 32 additions & 4 deletions test/server/graphql/v2/mutation/ExpenseMutations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -942,15 +942,29 @@ describe('server/graphql/v2/mutation/ExpenseMutations', () => {
describe('With transferwise', () => {
const fee = 1.74;
let getTemporaryQuote, expense;
const quote = {
payOut: 'BANK_TRANSFER',
paymentOptions: [
{
payInProduct: 'BALANCE',
fee: { total: fee },
payIn: 'BALANCE',
sourceCurrency: 'USD',
targetCurrency: 'EUR',
payOut: 'BANK_TRANSFER',
disabled: false,
},
],
};

before(async () => {
// Updates the collective balance and pay the expense
await fakeTransaction({ type: 'CREDIT', CollectiveId: collective.id, amount: 15000000 });
});

beforeEach(() => {
getTemporaryQuote = sandbox.stub(paymentProviders.transferwise, 'getTemporaryQuote').resolves({ fee });
sandbox.stub(paymentProviders.transferwise, 'payExpense').resolves({ quote: { fee } });
getTemporaryQuote = sandbox.stub(paymentProviders.transferwise, 'getTemporaryQuote').resolves(quote);
sandbox.stub(paymentProviders.transferwise, 'payExpense').resolves({ quote });
});

beforeEach(async () => {
Expand Down Expand Up @@ -1220,11 +1234,25 @@ describe('server/graphql/v2/mutation/ExpenseMutations', () => {
describe('processExpense > PAY > with 2FA payouts', () => {
const fee = 1.74;
let collective, host, collectiveAdmin, hostAdmin, sandbox, expense1, expense2, expense3, expense4, user;
const quote = {
payOut: 'BANK_TRANSFER',
paymentOptions: [
{
payInProduct: 'BALANCE',
fee: { total: fee },
payIn: 'BALANCE',
sourceCurrency: 'USD',
targetCurrency: 'EUR',
payOut: 'BANK_TRANSFER',
disabled: false,
},
],
};

before(() => {
sandbox = sinon.createSandbox();
sandbox.stub(paymentProviders.transferwise, 'payExpense').resolves({ quote: { fee } });
sandbox.stub(paymentProviders.transferwise, 'getTemporaryQuote').resolves({ fee });
sandbox.stub(paymentProviders.transferwise, 'payExpense').resolves({ quote });
sandbox.stub(paymentProviders.transferwise, 'getTemporaryQuote').resolves(quote);
});

after(() => sandbox.restore());
Expand Down
25 changes: 22 additions & 3 deletions test/server/paymentProviders/transferwise/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,32 @@ describe('server/paymentProviders/transferwise/index', () => {
const sandbox = sinon.createSandbox();
const quote = {
id: 1234,
source: 'USD',
target: 'EUR',
sourceCurrency: 'USD',
targetCurrency: 'EUR',
sourceAmount: 101.14,
targetAmount: 90.44,
rate: 0.9044,
fee: 1.14,
payOut: 'BANK_TRANSFER',
paymentOptions: [
{
formattedEstimatedDelivery: 'by March 18th',
estimatedDeliveryDelays: [],
allowedProfileTypes: ['PERSONAL', 'BUSINESS'],
payInProduct: 'BALANCE',
feePercentage: 0.0038,
estimatedDelivery: '2021-03-18T12:45:00Z',
fee: { transferwise: 3.79, payIn: 0, discount: 0, total: 3.79, priceSetId: 134, partner: 0 },
payIn: 'BALANCE',
sourceAmount: 101.14,
targetAmount: 90.44,
sourceCurrency: 'USD',
targetCurrency: 'EUR',
payOut: 'BANK_TRANSFER',
disabled: false,
},
],
};

let createQuote,
createRecipientAccount,
createTransfer,
Expand Down

0 comments on commit 8ae51c0

Please sign in to comment.