Ugrás a tartalomra
kassza

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.

app/api/stripe/webhook/route.ts
import { stripeWebhook } from 'kassza/payments/stripe'
import { kassza } from '@/lib/kassza'

export const POST = stripeWebhook({
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
  apiKey: process.env.STRIPE_SECRET_KEY,
  onPayment: (payment) => kassza.issueForPayment(payment, { vat: 27 }),
})

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óImportHitelességRészletek
Stripekassza/payments/stripeHMAC-SHA256 aláírásStripe
SimplePaykassza/payments/simplepayHMAC-SHA384 aláírás, aláírt IPN-válaszSimplePay
Barionkassza/payments/barionAz állapotot a kassza a Barion API-tól kérdezi leBarion
Revolutkassza/payments/revolutHMAC-SHA256 aláírás és időbélyegRevolut
PayPalkassza/payments/paypalEllenőrzés a PayPal API-valPayPal

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#

HelyzetVálaszKövetkezmény
Hibás aláírás, hiányzó vagy hibás adat400A 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 dobott500A 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
providerstripe, simplepay, barion, revolut vagy paypal
kindpaid, refunded (teljes visszatérítés), partially-refunded, failed vagy other
idA fizetés azonosítója a szolgáltatónál; a visszatérítés eseményében is ugyanaz
orderRefA 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)
methodA bizonylatra kerülő fizetési mód, például bankkártya
customerNév, e-mail, cím, adószám, ha a szolgáltató tudja
itemsTételek bruttó összeggel, ha a szolgáltató tudja
rawAz eredeti esemény

issueForPayment#

Az issueForPayment() a fizetés fajtája szerint dolgozik:

kindMit csinál
paidEldönti, nyugta vagy számla jár, és pontosan egyszer kiállítja.
refundedSztornózza a fizetéshez kiállított számlát vagy nyugtát (pontosan egyszer).
partially-refundedSzá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, otherKihagyja.
const eredmeny = await kassza.issueForPayment(payment, { vat: 27 })

switch (eredmeny.kind) {
  case 'receipt':
  case 'invoice':
    console.log('Kiállítva:', eredmeny.number, eredmeny.created)
    break
  case 'reversal':
    console.log('Sztornózva:', eredmeny.reversedNumber, eredmeny.number)
    break
  case 'correction':
    console.log('Helyesbítve:', eredmeny.corrections.map((helyesbites) => helyesbites.number))
    break
  case 'refund-proposal':
    console.log('Kézi teendő:', eredmeny.reason, eredmeny.refunds)
    break
  case 'skipped':
    console.log('Nincs bizonylat:', eredmeny.reason)
}

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
documentauto (alapérték), receipt vagy invoice a döntés felülírására
buyerIsBusinessA vevő cég, akkor is, ha nem adott meg adószámot
invoiceRequestedA vevő számlát kért
cashRegisterRequiredA tevékenységed pénztárgép-köteles: nyugta helyett hibát kapsz, mert Agent-nyugta nem adható
buyerA 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}/SS külső azonosítóval, nyugtánál {rendelésszám}/SN hí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 a previous_attributes kü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:

await kassza.issueForPayment(payment, {
  refundItems: ({ refund, items }) => visszakuldottTetelek(refund.id),
})

await kassza.issueForPayment(payment, { partialRefund: 'proportional' })

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:

import { upstashRedisStore } from 'kassza/stores'

export const POST = stripeWebhook({
  secret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
  dedupe: upstashRedisStore(Redis.fromEnv()),
  onPayment: (payment) => kassza.issueForPayment(payment, { vat: 27 }),
})

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:

app/api/stripe/webhook/route.ts
import { isSzamlazzError } from 'kassza'
import { stripeWebhook } from 'kassza/payments/stripe'
import { kassza } from '@/lib/kassza'
import { kezelesreVar } from '@/lib/rendelesek'

export const POST = stripeWebhook({
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
  apiKey: process.env.STRIPE_SECRET_KEY,
  onPayment: async (payment) => {
    try {
      await kassza.issueForPayment(payment, { vat: 27 })
    } catch (error) {
      if (isSzamlazzError(error) && !error.retryable) {
        await kezelesreVar(payment.id, `${error.category}: ${error.message}`)
        return
      }
      throw error
    }
  },
})

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.

app/api/terminal/webhook/route.ts
import { type PaymentEvent, respondToWebhook, WebhookVerificationError } from 'kassza/payments'
import { kassza } from '@/lib/kassza'
import { terminalAlairasJo } from '@/lib/terminal'

export function POST(request: Request) {
  return respondToWebhook({}, async () => {
    const body = await request.text()
    if (!(await terminalAlairasJo(request, body))) {
      throw new WebhookVerificationError('terminal', 'invalid_signature', 'Hibás aláírás.')
    }
    const esemeny = JSON.parse(body) as { id: string; osszeg: number; statusz: string }
    const payment: PaymentEvent = {
      provider: 'terminal',
      kind: esemeny.statusz === 'sikeres' ? 'paid' : 'other',
      id: esemeny.id,
      amount: { value: esemeny.osszeg, currency: 'HUF' },
      method: 'bankkártya',
      raw: esemeny,
    }
    await kassza.issueForPayment(payment, { vat: 27 })
    return new Response('OK')
  })
}

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}).

Kapcsolódó#

Oldal szerkesztéseUtoljára frissítve: