kassza 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.en.md +48 -0
  2. package/README.md +427 -32
  3. package/agents/README.md +26 -6
  4. package/agents/api.md +218 -3
  5. package/agents/pitfalls.md +27 -3
  6. package/agents/recipes.md +280 -51
  7. package/agents/skills/kassza/SKILL.md +14 -8
  8. package/agents/skills/migrate-from-szamlazz-js/SKILL.md +39 -0
  9. package/dist/batch/index.cjs +346 -0
  10. package/dist/batch/index.d.cts +91 -0
  11. package/dist/batch/index.d.ts +91 -0
  12. package/dist/batch/index.js +339 -0
  13. package/dist/cli.cjs +643 -0
  14. package/dist/cli.d.cts +1 -0
  15. package/dist/cli.d.ts +1 -0
  16. package/dist/cli.js +643 -0
  17. package/dist/client-CLFvJCVW.cjs +1452 -0
  18. package/dist/{client-CL3fuH6A.js → client-Cvf-hTz_.js} +306 -515
  19. package/dist/client-DeWWQ-RZ.d.ts +341 -0
  20. package/dist/client-SVZu25MB.d.cts +341 -0
  21. package/dist/context-CFNKzStE.d.cts +449 -0
  22. package/dist/context-kb33TgzE.d.ts +449 -0
  23. package/dist/cookie-stores/index.cjs +21 -150
  24. package/dist/cookie-stores/index.d.cts +4 -63
  25. package/dist/cookie-stores/index.d.ts +4 -63
  26. package/dist/cookie-stores/index.js +3 -144
  27. package/dist/create-CflXrPip.js +204 -0
  28. package/dist/create-CvUvLUaT.cjs +257 -0
  29. package/dist/{create-once-D0pwuenl.cjs → create-once-9yxR_Er_.cjs} +291 -33
  30. package/dist/create-once-Bh6fOWkW.d.cts +20 -0
  31. package/dist/{create-once-CqdYmQD8.js → create-once-Ca2gMk1h.js} +250 -34
  32. package/dist/create-once-VKp0tVow.d.ts +20 -0
  33. package/dist/{crypto-B8lERvR1.cjs → crypto-Dpn4FHQ6.cjs} +9 -0
  34. package/dist/{crypto-DK_osBVy.js → crypto-usDg8L2K.js} +4 -1
  35. package/dist/data-link/index.cjs +232 -0
  36. package/dist/data-link/index.d.cts +29 -0
  37. package/dist/data-link/index.d.ts +29 -0
  38. package/dist/data-link/index.js +224 -0
  39. package/dist/delegation/index.cjs +35 -34
  40. package/dist/delegation/index.d.cts +2 -1
  41. package/dist/delegation/index.d.ts +2 -1
  42. package/dist/delegation/index.js +4 -3
  43. package/dist/{errors-DLrb0g64.js → errors-DtN4g5Z9.js} +9 -1
  44. package/dist/{errors-DxeuTslP.cjs → errors-EE96_6D_.cjs} +20 -0
  45. package/dist/get-BdMTseh4.cjs +271 -0
  46. package/dist/get-Byc9T5WO.d.cts +151 -0
  47. package/dist/get-Byc9T5WO.d.ts +151 -0
  48. package/dist/get-CZEShCcj.js +260 -0
  49. package/dist/index-BhUPY_EH.d.cts +109 -0
  50. package/dist/index-BqGgQ4t3.d.ts +109 -0
  51. package/dist/{index-CYAGCdKJ.d.cts → index-C-7yWV7L.d.cts} +24 -1
  52. package/dist/{index-CYAGCdKJ.d.ts → index-C-7yWV7L.d.ts} +24 -1
  53. package/dist/index.cjs +19 -5
  54. package/dist/index.d.cts +11 -19
  55. package/dist/index.d.ts +11 -19
  56. package/dist/index.js +6 -6
  57. package/dist/ipn/index.cjs +26 -5
  58. package/dist/ipn/index.d.cts +12 -1
  59. package/dist/ipn/index.d.ts +12 -1
  60. package/dist/ipn/index.js +26 -6
  61. package/dist/issue-Bin4tKD_.cjs +988 -0
  62. package/dist/issue-DMA1Nbr1.js +905 -0
  63. package/dist/journal/index.cjs +454 -0
  64. package/dist/journal/index.d.cts +18 -0
  65. package/dist/journal/index.d.ts +18 -0
  66. package/dist/journal/index.js +445 -0
  67. package/dist/journal-DvI81Kt7.d.cts +114 -0
  68. package/dist/journal-Y9qrIB8G.d.ts +114 -0
  69. package/dist/mcp/index.cjs +10 -0
  70. package/dist/mcp/index.d.cts +71 -0
  71. package/dist/mcp/index.d.ts +71 -0
  72. package/dist/mcp/index.js +2 -0
  73. package/dist/money/index.cjs +2 -1
  74. package/dist/money/index.d.cts +2 -2
  75. package/dist/money/index.d.ts +2 -2
  76. package/dist/money/index.js +2 -2
  77. package/dist/{money-DAA7OwiO.js → money-Cw457SD6.js} +120 -12
  78. package/dist/{money-DrhGkWHW.cjs → money-K9j0wPit.cjs} +124 -10
  79. package/dist/nav/index.cjs +1046 -0
  80. package/dist/nav/index.d.cts +247 -0
  81. package/dist/nav/index.d.ts +247 -0
  82. package/dist/nav/index.js +1012 -0
  83. package/dist/nav-BNbc8U0R.cjs +203 -0
  84. package/dist/nav-BcCerwWS.js +156 -0
  85. package/dist/node/index.cjs +124 -0
  86. package/dist/node/index.d.cts +35 -0
  87. package/dist/node/index.d.ts +35 -0
  88. package/dist/node/index.js +120 -0
  89. package/dist/observe/index.cjs +311 -0
  90. package/dist/observe/index.d.cts +53 -0
  91. package/dist/observe/index.d.ts +53 -0
  92. package/dist/observe/index.js +303 -0
  93. package/dist/once-BN_3B5OF.cjs +103 -0
  94. package/dist/once-CqqZxsXG.js +74 -0
  95. package/dist/parse-Cr7yQ3_z.js +291 -0
  96. package/dist/parse-DOA2XQ3e.cjs +380 -0
  97. package/dist/payments/barion.cjs +26 -2
  98. package/dist/payments/barion.d.cts +2 -2
  99. package/dist/payments/barion.d.ts +2 -2
  100. package/dist/payments/barion.js +26 -2
  101. package/dist/payments/index.cjs +7 -2
  102. package/dist/payments/index.d.cts +4 -4
  103. package/dist/payments/index.d.ts +4 -4
  104. package/dist/payments/index.js +3 -3
  105. package/dist/payments/paypal.cjs +16 -2
  106. package/dist/payments/paypal.d.cts +2 -2
  107. package/dist/payments/paypal.d.ts +2 -2
  108. package/dist/payments/paypal.js +16 -2
  109. package/dist/payments/revolut.cjs +17 -3
  110. package/dist/payments/revolut.d.cts +2 -2
  111. package/dist/payments/revolut.d.ts +2 -2
  112. package/dist/payments/revolut.js +17 -3
  113. package/dist/payments/simplepay.cjs +37 -5
  114. package/dist/payments/simplepay.d.cts +2 -2
  115. package/dist/payments/simplepay.d.ts +2 -2
  116. package/dist/payments/simplepay.js +37 -5
  117. package/dist/payments/stripe.cjs +20 -3
  118. package/dist/payments/stripe.d.cts +2 -2
  119. package/dist/payments/stripe.d.ts +2 -2
  120. package/dist/payments/stripe.js +20 -3
  121. package/dist/push-BXj0Sf0Z.d.ts +52 -0
  122. package/dist/push-D4nNbCNV.d.cts +52 -0
  123. package/dist/reference-B5TATqb5.js +124 -0
  124. package/dist/reference-BIdYMK0t.cjs +183 -0
  125. package/dist/reports/index.cjs +11 -164
  126. package/dist/reports/index.d.cts +1 -1
  127. package/dist/reports/index.d.ts +1 -1
  128. package/dist/reports/index.js +1 -154
  129. package/dist/request-body-CH6ZGOrq.cjs +34 -0
  130. package/dist/request-body-uZHMRlQr.js +29 -0
  131. package/dist/serialize-B2eFh3D-.js +219 -0
  132. package/dist/serialize-CquIsxA-.cjs +290 -0
  133. package/dist/server-BrJUbXs7.js +1505 -0
  134. package/dist/server-Dw9j8Mnd.cjs +1558 -0
  135. package/dist/session-Ckl5eJhV.d.ts +7 -0
  136. package/dist/{session-CKOuE6Jc.cjs → session-DeM5vjvC.cjs} +69 -13
  137. package/dist/{session-rMKaJcmq.js → session-SaEEXu2N.js} +58 -14
  138. package/dist/session-YbChChx7.d.cts +7 -0
  139. package/dist/storage/fs.cjs +1 -1
  140. package/dist/storage/fs.js +1 -1
  141. package/dist/storage/index.cjs +1 -1
  142. package/dist/storage/index.js +1 -1
  143. package/dist/store-C1rHd9DB.d.cts +19 -0
  144. package/dist/store-C1rHd9DB.d.ts +19 -0
  145. package/dist/stores/index.cjs +24 -0
  146. package/dist/stores/index.d.cts +4 -0
  147. package/dist/stores/index.d.ts +4 -0
  148. package/dist/stores/index.js +3 -0
  149. package/dist/stores-BNrS0dWl.cjs +506 -0
  150. package/dist/stores-DhjGBrl_.js +441 -0
  151. package/dist/testing/index.cjs +1500 -15
  152. package/dist/testing/index.d.cts +198 -3
  153. package/dist/testing/index.d.ts +198 -3
  154. package/dist/testing/index.js +1498 -15
  155. package/dist/{types-COf1LAD-.d.ts → types-B1A8mUIE.d.cts} +14 -2
  156. package/dist/{types-COf1LAD-.d.cts → types-ByAEvGsZ.d.ts} +14 -2
  157. package/dist/{types-k--zHf4l.d.ts → types-D1zDOH_F.d.ts} +1 -1
  158. package/dist/{types-DH8QoNZK.d.cts → types-lnKkQdot.d.cts} +1 -1
  159. package/dist/warnings-B3DXnveC.d.cts +31 -0
  160. package/dist/warnings-B3DXnveC.d.ts +31 -0
  161. package/dist/warnings-DeIw5RFj.cjs +20 -0
  162. package/dist/warnings-DwTl5J-2.js +15 -0
  163. package/dist/{webhook-D4lvtex1.js → webhook-CQVShAfI.js} +47 -26
  164. package/dist/{webhook-C76bV-4R.cjs → webhook-Ckj-_hPD.cjs} +70 -25
  165. package/dist/webhook-CmdhGiH5.d.cts +11 -0
  166. package/dist/webhook-D6lEejob.d.ts +11 -0
  167. package/llms.txt +6 -5
  168. package/package.json +134 -4
  169. package/dist/client-CickRRFr.d.ts +0 -838
  170. package/dist/client-CsmGIFRS.cjs +0 -1637
  171. package/dist/client-DvWZoX_E.d.cts +0 -838
  172. package/dist/issue-BsyYdrIG.cjs +0 -1531
  173. package/dist/issue-C6Fcjvln.js +0 -1274
  174. package/dist/session-Dm_45x8K.d.cts +0 -11
  175. package/dist/session-Dm_45x8K.d.ts +0 -11
  176. package/dist/webhook-CfaDgcL0.d.cts +0 -6
  177. package/dist/webhook-q2OzNwd7.d.ts +0 -6
package/agents/README.md CHANGED
@@ -6,23 +6,37 @@
6
6
  - Receipts (nyugta): create, reverse, get, send by email.
7
7
  - Taxpayer lookup from the NAV database.
8
8
 
9
+ It also links delegated (principal) accounts through the Agent (`connectPrincipal` in `kassza/delegation`), and ships these modules:
10
+
11
+ - `kassza/payments`: Stripe, SimplePay, Barion, Revolut and PayPal webhooks that issue a receipt or invoice exactly once, and reverse it on refund.
12
+ - `kassza/reports` and `kassza/nav`: the NAV daily receipt report (mandatory since 2026-09-01), reconciliation with NAV, and a NAV receipt interface client.
13
+ - `kassza/delegation`: invoicing on behalf of many companies, each in its own Számlázz.hu account.
14
+ - `kassza/data-link`: a receiver for the Számlázz.hu financial data link (pushed invoices, bank transactions and receipts).
15
+ - `kassza/testing`: a mock client and a fake Számla Agent `fetch` for integration tests.
16
+ - `kassza/mcp` and the `kassza` CLI (`npx kassza doctor`, `npx kassza mcp`).
17
+ - `kassza/stores`: shared stores (Redis, Upstash, Cloudflare KV, Durable Object) for the session, the attempt ledger, the `createOnce` lock and webhook deduplication.
18
+ - `kassza/journal`: a document journal for the NAV daily summary, with pending entries and reconciliation.
19
+ - `kassza/batch`: rate-limited, restartable bulk and recurring invoicing, plus billing periods.
20
+ - `kassza/node`: Express, NestJS and `node:http` adapter for the webhook handlers.
21
+ - `kassza/observe`: structured logs, tracing and Prometheus metrics, with no telemetry.
22
+
9
23
  It runs on Node 22+, Bun, Deno, Cloudflare Workers and Vercel Edge.
10
24
 
11
25
  ## Read in this order
12
26
 
13
27
  1. [pitfalls.md](./pitfalls.md): hard rules. Follow them or you will create duplicate or invalid tax documents.
14
28
  2. [api.md](./api.md): every method, input and output.
15
- 3. [recipes.md](./recipes.md): webhooks, idempotent invoicing, receipts, IPN, PDF storage, serverless, tests.
29
+ 3. [recipes.md](./recipes.md): webhooks, exactly-once invoicing, receipts, IPN, PDF storage, serverless, tests, payment providers, NAV reports, delegated invoicing, data link.
16
30
 
17
- A ready-made Claude Code skill is in [skills/kassza/SKILL.md](./skills/kassza/SKILL.md). Copy the `skills/kassza` folder into `.claude/skills/` in the user's project.
31
+ A ready-made Claude Code skill is in [skills/kassza/SKILL.md](./skills/kassza/SKILL.md). To move a codebase off `szamlazz.js`, use [skills/migrate-from-szamlazz-js/SKILL.md](./skills/migrate-from-szamlazz-js/SKILL.md). Copy the `skills/kassza` folder into `.claude/skills/` in the user's project.
18
32
 
19
33
  ## The five things to get right
20
34
 
21
35
  1. Create the client once, on the server only: `createKassza()`. It reads `SZAMLAZZ_AGENT_KEY`.
22
- 2. Always set `orderNumber` on invoices and `callId` on receipts, derived from the order ID.
36
+ 2. Always set `orderNumber` on invoices and receipts (and `callId` on receipts), derived from the order ID.
23
37
  3. Give prices as `netUnitPrice` or `grossUnitPrice` plus `vat`, and never compute net, VAT or gross yourself.
24
- 4. Never retry `create` in a loop. After a `network`, `timeout`, `partial_success` or `duplicate` error, call `invoices.find({ orderNumber })`.
25
- 5. In tests, use `createMockKassza()` from `kassza/testing` instead of the real API.
38
+ 4. Never retry `create` in a loop. Prefer `invoices.createOnce()` and `receipts.createOnce()`: they look the document up, create it, and look it up again after an uncertain failure. With plain `create`, after a `network`, `timeout`, `partial_success` or `duplicate` error, call `invoices.find({ orderNumber })`.
39
+ 5. In tests, use `createMockKassza()` or `createFakeAgentFetch()` from `kassza/testing` instead of the real API.
26
40
 
27
41
  ## Minimal example
28
42
 
@@ -31,13 +45,15 @@ import { createKassza } from 'kassza'
31
45
 
32
46
  const kassza = createKassza()
33
47
 
34
- const invoice = await kassza.invoices.create({
48
+ const { number, created } = await kassza.invoices.createOnce({
35
49
  orderNumber: 'ORDER-1001',
36
50
  buyer: { name: 'Vevő Kft.', zip: '1111', city: 'Budapest', address: 'Fő utca 1.', email: 'vevo@example.hu' },
37
51
  items: [{ name: 'Termék', quantity: 2, grossUnitPrice: 12_700, vat: 27 }],
38
52
  })
39
53
  ```
40
54
 
55
+ `created` is `false` when an earlier call (a redelivered webhook, a retried job) already issued the invoice.
56
+
41
57
  ## Vocabulary
42
58
 
43
59
  | Hungarian | kassza |
@@ -57,3 +73,7 @@ const invoice = await kassza.invoices.create({
57
73
  | áfakulcs | vat |
58
74
  | nettó / bruttó egységár | netUnitPrice / grossUnitPrice |
59
75
  | Agent kulcs | agentKey |
76
+ | megbízó / megbízott | principal / delegate |
77
+ | pénztárgép-köteles | cashRegisterRequired |
78
+ | napi összesítő (NAV) | navDailyReports |
79
+ | pénzügyi adatkapcsolat | data link |
package/agents/api.md CHANGED
@@ -11,13 +11,18 @@ const kassza = createKassza({
11
11
  agentKey?: string,
12
12
  username?: string, password?: string,
13
13
  defaults?: { invoice?: InvoiceDefaults, receipt?: ReceiptDefaults },
14
- cookieStore?: CookieStore | false,
14
+ cookieStore?: KeyValueStore | false,
15
+ attemptLedger?: KeyValueStore,
16
+ attemptLedgerMode?: 'fail-open' | 'fail-closed',
17
+ createOnceLock?: KeyValueStore,
18
+ taxpayerCache?: { store: KeyValueStore, ttlSeconds?, invalidTtlSeconds? },
15
19
  timeoutMs?: number,
16
20
  maxAttempts?: number,
17
21
  retryDelayMs?: number,
22
+ maintenanceCooldownMs?: number,
18
23
  fetch?: typeof fetch,
19
24
  endpoint?: string,
20
- hooks?: { onRequest?, onResponse?, onError? },
25
+ hooks?: { onRequest?, onResponse?, onError?, onComplete?, onDocument?, onDocumentError?, onWarning? },
21
26
  })
22
27
  ```
23
28
 
@@ -27,15 +32,26 @@ const kassza = createKassza({
27
32
  - `timeoutMs` defaults to 60000.
28
33
  - `maxAttempts` defaults to 3 and is capped at 5. It applies only to operations that are safe to retry.
29
34
  - `hooks` receive events for logging. They never receive the XML payload or the key.
35
+ - `KeyValueStore` (alias `CookieStore`) is `{ get, set, delete }` plus the optional atomic `setIfAbsent`, `increment`, `deleteIfEquals`. Adapters live in `kassza/stores` (see below).
36
+ - `attemptLedger` is a shared store. It counts failed attempts of identical requests across processes for a day, atomically when the store has `increment`. After 5 failures kassza throws an `attempt_limit` error without sending the request; after a human fixed the cause, call `kassza.resetAttempts(error)`. If the store fails, `attemptLedgerMode: 'fail-open'` (default) falls back to an in-process counter and emits `onWarning`; `'fail-closed'` throws `store_unavailable` without sending (create the adapter with `resilient: false` so read errors reach kassza).
37
+ - `createOnceLock` is a shared store with `setIfAbsent` (Redis, Upstash, Durable Object; not Cloudflare KV). It makes `createOnce` exactly-once across processes. Calls for the same order inside one process are always merged, without any option.
38
+ - `taxpayerCache` caches `taxpayer.query` results (valid ones for a day, invalid ones for 10 minutes; errors are never cached).
39
+ - `maintenanceCooldownMs` defaults to 0. When set, a maintenance error (code 1) blocks all requests of the client for that long, and they fail at once with category `maintenance`.
40
+ - `hooks.onDocument(event)` runs after every created or reversed document and every registered payment, with `{ kind: 'invoice' | 'receipt', action: 'created' | 'reversed' | 'payment', number, document }` (plus `reversedNumber` for reversals and `input` for created invoices). It is awaited. `hooks.onDocumentError` decides what happens when it throws: `'warn'` (default, the call still succeeds), `'throw'` (a `DocumentHookError` carrying `event`, `document` and `number` of the document that WAS created; never re-create it) or a handler `(error, event) => void`.
41
+ - `hooks.onWarning(warning)` receives the errors kassza swallows on purpose: `{ kind: 'session' | 'ledger' | 'hook' | 'document' | 'lock' | 'dedupe' | 'cache', message, error, action?, operation? }`.
42
+ - `hooks.onRequest`, `onResponse`, `onError` and `onComplete` events carry `{ action, attempt, requestId }`; `onResponse` adds `status`, `durationMs`, `responseBytes`, `sessionReused`; `onComplete` adds `outcome: 'success' | 'error'`, `durationMs`, `error?`, `willRetry` and fires once per attempt.
43
+ - Retries of safe operations wait `retryDelayMs * 2^(attempt-1)` with ±20% jitter, and honour `Retry-After` (no retry when it exceeds 60 s).
30
44
 
31
45
  ## Invoices: `kassza.invoices`
32
46
 
33
47
  | Method | Returns | Retried automatically |
34
48
  |---|---|---|
35
49
  | `create(input: CreateInvoiceInput)` | `CreatedInvoice` | never |
50
+ | `createOnce(input: CreateInvoiceInput, options?: CreateOnceOptions)` | `InvoiceOnceResult` | never resends; looks the invoice up instead |
36
51
  | `preview(input: CreateInvoiceInput)` | `InvoicePreview` (PDF only, no document is created) | never |
37
52
  | `reverse(input: string \| ReverseInvoiceOptions)` | `ReversedInvoice` | never |
38
53
  | `registerPayment(input: RegisterPaymentInput)` | `RegisteredPayment` | only when `additive: false` |
54
+ | `registerPaymentOnce({ invoiceNumber, key, amount, method?, date?, description?, taxNumber? }, options?)` | `RegisteredPaymentOnce` (`{ invoiceNumber, key, marker, created, payment?, existing? }`) | never resends; looks the payment up by its marker |
39
55
  | `clearPayments(input: string \| { invoiceNumber })` | `RegisteredPayment` | yes |
40
56
  | `getPdf(ref: InvoiceReference)` | `InvoicePdf` | yes |
41
57
  | `get(ref: InvoiceReference, { includePdf? })` | `InvoiceDetails` | yes |
@@ -44,6 +60,21 @@ const kassza = createKassza({
44
60
 
45
61
  `InvoiceReference` is `string` (the invoice number), `{ invoiceNumber }`, `{ orderNumber }`, or `{ externalId }`. When several documents share an order number, the latest one is returned.
46
62
 
63
+ ### createOnce
64
+
65
+ `invoices.createOnce(input, { lookupFirst?, matchOrderNumber?, recoveryDelayMs?, signal?, lock?, lockTtlSeconds?, lockWaitMs?, lockFailure? })` makes invoicing exactly-once:
66
+
67
+ 1. It looks the invoice up by external ID (and, with `matchOrderNumber`, by order number) and returns it if it exists.
68
+ 2. Otherwise it creates the invoice.
69
+ 3. After an uncertain failure (`network`, `timeout`, `partial_success`, `duplicate`, `unexpected_response`, `unknown`) it looks the invoice up twice more, after `recoveryDelayMs` (default 1000, doubled for the second lookup).
70
+
71
+ - `orderNumber` is required. The external ID defaults to the order number with a suffix per type: `/D` proforma, `/E` advance, `/V` final, `/H` corrective, `/SZL` delivery note, none for a normal invoice. Pass `externalId` to override it.
72
+ - `lookupFirst` and `matchOrderNumber` default to `true`.
73
+ - It returns `InvoiceOnceResult`: `{ number, created, externalId, invoice?: CreatedInvoice, details?: InvoiceDetails }`. `created` is `false` when the invoice already existed.
74
+ - If the outcome stays unknown, it rethrows the original error with `details.outcome: 'unknown'`. Do not create the invoice by hand then; call `createOnce` again later.
75
+ - Concurrent calls for the same order in one process share one execution; the others get the result with `created: false`. Across processes `lock` (default: the client's `createOnceLock`, `false` disables) is held for `lockTtlSeconds` (600); a waiter polls up to `lockWaitMs` (10000) and then throws `in_progress` (respond 5xx in a webhook so it is redelivered). `lockFailure: 'throw'` (default) throws `store_unavailable` when the lock store is down; `'proceed'` continues without a lock and warns.
76
+ - `registerPaymentOnce` writes `[kassza:{key}]` into the payment description and looks for it on the invoice before registering. If the marker cannot be read back after registering, it throws `unexpected_response` with `details.registered: 'true'`: the payment exists, do not call again.
77
+
47
78
  ### CreateInvoiceInput
48
79
 
49
80
  ```ts
@@ -136,6 +167,8 @@ Notes on the fields:
136
167
  | Method | Returns | Retried automatically |
137
168
  |---|---|---|
138
169
  | `create(input: CreateReceiptInput)` | `Receipt` | only with `callId` |
170
+ | `createOnce(input: CreateReceiptInput, options?: CreateOnceOptions)` | `{ receipt: Receipt, created: boolean }` | only with `callId` (defaults to `orderNumber`) |
171
+ | `convertToInvoice(input: ConvertReceiptInput, options?: CreateOnceOptions)` | `ConvertedReceipt` | never resends; looks the invoice up instead |
139
172
  | `reverse(input: string \| { receiptNumber, callId?, downloadPdf?, template? })` | `Receipt` | only with `callId` |
140
173
  | `get(input: string \| { receiptNumber } \| { orderNumber }, + downloadPdf?)` | `Receipt` | yes |
141
174
  | `find(same as get)` | `Receipt \| null` | yes |
@@ -161,9 +194,12 @@ Notes on the fields:
161
194
 
162
195
  `Receipt` is `{ id, number, callId?, type: 'receipt' | 'reversal', isReversed, reversedReceiptNumber?, issueDate, paymentMethod, currency, orderNumber?, items, payments, totals: { netAmount, vatAmount, grossAmount, byVat }, pdf? }`.
163
196
 
197
+ - `receipts.createOnce` requires `orderNumber`, uses it as `callId` unless you give one, looks the receipt up by order number first (skip with `lookupFirst: false`), and after error 338 or another uncertain failure returns the receipt that already exists.
198
+ - `receipts.convertToInvoice({ receiptNumber, buyer, orderNumber?, prefix?, comment?, language?, template?, seller?, eInvoice?, downloadPdf?, vatMapping?, allowAlreadyReversed? })` reverses the receipt and issues an invoice for the same items to `buyer`, exactly once. It returns `{ receipt, reversal?, invoice: InvoiceOnceResult }`. The invoice external ID is `CONV/{receiptNumber}`. Receipt-only VAT codes (`ÁKK`, `MAA`, `EU`, `EUK`) need a `vatMapping` entry, chosen with an accountant.
199
+
164
200
  ## Taxpayer: `kassza.taxpayer`
165
201
 
166
- `query(taxNumber: string): TaxpayerInfo` accepts `12345678`, `12345678-2-42` or `HU12345678`.
202
+ `query(taxNumber: string): TaxpayerInfo` accepts `12345676`, `12345676-2-42` or `HU12345676`. With `taxpayerCache` the result is cached by the 8-digit taxpayer ID.
167
203
 
168
204
  `TaxpayerInfo` is `{ valid, name?, shortName?, taxNumber?: { taxpayerId, vatCode?, countyCode?, formatted? }, address?: TaxpayerAddress, addresses, incorporation?, infoDate? }`, and `TaxpayerAddress.formatted` looks like `'1031 Budapest, Záhony utca 7.'`.
169
205
 
@@ -171,6 +207,29 @@ Notes on the fields:
171
207
 
172
208
  - `kassza.verifyCredentials(): Promise<boolean>` returns `false` for a wrong key and throws for account problems.
173
209
  - `kassza.resetSession()` clears the session. Call it after changing account data in Számlázz.hu.
210
+ - `kassza.resetAttempts(errorOrKey)` clears the `attemptLedger` counter of an `attempt_limit` error (or of its `details.attemptKey`).
211
+ - `kassza.issueForPayment(payment, options?)` issues a receipt or invoice for a `PaymentEvent`, exactly once. See `kassza/payments` below.
212
+
213
+ ## Receipt or invoice: `chooseDocument`
214
+
215
+ ```ts
216
+ import { chooseDocument } from 'kassza'
217
+
218
+ const decision = chooseDocument({
219
+ grossTotal: 18_990,
220
+ currency: 'HUF',
221
+ exchangeRate?: number,
222
+ buyer?: { taxNumber?, euTaxNumber?, isBusiness? },
223
+ paidByFulfillment?: boolean,
224
+ invoiceRequested?: boolean,
225
+ cashRegisterRequired?: boolean,
226
+ })
227
+ ```
228
+
229
+ - It returns `{ type: 'receipt' | 'invoice' | 'cash-register', reasons: string[], grossTotalHuf }` and makes no network call.
230
+ - `invoice` means at least one of the four receipt conditions fails: a business buyer, a total of 900 000 HUF or more, not paid by fulfilment (`paidByFulfillment: false`), or `invoiceRequested: true`. `reasons` says which, in Hungarian.
231
+ - `cash-register` means a receipt would be due, but the activity needs an online cash register (`cashRegisterRequired: true`), so an Agent receipt must not be issued. An invoice is still allowed.
232
+ - Foreign currency totals need `exchangeRate`, because the limit is in HUF.
174
233
 
175
234
  ## Subpath modules
176
235
 
@@ -183,6 +242,17 @@ createMockKassza({ defaults?, taxpayers?: Record<taxpayerId, TaxpayerInfo>, cred
183
242
  - A `MockKassza` has the same interface as `Kassza`, plus `calls`, `invoiceRecords`, `receiptRecords`, `failNext(method, error?)` and `reset()`.
184
243
  - The method names used in `calls` and `failNext` look like `'invoices.create'` and `'receipts.send'`.
185
244
  - The mock runs the real validation and rounding, and throws the same error codes: 7, 335, 338 and 339.
245
+ - `createMockKassza({ hooks: { onDocument } })` fires the same document events as the real client.
246
+
247
+ ```ts
248
+ createFakeAgentFetch(options?: FakeAgentOptions): FakeAgent
249
+ ```
250
+
251
+ - A fake Számla Agent behind a `fetch`: pass `agent.fetch` to the real `createKassza({ agentKey, fetch: agent.fetch, retryDelayMs: 0 })`. It parses the real request XML, numbers documents, applies the Számlázz.hu rules (259–261, 336–340, 363–365, 395, 152, 202, 524, 7, 335, 339, 53, 57, 3) and answers in the real response formats.
252
+ - `FakeAgent` is `{ fetch, requests, invoices, receipts, fail(fault, { action?, times? }), acceptDelegation(taxNumber), reset() }`.
253
+ - Faults: `'ghostSuccess'` (created, but the response is lost), `'timeout'`, `'networkError'`, `'serverError'`, `'maintenance'`, `'partialSuccess'` (56), `'testAccountLimit'` (167), `'duplicateCallId'` (338), `'duplicateOrderNumber'` (152), or `{ code, message?, afterSuccess? }`.
254
+ - Options: `now`, `agentKeys` and `users` (strict authentication), `testAccount` (default `true`), `invoicePrefixes`, `defaultInvoicePrefix` (default `KASSZA`), `receiptPrefixes`, `rejectDuplicateOrderNumbers` (`boolean` or `{ invoices?, receipts? }`), `seller`, `taxpayers`, `principals` (`{ [taxNumber]: 'owned' | 'unowned' }`), `faults`.
255
+ - Use the mock for business logic, and the fake Agent for webhooks, retries and `createOnce` recovery.
186
256
 
187
257
  ### `kassza/ipn`
188
258
 
@@ -203,5 +273,150 @@ createMockKassza({ defaults?, taxpayers?: Record<taxpayerId, TaxpayerInfo>, cred
203
273
  ### `kassza/money`
204
274
 
205
275
  - `calculateInvoiceItem(input, currency?)` and `calculateReceiptItem(input, currency?)` return `ItemAmounts`.
276
+ - `allocateRefund(items: { name, vat, grossAmount, unit?, identifier? }[], refundGross, { decimals?, refundedBefore? })` splits a refund exactly over items (Sainte-Laguë, BigInt), path-independent: refunds split into parts give the same per-item totals as one refund.
206
277
  - `summarizeItems(items)` returns `{ netAmount, vatAmount, grossAmount, byVat }`.
207
278
  - `roundMoney(value, decimals)`, `isVatRate(value)`, `NUMERIC_VAT_RATES`, `SPECIAL_VAT_CODES`.
279
+
280
+ ### `kassza/storage`
281
+
282
+ - `storePdf(storage, key, pdf)` and `invoicePdfKey({ number })` save PDFs through adapters: `s3FetchStorage`, `s3Storage`, `r2BindingStorage`, `vercelBlobStorage`, `uploadthingStorage`, `supabaseStorage`, `memoryStorage`, and `fsStorage` from `kassza/storage/fs`.
283
+
284
+ ### `kassza/stores` (also `kassza/cookie-stores`)
285
+
286
+ - `memoryStore()`, `upstashRedisStore(redis)`, `ioredisStore(redis)`, `nodeRedisStore(client)`, `cloudflareKvStore(namespace)`, `customStore(store)`, `resilientStore(store)`; the old `*CookieStore` names are aliases.
287
+ - Redis adapters get the atomic operations through Lua (`eval`) when the client has it. Cloudflare KV has none: use `durableObjectStore(stub)` with `handleDurableObjectStoreRequest(this.ctx.storage, request)` inside your Durable Object class for locks and the journal on Workers.
288
+ - Adapters are resilient by default: `get`/`set`/`delete` errors are reported to `onError` and swallowed, atomic operation errors are reported and rethrown. `resilient: false` rethrows everything.
289
+ - `diagnoseStore(store)` returns `{ ok, capabilities, suitableFor: { session, attemptLedger, createOnceLock, journal, webhookDedupe }, latencyMs, problems }` after a round-trip with a temporary key.
290
+
291
+ ### `kassza/journal`
292
+
293
+ ```ts
294
+ const journal = createJournal(kvJournal(store), { now? })
295
+ createKassza({ hooks: { onDocument: (event) => journal.record(event), onDocumentError: 'throw' } })
296
+ ```
297
+
298
+ - `kvJournal(store, { prefix?, retentionDays? (400), reservationTtlDays? (30) })` needs a store with `increment`; `memoryJournal()` for tests; or implement `JournalStorage` for SQL.
299
+ - `record(event)` stores created and reversed documents (payments are ignored); idempotent by document number.
300
+ - `trackReceipt(orderNumber, run)` / `trackInvoice(orderNumber, run)` reserve before `run` and complete after it. Definite `SzamlazzError`s release the reservation; uncertain errors and crashes leave it pending. A failed journal write after success throws `JournalWriteError` with `result`.
301
+ - `pending(range?)` lists reservations (last 30 days by default); `settle(kassza, { range?, releaseNotFound? })` looks them up by order number and records what it finds.
302
+ - `entries({ from, to, kind? })`, `receipts({ from, to, includeTest? })` (feed into `navDailyReports`), at most 366 days per call.
303
+ - `reconcile(archive, { from?, to?, dryRun? })` compares with the data link receipt archive: `{ range, added, missingFromArchive, matched }`.
304
+
305
+ ### `kassza/payments`
306
+
307
+ Webhook handlers are `(request: Request) => Promise<Response>`, usable directly as Next.js route handlers, in Hono or in Workers. Each one verifies the request, turns it into a `PaymentEvent` and calls `onPayment`:
308
+
309
+ | Import | Handler | Required options |
310
+ |---|---|---|
311
+ | `kassza/payments/stripe` | `stripeWebhook` | `secret` (string or array for rotation); optional `apiKey` to load line items and refunds |
312
+ | `kassza/payments/simplepay` | `simplePayWebhook` | `secretKey` or `merchants` |
313
+ | `kassza/payments/barion` | `barionWebhook` | `posKey` |
314
+ | `kassza/payments/revolut` | `revolutWebhook` | `secret`, `apiKey` |
315
+ | `kassza/payments/paypal` | `payPalWebhook` | `webhookId`, `clientId`, `clientSecret` |
316
+
317
+ - Common options: `onPayment(payment)`, `onError?(error)`, `method?` (the payment method written on the document), `maxBodyBytes?`, `fetch?`; `sandbox?` where the provider has one.
318
+ - Responses: `400` for a bad signature or payload (never reaches `onPayment`), `200` on success (`204` for Revolut, a signed JSON for SimplePay), `500` when `onPayment` throws, so the provider redelivers.
319
+ - `PaymentEvent` is `{ provider, kind: 'paid' | 'refunded' | 'partially-refunded' | 'failed' | 'other', id, eventId?, eventType?, orderRef?, amount?: { value, currency }, refundedAmount?, refunds?: { id, amount, refundedBefore?, createdAt? }[], paidAt?, method, customer?, items?, raw }`. Amounts are in major units (12700 HUF, not minor units). `refunds` lists the individual refunds the provider reported (Barion and SimplePay: all; Revolut and PayPal: the current one; Stripe: the delta of `charge.refunded` keyed by the event ID).
320
+ - `dedupe?: KeyValueStore` and `dedupeTtlSeconds?` (7 days): an event that was already processed successfully is acknowledged without calling `onPayment` again. It is marked only after `onPayment` succeeds.
321
+
322
+ ```ts
323
+ const result = await kassza.issueForPayment(payment, {
324
+ vat?: VatRate,
325
+ vatFor?: (item: PaymentLineItem) => VatRate,
326
+ items?: PaymentDocumentItem[],
327
+ fallbackItemName?: string,
328
+ document?: 'auto' | 'receipt' | 'invoice',
329
+ buyer?: InvoiceBuyer,
330
+ buyerIsBusiness?, invoiceRequested?, cashRegisterRequired?,
331
+ orderNumber?: string,
332
+ exchangeRate?, exchangeBank?,
333
+ allowAmountMismatch?: boolean,
334
+ partialRefund?: 'auto' | 'proportional' | 'skip',
335
+ refundItems?: ({ refund, invoiceNumber, items }) => PaymentDocumentItem[] | undefined,
336
+ receipt?: Partial<CreateReceiptInput>,
337
+ invoice?: Partial<CreateInvoiceInput>,
338
+ })
339
+ ```
340
+
341
+ - `paid`: chooses receipt or invoice with `chooseDocument` (override with `document`) and issues it with `createOnce`. `refunded`: reverses that document exactly once. `failed` and `other`: skipped.
342
+ - `partially-refunded`: for an invoice, one corrective invoice per refund (external ID `{orderNumber}/R-{refund}`), with the refund allocated exactly over the original items (`allocateRefund`, house-monotone, never more than an item's original amount across refunds). If the invoice has several VAT rates and no `refundItems` is given, it returns `{ kind: 'refund-proposal', ... }` instead (or allocate proportionally with `partialRefund: 'proportional'`). For a receipt it always returns a `refund-proposal`. Without `payment.refunds` it is skipped with a reason.
343
+ - The order number defaults to `{PROVIDER}-{payment id}`, for example `STRIPE-pi_123`, so the payment and its refund share it.
344
+ - Items come from `items`, then the provider's line items, then one item with the paid amount (`fallbackItemName`). VAT comes from `vatFor`, the provider's per-item rate, then `vat`. Without any, it throws `validation`: kassza never guesses VAT.
345
+ - The document total must equal the paid amount, unless `allowAmountMismatch: true`.
346
+ - It returns `{ kind: 'receipt', number, created, receipt, decision, orderNumber }`, `{ kind: 'invoice', number, created, invoice, decision, orderNumber }`, `{ kind: 'reversal', document, reversedNumber, number?, created, orderNumber }`, `{ kind: 'correction', correctedNumber, created, corrections: { refundId, externalId, number, created, grossTotal, invoice }[], orderNumber }`, `{ kind: 'refund-proposal', document, documentNumber, refunds: { refundId, grossTotal, items }[], reason, orderNumber }` or `{ kind: 'skipped', reason, orderNumber }`.
347
+ - `issueForPayment(api, payment, options)` from `kassza/payments` does the same with any object that has `invoices` and `receipts` (`createOnce`, `find`, `reverse`), for example a delegated client.
348
+
349
+ ### `kassza/batch`
350
+
351
+ - `runBatch(kassza, { items: { key, document: () => ({ kind: 'invoice' | 'receipt', input }) }[], concurrency? (1, max 4), ratePerMinute? (30), dryRun?, journal?, signal?, onProgress?, recoveryDelayMs? })` issues each item with `createOnce`, using `key` as the order number, so a rerun never duplicates. It stops (marking the rest `skipped`) on `rate_limit`, `auth`, `account`, `configuration`, `maintenance`, `attempt_limit` or `store_unavailable`; other item errors land in `failed`. `dryRun` previews invoices and computes receipts locally. Returns `{ dryRun, items, created, existing, previewed, failed, skipped, stopped?, grossTotal }`.
352
+ - `billingPeriod({ interval: 'week' | 'month' | 'quarter' | 'year', anchor, paymentDueInDays? }, index)`, `billingPeriodAt(options, date)`, `billingPeriodsBetween(options, from, to)`, `todayPeriod(options)` return `{ index, interval, start, end, dueDate?, key }` in Budapest dates; month ends never drift (Jan 31 → Feb 28/29 → Mar 31). The fulfilment date is the caller's legal decision.
353
+
354
+ ### `kassza/node`
355
+
356
+ - `toNodeHandler(webHandler, { trustProxy?, onError? })` adapts any `(Request) => Response` handler to Express, NestJS and `node:http`. It streams the raw body (so signatures verify), uses `rawBody` (NestJS) or a Buffer `body` (`express.raw`), and throws `NodeBodyError` when `express.json()` already consumed the body. With Express `next`, errors go to `next`.
357
+ - `toWebRequest(nodeRequest)` and `writeWebResponse(response, nodeResponse)` for Fastify or custom servers.
358
+
359
+ ### `kassza/observe`
360
+
361
+ - `observe({ logger?, tracer?, metrics?, redact? (true), orderRefSalt?, logRequests? })` returns hooks: structured logs without secrets (order numbers as `hmac:` refs), one span per attempt (OpenTelemetry `Tracer` compatible), and metrics. `combineHooks(...hooks)` merges hook sets.
362
+ - `createMetricsRegistry(buckets?)` is a dependency-free registry with `renderPrometheus()`. Metrics: `kassza_requests_total{action,outcome}`, `kassza_request_duration_ms{action}`, `kassza_retries_total`, `kassza_maintenance_total`, `kassza_unexpected_response_total`, `kassza_store_errors_total{store}`, `kassza_warnings_total{kind}`, `kassza_documents_total{kind,action}`.
363
+
364
+ ### `kassza/delegation`
365
+
366
+ ```ts
367
+ connectPrincipal({ principal, user }, { agentKey?, verifyTaxNumber?, ... }): Promise<ConnectPrincipalResult>
368
+ probeDelegation({ username, password, ... }): Promise<DelegationProbeResult>
369
+ createDelegateKassza({ username, password, invoicePrefix, receiptPrefix?, defaults?, ... }): Kassza
370
+ createKasszaPool({ resolve, maxClients?, ... }): KasszaPool
371
+ ```
372
+
373
+ - `connectPrincipal` (the Agent call `action-agent_ceg_mb`) creates or links a principal's account. `principal` is `{ name, taxNumber, invoicePrefix, zip, city, address, email, postalAddress?, bankName?, bankAccount?, replyToEmail?, cashAccountingFrom?, cashAccountingTo?, kataFrom?, kataTo? }`; `user` is the dedicated user `{ email, password (8–128 characters), firstName, lastName? }`. The tax number must pass the checksum, and the prefix is at most 5 uppercase letters or digits.
374
+ - Its `status` is `'account-created'`, `'owner-invite-resent'`, `'join-request-sent'`, `'join-request-resent'` or `'unknown'`. Every call emails the principal again, so never call it in a loop.
375
+ - `probeDelegation` signs in as the dedicated user and returns `state`: `'active'`, `'awaiting-approval'` (error 3), `'awaiting-owner-registration'` (error 250) or `'multi-account-user'` (error 164). It also re-sends the owner invitation, so call it rarely.
376
+ - `createDelegateKassza` returns a normal `Kassza` that authenticates as the dedicated user and sends the principal's prefix on every document.
377
+ - `createKasszaPool({ resolve: (principalId) => credentials | undefined })` caches clients per principal (LRU, `maxClients` default 100): `get(principalId)`, `forget(principalId)`, `clear()`, `size`.
378
+ - `suggestedPrefix(error)` reads the prefix Számlázz.hu suggests in error 356.
379
+
380
+ ### `kassza/reports`
381
+
382
+ - `navDailyReports(receipts: Receipt[], { includeTest?, vatCategory?, series? }): NavReceiptReport[]` builds the NAV daily receipt report: one entry per day, receipt series and currency, with `applicableDate`, `series`, `serialNumber` (the first receipt number), `currency`, `exchangeRate`, `vatCategories: [{ vat, saleDocument, modifyingDocument }]`, `total`, `numberOfSaleDocument`, `numberOfModifyingDocument`, `receiptNumbers`. Test-account receipts are skipped unless `includeTest`.
383
+ - `dailyClose(receipts, { includeTest? })` returns per-day totals by payment method and VAT rate.
384
+ - `navVatCategory(vat)` maps a VAT rate to the NAV category (`0%`, `5%`, `18%`, `27%`, `Alanyi adómentes`, `Egyéb`); `describeNavReport(report)` gives a one-line Hungarian summary.
385
+
386
+ ### `kassza/nav`
387
+
388
+ ```ts
389
+ const nav = createNavReceiptClient({
390
+ environment: 'test' | 'production',
391
+ login, password | passwordHash, signatureKey, taxNumber,
392
+ softwareName?, allowWrite?: boolean, timeoutMs?, fetch?,
393
+ })
394
+ ```
395
+
396
+ - Reads: `listReports({ from, to, page?, pageSize? })`, `listAllReports({ from, to })`, `getReport(id)`, `listSoftware()`, `vatCategories()`, `currencies()`.
397
+ - Writes (`submitReport(data)`, `modifyReport(id, data)`, `invalidateReport(id)`, `registerSoftware(name?)`) throw `write_blocked` unless `allowWrite: true`. Never submit receipts that Számlázz.hu reports.
398
+ - `reconcileNavReports(local, remote)` compares local daily reports (for example `navDailyReports()` output) with `listAllReports()` and returns `{ matched, mismatched, missing, unexpected }`.
399
+ - `paperReceiptReport({ applicableDate, currency?, exchangeRate?, entries: [{ number, vat, gross, modifying? }] })` builds the daily report of a paper receipt pad, the one thing you submit yourself.
400
+ - Errors are `NavReceiptError` with `category`, `code`, `hint` and field errors.
401
+
402
+ ### `kassza/data-link`
403
+
404
+ ```ts
405
+ export const POST = dataLinkHandler({
406
+ keys?: string[],
407
+ verifyKey?: (key, push) => boolean | 'KEY_ERR' | 'KEY_DEL' | Promise<...>,
408
+ onPush: (push) => void | { registrationNumber?, keyError? },
409
+ onError?, maxBodyBytes?,
410
+ })
411
+ ```
412
+
413
+ - Receives the Számlázz.hu financial data link (PUSH). Either `keys` or `verifyKey` is required, because the requests are not signed; the key is in the `X-Szamlazzhu-Key` header.
414
+ - `push.kind` is `'invoice'` or `'incoming-invoice'` (`{ id, invoice: InvoiceDetails, deleted }`), `'bank-transaction'` (`{ transaction }`) or `'receipts'` (`{ receipts: [{ receipt, issuerTaxNumber? }] }`).
415
+ - The handler builds the required response XML. Return `registrationNumber` to store your filing number for invoices. Use the `id` field as the key: the same invoice can arrive again after a payment or reversal.
416
+ - `400` for bad XML (Számlázz.hu retries), `500` when `onPush` throws (retries for 72 hours), `KEY_ERR` for an unknown key, `KEY_DEL` to switch the link off for that customer.
417
+
418
+ ### `kassza/mcp` and the CLI
419
+
420
+ - `createKasszaMcpServer({ ...KasszaOptions, allowWrite?, confirmationSecret?, confirmationTtlMs? })` is a runtime-neutral Model Context Protocol server; `connect()` returns `{ handle(message), handleLine(line) }`.
421
+ - `npx kassza mcp` runs it over stdio. Tools: `preview_invoice`, `preview_receipt`, `preview_reversal`, `get_invoice`, `get_receipt`, `query_taxpayer`, `nav_daily_summary`, and with `--allow-write` (or `KASSZA_MCP_ALLOW_WRITE=1`) `create_invoice`, `create_receipt`, `reverse_invoice`, `reverse_receipt`. Write tools need the confirmation code returned by the matching preview tool for exactly the same input.
422
+ - Other commands: `kassza doctor` (Node, time zone, key, clock skew, session), `kassza verify`, `kassza xml preview <file.json>` (the XML kassza would send, with the key masked), `kassza invoice get`, `kassza receipt get`, `kassza nav summary --file receipts.json`. Exit codes: 0 success, 1 error, 2 usage.
@@ -5,11 +5,16 @@ Read this before writing any code that issues invoices or receipts. These rules
5
5
  ## Hard rules
6
6
 
7
7
  1. **Never retry creating an invoice or receipt in a loop.** Számlázz.hu bans accounts that resend requests. kassza already retries only when it is safe to do so. Do not wrap `invoices.create` in your own retry loop.
8
- 2. **After an uncertain failure, look the document up before creating it again.** A timeout, a network error, or error `56` (`partial_success`) can mean the invoice was created anyway. Always set an `orderNumber`, and on those errors call `kassza.invoices.find({ orderNumber })` first.
8
+ 2. **After an uncertain failure, look the document up before creating it again.** A timeout, a network error, or error `56` (`partial_success`) can mean the invoice was created anyway. Always set an `orderNumber`, and on those errors call `kassza.invoices.find({ orderNumber })` first. `invoices.createOnce()` and `receipts.createOnce()` do this for you.
9
9
  3. **Do not compute item amounts yourself.** Pass `netUnitPrice` (B2B) or `grossUnitPrice` (B2C) plus `vat`, and kassza applies the official rounding rules. Hand-computed floats cause errors 259–264, and on receipts 261 and 363–365.
10
10
  4. **Never use `new Date().toISOString().slice(0, 10)` for invoice dates.** Between midnight and 02:00 Budapest time it returns yesterday, which gives error 352. Leave dates out (kassza defaults to today in `Europe/Budapest`) or pass a `Date`.
11
11
  5. **The Agent key is a secret and must be lowercase.** Read it from `SZAMLAZZ_AGENT_KEY` on the server only, and never ship it to a browser bundle. kassza rejects keys containing uppercase letters before sending anything.
12
- 6. **Use the test account while developing.** It allows at most 500 invoices per 10 minutes. Never run tests against a production Agent key.
12
+ 6. **Use the test account while developing.** It allows at most 500 invoices per 10 minutes (error `167`, `rate_limit`). Never run tests against a production Agent key.
13
+ 7. **An Agent receipt is a computer-generated receipt (számítógéppel előállított nyugta).** It is not an online cash register receipt and not an e-receipt (e-nyugta). Issue it only for activities without the online cash register (OPG) obligation. Fixed-location retail (TEÁOR 47.1–47.7), restaurants and bars (56.1, 56.3, except mobile catering), accommodation (55.1–55.3), rental (77.1–77.2, 77.33), repair (95.1–95.2) and pharmacies need an online cash register or e-cash register instead. Typical valid uses: webshops, online tickets, downloadable products, food trucks and other mobile sales, services without a cash register obligation.
14
+ 8. **A receipt may replace an invoice only if all four conditions hold:** the buyer is not a taxable person or legal entity, the total is below 900 000 HUF, it is paid in full by fulfilment, and the buyer did not ask for an invoice. `chooseDocument()` applies these rules, and `issueForPayment()` uses it. If the buyer asks for an invoice later, use `receipts.convertToInvoice()`.
15
+ 9. **In serverless or multi-process setups, give `createOnce` a shared lock.** Two concurrent deliveries of the same webhook on two instances can both see "no invoice yet". Pass `createOnceLock: store` (Redis, Upstash or Durable Object from `kassza/stores`; Cloudflare KV cannot lock). Calls inside one process are merged automatically.
16
+ 10. **If your `onDocument` hook writes a legally relevant journal, set `onDocumentError: 'throw'`.** Otherwise a failed write is only a warning and the NAV daily summary silently misses a receipt. Use `kassza/journal` for this.
17
+ 11. **Never submit NAV receipt reports for receipts issued in Számlázz.hu.** Számlázz.hu reports them itself after the NAV connection, so a second submission is double reporting. The `kassza/nav` client is read-only unless you pass `allowWrite: true`; submit only reports of paper receipt pads.
13
18
 
14
19
  ## Behaviour worth knowing
15
20
 
@@ -32,7 +37,21 @@ Read this before writing any code that issues invoices or receipts. These rules
32
37
  | IPN webhook | Retried every 3 minutes, at most 10 times; only the latest one per invoice is sent | Respond with HTTP 200 quickly and process idempotently |
33
38
  | IPN source check | `isSzamlazzIp` checks the rightmost `x-forwarded-for` entry, the address your nearest proxy saw; the left side is client-controlled | Behind several proxies pass `{ trustedProxies: n }`; never feed it a header that no proxy of yours writes |
34
39
  | `buyer.groupTaxNumber`, item `dataDeletionCode` | Documented on docs.szamlazz.hu, but missing from the downloadable `xmlszamla.xsd` (and `torloKod` from `xmlnyugtacreate.xsd`) | Use them only when needed; if you get error 57, remove them |
35
- | NAV receipt data reporting | Mandatory since 2026-09-01, with a grace period until 2026-12-31 | No code change needed yet; watch the kassza changelog |
40
+ | NAV receipt data reporting | Mandatory since 2026-09-01, with a grace period until 2026-12-31; Számlázz.hu reports the receipts issued there after the NAV connection | Check with `navDailyReports()` and `reconcileNavReports()`; report only paper receipts yourself |
41
+ | Receipts and the NAV Online data connection | Számlázz.hu issues receipts only from accounts where the NAV Online data connection is set up | Set it up in Számlázz.hu before the first receipt |
42
+ | Error `524` | The receipt prefix is not enabled in the account | Enable it under Beállítások / Előtagok |
43
+ | Error `491` | KATA protection blocks documents to businesses | Leave out the buyer's tax number, or a human disables the protection |
44
+ | Error `250` | A delegated account has not been taken over by its owner, or the delegation is not accepted | Wait for the principal; check with `probeDelegation()` rarely, never in a loop |
45
+ | Error `167` (`rate_limit`) | Too many documents in the test account in a short time | Wait a few minutes; never retry automatically |
46
+ | `attempt_limit` error | The same request already failed 5 times (counted across processes with `attemptLedger`) | A human fixes the cause, then `kassza.resetAttempts(error)` |
47
+ | `attemptLedger` limits | The ledger is conservative: network and timeout failures count too, and the check (read) and the count (increment) are separate store calls, so many processes sending the *same* request at the very same moment can each pass the check | Pair it with `createOnceLock` for documents, so only one process sends at a time |
48
+ | `maintenanceCooldownMs` | After error `1`, kassza sends nothing for the cooldown and throws `maintenance` at once | Switch to a fallback, for example a paper receipt pad |
49
+ | `in_progress` error | Another process holds the `createOnce` lock for this order | Do not issue by hand; call `createOnce` again later (5xx in a webhook) |
50
+ | `store_unavailable` error | The lock store (or a `fail-closed` attempt ledger) is down; nothing was sent | Restore the store, or choose `lockFailure: 'proceed'` / `attemptLedgerMode: 'fail-open'` deliberately |
51
+ | Partial refund on a mixed-VAT invoice | `issueForPayment` returns `refund-proposal`, issues nothing | Pass `refundItems` (what was returned) or `partialRefund: 'proportional'` |
52
+ | `registerPayment` twice | Two payments on the invoice (`additive` defaults to `true`) | Use `registerPaymentOnce({ key })` with a stable bank or payment ID |
53
+ | `szamlaLetoltesPld` (PDF copies) | Obsolete in the XSD; Számlázz.hu ignores it, so kassza does not offer it | Nothing to do |
54
+ | `express.json()` before a webhook route | The raw body is gone, signatures cannot be verified | Mount the route before `express.json()`, or use `express.raw()`; see `kassza/node` |
36
55
 
37
56
  ## Error categories
38
57
 
@@ -47,6 +66,11 @@ Read this before writing any code that issues invoices or receipts. These rules
47
66
  | `not_found` | The document does not exist | No |
48
67
  | `partial_success` | The document exists, but a side effect (email) failed | No |
49
68
  | `maintenance` | Számlázz.hu maintenance (code 1) | kassza retries lookups automatically |
69
+ | `rate_limit` | Too many documents in the test account (code 167) | No, wait a few minutes |
70
+ | `attempt_limit` | The request already failed 5 times; kassza did not send it | No, a human must act, then `resetAttempts` |
71
+ | `in_progress` | Another process is issuing the same document (`createOnce` lock) | Later, by calling `createOnce` again |
72
+ | `store_unavailable` | A required store (lock, fail-closed ledger) is down; nothing was sent | After the store is back |
50
73
  | `network` / `timeout` | Transport failure; the outcome is unknown for writes | Look up by `orderNumber` before trying again |
51
74
  | `configuration` | Missing key, bad options | No |
52
75
  | `unexpected_response` | Számlázz.hu answered in an unknown format | Report it |
76
+ | `unknown` | An error code kassza does not know; the original message is kept | No, read `code` and `message` |