Fizetésből bizonylat
A webshop legtöbb számlázási hibája a fizetési webhookban születik: dupla számla újraküldött eseményből, rossz kerekítés, elfelejtett sztornó visszatérítéskor. A kassza/payments modul ezt egy helyen oldja meg. A fizetési szolgáltató webhookját ellenőrzi és egységes PaymentEvent objektummá alakítja, az issueForPayment() pedig ebből pontosan egyszer állít ki nyugtát vagy számlát.
Nem kell hozzá a szolgáltató SDK-ja: az aláírást Web Cryptóval ellenőrzi, a szolgáltató API-ját fetch-csel hívja, így Node.js-ben, Cloudflare Workersben és Vercel Edge-en is fut.
Szolgáltatók#
| Szolgáltató | Import | Hitelesség | Részletek |
|---|---|---|---|
| Stripe | kassza/payments/stripe | HMAC-SHA256 aláírás | Stripe |
| SimplePay | kassza/payments/simplepay | HMAC-SHA384 aláírás, aláírt IPN-válasz | SimplePay |
| Barion | kassza/payments/barion | Az állapotot a kassza a Barion API-tól kérdezi le | Barion |
| Revolut | kassza/payments/revolut | HMAC-SHA256 aláírás és időbélyeg | Revolut |
| PayPal | kassza/payments/paypal | Ellenőrzés a PayPal API-val | PayPal |
Minden webhookkezelő (request: Request) => Promise<Response> függvény, így közvetlenül exportálható Next.js route handlerként, vagy meghívható Hono, Express (fetch adapterrel) és Workers alatt.
A webhook válaszai#
| Helyzet | Válasz | Következmény |
|---|---|---|
| Hibás aláírás, hiányzó vagy hibás adat | 400 | A szolgáltató hibát jelez; az esemény nem ér el az onPayment-ig. |
| Sikeres feldolgozás vagy olyan esemény, amelyhez nincs teendő | 200 (Revolutnál 204, SimplePaynél aláírt JSON) | A szolgáltató nem küldi újra. |
Az onPayment vagy a szolgáltató API-hívása hibát dobott | 500 | A szolgáltató később újraküldi az eseményt. |
Az 500-as hibát az onError opcióban kapod meg naplózásra, alapból a console.error írja ki. Az újraküldés biztonságos, mert az issueForPayment() minden hívás előtt megkeresi a már kiállított bizonylatot.
PaymentEvent#
A webhookkezelő minden szolgáltató eseményét ugyanarra az alakra hozza:
| Mező | Jelentés |
|---|---|
provider | stripe, simplepay, barion, revolut vagy paypal |
kind | paid, refunded (teljes visszatérítés), partially-refunded, failed vagy other |
id | A fizetés azonosítója a szolgáltatónál; a visszatérítés eseményében is ugyanaz |
orderRef | A saját rendelésazonosítód, ha a szolgáltató visszaküldi |
amount, refundedAmount | Összeg és pénznem, főegységben (12700 Ft, nem fillérben) |
method | A bizonylatra kerülő fizetési mód, például bankkártya |
customer | Név, e-mail, cím, adószám, ha a szolgáltató tudja |
items | Tételek bruttó összeggel, ha a szolgáltató tudja |
raw | Az eredeti esemény |
issueForPayment#
Az issueForPayment() a fizetés fajtája szerint dolgozik:
kind | Mit csinál |
|---|---|
paid | Eldönti, nyugta vagy számla jár, és pontosan egyszer kiállítja. |
refunded | Sztornózza a fizetéshez kiállított számlát vagy nyugtát (pontosan egyszer). |
partially-refunded | Számlánál visszatérítésenként egy helyesbítő számlát állít ki (pontosan egyszer); nyugtánál javaslatot ad, lásd Részleges visszatérítés. |
failed, other | Kihagyja. |
A nyugta előtagját és fizetési módját a kliens defaults.receipt beállítása adja, a számláét a defaults.invoice (lásd Közös kliens). Egy-egy mezőt a receipt és az invoice opcióban írhatsz felül, például receipt: { comment: 'Köszönjük!' }. A nyugta előtagja nem lehet olyan, amelyet számlán már használtál (336).
Nyugta vagy számla#
Alapból a nyugta vagy számla szabályai döntenek: számla jár, ha a vevő cég (adószámot adott meg), ha az összeg eléri a 900 000 Ft-ot, vagy ha a vevő számlát kér. Különben nyugta. A döntés indoklása az eredmény decision mezőjében van.
| Opció | Jelentés |
|---|---|
document | auto (alapérték), receipt vagy invoice a döntés felülírására |
buyerIsBusiness | A vevő cég, akkor is, ha nem adott meg adószámot |
invoiceRequested | A vevő számlát kért |
cashRegisterRequired | A tevékenységed pénztárgép-köteles: nyugta helyett hibát kapsz, mert Agent-nyugta nem adható |
buyer | A számla vevője; alapból a fizetés vevőadataiból készül, ha van név és teljes cím |
Tételek és áfa#
A tételek sorrendje: az items opció, a szolgáltatótól kapott tételek, végül egyetlen tétel a kifizetett összeggel (fallbackItemName, alapból „Termék vagy szolgáltatás”). Az áfakulcs a vatFor(tétel) függvényből, a szolgáltató tételenkénti áfájából vagy a vat opcióból jön, ebben a sorrendben. Ha egyik sincs, validation hibát kapsz: az áfát a kassza soha nem találja ki.
A kassza ellenőrzi, hogy a bizonylat bruttó végösszege egyezik-e a kifizetett összeggel. Ha nem (például kimaradt a szállítási díj), validation hibát ad. Szándékos eltérésnél add meg az allowAmountMismatch: true opciót.
Deviza#
A bizonylat pénzneme mindig a fizetésé. Devizás nyugtához add meg az árfolyamot (exchangeRate); a bank alapból az MNB. Devizás számlánál árfolyam nélkül az MNB napi árfolyamát használja a Számlázz.hu.
Pontosan egyszer#
A rendelésszám a szolgáltatóból és a fizetés azonosítójából áll, például STRIPE-pi_3Q…, így a fizetés és a későbbi visszatérítés ugyanahhoz a rendelésszámhoz kötődik. Saját rendelésszámot az orderNumber opcióval adhatsz meg.
- Nyugta:
receipts.createOnce()a rendelésszámmal, hívásazonosítóként is a rendelésszámot küldve. - Számla:
invoices.createOnce(), külső azonosítónak a rendelésszámmal, kifizetettként. - Sztornó: számlánál
{rendelésszám}/SSkülső azonosítóval, nyugtánál{rendelésszám}/SNhívásazonosítóval.
Ha a webhook kétszer érkezik, a második hívás created: false eredményt ad, és nem állít ki új bizonylatot. Ha a sztornó után ismét paid esemény jön, a kassza kihagyja, mert a rendeléshez tartozó nyugtát már sztornózták. Ha a nyugtát időközben számlává alakították, visszatérítéskor az átalakított számlát sztornózza.
Részleges visszatérítés#
Ha a vásárló a rendelés egy részét kapja vissza, a szolgáltatók partially-refunded eseményt küldenek. A kassza ilyenkor az eredeti számlához visszatérítésenként egy helyesbítő számlát állít ki, negatív mennyiségű tételekkel:
- A külső azonosító
{rendelésszám}/R-{visszatérítés}, így egy újraküldött webhook vagy egy több visszatérítést felsoroló esemény sem állít ki duplát. - A visszatérített összeget a kassza az eredeti tételek között pontosan osztja szét, tételenként a saját áfakulcsán. Több egymás utáni visszatérítés együtt sem haladja meg egyik tétel eredeti összegét sem, és a teljes összeg visszatérítése pontosan az eredeti tételeket adja.
- A szolgáltatók tételesen adják a visszatérítéseket (
payment.refunds): Barionnál és SimplePaynél a teljes listát, Revolutnál és PayPalnál az aktuálisat, Stripe-nál az esemény azonosítójával és aprevious_attributeskülönbségével. Ha ez hiányzik, a kassza kihagyja az eseményt (skipped), és megírja, miért.
Ha az eredeti számlán több áfakulcs van, a kassza nem tudhatja, melyik tétel került vissza (egy 5%-os könyv és egy 27%-os póló közül nem mindegy, melyiket hozták vissza). Ilyenkor alapból nem állít ki semmit, hanem refund-proposal eredményt ad a javasolt bontással. Két lehetőséged van:
A refundItems a visszatérített tételeket adja vissza pozitív bruttó egységárral (a kassza fordítja negatívba, és ellenőrzi, hogy az összeg egyezik). A partialRefund: 'proportional' arányos szétosztást kér, ami például százalékos utólagos kedvezménynél helyes. A partialRefund: 'skip' a régi viselkedést adja vissza (kihagyás).
Nyugtánál a részleges visszatérítés jogi menete nem egyértelmű (sztornó és új nyugta a megmaradó tételekről), ezért a kassza nem állít ki semmit: refund-proposal eredményt ad a kiszámolt bontással, amit a könyvelőddel egyeztetve kézzel intézhetsz.
Ismételt webhookok szűrése#
A szolgáltatók ugyanazt az eseményt napokig újraküldhetik. A createOnce ilyenkor sem állít ki duplát, de minden újraküldés legalább egy lekérdezés a Számla Agent felé. A dedupe opcióval a már sikeresen feldolgozott eseményt a kassza a Számla Agent hívása nélkül nyugtázza:
Az eseményt csak a sikeres onPayment után jelöli feldolgozottnak (alapból 7 napig, dedupeTtlSeconds), így egy elbukott feldolgozás újraküldve újra lefut. Ha a tároló nem érhető el, a feldolgozás megtörténik, és figyelmeztetés megy az onWarning-ra.
Hibakezelés a webhookban#
Ha az issueForPayment() hibát dob, a webhook 500-at ad, és a szolgáltató később újraküldi. Ez hálózati hibánál és karbantartásnál pont jó, de egy hibás adatból (például hiányzó áfakulcs vagy számlacím) eredő hiba újraküldve is hibás marad. Ezeket érdemes elkapni és emberre bízni:
Más fizetési szolgáltató#
Ha a szolgáltatód nincs a listán (például egy kártyaterminál webhookja), a PaymentEvent objektumot magad is összeállíthatod, a respondToWebhook() pedig ugyanazt a hibakezelést adja, mint a beépített kezelők: WebhookVerificationError esetén 400, más hibánál 500.
A provider tetszőleges címke lehet; a rendelésszám ebből és az azonosítóból készül (itt TERMINAL-{id}).