「御社の請求書、合計が当方の計算と 1 円合わないのですが」— 経理から、あるいは取引先から、この問い合わせを受けたことのある開発者は少なくないはずです。

金額は、業務アプリでもっとも「ズレてはいけない」データでありながら、もっとも壊れやすいデータでもあります。日本の現場では、同じ事故が形を変えて繰り返し起きます。

  • 単価 × 数量を double で計算したら、合計が 9999.9999996 のような端数になる(浮動小数点の誤差
  • 金額を "¥1,234" のような文字列でセルに入れたら、合計も並べ替えもできなくなった(文字列化
  • 消費税の端数処理が取引先と食い違い、請求総額が 1 円ずれる(端数処理の不一致
  • 軽減税率 8% と標準税率 10% が混在する明細で、税額の計算をどこでまとめるか迷う(軽減税率

これらはどれも「気をつける」で減らせるものではありません。原因は構造的で、対策も構造的です。この記事では ReoGrid を使い、セルのデータは数値のまま保ち、表示だけを通貨書式にするという原則に沿って、通貨表示・消費税・端数処理・税率別集計を C# で組み立てます。WinForms / WPF どちらでも、Excel のインストールは不要です。


ここでつまずく — 金額を「文字列」にしてしまう

いちばん多い失敗は、表示を整えたいあまり、金額そのものを文字列にしてしまうことです。

// よくある実装 — これは壊れます
sheet["E5"] = $"¥{amount:N0}";   // "¥1,234,567" という文字列が入る

見た目は完璧です。¥1,234,567 と表示されます。しかし、このセルに入っているのは数値ではなく文字列です。その瞬間、次のすべてが壊れます。

  • =SUM(E5:E9)0 を返す(文字列は合計されない)
  • 金額の列で並べ替えると、¥1,000,000¥999 より前に来る(文字列としての辞書順
  • 別のセルでこの値を参照した数式が #VALUE! になる

double で持つのも別の地雷を踏みます。0.1 + 0.2 != 0.3 の世界なので、税率を掛けて積み上げた合計に微小な誤差が残り、ROUND をかけ忘れた箇所で 1 円ズレます。

正しい原則はひとつです。セルには素の数値を入れ、「¥」や桁区切りは「書式」として後からかぶせる。データと見た目を分離すれば、合計も並べ替えも数式もすべて生き続けます。


通貨書式 — データは数値、表示は「¥1,234,567」

ReoGrid では、セルに数値を入れたうえで SetRangeDataFormat に通貨書式を指定します。

using unvell.ReoGrid;
using unvell.ReoGrid.DataFormat;

var sheet = grid.CurrentWorksheet;

// データはあくまで数値
sheet["E5"] = 1234567;

// 表示だけを通貨書式にする
sheet.SetRangeDataFormat("E5:E20", CellDataFormatFlag.Currency,
    new CurrencyDataFormatter.CurrencyFormatArgs
    {
        CultureEnglishName = "ja-JP",
        PrefixSymbol = "¥",
        DecimalPlaces = 0,      // 円は小数なし
        UseSeparator = true,    // 3桁区切り
    });

これで E5¥1,234,567 と表示されますが、セルが保持しているのは数値 1234567 のままです。=SUM(E5:E20) も並べ替えも、何ごともなく動きます。

円は小数点以下を持たないのが普通なので DecimalPlaces = 0 を指定します。1 円未満を扱う単価(例:ガソリン単価 156.7円/L)がある列だけ DecimalPlaces = 1 にする、といった列ごとの使い分けも自由です。

マイナスを赤字や「▲」で表示する

会計帳票では、マイナスの金額を赤字や三角(▲)で示す慣習があります。NegativeStyle で指定できます。

sheet.SetRangeDataFormat("E5:E20", CellDataFormatFlag.Currency,
    new CurrencyDataFormatter.CurrencyFormatArgs
    {
        CultureEnglishName = "ja-JP",
        PrefixSymbol = "¥",
        DecimalPlaces = 0,
        UseSeparator = true,
        // 赤字+括弧:  ¥(1,234)
        NegativeStyle = NumberDataFormatter.NumberNegativeStyle.RedBrackets,
    });

日本式の三角表記が必要なら Prefix_Sankaku を使います。

// ▲ 1,234  という日本式のマイナス表記
NegativeStyle = NumberDataFormatter.NumberNegativeStyle.Prefix_Sankaku,

NumberNegativeStyle はフラグなので、Red | Brackets のように組み合わせることもできます。いずれの場合も、セルのデータは負の数値そのまま — 表示の見え方が変わるだけです。


消費税と端数処理 — 「どこで丸めるか」が 1 円を分ける

金額がズレるいちばんの原因は、消費税の端数処理です。1234 × 0.1 = 123.40.4 をどう扱うか。ここで二つの判断が必要になります。

  1. 丸め方 — 切り捨て / 四捨五入 / 切り上げ。これは法律で一律に決まっておらず、事業者が選んで継続適用するものです(多くの BtoC は切り捨て、BtoB は取引先との取り決めによる)。
  2. どの単位で丸めるか — 明細の行ごとに丸めるのか、税率ごとの合計に対して一度だけ丸めるのか。

特に 2 番目が重要です。インボイス制度(適格請求書)では、消費税額の端数処理は「1 つの請求書につき、税率ごとに 1 回」が原則です。行ごとに丸めて積み上げる旧来のやり方は認められません。つまり、先に税率ごとの税抜金額を合計し、その合計に対して 1 回だけ端数処理をするのが正しい順序です。

ReoGrid は ROUND / ROUNDDOWN / ROUNDUP を内蔵しているので、この丸めをそのまま数式で表現できます。

// 税抜の小計(数値)に対して、消費税を切り捨てで 1 回だけ計算する
sheet["E20"] = "=ROUNDDOWN(E19*0.1, 0)";   // 第2引数 0 → 1円未満を切り捨て

C# 側で計算して結果だけ入れたい場合は、double ではなく decimal を使うのが鉄則です。

// 金額計算は必ず decimal で(double は誤差が出る)
decimal subtotal = 12_345m;
decimal tax = Math.Floor(subtotal * 0.10m);   // 切り捨て → 1234
sheet["E20"] = tax;                            // 数値としてセルへ

Math.Floor が切り捨て(ROUNDDOWN 相当)、Math.Round(x, MidpointRounding.AwayFromZero) が四捨五入(ROUND 相当)です。丸め方を「請求書の中で 1 か所」に集約しておくこと。あちこちで丸めると、合計と内訳が合わなくなります。


軽減税率 — 税率別の集計は SUMIF で

軽減税率 8%(飲食料品など)と標準税率 10% が 1 枚の請求書に混在するのは、いまや珍しくありません。インボイスでは税率ごとに区分して合計・税額表示する必要があります。

明細表をこう作るとします。C 列に税率(108)、D 列に税抜金額を入れる構成です。

B(品名)C(税率%)D(税抜金額)
5コピー用紙103,000
6会議用 弁当84,000
7トナー1012,000
8来客用 茶菓子81,500

税率別の集計と税額は、SUMIFROUNDDOWN を組み合わせて数式で組めます。

var sheet = grid.CurrentWorksheet;

// --- 税率別の税抜合計 ---
sheet["D11"] = "=SUMIF(C5:C8, 10, D5:D8)";   // 10%対象の税抜計 → 15,000
sheet["D12"] = "=SUMIF(C5:C8, 8,  D5:D8)";   //  8%対象の税抜計 →  5,500

// --- 税率ごとに 1 回だけ端数処理(インボイス対応) ---
sheet["D13"] = "=ROUNDDOWN(D11*0.1,  0)";    // 10%消費税 → 1,500
sheet["D14"] = "=ROUNDDOWN(D12*0.08, 0)";    //  8%消費税 →   440

// --- 税込総合計 ---
sheet["D15"] = "=D11+D12+D13+D14";           // 22,440

// 金額列に通貨書式をまとめて適用
sheet.SetRangeDataFormat("D5:D15", CellDataFormatFlag.Currency,
    new CurrencyDataFormatter.CurrencyFormatArgs
    {
        CultureEnglishName = "ja-JP",
        PrefixSymbol = "¥",
        DecimalPlaces = 0,
        UseSeparator = true,
    });

ポイントは、税額を「税率ごとの合計(D11 / D12)に対して」計算していることです。各行で丸めてから足すのではなく、SUMIF でまず税率別に積んでから ROUNDDOWN を 1 回かける — これがインボイス制度の求める順序であり、取引先と 1 円もズレない計算です。

数式はセルに入れるだけで ReoGrid の内蔵エンジンが再計算します。D5 の値を書き換えれば D11D15 が自動で追従するので、ユーザーが画面上で金額を直しても合計は常に正しいままです。


テンプレート × 数式で「崩れない帳票」にする

ここまでを組み合わせると、見た目は Excel で作ったテンプレートに任せ、コードは数値と数式を流し込むだけ、という構成になります。罫線・ロゴ・ラベルといった固定レイアウトは .xlsx テンプレートが持ち、可変の金額だけをコードが埋めます。

grid.Load("invoice-template.xlsx");          // レイアウトは Excel で用意
var sheet = grid.CurrentWorksheet;

// 明細(税率と税抜金額は数値で入れる)
var items = new[]
{
    ("コピー用紙",   10, 3000m),
    ("会議用 弁当",   8, 4000m),
    ("トナー",       10, 12000m),
    ("来客用 茶菓子", 8, 1500m),
};

int r = 5;
foreach (var (name, rate, price) in items)
{
    sheet[$"B{r}"] = name;
    sheet[$"C{r}"] = rate;
    sheet[$"D{r}"] = price;   // decimal をそのまま数値セルへ
    r++;
}

// 集計は数式(テンプレート側にあらかじめ入れておいてもよい)
sheet["D11"] = "=SUMIF(C5:C8, 10, D5:D8)";
sheet["D12"] = "=SUMIF(C5:C8, 8,  D5:D8)";
sheet["D13"] = "=ROUNDDOWN(D11*0.1,  0)";
sheet["D14"] = "=ROUNDDOWN(D12*0.08, 0)";
sheet["D15"] = "=D11+D12+D13+D14";

grid.Save($"invoice-{DateTime.Now:yyyyMMdd}.xlsx");   // 控えを .xlsx で保存

テンプレート側に通貨書式と集計式を作り込んでおけば、コードはさらに薄くなります。.xlsx で控えを残せば、相手が Excel で開いても書式と数値の両方がファイルの中に保持されるので、金額が崩れることはありません(テンプレート流し込みの考え方は 約30行で領収書発行アプリを作る で詳しく扱っています)。


まとめ

  • 金額が崩れる原因は構造的 — 文字列化(合計・並べ替えが壊れる)と double の誤差
  • セルには素の数値を入れ、「¥」と桁区切りは通貨書式でかぶせる。データと表示を分離すれば数式も並べ替えも生き続ける
  • 通貨書式は SetRangeDataFormat(..., CellDataFormatFlag.Currency, new CurrencyFormatArgs { PrefixSymbol = "¥", DecimalPlaces = 0, UseSeparator = true })
  • マイナスは NegativeStyle で赤字・括弧・日本式の Prefix_Sankaku(▲)
  • 金額計算は C# なら decimal、丸めは Math.Floor(切り捨て)/ Math.Round(四捨五入)。丸め方は請求書内で 1 か所に集約する
  • 消費税の端数処理は インボイス制度の原則どおり「税率ごとに 1 回」SUMIF で税率別に合計してから ROUNDDOWN を 1 回かける
  • 軽減税率の混在は SUMIF(範囲, 税率, 金額範囲) で税率別に区分集計
  • レイアウトは .xlsx テンプレートに任せ、コードは数値と数式だけ流し込む

金額のズレは「検算でなんとかする」ものではありません。データは数値のまま持ち、丸める場所を 1 か所に決める — この 2 つを守れば、合計は最初から合います。


次に読むもの