Деньги в FastAPI: Decimal, точность цены и понятный JSON-контракт
Клиент передаёт цену 19.90 и количество три. Внутри API хочется получить ровно 59.70, а в ответе сохранить денежный формат. Если использовать float для десятичных денежных правил, придётся отдельно решать ошибки представления и округления. Удобнее договориться о строковом JSON-представлении и выполнять расчёт через Decimal.
Точность начинается с договора о входе
В этом API цена выражается в рублях и приходит десятичной строкой с точкой. Разрешено не больше двух знаков после неё, цена положительная. Мы отклоняем 19.901, поскольку автоматическое округление могло бы изменить сумму заказа без согласия клиента. Если предметная область требует округления, задайте момент и правило отдельно: округление каждой позиции и всей корзины даёт разные результаты.
Pydantic преобразует строку в Decimal и проверяет max_digits и decimal_places. Отдельный before-валидатор требует исходную строку. Это сохраняет точность на транспортной границе: JSON-число могло уже пройти через двоичное представление у клиента. Для суммы ответа объявлена Decimal-модель; в JSON Pydantic сериализует такое значение строкой.
Вычислим сумму и проверим представление
Сохраните пример целиком. Понадобятся FastAPI, Pydantic v2 и HTTPX. Внутри обработчика нет float: множитель является целым числом, а цена — Decimal. Тест проверяет итоговую строку, цену с лишним знаком и числовой ввод вместо согласованной строки.
Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.
from decimal import Decimal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class LineIn(BaseModel):
price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
quantity: int = Field(ge=1, le=1000)
@field_validator("price", mode="before")
@classmethod
def string_price(cls, value):
if not isinstance(value, str):
raise ValueError("send price as a decimal string")
return value
class TotalOut(BaseModel):
total: Decimal
@app.post("/total", response_model=TotalOut)
def total(line: LineIn):
return TotalOut(total=(line.price * line.quantity).quantize(Decimal("0.01")))
with TestClient(app) as client:
r = client.post("/total", json={"price": "19.90", "quantity": 3})
assert r.status_code == 200
assert r.json() == {"total": "59.70"}
for price in ("19.901", "-1.00", 19.90):
assert client.post("/total", json={"price": price, "quantity": 3}).status_code == 422
print(r.json())Что именно гарантирует проверка
Ограничение двух знаков проверяет цену позиции до умножения. quantize фиксирует два знака у суммы; здесь оно не меняет денежное значение, потому что исходная цена уже имеет допустимую точность, а количество целое. При добавлении процента скидки это перестанет быть очевидным: сначала определите округление и проверьте половину копейки.
Для базы выберите согласованный способ хранения: NUMERIC с нужной точностью либо целое количество минимальных денежных единиц. Не превращайте Decimal в float при записи, иначе преимущество потеряется на следующем шаге. SQLite имеет особенности числового хранения, поэтому для финансовой логики полезно отдельно проверить точный способ чтения и записи выбранного драйвера.
Как использовать договор на стороне клиента
Форма может показывать запятую пользователю, но в JSON отправлять строку с точкой. Разделите отображение и протокол. В ответе total тоже строка: интерфейс форматирует её как деньги, а не выполняет расчёт через Number без дополнительного решения о точности. При нескольких валютах добавьте код валюты и правила разрядности для каждой.
Наконец, цена в присланном документе не обязана быть доверенным источником для оплаты. В реальном магазине сервер обычно читает актуальную цену по идентификатору товара. Разбор Decimal остаётся полезен для разрешённых операций ввода цены и для точного расчёта, но не заменяет проверку того, кто имеет право её задавать.
Следующая практика
Закрепите договор JSON на открытой практике POST: опишите модель команды и получите её в обработчике. Затем замените учебное числовое поле ценой Decimal и добавьте проверки денежного формата из этого разбора. Перейдите к бесплатной практике POST и Pydantic.
После проверки денежного входа следующий шаг — хранение и чтение ресурса. Бесплатный курс FastAPI проводит через Pydantic, SQLAlchemy и HTTP-тесты на одном backend-проекте.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
