# Fakturownia integration reliability audit — 2026-09-11

## Scope and outcome

Reviewed payment capture and confirmation, deferred processing, buyer snapshots,
private/company client resolution, API transport, document creation/recovery,
dictionary rules, environment isolation, receipt administration and payment reports.
The audit was prompted by existing production documents displayed as failed issuance
with an issuance retry button. The fixes follow the test-next-to-production
deployment and verification process described below.

No production document, client card, account setting or database row was changed.
No fiscalization or email endpoint was invoked. Real API checks used only
`michal-machowski90.fakturownia.pl`, department `2050321`, synthetic buyers and
clearly marked TEST documents. Before the experiment the account UI showed no
default printer, API automatic fiscalization off and automatic e-receipt email off.
Paragony.pl was not activated.

## Findings and repairs

| Finding | Consequence | Repair |
| --- | --- | --- |
| Document ID was stored only after all content checks passed. | A created document appeared missing after a buyer-field discrepancy. | Persist the recognized document ID/number before content validation; retain its link in `document_review`. |
| The same retry action combined recovery with creation. | Operators could repeatedly run a failing validation while being told issuance failed. | Separate GET-only **Sprawdź dokument** from **Wystaw paragon**. Persist uncertain POST outcomes before calling the provider. |
| Validation required empty first name and a specific last-name layout, and rejected a different `client_id`. | Equivalent buyer data could produce an issuance error. | Validate the document's actual name, address, NIP, recipient and financial fields; ignore storage layout/card ID alone and normalize non-semantic formatting. Report the actual differing fields. |
| `buyer_override` alone did not reliably retain a renamed client's card. | Real API created/selected another card even though the supplied document buyer data was correct. | Explicitly update the selected card from the purchase before invoice POST, while retaining explicit document overrides. |
| Customer lock ended before document POST. | Concurrent buyers sharing a NIP could interleave card updates and creation. | Hold the account/company lock through lookup, updates and POST. |
| Company external identifiers exceeded the provider's persisted 30-character limit. | POST echoed the full key but later GET returned a truncated key; the next purchase created another company card. | Company lookup uses the full `tax_no` directly. New PROD external labels contain only NIP; DEV labels add a short environment marker. Reject oversized external labels before HTTP. |
| Company adoption depended on generic names/unclaimed external IDs. | An existing named company could be duplicated despite an identical NIP. | Within the permitted environment, full NIP is the sole company identity. Reuse the oldest full-NIP match, regardless of company name, email, RS account or external label. Do not merge/delete historical cards. |
| Fulfillment/donation checks ran before recovery. | A reversal or reclassification could hide a document created before a lost response. | Resolve possibly created documents first; still require eligible paid/fulfilled sales before every new create. Keep unresolved outcomes visible after payment changes. |
| Fake API echoed buyer data and unlimited external IDs without provider behavior. | Existing regression tests missed the two real API behaviors above. | Model silent key truncation and changed-name card selection; add recovery, mismatch, company lookup, locking and rendered-action tests. |

## Identity and update contract

- Private purchaser: the captured RS account ID; child purchases use the paying
  parent/account. Later edits do not change the frozen purchase snapshot.
- Company: **only the complete NIP**. It is not the RS account, purchaser name,
  company name, email, or `external_id`. A card whose NIP has been changed to another
  NIP represents a different company for lookup purposes.
- Names, recipient and optional address/note come from the purchase snapshot and
  update the selected card before a new document is created. Issued documents are
  never automatically rewritten when a profile is changed.
- DEV company lookup additionally restricts candidates to its environment marker;
  PROD excludes TEST cards. These markers enforce environment separation, not a
  second business identifier. Old truncated DEV markers remain recognizable only
  after the entire NIP matched.
- Several cards with the same full NIP identify the same company. Deterministically
  reuse the oldest matching card without merging records or creating another copy.
- Private external keys that already fit 30 characters remain unchanged. Longer
  keys use a bounded deterministic hash. Company processing does not depend on
  correctness of former external keys.

## State and recovery contract

| State | Meaning | Available operation |
| --- | --- | --- |
| `ready`, `preparation_failed` | No unresolved document POST; new creation still requires payment checks. | Issue after fixing any preparation error. |
| `creation_pending`, `creation_uncertain` | POST may have created a document; a crash or exception cannot prove absence. | GET-only lookup. Successful absence enables a separate issue action. |
| legacy `blocked` with payload and attempts | Previous release did not distinguish creation and validation failures. | GET-only lookup first. |
| `document_review` | A recognized document exists and is linked; content/read verification needs attention. | Open it in Fakturownia or refresh by ID. No new creation. |
| `issued` | Creation was confirmed and content checks passed. | Open document. Terminal for processing. |

Only a valid provider ID with the expected OID is linked. Amount, currency,
department, kind, recipient, NIP and buyer content checks happen after that link is
saved. Actual differences retain the link and report field names. A failing GET
cannot make an existing/uncertain document eligible for POST. Existing fiscalization
history remains terminal, and the application still exposes no fiscalization action.

## Verified unchanged rules

- One paid-payment event in `Payment::save`; P24, cash and bank-transfer callers do
  not each implement receipt creation. Uncommitted/rolled-back operations do not
  reach Fakturownia. API failures do not undo an accepted payment.
- P24 requires successful confirmation and fulfillment; retries use the captured
  amount, recipient, purchase items and selected NIP, not today's profile/prices.
- Donations, self-paid section donations, technical and zero-value settlements
  do not create new receipts. Traditional section transfers follow the dictionary.
- Unlinked payments require administrator preparation; a mere payment save sends
  no document. Manual items remain Getry/Odzież, quantity unit defaults to szt., VAT
  is fixed to ZW. Dictionary exclusions and mixed balance lines remain respected.
- Optional checkout NIP remains opt-in per purchase. Account company/NIP defaults
  do not silently convert a private purchase into a company purchase.
- Admin operations retain authorization, POST/CSRF and the common confirmation
  modal. Receipt links retain the saved account URL, pagination/filter boundaries
  remain enforced, and payment reports derive document existence from its saved ID.
- Local/test recipient routing, the feature flag, preview mode, account/instance
  isolation and provider-controlled fiscalization/delivery remain in place.

## Real API evidence

All document IDs below belong to the TEST account; no production document was used
for write testing. The experiments left 15 marked, non-fiscal test documents for
inspection. Returned `fiscal_status` was null.

- Documents 1–2 (`562067199`, `562067201`): supplying card `275325267` plus changed
  buyer fields and `buyer_override` yielded card `275325270`. Original card retained
  the old name/address. This reproduced provider-selected card replacement.
- Documents 3–4 (`562067392`, `562067395`): explicit PUT before POST kept card
  `275325267`, applied the address and then cleared it.
- Full model test, documents 5–8: parent identity, private rename/address removal
  and GET-only recovery worked. The shared-NIP assertion failed. Reading cards
  `275325599` and `275325601` showed the same external key truncated to 30 characters,
  despite POST having echoed the full value. This exposed a second integration bug.
- Documents 9–12 exercised the repair during investigation. They are test artifacts,
  not a separate supported implementation contract.
- Final company lookup test, documents 13–15 (`562069883`, `562069887`, `562069890`):
  two purchaser names under the same NIP reused card `275325601`; a different NIP
  used card `275325755`. The tests executed the actual `PaymentReceipt` model with
  connection-local temporary database tables. No persistent local payment/user
  records were inserted or changed.

The exact original differing field for production payments 74549/74552 was not
retrieved from their frozen production payload/API responses. Both documents were
previously verified as existing. The test reproduced defects that can explain such
errors, but does not prove which field triggered those two historical messages.

## Regression verification and rollout

Run from the repository in `web-rollschool`:

```sh
php tests/fakturownia_client_test.php
php tests/fakturownia_receipts_test.php
php tests/fakturownia_receipts_test.php --disabled
php tests/fakturownia_receipts_test.php --preview
php tests/receipt_administration_test.php
php tests/fakturownia_settings_test.php
php tests/przelewy24_registration_test.php
php tests/payment_admin_security_test.php
php tests/individual_lesson_instructor_workflow_test.php
php tests/gift_voucher_workflow_test.php
```

The main receipt suite also runs `fixtures/receipt_recovery_scenarios.php`.
Regression tests use temporary tables and fake HTTP; they do not contact the live
provider. No migration, server configuration change, worker or cron is required.
All new status values fit the existing `varchar(32)` column.

After deploying to test-next and verifying the register, use **Sprawdź dokument**
for historical blocked entries. It records an existing ID/number and clears obsolete
card/layout-only errors. If actual buyer/financial fields differ, the entry retains
the document link and identifies the fields to inspect. It never reissues, updates,
fiscalizes or resends a provider document. Production recovery should use this same
action after production deployment, without manual SQL.

Do not revert to the previous faulty processor with pending recovery states without
checking affected entries: the old code does not understand their action semantics.
No automatic schema rollback or historical document rewrite is involved.

Primary API contract: [Fakturownia API](https://github.com/fakturownia/API).
