Skip to content

Commit

Permalink
refact: decouple payExpensesBatchGroup and improve batch group sync
Browse files Browse the repository at this point in the history
  • Loading branch information
kewitz committed Mar 6, 2025
1 parent 2e9fd24 commit 2748688
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 75 deletions.
24 changes: 24 additions & 0 deletions scripts/setup-transferwise-webhook.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ const run = async () => {
}
console.log('Deleting webhook ', id);
await deleteApplicationWebhook(id);
} else if (action === 'dev') {
const url = process.argv?.[3];
if (!url) {
console.error('Missing url.');
process.exit(1);
}
console.log('Creating TransferWise app webhook for dev...');
const webhooks = await transferwise.createWebhooksForHost(url);
webhooks.forEach(webhook => {
console.log(`Webhook created: ${webhook.id} -> ${webhook.trigger_on} ${webhook.delivery.url}`);
});

console.log('Webhooks created, awaiting for SIGINT (Ctrl + C) to delete them..');
process.stdin.resume();
process.on('SIGINT', async () => {
await Promise.all(
webhooks.map(webhook => {
console.log(`Deleting webhook ${webhook.id} -> ${webhook.trigger_on} ${webhook.delivery.url}`);
return deleteApplicationWebhook(webhook.id);
}),
);
process.exit();
});
return;
} else {
console.log('Usage: npm run script scripts/setup-transferwise-webhook.js [up|list|down] [id]');
process.exit();
Expand Down
8 changes: 4 additions & 4 deletions server/controllers/transferwise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ export async function payBatch(
reportMessageToSentry('Wise Batch Pay: Could not find all requested expenses', { extra: errorInfo });
throw new errors.NotFound('Could not find requested expenses');
}
const ottHeader = headers['x-2fa-approval'] as string;
const x2faApproval = headers['x-2fa-approval'] as string;

const fundResponse = ottHeader
const fundResponse = x2faApproval
? // Forward OTT response if included
await transferwise.payExpensesBatchGroup(host, undefined, ottHeader, remoteUser)
await transferwise.approveExpenseBatchGroupPayment({ host, x2faApproval, remoteUser })
: // Otherwise, send the list of Expenses to pay the batch
await transferwise.payExpensesBatchGroup(host, expenses, undefined, remoteUser);
await transferwise.payExpensesBatchGroup({ host, expenses, remoteUser });

// If OTT response, proxy it to the frontend and return early
if ('status' in fundResponse && 'headers' in fundResponse) {
Expand Down
3 changes: 3 additions & 0 deletions server/graphql/common/expenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3455,6 +3455,9 @@ export async function payExpense(req: express.Request, args: PayExpenseArgs): Pr
throw new Error('No Paypal account linked, please reconnect Paypal or pay manually');
}
} else if (payoutMethodType === PayoutMethodTypes.BANK_ACCOUNT) {
if (host.settings?.transferwise?.ott === true) {
throw new Error('You cannot pay this expense directly without Scheduling it for payment first.');
}
const connectedAccount = await host.getAccountForPaymentProvider(Service.TRANSFERWISE);

const data = await paymentProviders.transferwise.payExpense(
Expand Down
171 changes: 100 additions & 71 deletions server/paymentProviders/transferwise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ async function scheduleExpenseForPayment(
const balanceInSourceCurrency = wiseBalances.find(b => b.currency === quote.sourceCurrency);

// Check for any existing Batch Group where status = NEW, create a new one if needed
const batchGroup = await getOrCreateActiveBatch(host, { connectedAccount, token });
let batchGroup = await getOrCreateActiveBatch(host, { connectedAccount, token });
assert(batchGroup.id, 'Failed to create a new batch group');
let totalAmountToPay = quote.paymentOption.sourceAmount;
if (batchGroup.transferIds.length > 0) {
const batchedExpenses = await Expense.findAll({
Expand All @@ -401,13 +402,17 @@ async function scheduleExpenseForPayment(
`Insufficient balance in ${quote.sourceCurrency} to cover the existing batch plus this expense amount, you need ${roundedTotalAmountToPay} ${quote.sourceCurrency} and you currently have ${balanceInSourceCurrency.amount.value} ${balanceInSourceCurrency.amount.currency}. Please add funds to your Wise ${quote.sourceCurrency} account.`,
);

await createTransfer(connectedAccount, expense.PayoutMethod, expense, {
const { transfer } = await createTransfer(connectedAccount, expense.PayoutMethod, expense, {
batchGroupId: batchGroup.id,
token,
details: transferDetails,
});

batchGroup = await transferwise.getBatchGroup(connectedAccount, batchGroup.id);
assert(batchGroup.transferIds.includes(transfer.id), new Error('Failed to add transfer to existing batch group'));
await expense.reload();
await expense.update({ data: { ...expense.data, batchGroup } });
await updateBatchGroup(batchGroup);
return expense;
}

Expand Down Expand Up @@ -441,90 +446,114 @@ async function unscheduleExpenseForPayment(expense: Expense): Promise<void> {
);
}

async function payExpensesBatchGroup(host, expenses, x2faApproval?: string, remoteUser?) {
const updateBatchGroup = async (batchGroup: BatchGroup): Promise<void> => {
return await sequelize.query(
`
UPDATE "Expenses" SET "data" = JSONB_SET("data", '{batchGroup}', :newBatchGroup::JSONB) WHERE
"data"#>>'{batchGroup, id}' = :batchGroupId;
`,
{
replacements: {
newBatchGroup: JSON.stringify(batchGroup),
batchGroupId: batchGroup.id,
},
},
);
};

async function payExpensesBatchGroup({
host,
expenses,
remoteUser,
}: {
host: Collective;
expenses: Expense[];
remoteUser?: User;
}) {
assert(expenses.length > 0, 'No expenses provided to pay');
const connectedAccount = await host.getAccountForPaymentProvider(Service.TRANSFERWISE);
assert(connectedAccount, `No connected account found for host ${host.id} and user ${remoteUser?.id}`);

const profileId = connectedAccount.data.id;
const token = await transferwise.getToken(connectedAccount);

try {
if (!x2faApproval && expenses) {
let batchGroup = await transferwise.getBatchGroup(connectedAccount, expenses[0].data.batchGroup.id);
// Throw if batch group was already paid
if (batchGroup.status === 'COMPLETED' && batchGroup.alreadyPaid === true) {
throw new Error('Can not pay batch group, existing batch group was already paid');
}
// Throw if batch group is cancelled
else if (['MARKED_FOR_CANCELLATION', 'PROCESSING_CANCEL', 'CANCELLED'].includes(batchGroup.status)) {
throw new Error(`Can not pay batch group, existing batch group was cancelled`);
let batchGroup = await transferwise.getBatchGroup(connectedAccount, expenses[0].data.batchGroup.id);
// Throw if batch group was already paid
if (batchGroup.status === 'COMPLETED' && batchGroup.alreadyPaid === true) {
throw new Error('Can not pay batch group, existing batch group was already paid');
}
// Throw if batch group is cancelled
else if (['MARKED_FOR_CANCELLATION', 'PROCESSING_CANCEL', 'CANCELLED'].includes(batchGroup.status)) {
throw new Error(`Can not pay batch group, existing batch group was cancelled`);
}
// If it is new, check if the expenses match the batch group and mark it as completed
else if (batchGroup.status === 'NEW') {
const expenseTransferIds = expenses.map(e => e.data.transfer.id);
if (difference(batchGroup.transferIds, expenseTransferIds).length > 0) {
throw new Error(`Expenses requested do not match the transfers added to batch group ${batchGroup.id}`);
}
// If it is new, check if the expenses match the batch group and mark it as completed
else if (batchGroup.status === 'NEW') {
const expenseTransferIds = expenses.map(e => e.data.transfer.id);
if (difference(batchGroup.transferIds, expenseTransferIds).length > 0) {
throw new Error(`Expenses requested do not match the transfers added to batch group ${batchGroup.id}`);
expenses.forEach(expense => {
if (expense.data.batchGroup.id !== batchGroup.id) {
throw new Error(
`All expenses should belong to the same batch group. Unschedule expense ${expense.id} and try again`,
);
}
expenses.forEach(expense => {
if (expense.data.batchGroup.id !== batchGroup.id) {
throw new Error(
`All expenses should belong to the same batch group. Unschedule expense ${expense.id} and try again`,
);
}
if (moment().isSameOrAfter(expense.data.quote.expirationTime)) {
throw new Error(`Expense ${expense.id} quote expired. Unschedule expense and try again`);
}
if (!batchGroup.transferIds.includes(expense.data.transfer.id)) {
throw new Error(`Batch group ${batchGroup.id} does not include expense ${expense.id}`);
}
});

batchGroup = await transferwise.completeBatchGroup(connectedAccount, batchGroup.id, batchGroup.version);
if (moment().isSameOrAfter(expense.data.quote.expirationTime)) {
throw new Error(`Expense ${expense.id} quote expired. Unschedule expense and try again`);
}
if (!batchGroup.transferIds.includes(expense.data.transfer.id)) {
throw new Error(`Batch group ${batchGroup.id} does not include expense ${expense.id}`);
}
});

// Update batchGroup status to make sure we don't try to reuse a completed batchGroup
await sequelize.query(
`
UPDATE "Expenses" SET "data" = JSONB_SET("data", '{batchGroup}', :newBatchGroup::JSONB) WHERE "id" IN (:expenseIds) AND "data"#>>'{batchGroup, id}' = :batchGroupId;
`,
{
replacements: {
expenseIds: expenses.map(e => e.id),
newBatchGroup: JSON.stringify(batchGroup),
batchGroupId: batchGroup.id,
},
},
);
}
// If it is completed, fund it and forward the OTT
const fundResponse = await transferwise.fundBatchGroup(token, profileId, batchGroup.id);
if ('status' in fundResponse && 'headers' in fundResponse) {
const cacheKey = `transferwise_ott_${fundResponse.headers['x-2fa-approval']}`;
await sessionCache.set(cacheKey, batchGroup.id, 30 * 60);
}
return fundResponse;
} else if (x2faApproval) {
const cacheKey = `transferwise_ott_${x2faApproval}`;
const batchGroupId = await sessionCache.get(cacheKey);
if (!batchGroupId) {
throw new Error('Invalid or expired OTT approval code');
}
const batchGroup = (await transferwise.fundBatchGroup(
token,
profileId,
batchGroupId,
x2faApproval,
)) as BatchGroup;
await sessionCache.delete(cacheKey);
return batchGroup;
} else {
throw new Error('payExpensesBatchGroup: you need to pass either expenses or x2faApproval');
batchGroup = await transferwise.completeBatchGroup(connectedAccount, batchGroup.id, batchGroup.version);
// Update batchGroup status to make sure we don't try to reuse a completed batchGroup
await updateBatchGroup(batchGroup);
}
// If it is completed, fund it and forward the OTT
const fundResponse = await transferwise.fundBatchGroup(token, profileId, batchGroup.id);
if ('status' in fundResponse && 'headers' in fundResponse) {
const cacheKey = `transferwise_ott_${fundResponse.headers['x-2fa-approval']}`;
await sessionCache.set(cacheKey, batchGroup.id, 30 * 60);
}
return fundResponse;
} catch (e) {
logger.error('Error paying Wise batch group', e);
throw e;
}
}

async function approveExpenseBatchGroupPayment({
host,
x2faApproval,
remoteUser,
}: {
host: Collective;
x2faApproval: string;
remoteUser?: User;
}) {
const connectedAccount = await host.getAccountForPaymentProvider(Service.TRANSFERWISE);
assert(connectedAccount, `No connected account found for host ${host.id} and user ${remoteUser?.id}`);

const profileId = connectedAccount.data.id;
const token = await transferwise.getToken(connectedAccount);
try {
const cacheKey = `transferwise_ott_${x2faApproval}`;
const batchGroupId = await sessionCache.get(cacheKey);
if (!batchGroupId) {
throw new Error('Invalid or expired OTT approval code');
}
const batchGroup = (await transferwise.fundBatchGroup(token, profileId, batchGroupId, x2faApproval)) as BatchGroup;
await sessionCache.delete(cacheKey);
await updateBatchGroup(batchGroup);
return batchGroup;
} catch (e) {
logger.error('Error approving Wise batch group payment', e);
throw e;
}
}

async function getAvailableCurrencies(
host: Collective,
ignoreBlockedCurrencies = true,
Expand Down Expand Up @@ -788,8 +817,7 @@ const oauth = {
},
};

async function createWebhooksForHost(): Promise<Webhook[]> {
const url = `${config.host.api}/webhooks/transferwise`;
async function createWebhooksForHost(url = `${config.host.api}/webhooks/transferwise`): Promise<Webhook[]> {
const existingWebhooks = await transferwise.listApplicationWebhooks();

const requiredHooks = [
Expand Down Expand Up @@ -852,6 +880,7 @@ export default {
quoteExpense,
payExpense,
payExpensesBatchGroup,
approveExpenseBatchGroupPayment,
createWebhooksForHost,
validatePayoutMethod,
scheduleExpenseForPayment,
Expand Down

0 comments on commit 2748688

Please sign in to comment.