From ac03ad019398a30c9e607a39c1f3271a8c1567c7 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Thu, 21 Dec 2023 11:28:01 +0100 Subject: [PATCH] Expense: add support for item currency (#975) --- components/ExpenseInvoice.js | 3 ++- components/ExpenseItemsTable.js | 34 ++++++++++++++++++++++++++++----- lib/constants/currency.js | 18 +++++++++++++++++ lib/expenses.js | 23 ++++++++++++++++++++++ lib/graphql/queries.js | 10 +++++++++- lib/utils.js | 19 ++++++++++++++---- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 lib/constants/currency.js create mode 100644 lib/expenses.js diff --git a/components/ExpenseInvoice.js b/components/ExpenseInvoice.js index 3f77b285..b935cfcb 100644 --- a/components/ExpenseInvoice.js +++ b/components/ExpenseInvoice.js @@ -12,6 +12,7 @@ import StyledLink from '@opencollective/frontend-components/components/StyledLin import { H2, P, Span } from '@opencollective/frontend-components/components/Text'; import Container from '@opencollective/frontend-components/components/Container'; import StyledHr from '@opencollective/frontend-components/components/StyledHr'; +import { sumItemsInExpenseCurrency } from '../lib/expenses'; const getPageHeight = (pageFormat) => { const dimensions = PageFormat[pageFormat]; @@ -60,7 +61,7 @@ const ExpenseInvoice = ({ expense, pageFormat }) => { const { account, payee, payeeLocation } = expense; const billToAccount = getBillTo(expense); const chunkedItems = chunkItems(expense, billToAccount); - const grossAmount = sumBy(expense.items, 'amount'); + const grossAmount = sumItemsInExpenseCurrency(expense.items); return (
{chunkedItems.map((itemsChunk, pageNumber) => ( diff --git a/components/ExpenseItemsTable.js b/components/ExpenseItemsTable.js index 623daa4b..2a8b637d 100644 --- a/components/ExpenseItemsTable.js +++ b/components/ExpenseItemsTable.js @@ -2,9 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Tr, Td } from './StyledTable'; import { FormattedMessage, FormattedDate } from 'react-intl'; -import { formatCurrency } from '../lib/utils'; +import { formatAmount, formatCurrency } from '../lib/utils'; import { round, sumBy, uniq } from 'lodash'; import { Span } from '@opencollective/frontend-components/components/Text'; +import { getItemAmounts } from '../lib/expenses'; const ExpenseItemsTable = ({ items, expense }) => { const allTaxTypes = uniq(expense.taxes.map((tax) => tax.type)); @@ -33,6 +34,7 @@ const ExpenseItemsTable = ({ items, expense }) => { {items.map((item) => { + const amounts = getItemAmounts(item); return ( @@ -45,9 +47,31 @@ const ExpenseItemsTable = ({ items, expense }) => { )} - {formatCurrency(item.amount, expense.currency)} - {formatCurrency(item.amount * taxRate, expense.currency)} - {formatCurrency(item.amount * (1 + taxRate), expense.currency)} + + {!amounts.inItemCurrency.exchangeRate ? ( + formatAmount(amounts.inItemCurrency, { showCurrencySymbol: true }) + ) : ( +
+ {formatAmount(amounts.inExpenseCurrency, { showCurrencySymbol: true })} + + {' ('} + {formatAmount(amounts.inItemCurrency, { showCurrencySymbol: true })} + {' * '} + {amounts.inItemCurrency.exchangeRate.value} + {')'} + +
+ )} + + + {formatCurrency(amounts.inExpenseCurrency.valueInCents * taxRate, amounts.inExpenseCurrency.currency)} + + + {formatCurrency( + amounts.inExpenseCurrency.valueInCents * (1 + taxRate), + amounts.inExpenseCurrency.currency, + )} + ); })} @@ -63,7 +87,7 @@ ExpenseItemsTable.propTypes = { }), items: PropTypes.arrayOf( PropTypes.shape({ - amount: PropTypes.number, + amountV2: PropTypes.object, description: PropTypes.string, incurredAt: PropTypes.string, }), diff --git a/lib/constants/currency.js b/lib/constants/currency.js new file mode 100644 index 00000000..0cba4d3c --- /dev/null +++ b/lib/constants/currency.js @@ -0,0 +1,18 @@ +export const ZERO_DECIMAL_CURRENCIES = [ + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'JPY', + 'KMF', + 'KRW', + 'MGA', + 'PYG', + 'RWF', + 'UGX', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', +]; diff --git a/lib/expenses.js b/lib/expenses.js new file mode 100644 index 00000000..949da55e --- /dev/null +++ b/lib/expenses.js @@ -0,0 +1,23 @@ +import { round, sumBy } from 'lodash'; +import { getCurrencyPrecision } from './utils'; + +export const getItemAmounts = (item) => { + if (!item.amountV2.exchangeRate) { + return { inItemCurrency: item.amountV2, inExpenseCurrency: item.amountV2 }; + } else { + return { + inItemCurrency: item.amountV2, + inExpenseCurrency: { + currency: item.amountV2.exchangeRate.toCurrency, + valueInCents: round( + item.amountV2.valueInCents * item.amountV2.exchangeRate.value, + getCurrencyPrecision(item.amountV2.exchangeRate.toCurrency), + ), + }, + }; + } +}; + +export const sumItemsInExpenseCurrency = (items) => { + return sumBy(items, (item) => getItemAmounts(item).inExpenseCurrency.valueInCents); +}; diff --git a/lib/graphql/queries.js b/lib/graphql/queries.js index 6c5cc090..b8be8489 100644 --- a/lib/graphql/queries.js +++ b/lib/graphql/queries.js @@ -311,10 +311,18 @@ export async function fetchExpenseInvoiceData(expenseId, accessToken, apiKey) { } items { id - amount description incurredAt url + amountV2 { + valueInCents + currency + exchangeRate { + fromCurrency + toCurrency + value + } + } } } } diff --git a/lib/utils.js b/lib/utils.js index c33fdda7..3beda8c8 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,10 +1,16 @@ -import { get, pickBy, isEmpty } from 'lodash'; +import { get, pickBy, isEmpty, isNil } from 'lodash'; +import { ZERO_DECIMAL_CURRENCIES } from './constants/currency'; + +export function getCurrencyPrecision(currency) { + return ZERO_DECIMAL_CURRENCIES.includes(currency) ? 0 : 2; +} export function formatCurrency(amount, currency = 'USD', options = {}) { amount = amount / 100; - let minimumFractionDigits = 2; - let maximumFractionDigits = 2; + const defaultPrecision = getCurrencyPrecision(currency); + let minimumFractionDigits = defaultPrecision; + let maximumFractionDigits = defaultPrecision; if (Object.prototype.hasOwnProperty.call(options, 'minimumFractionDigits')) { minimumFractionDigits = options.minimumFractionDigits; @@ -21,13 +27,18 @@ export function formatCurrency(amount, currency = 'USD', options = {}) { currencyDisplay: 'symbol', }); - if (options?.showCurrencySymbol) { + if (options?.showCurrencySymbol && !/^[A-Z]{2,3}\$/.test(result)) { return `${currency} ${result}`; } else { return result; } } +export function formatAmount(amount, options = {}) { + const valueInCents = amount?.valueInCents || amount?.value * 100; + return isNil(valueInCents) || isNaN(valueInCents) ? '--,--' : formatCurrency(valueInCents, amount.currency, options); +} + function getLocaleFromCurrency(currency) { let locale; switch (currency) {