# Pontosan egyszer

URL: https://www.kasszajs.hu/docs/alapok/pontosan-egyszer

> Számla és nyugta pontosan egyszer a createOnce metódusokkal, egyidejű hívások és elosztott zár, védelem a fiók kitiltása ellen folyamatok között (attemptLedger), karbantartási szünet, az onDocument hook és a figyelmeztetések.

Egy webhook, egy várólista vagy egy türelmetlen felhasználó ugyanazt a kérést többször is elküldheti. A Számlázz.hu ugyanakkor nem engedi, hogy egy sikertelen kérést ötnél többször elküldj, és egy időtúllépés után nem tudhatod, elkészült-e a bizonylat. Ezt a három problémát oldják meg az alábbi eszközök.

## createOnce: lekérdezés, kiállítás, visszakeresés [#createonce-lekérdezés-kiállítás-visszakeresés]

Az `invoices.createOnce()` és a `receipts.createOnce()` ugyanazt csinálja, amit a [Hibakezelés](/docs/alapok/hibakezeles#bizonytalan-kimenet-számla-készült-vagy-nem) oldal kézzel leír:

1. megkeresi a bizonylatot, és ha már létezik, azt adja vissza,
2. ha nem létezik, kiállítja,
3. ha a kiállítás kimenete bizonytalan (hálózati hiba, időtúllépés, 56-os vagy dupla hiba), rövid várakozás után kétszer újra megkeresi.

```ts
const eredmeny = await kassza.invoices.createOnce({
  orderNumber: 'WEB-1042',
  paid: true,
  buyer: { name: 'Vevő Kft.', zip: '1111', city: 'Budapest', address: 'Fő utca 1.' },
  items: [{ name: 'Előfizetés', grossUnitPrice: 12_700, vat: 27 }],
})

console.log(eredmeny.number, eredmeny.created)
```

| Mező | Jelentés |
| --- | --- |
| `number` | A számla száma, akár most készült, akár korábban. |
| `created` | `true`, ha ez a hívás állította ki. |
| `externalId` | A külső azonosító, amivel a számlát keresi. |
| `invoice` | A kiállítás válasza, ha ez a hívás állította ki. |
| `details` | A lekérdezett számla, ha már létezett. |

A rendelésszám kötelező. A külső azonosító alapból a rendelésszám, a bizonylattípus jelével: díjbekérőnél `/D`, előlegszámlánál `/E`, végszámlánál `/V`, helyesbítőnél `/H`, szállítólevélnél `/SZL`. Így egy rendeléshez tartozó díjbekérő és számla nem keveredik össze. Saját `externalId` is megadható.

Nyugtánál a `receipts.createOnce()` a rendelésszámot hívásazonosítóként (`callId`) is használja, ha nem adsz meg mást. A visszatérési értéke `{ receipt, created }`.

| Opció | Alapérték | Jelentés |
| --- | --- | --- |
| `lookupFirst` | `true` | Kiállítás előtt megkeresi a bizonylatot. |
| `matchOrderNumber` | `true` | Számlánál a külső azonosító mellett a rendelésszámra is keres. |
| `recoveryDelayMs` | `1000` | A visszakeresések előtti várakozás (az első után duplázódik). |
| `signal` | – | Megszakítás. |
| `lock` | a `createOnceLock` | Hívásonkénti elosztott zár, vagy `false` a kikapcsoláshoz. |
| `lockTtlSeconds` | `600` | Meddig él a zár, ha a folyamat menet közben leáll. |
| `lockWaitMs` | `10000` | Meddig vár egy másik folyamat zárjára, mielőtt `in_progress` hibát ad. |
| `lockFailure` | `'throw'` | Mi legyen, ha a zár tárolója nem érhető el: `'throw'` vagy `'proceed'`. |

<Callout type="warning" title="Ha a visszakeresés sem talál semmit">
  Ritkán előfordul, hogy a kiállítás kimenete bizonytalan, és a visszakeresés sem találja a
  bizonylatot. Ilyenkor a kassza az eredeti hibát dobja tovább, `details.outcome: 'unknown'`
  jelöléssel. Ne állítsd ki kézzel újra: később hívd meg ugyanígy a `createOnce()`-t, az előbb
  megkeresi a bizonylatot.
</Callout>

## Egyidejű hívások: egy rendelés, egy bizonylat [#egyidejű-hívások-egy-rendelés-egy-bizonylat]

Ugyanaz a webhook kétszer is megérkezhet egyszerre, vagy két serverless példány dolgozhatja fel ugyanazt a rendelést. Ha két `createOnce()` hívás egyszerre keres, mindkettő azt látja, hogy még nincs számla, és mindkettő kiállítaná. Nyugtánál a Számlázz.hu a második kérést a hívásazonosító miatt elutasítja, számlánál viszont csak akkor, ha a fiókban be van kapcsolva a rendelésszám ismétlődésének tiltása.

A kassza ezért két szinten véd:

1. **Egy folyamaton belül** az azonos rendelésre egyszerre érkező hívások összevonódnak: az első kiállít, a többi ugyanazt a bizonylatot kapja `created: false` jelzéssel. Ez akkor is működik, ha kérésenként új kassza példányt hozol létre (serverless minta). Nem kell hozzá beállítás.
2. **Folyamatok között** egy közös tárolóban tartott, rövid életű zár véd. A zárat az kapja meg, aki elsőként kéri; a többi folyamat megvárja, majd visszakeresi a már elkészült bizonylatot.

```ts title="lib/kassza.ts"
import { Redis } from '@upstash/redis'
import { createKassza } from 'kassza'
import { upstashRedisStore } from 'kassza/stores'

const store = upstashRedisStore(Redis.fromEnv())

export const kassza = createKassza({
  cookieStore: store,
  attemptLedger: store,
  createOnceLock: store,
})
```

A zárhoz olyan tároló kell, amely atomikusan tud „írni, ha még nincs” (`setIfAbsent`): a `kassza/stores` Redis, Upstash és Durable Object tárolója ilyen, a Cloudflare KV nem. Ha egy másik folyamat a várakozási időn belül nem végez, a kassza `in_progress` kategóriájú hibát dob: ilyenkor ne állítsd ki kézzel, hanem hívd meg később ugyanígy a `createOnce()`-t (webhookban adj vissza 5xx választ, és a szolgáltató újraküldi).

<Callout type="info" title="Tároló kiválasztása">
  A `kassza/stores` `diagnoseStore(tároló)` függvénye kipróbálja a tárolót, és megmondja, mire alkalmas
  (munkamenet, próbálkozás-napló, zár, bizonylatnapló).
</Callout>

## Kitiltás elleni védelem folyamatok között [#kitiltás-elleni-védelem-folyamatok-között]

A Számlázz.hu egy kérést legfeljebb ötször enged elküldeni. A kassza egy folyamaton belül ezt magától betartja, de ha ugyanazt a kérést több szerver, több serverless példány vagy egy újrapróbáló várólista küldi, a folyamatok nem tudnak egymásról. Erre való az `attemptLedger` opció: egy közös tároló, amelyben a kassza számolja az azonos kérések sikertelen próbálkozásait.

```ts title="lib/kassza.ts"
import { Redis } from '@upstash/redis'
import { createKassza } from 'kassza'
import { upstashRedisStore } from 'kassza/stores'

const store = upstashRedisStore(Redis.fromEnv())

export const kassza = createKassza({
  cookieStore: store,
  attemptLedger: store,
})
```

A tároló ugyanaz a felület, mint a munkamenet tárolója, így bármelyik [`kassza/stores`](/docs/alapok/munkamenet) adapter megfelel (a régi `kassza/cookie-stores` útvonal is működik). A kulcsot a kérés tartalmából képzi a kassza (SHA-256), az Agent kulcsot nem tárolja, és egy napig őrzi. Ha a tároló tud atomikus számlálót (`increment`, a Redis és Upstash adapterek igen), két egyszerre hibázó folyamat sem veszít el próbálkozást.

Ha a tároló nem érhető el, a kassza alapból továbbengedi a kérést, és a folyamaton belüli számlálóval véd (`attemptLedgerMode: 'fail-open'`), a hibát pedig az `onWarning` hooknak jelzi. Ha inkább ne menjen ki kérés, amíg a napló nem működik, állítsd `'fail-closed'`-ra: ilyenkor `store_unavailable` hibát kapsz. Ehhez a tárolót `resilient: false` beállítással hozd létre, különben az adapter maga elnyeli az olvasási hibát.

Öt sikertelen próbálkozás után a kassza már el sem küldi a kérést, hanem `attempt_limit` kategóriájú hibát dob. Ilyenkor embernek kell ránéznie. A hiba elhárítása után a számlálót a hibával törölheted:

```ts
import { isSzamlazzError } from 'kassza'

try {
  await kassza.invoices.create(szamla)
} catch (error) {
  if (isSzamlazzError(error) && error.category === 'attempt_limit') {
    await riasztas(error.message)
    await kassza.resetAttempts(error)
  }
  throw error
}
```

Sikeres válasz után a számláló magától törlődik. A próbálkozások közé csak a kérésből eredő hibák számítanak (például validáció, fiókhiba, hálózati hiba); a dupla bizonylat és a nem található bizonylat nem.

## Karbantartási szünet [#karbantartási-szünet]

Karbantartás (1-es hiba) idején a Számlázz.hu percekig nem érhető el. A `maintenanceCooldownMs` opcióval a kassza az első karbantartási hiba után a megadott ideig el sem küldi a kéréseket, hanem azonnal `maintenance` hibát ad. Így egy várólista nem égeti el a próbálkozásait, a pénztár pedig gyorsan átválthat tartalék megoldásra.

```ts
const kassza = createKassza({ maintenanceCooldownMs: 120_000 })
```

## onDocument: minden elkészült bizonylatról értesítés [#ondocument-minden-elkészült-bizonylatról-értesítés]

Az `onDocument` hook minden sikeres kiállítás, sztornó és befizetés után lefut, a bizonylat adataival. Jó hely a könyvelési napló, az e-mail sor vagy a statisztika frissítésére. Ha a hook hibát dob, a bizonylat attól még elkészült. Hogy ilyenkor mi történjen, az `onDocumentError` dönti el:

| `onDocumentError` | Viselkedés |
| --- | --- |
| `'warn'` (alap) | Figyelmeztet (`onWarning`, vagy ha nincs, a konzolra), és a hívás sikeresen visszatér. |
| `'throw'` | `DocumentHookError`-t dob, amely tartalmazza a már elkészült bizonylatot (`error.event`, `error.document`, `error.number`). |
| függvény | A te kezelőd kapja meg a hibát és az eseményt; ha az is dob, `DocumentHookError` lesz belőle. |

Ha a hook egy jogilag fontos naplót ír (például a [NAV napi összesítőhöz](/docs/nav-nyugta/napi-osszesito) használt [bizonylatnaplót](/docs/kiegeszitok/bizonylatnaplo)), használd a `'throw'` módot, hogy egy kimaradt bejegyzés ne maradjon észrevétlen. A hibából a bizonylat száma kiolvasható: ne állítsd ki újra, csak a naplóba írást pótold.

```ts
const kassza = createKassza({
  hooks: {
    onDocument: async (esemeny) => {
      await naplo.mentes({
        fajta: esemeny.kind,
        muvelet: esemeny.action,
        szam: esemeny.number,
      })
    },
  },
})
```

| `kind` | `action` | Mikor |
| --- | --- | --- |
| `invoice` | `created` | Számla, díjbekérő, előleg-, vég- vagy helyesbítő számla elkészült. |
| `invoice` | `reversed` | Sztornó számla készült; a `reversedNumber` az eredeti szám. |
| `invoice` | `payment` | Befizetés rögzítése vagy törlése. |
| `receipt` | `created` | Nyugta készült. |
| `receipt` | `reversed` | Sztornónyugta készült; a `reversedNumber` az eredeti szám. |

## onWarning: a korábban csendes hibák [#onwarning-a-korábban-csendes-hibák]

Néhány hibát a kassza szándékosan elnyel, hogy a számlázás ne akadjon el: a munkamenet tárolójának kiesését, a próbálkozás-napló hibáját, a hookok kivételeit, a zár felszabadításának hibáját. Az `onWarning` hook ezeket mind megkapja, így naplózhatod vagy riaszthatsz rájuk:

```ts
const kassza = createKassza({
  hooks: {
    onWarning: (figyelmeztetes) => {
      logger.warn({ fajta: figyelmeztetes.kind, muvelet: figyelmeztetes.operation }, figyelmeztetes.message)
    },
  },
})
```

A `kind` értéke `session`, `ledger`, `hook`, `document`, `lock`, `dedupe` vagy `cache`. A [megfigyelhetőség](/docs/kiegeszitok/megfigyelhetoseg) modul ezeket metrikaként is számolja.

## Kapcsolódó [#kapcsolódó]

<Cards>
  <Card title="Hibakezelés" href="/docs/alapok/hibakezeles">
    A hibakategóriák és a bizonytalan kimenet kézi kezelése.
  </Card>
  <Card title="Fizetésből bizonylat" href="/docs/fizetesek">
    Webhookból automatikusan, pontosan egyszer.
  </Card>
  <Card title="Hamis Számla Agent" href="/docs/kiegeszitok/hamis-agent">
    Elveszett válasz és karbantartás szimulálása tesztben.
  </Card>
</Cards>
