В официальном образе Odoo 19 Community 685 модулей, и ни один из них не умеет повторяющиеся платежи. Нет ни sale_subscription, ни поля recurring_invoice, от которого можно было бы наследоваться. Поэтому как только проекту нужно списывать с одного и того же клиента каждый месяц, деньги живут снаружи, и остаётся один вопрос: что возвращается обратно и насколько этому можно верить.
На agrobessarabia.com мы продаём две вещи — подписку на присутствие в справочнике и место в блоке на главной, — а списывает Paddle. Дальше — про коннектор между ними, с кодом, который это делает. Это отдельный модуль Odoo, и сайт — его первый покупатель.
Merchant of record — это не платёжный шлюз
Со шлюзом продавец по-прежнему вы: НДС в стране покупателя ваш, счёт выставляете вы, чарджбэк разбираете тоже вы. Paddle — merchant of record: он продаёт вашему клиенту от своего имени, начисляет налог по каждой юрисдикции, платит его и переводит вам остаток. Для маленькой команды, торгующей через границу, это снимает целый пласт соответствия требованиям, а номер карты не попадает в базу Odoo вообще.
Взамен вы отдаёте контроль и долю выручки. И получаете следствие, из которого растёт весь код ниже: Odoo не видит платежа. Он видит поток утверждений о подписке, доставляемых по HTTP системой, у которой есть все основания повторяться.
Вся интеграция — это один эндпоинт и один флаг
Коннектор принимает подписанные вебхуки, журналирует каждый, зеркалит подписку в запись и отдаёт наружу один булев признак: оплачен ли доступ этого клиента прямо сейчас. Всё, что делает сайт — держит элеватор в справочнике, держит место на главной, — читает этот флаг и больше ничего.
Поверхность сознательно маленькая. Не маленькое здесь другое — число способов, которыми ломается доставка этих утверждений.
200 значит «доставлено», поэтому его нельзя отдать случайно
Типы маршрутов в Odoo здесь не взаимозаменяемы. Маршрут jsonrpc всегда отвечает HTTP 200 и кладёт ошибку внутрь тела. Для Paddle 200 — это «доставлено», а доставленное событие не повторяется никогда: исключение в обработчике превращается в оплату, о которой никто не узнает. Выбирать код ответа позволяет только type='http'.
@http.route("/paddle/webhook", type="http", auth="public",
methods=["POST"], csrf=False, save_session=False)
def receive(self, **kwargs):
secret = request.env["ir.config_parameter"].sudo().get_param(
"paddle_connector.webhook_secret")
raw_body = request.httprequest.get_data()
header = request.httprequest.headers.get("Paddle-Signature")
# An empty secret means "not configured", not "accept anything".
# A 503 is retried, so the event comes back once it is set up.
if not secret:
return _reply("error", 503, message="webhook secret not configured")
# 401 rather than 200: a forgery and a rotated secret look the same
# from here, and a retry fixes the second case.
if not signature.verify(raw_body, header, secret):
return _reply("error", 401, message="invalid signature")Поэтому коды выбраны осознанно. 503, пока не настроен секрет: это ретраится, и событие вернётся, когда настройку доделают. 401 на неверную подпись: подделку и только что ротированный секрет отсюда не отличить, и второй случай ретрай чинит сам. 500 — только после отката транзакции, потому что частично применённое состояние хуже непринятого события.
Подпись считается по байтам, которые вы ещё не разбирали
Заголовок — ts=<unix>;h1=<hex>, а подписывается метка времени, двоеточие и сырое тело запроса. Сырое — в этом весь смысл: распарсить JSON и сериализовать обратно значит изменить порядок ключей и пробелы, и байты перестанут совпадать с подписанными. В Odoo это значит прочитать request.httprequest.get_data() раньше, чем до тела доберётся json.loads.
def compute(body: bytes, secret: str, ts: int) -> str:
"""HMAC-SHA256 of "<ts>:" + body, lowercase hex."""
mac = hmac.new(secret.encode("utf-8"),
f"{ts}:".encode() + body,
hashlib.sha256)
return mac.hexdigest()
def verify(body, header, secret, tolerance=300, now=None):
parsed = parse_header(header) # ts=...;h1=...
if parsed is None:
return False
ts, digest = parsed
# Anti-replay. The future is rejected too: a sender whose clock ran
# ahead would otherwise open a window a day wide.
current = int(time.time()) if now is None else now
if abs(current - ts) > tolerance:
return False
# Constant time only: comparing hex strings character by character
# lets an attacker guess the signature from the response time.
return hmac.compare_digest(compute(body, secret, ts), digest.lower())Две детали, в которых легко ошибиться. Сравнивать за постоянное время — посимвольное сравнение hex-строк отдаёт ответ через время отклика. И отклонять метку времени не только из прошлого, но и из будущего: иначе ушедшие вперёд часы отправителя открывают окно повтора на сутки.
Допуск здесь пять минут, тогда как собственный SDK Paddle по умолчанию даёт пять секунд. Это сознательное ослабление. Odoo на своём сервере — не машина с дисциплинированными часами, и пятисекундное окно превращает любой сбой NTP в отвергнутое событие об оплате. Пять минут при этом всё ещё слишком мало, чтобы переиграть запрос, найденный в логе.
Каждое событие приходит минимум дважды
Paddle повторяет доставку до первого 2xx: в live — до 60 попыток за примерно трое суток, и каждый раз с тем же event_id. Обработать событие дважды — не крайний случай, который когда-нибудь прикроют, а обычный трафик. Защита — журнал с уникальным ключом, и вставка делается первой, до того как тронуто хоть какое-то состояние.
@api.model
def register(self, event_id, event_type, occurred_at, payload):
"""Register an event. Returns (record, is_duplicate)."""
try:
with self.env.cr.savepoint():
record = self.create({
"event_id": event_id, "event_type": event_type,
"occurred_at": occurred_at, "payload": payload,
})
# The flush is mandatory: without it UNIQUE is checked later,
# outside the savepoint, breaking the whole transaction.
record.flush_recordset()
except IntegrityError:
existing = self.search([("event_id", "=", event_id)], limit=1)
if existing.state in self.TERMINAL_STATES:
return existing, True
# pending/failed is a delivery interrupted midway. Hand it back
# for reprocessing instead of dismissing it as a duplicate.
existing.attempts += 1
return existing, False
return record, FalseЗдесь важны три детали, и ни одна не видна снаружи. Уникальность обязан держать индекс, а не поиск перед вставкой: две параллельные доставки проходят такой поиск обе. Flush обязан случиться внутри savepoint, иначе ограничение проверится позже, уже вне его, и уронит всю транзакцию вместо одного запроса. А savepoint надо брать контекстным менеджером: Savepoint.close() в Odoo по умолчанию откатывает и тихо убирает только что записанную строку.
Доставки не упорядочены
Ретрай старого события может прийти после нового. Применённый вслепую, он воскрешает отменённую подписку или выключает активную апдейтом, который устарел несколько часов назад. Поэтому каждая подписка помнит время последнего применённого события, а всё, что старше, попадает в журнал и игнорируется.
def _is_stale_event(self, occurred_at):
"""Paddle events are not ordered."""
self.ensure_one()
return bool(
self.last_occurred_at and occurred_at
and occurred_at < self.last_occurred_at
)past_due — это платящий клиент
Когда списание не проходит, Paddle ничего не отменяет: подписка переходит в past_due, и ретраи идут сутками. Считать это неоплатой значит закрыть доступ человеку, у которого просто истёк срок карты, ровно в тот день, когда он вероятнее всего это исправит. Доступ снимается на paused и canceled, не раньше.
# past_due is here on purpose: it means "the last charge failed,
# Paddle is retrying". Access is revoked on paused/canceled.
ENTITLED_STATUSES = ("active", "trialing", "past_due")
@api.depends("status")
def _compute_is_entitled(self):
for record in self:
record.is_entitled = record.status in ENTITLED_STATUSESТа же ошибка во втором обличье. Отмена в конце периода приезжает как запланированное изменение, а статус при этом остаётся active. Держите её в отдельном поле — иначе отключите клиента, у которого оплачены ещё три недели.
Цена, которую вы видите впервые, — это не мусор
Событие с ценой, которой нет в каталоге планов, подлинное, оплаченное и неприменимое. Выбросить его — потерять оплату; ответить ошибкой — заставить Paddle трое суток ретраить то, что чинится только руками. Поэтому подписка сохраняется без плана, событие остаётся как unmapped, а эндпоинт отвечает 2xx. Оно ждёт под фильтром «Needs attention», пока каталог не поправят, и переигрывается оттуда.

Журнал входящих событий: каждая доставка, её состояние и тело, с которым она пришла.
Пять секунд — это весь бюджет
Paddle считает доставку неудачной, если за пять секунд никто не ответил, и это задаёт форму обработчика: проверить, записать в журнал, обновить зеркало, ответить. Всё тяжёлое — выдача прав, письма, сверка с API — уезжает в крон или очередь, которые работают уже после отправленного ответа.
Как это выглядит на agrobessarabia.com
Экран настроек — это вся конфигурация: URL эндпоинта, который вставляют в Paddle, секрет подписи этого notification destination, среда и собственный API-ключ покупателя, ограниченный системными администраторами. Sandbox и production — разные destination с разными секретами, и событие, подписанное не тем, отклоняется.

Настройки коннектора в Odoo 19: URL вебхука для вставки в Paddle, секрет подписи и среда.
Сам коннектор ничего не знает ни про элеваторы, ни про перевозчиков, ни про главную страницу — и не должен: это отдельный продукт со своей лицензией и своими тестами. Связка живёт в нашем модуле, расширением его модели: два десятка строк, превращающих «подписка изменилась» в «это размещение видно или не видно».
class PaddleSubscription(models.Model):
_inherit = "paddle.subscription"
def write(self, vals):
result = super().write(vals)
# Exactly the fields that visibility depends on.
if {"status", "current_period_end", "price_id"} & set(vals):
self._sync_agro_placement()
return resultПод этим лежит правило, которое стоит проговорить вслух: наш каталог пишет в каталог коннектора и никогда наоборот. Два источника правды о том, какая цена какому плану соответствует, разойдутся за месяц, и каждое расхождение всплывёт как unmapped-событие, на которое уже перестали смотреть.

Зеркало подписок: статус, расчётный период, план и тот самый флаг доступа, который читает сайт.
Три вопроса, которые стоит задать до того, как строить своё
Что эндпоинт отвечает, когда ваша собственная база недоступна, и вернёт ли этот ответ событие обратно?
Какое поле связывает платёж с клиентом и кто управляет его значением — вы или тот, кто печатает в форме оплаты?
Как вы завтра утром узнаете, что все ночные события применились?
If you sell a subscription out of Odoo and the billing half is still a spreadsheet, write to me. The connector is a module of its own, it runs on Odoo 19, and it is not tied to our domain in any way.