Как округлять числа до ближайших 0,05 или другого шага в JavaScript

Узнайте, как округлять валюту и числа до определённых шагов, например 0,05 или 0,10, для стран без мелких монет

Введение

Покупатель из Канады добавляет товары в корзину на сумму $11,23, но итоговая сумма к оплате — $11,25. Это происходит потому, что в 2013 году Канада отказалась от пенни, и наличные платежи теперь округляются до ближайших 0,05. Вашему приложению нужно показывать цены, которые реально платит покупатель.

Округление до определённого шага решает эту задачу. Можно округлять числа до ближайших 0,05 (никель), 0,10 (дайм) или любого другого значения. Это важно для форматирования валюты, измерительных систем и любых случаев, когда значения должны соответствовать определённому шагу.

В этом гайде показано, как округлять числа до нужного шага на JavaScript — вручную и с помощью современной Intl.NumberFormat API.

Зачем округлять до определённых шагов

Во многих странах мелкие монеты убрали по практическим причинам: их производство и обращение обходятся дороже номинала. Поэтому наличные платежи округляются до ближайшей доступной монеты.

Страны, где наличные платежи округляются до 0,05:

  • Канада (отказ от пенни в 2013)
  • Австралия (отказ от монет 1 и 2 цента)
  • Нидерланды (округление до 0,05 евро)
  • Бельгия (округление до 0,05 евро)
  • Ирландия (округление до 0,05 евро)
  • Италия (округление до 0,05 евро)
  • Финляндия (округление до 0,05 евро)
  • Швейцария (минимальная монета — 0,05 CHF)

Страны, где округляют до 0,10:

  • Новая Зеландия (отказ от монеты 5 центов в 2006)

США планируют прекратить выпускать пенни к началу 2026 года, поэтому для наличных расчетов потребуется округление до 0,05.

Округление наличных применяется только к итоговой сумме транзакции, а не к отдельным позициям. Электронные платежи остаются точными, так как физические монеты не используются.

Помимо валюты, вам могут понадобиться пользовательские шаги округления для:

  • Систем измерения с определённой точностью
  • Научных расчетов, округляемых до точности прибора
  • Элементов интерфейса, которые фиксируются на определённых значениях
  • Ценовых стратегий с психологическими ценами

Математическая концепция округления по шагу

Округление до шага означает поиск ближайшего кратного этого шага. Формула делит число на шаг, округляет до ближайшего целого, затем умножает обратно на шаг.

roundedValue = Math.round(value / increment) * increment

Например, округление 11,23 до ближайших 0,05:

  1. Делим: 11,23 / 0,05 = 224,6
  2. Округляем: Math.round(224,6) = 225
  3. Умножаем: 225 * 0,05 = 11,25

Деление переводит шаг в целые значения. Округление 224,6 до 225 находит ближайший шаг. Умножение возвращает к исходному масштабу.

Для 11,27, округленного до ближайших 0,05:

  1. Делим: 11,27 / 0,05 = 225,4
  2. Округляем: Math.round(225,4) = 225
  3. Умножаем: 225 * 0,05 = 11,25

И 11,23, и 11,27 округляются до 11,25, потому что они находятся в пределах 0,025 от этого значения. Середина 11,25 остаётся 11,25.

Как вручную реализовать округление по шагу

Создайте функцию, которая округляет любое число до заданного шага:

function roundToIncrement(value, increment) {
  return Math.round(value / increment) * increment;
}

console.log(roundToIncrement(11.23, 0.05)); // 11.25
console.log(roundToIncrement(11.27, 0.05)); // 11.25
console.log(roundToIncrement(11.28, 0.05)); // 11.30
console.log(roundToIncrement(4.94, 0.10)); // 4.90
console.log(roundToIncrement(4.96, 0.10)); // 5.00

Это работает для любого шага, не только для валюты:

console.log(roundToIncrement(17, 5)); // 15
console.log(roundToIncrement(23, 5)); // 25
console.log(roundToIncrement(2.7, 0.5)); // 2.5

Арифметика с плавающей запятой в JavaScript может приводить к небольшим ошибкам точности. Для денежных расчетов, где важна точность, умножайте значения на степень 10 и работайте с целыми числами:

function roundToIncrementPrecise(value, increment) {
  // Find the number of decimal places in the increment
  const decimals = (increment.toString().split('.')[1] || '').length;
  const multiplier = Math.pow(10, decimals);

  // Convert to integers
  const valueInt = Math.round(value * multiplier);
  const incrementInt = Math.round(increment * multiplier);

  // Round and convert back
  return Math.round(valueInt / incrementInt) * incrementInt / multiplier;
}

console.log(roundToIncrementPrecise(11.23, 0.05)); // 11.25

Math.round() использует округление по принципу half-up, когда значения ровно посередине между двумя инкрементами округляются вверх. Некоторым приложениям нужны другие режимы округления:

function roundToIncrementWithMode(value, increment, mode = 'halfUp') {
  const divided = value / increment;

  let rounded;
  switch (mode) {
    case 'up':
      rounded = Math.ceil(divided);
      break;
    case 'down':
      rounded = Math.floor(divided);
      break;
    case 'halfDown':
      rounded = divided % 1 === 0.5 ? Math.floor(divided) : Math.round(divided);
      break;
    case 'halfUp':
    default:
      rounded = Math.round(divided);
      break;
  }

  return rounded * increment;
}

console.log(roundToIncrementWithMode(11.225, 0.05, 'halfUp')); // 11.25
console.log(roundToIncrementWithMode(11.225, 0.05, 'halfDown')); // 11.20
console.log(roundToIncrementWithMode(11.23, 0.05, 'up')); // 11.25
console.log(roundToIncrementWithMode(11.23, 0.05, 'down')); // 11.20

Использование Intl.NumberFormat с roundingIncrement

Современные браузеры поддерживают опцию roundingIncrement в Intl.NumberFormat, которая автоматически обрабатывает округление по инкременту при форматировании чисел.

Базовое использование для округления канадского доллара до 5 центов:

const formatter = new Intl.NumberFormat('en-CA', {
  style: 'currency',
  currency: 'CAD',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  roundingIncrement: 5
});

console.log(formatter.format(11.23)); // CA$11.25
console.log(formatter.format(11.27)); // CA$11.25
console.log(formatter.format(11.28)); // CA$11.30

Опция roundingIncrement принимает только определённые значения:

  • 1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000

Для округления до 0,05 установите maximumFractionDigits: 2 и roundingIncrement: 5. Для округления до 0,10 установите maximumFractionDigits: 1 и roundingIncrement: 1, либо оставьте maximumFractionDigits: 2 с roundingIncrement: 10.

Округление швейцарского франка до 0,05:

const chfFormatter = new Intl.NumberFormat('de-CH', {
  style: 'currency',
  currency: 'CHF',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  roundingIncrement: 5
});

console.log(chfFormatter.format(8.47)); // CHF 8.45
console.log(chfFormatter.format(8.48)); // CHF 8.50

Округление новозеландского доллара до 0,10:

const nzdFormatter = new Intl.NumberFormat('en-NZ', {
  style: 'currency',
  currency: 'NZD',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  roundingIncrement: 10
});

console.log(nzdFormatter.format(15.94)); // $15.90
console.log(nzdFormatter.format(15.96)); // $16.00

Можно комбинировать roundingIncrement с разными режимами округления:

const formatterUp = new Intl.NumberFormat('en-CA', {
  style: 'currency',
  currency: 'CAD',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  roundingIncrement: 5,
  roundingMode: 'ceil'
});

console.log(formatterUp.format(11.21)); // CA$11.25 (rounded up)

Доступные режимы округления:

  • ceil: округление в сторону плюс бесконечности
  • floor: округление в сторону минус бесконечности
  • expand: округление от нуля
  • trunc: округление к нулю
  • halfCeil: округление половины в сторону плюс бесконечности
  • halfFloor: округление половины в сторону минус бесконечности
  • halfExpand: округление половины от нуля (по умолчанию)
  • halfTrunc: округление половины к нулю
  • halfEven: округление половины до чётного (банковское округление)

Важные ограничения для roundingIncrement:

  • minimumFractionDigits и maximumFractionDigits должны иметь одинаковое значение
  • Нельзя комбинировать с округлением по значимым цифрам
  • Работает только если roundingPriority равен auto (по умолчанию)

Проверьте, поддерживает ли браузер roundingIncrement:

function supportsRoundingIncrement() {
  try {
    const formatter = new Intl.NumberFormat('en-US', {
      roundingIncrement: 5,
      minimumFractionDigits: 2,
      maximumFractionDigits: 2
    });
    const options = formatter.resolvedOptions();
    return options.roundingIncrement === 5;
  } catch (e) {
    return false;
  }
}

if (supportsRoundingIncrement()) {
  // Use Intl.NumberFormat with roundingIncrement
} else {
  // Fall back to manual rounding
}

Практические примеры реализации

Создайте форматтер валюты, который учитывает округление наличных по локали:

function createCashFormatter(locale, currency) {
  // Define cash rounding rules by currency
  const cashRoundingRules = {
    'CAD': { increment: 5, digits: 2 },
    'AUD': { increment: 5, digits: 2 },
    'EUR': { increment: 5, digits: 2 }, // Some eurozone countries
    'CHF': { increment: 5, digits: 2 },
    'NZD': { increment: 10, digits: 2 }
  };

  const rule = cashRoundingRules[currency];

  if (!rule) {
    // No special rounding needed
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currency
    });
  }

  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
    minimumFractionDigits: rule.digits,
    maximumFractionDigits: rule.digits,
    roundingIncrement: rule.increment
  });
}

const cadFormatter = createCashFormatter('en-CA', 'CAD');
console.log(cadFormatter.format(47.83)); // CA$47.85

const usdFormatter = createCashFormatter('en-US', 'USD');
console.log(usdFormatter.format(47.83)); // $47.83 (no rounding)

Обрабатывайте и наличные, и безналичные платежи:

function formatPaymentAmount(amount, currency, locale, paymentMethod) {
  if (paymentMethod === 'cash') {
    const formatter = createCashFormatter(locale, currency);
    return formatter.format(amount);
  } else {
    // Electronic payments use exact amounts
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currency
    }).format(amount);
  }
}

console.log(formatPaymentAmount(11.23, 'CAD', 'en-CA', 'cash')); // CA$11.25
console.log(formatPaymentAmount(11.23, 'CAD', 'en-CA', 'card')); // CA$11.23

Рассчитайте фактическую сумму к оплате:

function calculateCashTotal(items, currency) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);

  // Apply cash rounding only to the final total
  const cashRoundingRules = {
    'CAD': 0.05,
    'AUD': 0.05,
    'CHF': 0.05,
    'NZD': 0.10
  };

  const increment = cashRoundingRules[currency];

  if (!increment) {
    return subtotal;
  }

  return roundToIncrement(subtotal, increment);
}

function roundToIncrement(value, increment) {
  return Math.round(value / increment) * increment;
}

const items = [
  { price: 5.99 },
  { price: 3.49 },
  { price: 1.75 }
];

console.log(calculateCashTotal(items, 'CAD')); // 11.25
console.log(calculateCashTotal(items, 'USD')); // 11.23

Используйте округление по шагу для невалютных значений:

// Temperature display rounded to nearest 0.5 degrees
function formatTemperature(celsius) {
  const rounded = roundToIncrement(celsius, 0.5);
  return new Intl.NumberFormat('en-US', {
    style: 'unit',
    unit: 'celsius',
    minimumFractionDigits: 1,
    maximumFractionDigits: 1
  }).format(rounded);
}

console.log(formatTemperature(22.3)); // 22.5°C
console.log(formatTemperature(22.6)); // 22.5°C

// Slider values snapped to increments
function snapToGrid(value, gridSize) {
  return roundToIncrement(value, gridSize);
}

console.log(snapToGrid(47, 5)); // 45
console.log(snapToGrid(53, 5)); // 55

Важные моменты и крайние случаи

Применяйте округление наличных только к итоговой сумме транзакции, а не к отдельным позициям. Округление каждого товара отдельно даст другой результат, чем округление суммы:

const items = [1.23, 1.23, 1.23];

// Wrong: rounding each item
const wrongTotal = items
  .map(price => roundToIncrement(price, 0.05))
  .reduce((sum, price) => sum + price, 0);
console.log(wrongTotal); // 3.75

// Correct: rounding the total
const correctTotal = roundToIncrement(
  items.reduce((sum, price) => sum + price, 0),
  0.05
);
console.log(correctTotal); // 3.70

Показывайте пользователям округлённые суммы, но храните точные значения в базе данных. Это сохраняет точность для бухгалтерии и позволяет менять правила округления без потери данных:

const transaction = {
  items: [
    { id: 1, price: 5.99 },
    { id: 2, price: 3.49 },
    { id: 3, price: 1.75 }
  ],
  subtotal: 11.23, // Store exact value
  currency: 'CAD',
  paymentMethod: 'cash'
};

// Calculate display amount
const displayAmount = roundToIncrement(transaction.subtotal, 0.05); // 11.25

В разных странах, использующих одну и ту же валюту, могут быть разные правила округления наличных. Евро используется в странах с округлением и без него. Проверяйте правила конкретной страны, а не только валюту.

Отрицательные суммы округляются к нулю по абсолютному значению, а затем применяется минус:

console.log(roundToIncrement(-11.23, 0.05)); // -11.25
console.log(roundToIncrement(-11.22, 0.05)); // -11.20

Очень большие числа могут терять точность при вычислениях с плавающей точкой. Для финансовых приложений с большими суммами используйте целые числа в минимальной денежной единице:

function roundToIncrementCents(cents, incrementCents) {
  return Math.round(cents / incrementCents) * incrementCents;
}

// Work in cents
const amountCents = 1123; // $11.23
const roundedCents = roundToIncrementCents(amountCents, 5); // 1125
const dollars = roundedCents / 100; // 11.25

Тестируйте свою реализацию округления на крайних случаях:

// Test cases for 0.05 rounding
const testCases = [
  { input: 0.00, expected: 0.00 },
  { input: 0.01, expected: 0.00 },
  { input: 0.02, expected: 0.00 },
  { input: 0.025, expected: 0.05 },
  { input: 0.03, expected: 0.05 },
  { input: 0.99, expected: 1.00 },
  { input: -0.03, expected: -0.05 },
  { input: 11.225, expected: 11.25 }
];

testCases.forEach(({ input, expected }) => {
  const result = roundToIncrement(input, 0.05);
  console.log(`${input} -> ${result} (expected ${expected})`);
});

Опция roundingIncrement в Intl.NumberFormat поддерживается в современных браузерах. Firefox добавил поддержку с версии 93. Для приложений, поддерживающих старые браузеры, реализуйте запасной вариант:

function formatCurrency(amount, locale, currency, increment) {
  try {
    const formatter = new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: currency,
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
      roundingIncrement: increment
    });

    // Check if roundingIncrement was actually applied
    if (formatter.resolvedOptions().roundingIncrement === increment) {
      return formatter.format(amount);
    }
  } catch (e) {
    // roundingIncrement not supported
  }

  // Fallback: manual rounding
  const rounded = roundToIncrement(amount, increment / 100);
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency
  }).format(rounded);
}

Чётко документируйте логику округления в пользовательских интерфейсах. Пользователи должны понимать, почему отображаемые суммы отличаются от итоговых по товарам:

<div class="cart-summary">
  <div class="line-item">Subtotal: $11.23</div>
  <div class="line-item total">Total (cash): $11.25</div>
  <div class="note">Cash payments rounded to nearest $0.05</div>
</div>

В разных юрисдикциях разные правила, кто получает выгоду от округления (клиент или продавец) и как обрабатывать крайние случаи. Изучите местные нормы для вашего случая.