Sari la conținut

Paddle și Odoo 19: ce trebuie să supraviețuiască un webhook de abonament

20 septembrie 2026 de

Imaginea oficială Odoo 19 Community vine cu 685 de module și niciunul nu face facturare recurentă. Nu există sale_subscription și nici câmpul recurring_invoice din care să moștenești. Așa că în clipa în care un proiect trebuie să încaseze lunar de la același client, banii se întâmplă în altă parte, iar întrebarea rămasă e ce se întoarce înapoi și cât din asta e de încredere.

Pe agrobessarabia.com vindem două lucruri — un abonament de prezență în catalog și un loc pe prima pagină —, iar încasarea e la Paddle. Urmează conectorul dintre ele, cu codul care face treaba. E un modul Odoo de sine stătător, iar site-ul e primul lui client.

Un merchant of record nu e un gateway de plăți

Cu un gateway vânzătorul rămâi tu: TVA-ul din țara cumpărătorului e al tău, factura o emiți tu, chargeback-ul îl lămurești tot tu. Paddle e merchant of record — vinde clientului tău în nume propriu, calculează taxa din fiecare jurisdicție, o virează și îți plătește restul. Pentru o echipă mică ce vinde peste graniță asta scoate din discuție un strat întreg de conformitate, iar niciun număr de card nu ajunge vreodată în baza Odoo.

În schimb cedezi control și o parte din încasări. Și primești o consecință din care crește tot codul de mai jos: Odoo nu vede plata. Vede un flux de afirmații despre un abonament, livrate prin HTTP de un sistem care are toate motivele să se repete.

Toată integrarea e un endpoint și un indicator

Conectorul primește webhook-uri semnate, le jurnalizează pe toate, oglindește abonamentul într-o înregistrare și expune un singur boolean: dacă accesul acestui client e plătit chiar acum. Tot ce face site-ul cu asta — ține un siloz în catalog, ține un loc pe prima pagină — citește acel indicator și nimic altceva.

Suprafața e mică intenționat. Ce nu e mic e altceva: numărul de feluri în care livrarea acestor afirmații o ia razna.

Un 200 înseamnă livrat, deci nu-l da din greșeală

Tipurile de rute din Odoo nu sunt interschimbabile aici. O rută jsonrpc răspunde mereu cu HTTP 200 și pune eroarea în corp. Pentru Paddle 200 înseamnă livrat, iar un eveniment livrat nu mai vine niciodată: o excepție în handler devine o plată despre care nu află nimeni. Doar type='http' lasă handlerul să aleagă codul de răspuns.

@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")

De aceea codurile sunt alese cu cap. Un 503 cât timp secretul nu e configurat, pentru că asta se reia și evenimentul se întoarce după ce îl completează cineva. Un 401 la semnătură greșită, pentru că un fals și un secret tocmai rotit nu se deosebesc de aici, iar al doilea caz se repară singur prin reluare. Un 500 abia după rollback-ul tranzacției, pentru că o stare aplicată pe jumătate e mai rea decât un eveniment neacceptat.

Semnătura acoperă octeți pe care încă nu i-ai parsat

Antetul e ts=<unix>;h1=<hex>, iar ce se semnează e marca de timp, două puncte și corpul brut al cererii. Brut e tot rostul: parsezi JSON-ul și îl serializezi înapoi, iar ordinea cheilor și spațiile se schimbă, deci octeții nu mai corespund celor semnați. În Odoo asta înseamnă să citești request.httprequest.get_data() înainte ca json.loads să atingă corpul.

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())

Două detalii ușor de greșit. Compară în timp constant — compararea șirurilor hex caracter cu caracter scurge răspunsul prin timpul de răspuns. Și respinge o marcă de timp nu doar prea veche, ci și prea din viitor: altfel un expeditor cu ceasul luat înainte deschide o fereastră de reluare de o zi.

Toleranța de aici e de cinci minute, în timp ce SDK-ul Paddle folosește implicit cinci secunde. E o relaxare deliberată. Un Odoo găzduit pe server propriu nu e o mașină cu ceas disciplinat, iar o fereastră de cinci secunde transformă orice sughiț NTP într-un eveniment de plată respins — cinci minute rămân oricum mult prea puțin ca să reiei o cerere găsită într-un log.

Fiecare eveniment ajunge de cel puțin două ori

Paddle reia până primește un 2xx: în live, până la 60 de încercări în aproximativ trei zile, de fiecare dată cu același event_id. Deci a procesa un eveniment de două ori nu e un caz limită de acoperit cândva, ci traficul obișnuit. Apărarea e un jurnal cu cheie unică, iar inserarea se face prima, înainte să fie atinsă vreo stare.

@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

Contează trei detalii și niciunul nu se vede din afară. Unicitatea trebuie impusă de index, nu de o căutare dinaintea inserării: două livrări simultane trec amândouă de acea căutare. Flush-ul trebuie să se întâmple în interiorul savepoint-ului, altfel constrângerea e verificată mai târziu, în afara lui, și pică toată tranzacția în loc de o singură instrucțiune. Iar savepoint-ul se ia ca context manager: Savepoint.close() în Odoo face rollback implicit și șterge tăcut rândul tocmai scris.

Livrările nu sunt ordonate

Reluarea unui eveniment mai vechi poate ajunge după unul mai nou. Aplicată orbește, învie un abonament anulat sau stinge unul activ cu un update depășit de ore bune. De aceea fiecare abonament ține minte când a avut loc ultimul eveniment aplicat, iar tot ce e mai vechi ajunge în jurnal și e ignorat.

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 e un client care plătește

Când o încasare eșuează, Paddle nu anulează nimic: abonamentul trece în past_due, iar reluările merg zile la rând. A trata asta ca neplată blochează pe cineva căruia pur și simplu i-a expirat cardul, exact în ziua în care e cel mai probabil să repare. Accesul cade la paused și canceled, nu mai devreme.

# 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

Aceeași greșeală în a doua formă. O anulare la finalul perioadei vine ca o schimbare programată, în timp ce statusul rămâne active. Ține-o într-un câmp separat, altfel tai accesul unui client care mai are trei săptămâni plătite.

Un preț pe care nu l-ai mai văzut nu e gunoi

Un eveniment cu un preț care nu e în catalogul de planuri e autentic, plătit și inaplicabil. Aruncat, pierde plata; răspuns cu eroare, pune Paddle să reia trei zile ceva ce doar un om poate repara. Așa că abonamentul se salvează fără plan, evenimentul rămâne ca unmapped, iar endpointul răspunde 2xx. Așteaptă sub filtrul „Needs attention" până se corectează catalogul și e reluat de acolo.

Journal of incoming Paddle webhook events in Odoo 19

Jurnalul evenimentelor primite: fiecare livrare, starea ei și corpul cu care a venit.

Cinci secunde sunt tot bugetul

Paddle marchează livrarea ca eșuată dacă nimeni nu a răspuns în cinci secunde, iar asta dă forma handlerului: verifică, jurnalizează, scrie oglinda, răspunde. Tot ce e mai greu — acordarea drepturilor, e-mailurile, reconcilierea prin API — ține de un cron sau o coadă care rulează după ce răspunsul a plecat.

Cum arată pe agrobessarabia.com

Ecranul de setări e toată configurarea: URL-ul endpointului de lipit în Paddle, secretul de semnare al acelui notification destination, mediul și cheia API proprie a cumpărătorului, restricționată la administratorii de sistem. Sandbox și producție sunt destinații separate cu secrete separate, iar un eveniment semnat cu cel greșit e refuzat.

Paddle connector settings in Odoo 19

Setările conectorului în Odoo 19: URL-ul webhookului de lipit în Paddle, secretul de semnare și mediul.

Conectorul în sine nu știe nimic despre silozuri, transportatori sau prima pagină, și nici nu trebuie: e un produs separat, cu licența lui și testele lui. Legătura stă în modulul nostru, ca extensie a modelului lui — două duzini de rânduri care transformă „abonamentul s-a schimbat" în „plasarea asta se vede sau nu".

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

Dedesubt stă o regulă care merită spusă răspicat: catalogul nostru scrie în catalogul conectorului, niciodată invers. Două surse de adevăr despre ce preț înseamnă ce plan se despart într-o lună, iar fiecare nepotrivire iese la suprafață ca eveniment unmapped la care nimeni nu se mai uită.

Mirrored Paddle subscriptions with the access flag

Abonamente oglindite: status, perioadă de facturare, plan și exact indicatorul de acces pe care îl citește site-ul.

Trei întrebări înainte să-ți construiești unul

Ce răspunde endpointul când propria ta bază de date e căzută și aduce acel răspuns evenimentul înapoi?

Ce câmp leagă o plată de un client și cine îi controlează valoarea — tu sau cel care tastează în checkout?

Cum afli mâine dimineață că toate evenimentele de azi-noapte s-au aplicat?

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.

Integrare Odoo: ce se rupe după ce conectorul merge