# Stripe webhook

URL: https://kassza-amber.vercel.app/docs/receptek/stripe-webhook

> Számla a Stripe Checkout checkout.session.completed eseményéből. Aláírás-ellenőrzés, idempotencia rendelésszámmal, lekérdezés a kiállítás előtt, és korlátozott újrapróbálás.

A vevő a Stripe Checkout oldalán fizet, a Stripe pedig `checkout.session.completed` eseményt küld a webhook végpontodra. A végpont ellenőrzi az aláírást, megkeresi a rendelést, és kiállítja a számlát. A Stripe addig küldi újra az eseményt, amíg 2xx választ nem kap, ezért a számlázásnak idempotensnek kell lennie.

A recept a [Fizetett rendelés számlája](/docs/receptek/fizetett-rendeles-szamla) recept `szamlazFizetettRendelest()` függvényét használja, amely kiállítás előtt rendelésszám alapján megkeresi a számlát.

## Telepítés [#telepítés]

<CodeBlockTabs defaultValue="npm" groupId="package-manager">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npm install stripe
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm add stripe
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn add stripe
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun add stripe
    ```
  </CodeBlockTab>
</CodeBlockTabs>

```dotenv title=".env.local"
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```

## A Checkout munkamenet [#a-checkout-munkamenet]

A webhook két dolgot vár a munkamenettől: a saját rendelésazonosítódat a `metadata` mezőben, és a vevő számlázási címét. A cím nélkül nem lehet számlát kiállítani. A `stripeTetelek()` a saját függvényed, amely a rendelés sorait a Stripe `line_items` formátumára alakítja.

```ts title="app/api/checkout/route.ts"
import Stripe from 'stripe'
import { rendelesBetoltese, stripeTetelek } from '@/lib/rendelesek'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { rendelesId } = (await request.json()) as { rendelesId: string }
  const rendeles = await rendelesBetoltese(rendelesId)
  if (!rendeles) return Response.json({ hiba: 'Nincs ilyen rendelés.' }, { status: 404 })

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: stripeTetelek(rendeles),
    billing_address_collection: 'required',
    metadata: { rendelesId: rendeles.id },
    success_url: `${process.env.SITE_URL}/rendeles/${rendeles.id}/koszonjuk`,
    cancel_url: `${process.env.SITE_URL}/kosar`,
  })

  return Response.json({ url: session.url })
}
```

## A webhook [#a-webhook]

A `@/lib/rendelesek` a saját adatbázis-réteged. A `szamlazasiKiserlet()` megnöveli és visszaadja a rendelés számlázási kísérleteinek számát, a `kezelesreVar()` pedig emberi ellenőrzésre jelöli a rendelést.

```ts title="app/api/stripe/webhook/route.ts"
import { type InvoiceBuyer, isSzamlazzError } from 'kassza'
import Stripe from 'stripe'
import {
  kezelesreVar,
  rendelesBetoltese,
  szamlaszamMentese,
  szamlazasiKiserlet,
} from '@/lib/rendelesek'
import { szamlazFizetettRendelest } from '@/lib/szamlazas'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

const MAX_KISERLET = 3

const ORSZAGNEVEK = new Intl.DisplayNames(['hu'], { type: 'region' })

function vevoStripebol(session: Stripe.Checkout.Session): InvoiceBuyer | undefined {
  const adatok = session.customer_details
  const cim = adatok?.address
  if (!adatok?.name || !cim?.postal_code || !cim.city || !cim.line1) return undefined
  return {
    name: adatok.name,
    country: cim.country && cim.country !== 'HU' ? ORSZAGNEVEK.of(cim.country) : undefined,
    zip: cim.postal_code,
    city: cim.city,
    address: [cim.line1, cim.line2].filter(Boolean).join(', '),
    email: adatok.email ?? undefined,
  }
}

export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) return new Response('Hiányzó aláírás', { status: 400 })

  const payload = await request.text()
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response('Érvénytelen aláírás', { status: 400 })
  }

  if (
    event.type !== 'checkout.session.completed' &&
    event.type !== 'checkout.session.async_payment_succeeded'
  ) {
    return new Response(null, { status: 200 })
  }

  const session = event.data.object
  if (session.payment_status !== 'paid') return new Response(null, { status: 200 })

  const rendelesId = session.metadata?.rendelesId
  const rendeles = rendelesId ? await rendelesBetoltese(rendelesId) : null
  if (!rendeles) {
    console.error('Stripe fizetés ismeretlen rendeléshez', session.id)
    return new Response(null, { status: 200 })
  }
  if (rendeles.szamlaszam) return new Response(null, { status: 200 })

  const vevo = vevoStripebol(session)
  if (!vevo) {
    await kezelesreVar(rendeles.id, 'A Stripe munkamenetből hiányzik a számlázási cím.')
    return new Response(null, { status: 200 })
  }

  if ((await szamlazasiKiserlet(rendeles.id)) > MAX_KISERLET) {
    await kezelesreVar(rendeles.id, 'Túl sok sikertelen számlázási kísérlet.')
    return new Response(null, { status: 200 })
  }

  try {
    const szamlaszam = await szamlazFizetettRendelest({
      id: rendeles.id,
      fizetesiMod: 'bankkártya',
      vevo,
      tetelek: rendeles.tetelek,
    })
    await szamlaszamMentese(rendeles.id, szamlaszam)
    return new Response(null, { status: 200 })
  } catch (error) {
    if (!isSzamlazzError(error)) throw error
    if (error.retryable) return new Response('A Számlázz.hu most nem elérhető', { status: 503 })
    await kezelesreVar(rendeles.id, `${error.category}: ${error.message}`)
    return new Response(null, { status: 200 })
  }
}
```

A válaszkódok szándékosak:

| Helyzet                                                                  | Válasz | Mi történik                                                                           |
| ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------- |
| Hibás vagy hiányzó aláírás                                               | `400`  | A Stripe jelzi a hibát a Dashboardon.                                                 |
| Más esemény, fizetetlen munkamenet, már számlázott rendelés              | `200`  | Nincs teendő.                                                                         |
| Hálózati hiba, időtúllépés vagy karbantartás, és a számla nem készült el | `503`  | A Stripe később újraküldi az eseményt, és a függvény újra lekérdez a kiállítás előtt. |
| Validációs, fiók- vagy egyéb végleges hiba                               | `200`  | A rendelés emberi ellenőrzésre vár, az újraküldés nem segítene.                       |
| Adatbázis- vagy egyéb nem kassza hiba                                    | `500`  | A Stripe újraküldi az eseményt.                                                       |

## Buktatók [#buktatók]

<Callout type="danger" title="Korlátozd a kísérletek számát">
  A Stripe napokig újraküldheti az eseményt. Minden újraküldés egy újabb számlakészítési kérés
  lehet, a Számlázz.hu pedig legfeljebb öt próbálkozást enged egy kérésre. Ezért számolja a recept
  a kísérleteket, és a harmadik után emberre bízza a rendelést.
</Callout>

<Callout type="warning" title="Az aláírást a nyers törzsön ellenőrizd">
  A `constructEvent()` a kérés eredeti szövegét várja, ezért a törzset a `request.text()` olvassa
  be. Ha előbb JSON-ként olvasod és újra szöveggé alakítod, az aláírás nem fog egyezni. Edge
  runtime alatt az aszinkron `constructEventAsync()` változatra lehet szükség, ezt a Stripe
  dokumentációjában ellenőrizd.
</Callout>

<Callout type="warning" title="A számla tételei a saját rendelésedből jönnek">
  A tételeket és az árakat a saját adatbázisodból vedd, ne a Stripe összegeiből. A Stripe az
  összegeket a pénznem legkisebb egységében adja meg, a Számlázz.hu viszont forintban és
  áfakulccsal várja őket. A saját rendelésed az áfakulcsot is tudja.
</Callout>

<Callout type="info" title="Késleltetett fizetési módok">
  Egyes fizetési módoknál a `checkout.session.completed` még `unpaid` állapottal érkezik, a
  sikeres fizetésről később `checkout.session.async_payment_succeeded` esemény szól. A recept
  mindkettőt figyeli, és csak `paid` állapotnál számláz. Mindkét eseményt jelöld be a webhook
  beállításainál.
</Callout>

<Callout type="note" title="Céges vevő adószáma">
  A Checkout alapból nem kér adószámot. Ha cégeknek is értékesítesz, az adószámot a saját
  pénztár oldaladon kérd be és mentsd a rendeléshez, majd add át a `buyer.taxNumber` mezőben.
  Az [Adószám alapú kitöltés](/docs/receptek/adoszam-urlap) recept ehhez ad űrlapot.
</Callout>

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

<Cards>
  <Card title="Fizetett rendelés számlája" href="/docs/receptek/fizetett-rendeles-szamla">
    Az idempotens számlázó függvény, amelyet a webhook hív.
  </Card>

  <Card title="Hibakezelés, hibakódok" href="/docs/alapok/hibakezeles">
    Mely hibák bizonytalan kimenetűek, és melyek véglegesek.
  </Card>

  <Card title="Hálózat és biztonság" href="/docs/alapok/halozat-es-biztonsag">
    Időkorlát egy hívásra, újrapróbálás és naplózás.
  </Card>

  <Card title="Egységtesztek" href="/docs/receptek/egysegtesztek">
    A számlázó függvény tesztelése hálózat nélkül.
  </Card>
</Cards>
