多行交易货币转换时出错

Errors during currency conversion on multiple line transaction

我有一个问题,我认为除了完全重新考虑应用程序之外可能没有答案,但希望你们能证明我是错的!

我有一个应用程序使用汇率来计算从货币值到小数点后四位的基础值。计算非常简单,并通过了所有相关的单元测试,每次都给出正确的结果:

    public static decimal GetBaseValue(decimal currencyAmount, RateOperator rateOperator, 
        double exchangeRate)
    {
        if (exchangeRate <= 0)
        {
            throw new ArgumentException(ErrorType.
               ExchangeRateLessThanOrEqualToZero.ErrorInfo().Description);
        }

        decimal baseValue = 0;

        if (currencyAmount != 0)
        {
            switch (rateOperator)
            {
                case RateOperator.Divide:
                    baseValue = Math.Round(currencyAmount * Convert.ToDecimal(exchangeRate), 
                        4, MidpointRounding.AwayFromZero);
                    break;
                case RateOperator.Multiply:
                    baseValue = Math.Round(currencyAmount / Convert.ToDecimal(exchangeRate), 
                        4, MidpointRounding.AwayFromZero);
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(rateOperator));
            }
        }

        return baseValue;
    }

我也有从基础计算货币的等价物,没有显示以避免混淆问题,并且在任何情况下除了参数名称和 switch 语句中数学运算符的反转之外几乎相同的代码。

当我需要将此过程应用于具有多行的交易时,我的问题就来了。规则是借方总额必须等于贷方总额,但有些数字会少一分钱。这是因为我们将两个或多个单独转换的数字的结果与一次转换相加,我认为累积的舍入会导致错误。

让我们假设如下:

  1. 商品价值 1.00 美元
  2. 增值税价值 0.20 美元 (20%)
  3. 1.4540 美元兑英镑的汇率

现在让我们回顾一个示例交易:

Line #  Debit    Credit
1                .20
2       .00
3       [=11=].20

这通过了测试,因为总贷方与总贷方相匹配。

当我们转换(将每个美元值除以 1.454)时,我们看到问题:

Line #  Debit    Credit
1                £0.8253
2       £0.6878
3       £0.1376
========================
Total   £0.8254  £0.8253 
========================

这失败了并且违反了规则,我相信这是借方栏中的两组舍入和贷方栏中只有一组舍入的结果。然而,我需要能够准确地向后和向前计算,并且我需要确保所有行的货币和基础交易余额。

所以我的问题是:如何最好地解决这个问题?任何人都遇到过类似的事情,如果有,您介意分享一下您是如何解决的吗?

编辑 - 我使用的解决方案

根据 Charles 的回答,这绝对为我指明了正确的方向,我发布我的工作方法是为了让其他可能面临类似问题的人受益。虽然了解此特定代码可能不适合直接复制和粘贴解决方案,但我希望评论和后续过程对您有所帮助:

    private static void SetBaseValues(Transaction transaction)
    {
        // Get the initial currency totals
        decimal currencyDebitRunningTotal = transaction.CurrencyDebitTotal;
        decimal currencyCreditRunningTotal = transaction.CurrencyCreditTotal;

        // Only one conversion, but we do one per column
        // Note that the values should be the same anyway 
        // or the transaction would be invalid
        decimal baseDebitRunningTotal = 
            Functions.GetBaseValue(currencyDebitRunningTotal, 
            transaction.MasterLine.RateOperator, 
            transaction.MasterLine.ExchangeRate);
        decimal baseCreditRunningTotal = 
            Functions.GetBaseValue(currencyCreditRunningTotal,
            transaction.MasterLine.RateOperator, 
            transaction.MasterLine.ExchangeRate);

        // Create a list of transaction lines that belong to this transaction
        List<TransactionLineBase> list = new List<TransactionLineBase> 
        { transaction.MasterLine };
        list.AddRange(transaction.TransactionLines);

        // If there is no tax line, don't add a null entry 
        // as that would cause conversion failure
        if (transaction.TaxLine != null)
        {
            list.Add(transaction.TaxLine);
        }

        // Sort the list ascending by value
        var workingList = list.OrderBy(
            x => x.CurrencyCreditAmount ?? 0 + x.CurrencyDebitAmount ?? 0).ToList();

        // Iterate the lines excluding any entries where Credit and Debit 
        // values are both null (this is possible on some rows on 
        // some transactions types e.g. Reconciliations
        foreach (var line in workingList.Where(
            line => line.CurrencyCreditAmount != null || 
            line.CurrencyDebitAmount != null))
        {
            if (transaction.CanConvertCurrency)
            {
                SetBaseValues(line);
            }
            else
            {
                var isDebitLine = line.CurrencyCreditAmount == null;

                if (isDebitLine)
                {
                    if (line.CurrencyDebitAmount != 0)
                    {
                        line.BaseDebitAmount = 
                            line.CurrencyDebitAmount ?? 0 / 
                            currencyDebitRunningTotal * baseDebitRunningTotal;
                        currencyDebitRunningTotal -= 
                            line.CurrencyDebitAmount ?? 0;
                        baseDebitRunningTotal -= line.BaseDebitAmount ?? 0;                            
                    }
                }
                else
                {
                    if (line.CurrencyCreditAmount != 0)
                    {
                        line.BaseCreditAmount = 
                            line.CurrencyCreditAmount ?? 0/
                       currencyCreditRunningTotal*baseCreditRunningTotal;
                        currencyCreditRunningTotal -= line.CurrencyCreditAmount ?? 0;
                        baseCreditRunningTotal -= line.BaseCreditAmount ?? 0;
                    }
                }                    
            }
        }
    }

这通常是业务问题,而不是编程问题。您看到的结果是数学舍入的结果。在财务计算中,企业将决定何时应用汇率并且必须应用一次。例如,在您的情况下,可能会决定将其应用于商品价值,然后乘以增值税以获得您的总额。这通常也出现在您所在地区接受的 accounting/financial 接受标准中。

这确实要视情况而定,但对此的一种选择是仅转换总计,然后使用余额递减法将其按比例分配给每个部分。这样可以确保各部分的总和始终恰好等于总和。

你的借方和贷方栏加起来都是 1.20 美元,所以你按你的汇率换算后得到 0.8253 英镑。

然后您按比例将其分配给您的借记金额,从最小到最大。排序背后的理论是您不太可能关心较大数量的 extra/missing 便士。

所以您从 1.20 美元和 0.6878 英镑的总和开始,然后计算适用于您的最小美元金额的转换后余额的比例:

[=10=].20 / .20 * 0.8253 = £0.1376

然后您从总计中扣除金额(这是 'reducing balance' 部分):

.20 - [=11=].20 = .00
£0.8253 - £0.1376 = £0.6877

然后计算下一个最大的(因为在这个例子中你只有 1 个数量,这是微不足道的):

.00 / .00 * £0.6877 = £0.6877

所以这给你:

Line #  Debit    Credit
1                £0.8253
2       £0.6877
3       £0.1376
========================
Total   £0.8253  £0.8253 
========================