Деньги в 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-проекте.

Продолжить на практике