В офіційному образі 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.