# Fakturownia receipts

Rollschool creates receipts for newly paid sales, whether paid in cash, by
traditional bank transfer or through Przelewy24. Fakturownia controls fiscalization
and email delivery through its own settings and panel. Rollschool only creates
the purchase document; it never requests fiscalization or email delivery.
Document creation does not confirm that the printer completed fiscalization or
an email was sent. DEV can create test documents subject to the environment
safeguards described below.

## One payment event

`Payment::save()` is the central event source. After a successful save it calls
`PaymentReceipt::onPaymentSaved()`. There are no separate receipt handlers in
cash, bank-transfer or P24 controllers. Rental payment creation and gift-voucher
online confirmation also use the Payment model instead of bypassing it with SQL.
Zero-value, donation, technical and historical card/voucher payment types do
not represent a new sale for this integration.

The event uses the payment's PDO connection. When `Payment::save()` owns its
transaction, it commits the payment and loyalty writes before publishing the
receipt event. Receipt capture is therefore not atomic with that commit: a capture
failure can leave a saved payment without an obligation, visible in the receipt
register. When the caller already owns the transaction, payment and receipt
writes participate in that transaction and roll back together. The event schedules
a single attempt at the end of the current PHP request, after remaining purchase
assignments and activation have finished. Explicitly classified unlinked sales
also schedule this attempt and need no purchase assignment. There is no worker,
cron entry or separate process. The callback skips open transactions and reloads
committed payment/source state before any external call. Each API request has
an 8-second total timeout and 2-second connection timeout. A lookup plus create
can add up to about 16 seconds to the request. Account purchases and purchases with
a NIP also look up the provider client and may create/update its metadata (usually
another one to four requests).

A database record preserves unfinished work after a crash or API failure.
In **Lista paragonów**, **Sprawdź dokument** reconciles an uncertain result by
reading Fakturownia; **Wystaw paragon** creates a document only when no earlier
creation is unresolved. There is no scheduled retry. A repeated payment event
processes the existing record under the same safeguards. Errors never reverse a
recorded payment. A hard process failure after receipt capture but before the
attempt leaves that receipt for an administrator; failure before capture leaves
an uncaptured payment instead.

The model captures new manual payments when first saved as paid. It never creates
a receipt merely because an already-paid historical record was edited.
For P24, checkout additionally snapshots the resolved recipient email and exact
balance items before registering the payment. This is preparation, not a second
paid-payment event. Only the later successful payment save after signed, remote
P24 verification confirms the receipt. Online attempts started while integration
was off are not backfilled even if it is enabled before the notification arrives.

New positive cash/bank payments entered through the unlinked-payment form require
an explicit position selected by the cashier. The paid-payment event freezes a
ready receipt from that choice and queues creation in the same request, without
separate administrator approval. `payment.receipt_item_key` stores the category
and `payment.receipt_name` stores its configured description at entry. Neither
can be replaced after the payment is paid. Payments without a saved category,
including historical rows and temporary loose payments awaiting purchase
assignment, retain the `manual_required` path: assignment can resolve a purchase,
or an administrator can prepare **Wystaw paragon** from the receipt list. No
position is inferred from the comment or invented for an unclassified payment.

Donation exclusions take precedence over every dictionary policy:

- Any payment with `payment_type = TYPE_DONATION` is excluded, regardless of the
  linked purchase or whether the payment is unlinked.
- A P24 payment (`TYPE_ONLINE`) linked by `id_sekcja_period_payment` is a section
  donation, matching its existing P24 description, and is always excluded.
- Ordinary bank-transfer and cash section payments follow the section dictionary
  rule, just like other sales.

`PaymentReceipt::isDonationPayment()` is used by the central save/capture flow,
checkout notice/NIP, fulfillment and processing guards. New donations create no
receipt obligation and make no Fakturownia request. Historical obligations with
no unresolved creation are excluded on confirmation or processing; frozen payloads
remain intact. Existing provider documents and fiscalization history are preserved.
If a sale is reclassified as a donation after an uncertain creation attempt, the
register retains **Sprawdź dokument** for read-only reconciliation. It cannot create
another receipt. Other paid donations show **Bez paragonu**, without preparation
or issuance actions, and their unissued errors are excluded from the errors filter.
Direct manual preparation is also rejected by the model. The list SQL tests cover
the same rule.

P24 section registration retains the full `C<id> - darowizna na cele statutowe
Fundacji Rolki dla Każdego` transaction description and separately sends
`transferLabel = Darowizna statutowa` (19 characters). This registration rule works
with receipt integration enabled or disabled. Other purchase flows explicitly pass
their existing reference to `transferLabel`, independently of the full `description`:

| Purchase | Transfer label | Identifier source |
| --- | --- | --- |
| Balance | `S<id>` | Account ID from the balance checkout |
| Pass | `K<id>` | Pass ID |
| CLASSIC 4+4 | `B<id>` | Pass bundle ID |
| Group lesson | `Z<id>` | User booking ID |
| Individual lesson | `I<id>` | Individual lesson ID |
| Accelerated course | `P<id>` | User course registration ID |
| Rental | `R<id>` | Rental order ID |
| Gift voucher | `V<id>` | Gift voucher ID |

These references identify the purchase/account, not the separate payment ID sent
as P24 `sessionId`. Rental and voucher labels were already passed; the other
flows previously calculated a label without forwarding it to registration.
Individual-lesson and course references use uppercase `I` and `P` in both new
transfer labels and descriptions. Internal checkout route codes remain lowercase.
Confirmation loads the payment by `sessionId` and verifies `orderId`, amount,
currency and signature; it does not parse these display prefixes. The returned
`statement` is used as received when checking the signature, so historical lowercase
titles remain valid as well.
The [P24 API documentation](https://developers.przelewy24.pl/index.php)
(checked 2026-09-07) distinguishes `description` from `transferLabel`, limits the
latter to 20 characters, and explicitly describes overriding the title in its BLIK
section. The final display for a particular bank/payment method still needs a new
payment-flow check; sending the field alone is not proof of its bank-side display.
Existing registrations and completed bank transfers are not changed. No migration
or new configuration is required. `tests/przelewy24_registration_test.php` verifies
the section payload with integration enabled/disabled and preserves other transfer
labels with `--with-transfer-label`, using a fake P24 response.

## Controls and dictionary

**Ustawienia systemu → Integracja z Fakturownią** (`FACTUROWNIA_ENABLED`) is the
application switch, default off. It disables new capture and processing/retry,
retaining existing records. In-flight P24 confirmations remain recorded while
disabled; an administrator can retry them after re-enabling. No historical scan
or automatic backfill exists. No cron or additional server files are needed.

**Data początkowa integracji** (`FACTUROWNIA_START_DATE`) is a required date nested
under the integration switch in system settings. It sets the receipt register's
inclusive lower bound at `00:00:00`, using obligation creation time or payment time
when no obligation exists. Migration execution seeds the database's current date
once; later runs preserve an administrator's chosen value. The date does not move
forward each day. Administrators can move it backward to inspect older entries.
This is a visibility limit, not a capture/processing rule: changing it does not
create, delete or modify obligations or provider documents, and does not trigger
historical backfill. It applies even while the integration switch is off.

**Konfiguracja paragonów** (`zarzadzanie/paragony/`) contains the existing
configuration summary and editable dictionary. **Lista paragonów**
(`zarzadzanie/lista_paragonow/`) is a separate administrator-only page with
50 records per page and no record-count cap. Both pages link to each other.
In the administration menu, **Lista paragonów** appears under **Użytkownicy
i rozliczenia**, immediately after **Wpłaty**; **Konfiguracja paragonów** remains
under **Treści i konfiguracja**.
Within the configured start-date boundary, the list includes active receipt
obligations for paid payments and paid, active sale payments without an obligation.
Unpaid checkout attempts are excluded from results, searches, counts and pagination;
the same record becomes visible after payment confirmation. Existing provider
documents and unresolved creation attempts remain accessible if a payment is
subsequently marked unpaid or inactive, with a payment warning and no issuance
action. Historical inclusion in the list does not create documents.
Filters cover all records, preparation failures, uncertain creation outcomes,
documents requiring review and legacy errors (excluding unprepared unlinked
payments), confirmed payments without provider documents (including exclusions
and uncaptured sales, but excluding unresolved creation attempts), and issued
documents. There is no unpaid-attempt filter; old `pending_payment` filter
URLs fall back to all records without exposing unpaid attempts. Other purchases
(stored internally as unlinked payments) have two separate filters:
**Inne zakupy — do wystawienia** (`unlinked`) includes active, paid,
positive sale payments without a provider document, including uncaptured payments,
manual preparation and failed creation attempts. It excludes donations, technical
settlements, zero amounts and already issued receipts. The existing `unlinked` URL
now opens this work list. **Inne zakupy — z paragonem** (`unlinked_issued`) shows
paid unlinked payments with a provider document, including historical documents
for donations. Both filters check purchase references in both directions.
A provider ID, not a fiscalization/email status, defines an issued document:
after creation, the payment leaves the work list even if fiscalization is pending.
This is a query/UI change; it needs no migration and does not create or update receipts.

When this other-purchase work list contains any entries, the register shows a
warning alert directly below its heading with the total number of other purchases
awaiting a receipt. **Pokaż zakupy** opens
`zarzadzanie/lista_paragonow/?filter=unlinked`, clearing search, date and page
parameters so the entire work list is available. The alert count is independent
of the current status filter, search, date filters and page; it uses the same
eligibility rules as `unlinked` and retains the `FACTUROWNIA_START_DATE` boundary.
Uncertain creation attempts remain included for recovery with **Sprawdź dokument**;
the count does not assert that these documents are absent in Fakturownia. The
alert disappears at zero and remains available when the integration is disabled,
while issuance stays disabled. Reading the alert and following its link do not
change payments, receipt records or historical data and require no migration or
configuration change. With automatic initial entry, this remains a recovery
work list for historical unclassified payments, uncaptured payments and failed or
uncertain automatic attempts; it is not a required approval step for new sales.

Search accepts
a payment ID, recipient email fragment or NIP. Literal SQL wildcard characters
are escaped. Inclusive date filters use obligation creation time, falling back to
payment time for uncaptured payments. All status filters, searches, result counts
and pagination retain the configured start-date boundary. An earlier `date_from`
cannot bypass it; a later one narrows the range. The list displays the boundary
and links to its setting. Ordering is payment ID descending; pagination
and retries retain filters. Invalid dates are rejected; out-of-range pages clamp
to the last page. Dictionary writes and retries use POST, CSRF and administrator
authorization.

The register presents a short status, date of entry, payment method, recipient and
available action. Donations show **Bez paragonu** and their payment classification,
without describing dictionary precedence. The broad **Wpłaty bez paragonów** filter
explicitly includes payments that do not require a receipt; the unlinked work filter
remains the place to find historical issuance and failed-attempt recovery work.
Each row offers **Szczegóły wpłaty**
to inspect the payment and its comment. Creation actions read **Wystaw paragon** and are unavailable for unpaid, inactive,
zero-value or technical payments, donations and existing documents. **Sprawdź dokument**
only reads Fakturownia and updates local state. It is offered for uncertain creation
outcomes, legacy blocked attempts with a prepared payload, and existing documents
requiring review. A successful empty lookup enables a separate issuance action;
it never creates a document in the same refresh request. Recovery remains available
after a payment is reversed or reclassified. Unknown outcomes are not counted as
confirmed missing receipts by **Wpłaty bez paragonów**. Confirmation text explicitly
distinguishes these two actions and explains provider-controlled fiscalization/delivery.
Technical errors are available in an expandable detail. The page explains once
that fiscalization and delivery status are checked in Fakturownia; it does not
display an application fiscalization mode. Test origins remain visibly marked.
Disabling the integration shows one notice explaining why issuance is unavailable,
while document and payment links remain accessible. Empty states distinguish no
entries in the period from no matches for filters. Pagination displays the visible
range and uses non-interactive controls at its boundaries. No schema or environment
configuration change is required for this presentation.

The register retains the standard card body with separated rows, plain bold status
text and outline action buttons. It inherits the application theme colors; do not
replace the rows with list-group components or apply status background colors.
The theme's list-group text is white even in light mode, which makes receipt
metadata unreadable. Verify presentation changes in both light and dark modes.

The register joins a distinct set of reverse payment references once per query;
it does not repeatedly scan purchase tables for each historical payment.

The dictionary includes group lessons, individual lessons, accelerated courses,
equipment rental, group/individual gift vouchers, ordinary section fees, and only
these current pass products: CLASSIC 4, CLASSIC 4+4, FLEX 4 hours, FLEX 8 hours,
and FLEX Open. CLASSIC 4+4 is one receipt position, even though the application
stores its purchase as a `PassBundle` containing two CLASSIC passes. Its single
dictionary key is `pass_4hb`. Historical pass types are not editable sale rules.
The same dictionary also includes three unlinked-payment categories:

| Category | Canonical key | Initial receipt description |
| --- | --- | --- |
| Getry | `skate_covers` | Getry |
| Odzież | `unlinked_clothing` | Odzież |
| Inna opłata | `unlinked_other` | Opłata Rollschool |

These descriptions are migration defaults, not runtime fallbacks. Administrators
edit each description and quantity unit in **Konfiguracja paragonów**. The
category labels remain unchanged, so renaming a receipt description does not
change a category's identity. Both initial payment entry and historical receipt
preparation submit the canonical key and show the configured description.
`skate_covers` is shared by ordered skate covers and unlinked Getry payments.
The identifier migration converts the historical `unlinked_leggings` dictionary,
payment classifications and receipt-source item keys to `skate_covers`. Runtime
lookups accept only the canonical key. Prepared and issued invoice payloads are
not rewritten.

The administrator sets the receipt name, quantity unit and policy (`include`,
`exclude`, `pending`) for linked purchase categories. The three unlinked categories
always use `include`, displayed as **Wystawiaj paragon**; the model rejects
`exclude` or `pending` for them. This preserves automatic issuance for the
cashier's selection. VAT is fixed to **ZW** (`ReceiptItem::TAX_EXEMPT`, API value
`zw`) for every newly prepared dictionary or custom receipt. Both forms display
plain text instead of a VAT selector. Submitted VAT values are ignored; dictionary
saves, generated positions and new invoice payloads always use `zw`. The existing
`receipt_item.tax` column remains compatible with older code, but legacy values in
it do not determine the rate. The fixed VAT rule itself needs no schema migration.
Names use 1–100 plain-text characters.
Every dictionary entry initially uses `szt.`. The administrator can edit the unit
per entry in **Konfiguracja paragonów**; it is required and accepts 1–32 plain-text
characters. The chosen value is sent explicitly as each position's `quantity_unit`,
with quantity `1`, independently of the Fakturownia account's default. Both
automatically captured and administrator-prepared unlinked receipts use quantity
`1` and the selected category's configured unit.
`tests/fakturownia_receipts_test.php` verifies defaults, unit editing/validation,
provider payloads and preservation of prepared units after dictionary changes.
Unconfigured linked-purchase policies block only the document. Missing, inactive
or invalid unlinked dictionary entries instead block initial payment recording,
including when integration is disabled. There is no hardcoded description
fallback. **Opłata za sekcję sportową** keeps
the existing `section_donation` key for snapshot/configuration compatibility; the
old section pass source also maps to that rule. Its policy applies only to ordinary
section payments. Donation exclusions above cannot be overridden by the dictionary.
The original migration seeded this entry as excluded; set it to `include` through
the existing dictionary for ordinary section sales. The user already configured
test-next this way; local settings are adjusted directly without a migration.
Technical payments and voucher
redemption itself do not issue another receipt. The voucher purchase policy
remains to be decided in the dictionary.

The first preparation freezes names, quantity units, fixed ZW, department, recipient, payment method
and actual discounted gross amounts in `invoice_json`. Changing the dictionary
does not alter prepared or issued documents. A balance payment keeps the exact
individual group/solo line amounts captured before checkout. All source amounts
must sum to the payment amount; excluded lines are omitted from the receipt.
An entirely excluded purchase makes no API call.

A previously prepared invoice containing a non-ZW position is not rewritten or
sent on retry: processing stops with a specific review message before any provider
request. Already issued documents retain their recorded tax and provider history.

## E-receipt notice at P24 checkout

When integration is enabled, the shared payment confirmation form displays a
notice immediately before **Potwierdź opłatę**: pressing that button means consent
to an electronic fiscal receipt and email delivery of its link. It is shown for
every applicable purchase. There is no consent checkbox, hidden consent field, separate
acceptance action, consent validation, consent version or persisted consent record.
The payment form retains POST and scoped CSRF protection. There is no
paper-receipt option.

The notice shows the actual receipt recipient: the paying account on production,
the saved buyer address for guest vouchers, or the configured `MAIL_TO` address
on DEV. The recipient resolver is shared with receipt capture and P24 registration.
Its visibility follows dictionary policies for the actual purchase sources. Only
`exclude` hides it; `include`, `pending` and missing rules show it. For a balance,
one non-excluded line is enough. Zero payments and a disabled integration do not
show it. P24 section donations always omit the notice and NIP fields, even with
the section dictionary rule set to `include`.

The consent and agreed electronic delivery requirement follows Article 111(3a)(1)(b)
of the [VAT Act](https://eli.gov.pl/api/acts/DU/2025/775/text/U/D20250775Lj.pdf)
(consolidated text dated 2026-07-20, checked 2026-09-06). The notice tied to payment
confirmation is the chosen implementation, not a prescribed statutory wording.

## Buyer details and unlinked payments

The optional **Chcę podać numer NIP do paragonu** checkbox reveals a required NIP
field. This checkbox is unrelated to the receipt consent notice. It appears at
applicable P24 checkout and is also available on manual payment forms (cash,
bank transfer, rentals, vouchers and unlinked sale payments). Donation, technical
and legacy voucher payment types hide and disable receipt-only NIP controls; the
server ignores their submitted NIP before validation. A Polish NIP is normalized to ten
digits and its checksum is validated; spaces, hyphens and an optional PL prefix
are accepted. Invalid input stops registration/accounting and is retained for
correction. Zero payments and purchases entirely excluded by the dictionary do
not require NIP at P24 checkout.

`payment.buyer_tax_no` keeps the payment's tax number; `payment_receipt.buyer_tax_no`
freezes it for document preparation and retry. The provider receives `buyer_tax_no`
and `buyer_company=true` when a NIP was supplied. The company NIP is never enabled
just because it was saved on the account: every online purchase must explicitly
request it. A stored profile NIP is only an editable default in the unchecked
checkout control; old input from validation errors takes precedence. Guest voucher
links cannot disclose account billing data: only a logged-in purchaser whose ID
matches the voucher's account receives the default. Staff payment forms do not
prefill the staff member's NIP.

### Optional account billing details

`konto/moje_dane/` includes **Dane do dokumentów zakupu (opcjonalne)** with a
read-only summary and one **Edytuj** button to the existing account editor.
All profile sections sit in one card and use that editor with one **Zapisz**
button for the whole form. `user.billing_street`, `billing_city`, `billing_post_code`,
`billing_company_name` and `billing_tax_no` are nullable. They are separate from
the existing residence city/district. The address labels are **Adres**, **Kod pocztowy** and **Miejscowość**, in that order in both
self-service and administrator views.
The self-service profile always shows the billing section and all five fields,
using **Nie podano** for missing values so the account owner can discover and
complete them. Only the administrator's profile preview omits empty billing fields
and hides the entire section when none are filled. Field headings use the same
markup as the other profile details. All optional fields remain available in the editors.
The address line accepts a street and house/unit number or, without a street, a
locality and house number. The locality can be a city, town or village. Provider
field `buyer_street` still carries this address line; no street is required.
Address fields are individually optional;
company name and NIP are separated by an explanation. The form reminds the buyer
to select **Chcę podać numer NIP do paragonu** before every payment, otherwise the
receipt has no NIP and cannot be used to issue a company invoice.

`User::saveUser()` validates all billing fields before changing the model,
trims fields, validates and normalizes a non-empty Polish NIP and accepts clearing
all fields. The existing authenticated account-edit POST checks CSRF and saves only the
current account's allowed profile fields, independent of the integration switch. Errors preserve
safe form input. Account deactivation clears these fields along with other profile
data. No company name or NIP is mandatory solely because another optional field
was entered.

The administrator's user profile shows these same saved fields. Its account editor
(`zarzadzanie/edycja_konta_uzytkownika/<id>`) shares the billing field template and
normalizer with the self-service editor and saves them through
`User::saveUserByAdmin()`. Both editors use one form and one save button. The admin
writer requires administrator rights and preserves the existing target-role limit;
the controller retains its target-specific CSRF scope. Invalid data leaves stored
fields unchanged and remains visible for correction. For a child profile, the
editor links to the parent account's billing fields instead of storing unused
child billing data. Editing a profile never rewrites a captured or issued receipt.

### Private and company customers in Fakturownia

New account purchases freeze the buyer's RS account ID, name and optional billing
fields in `payment_receipt.customer_json` when captured, before any DEV email
override. They do not read current profile data again on retry. Ordinary P24
checkout uses the authenticated payer; manual payments for a child use the parent.
The account email must match the purchase recipient. Staff members, voucher
recipients and guest emails are never inferred as purchasers. Automatic unlinked
entry resolves the account/parent directly. Historical administrator preparation
only captures an account when its separately entered recipient matches that
account.

For private purchases, the provider document and client `name` contain the
purchaser's **first and last name**. The internal `shortcut` remains e.g.
`Anna Kowalska #12345`; its RS number does not appear in document fields. Without
an identified account the existing generic buyer fallback remains available.

Company purchases share a client identified by the **purchase NIP**, regardless
of the account or name. If that NIP matches the saved profile NIP and the company
name was supplied, `buyer_name` and the client shortcut use the **company name**.
Otherwise the document uses the NIP as its buyer name. A different NIP entered at
checkout never receives the company name associated with the profile's NIP.
The account purchaser's name is sent separately as `buyer_note`, so the shared
company name does not contain the personal name. Each purchase retains its own
recipient and NIP, including different accounts purchasing for the same company.

The account address is sent through `buyer_street`, `buyer_city` and
`buyer_post_code`. Newly created documents explicitly send empty address/note fields
when they were not filled: an earlier address or person from a shared provider
card must not be silently inherited. Before document creation, Rollschool explicitly
updates the selected client card's name, first/last name, company flag, email, NIP,
address and note from the frozen purchase. The document then also sends these
fields with `buyer_override=true`. The real API can select/create a different card
when only the invoice override is used after a name change; updating the card first
preserves its identity. The customer lock remains held through the invoice POST.
Card data is not the source of truth for a purchase. Guest company
purchases also send empty fields so they cannot inherit a previous buyer name or
address from the shared NIP client. Earlier issued
documents retain their frozen fields. These fields describe the provider's PDF
purchase document; this change does not add printer commands or change the fiscal
receipt generation mode.

**Provider print setting required:** before deploying the company-purchaser note,
check **Ustawienia → Ustawienia konta → Wydruki → Wstaw dodatkowy opis Nabywcy** on
the intended test account, and then on production. It must be enabled for
`buyer_note` (the purchaser's name on company documents) to appear on the PDF.
This is a Fakturownia account/template setting, not a `config.php` value. Check
an actual PDF for the selected template; sending `buyer_note` alone does not
prove its visibility. The application does not toggle the provider's print,
email or fiscalization settings. Record the previous setting to restore it if
rolling back. No local configuration keys or additional server files are needed.
Sources: [API buyer fields and override](https://github.com/fakturownia/API),
[buyer-note print configuration](https://pomoc.fakturownia.pl/1140990-Dodatkowy-opis-przy-Nabywcy-podczas-generowania-faktury).

Private purchases use an external identity derived from environment/host/instance
and the RS account ID. Existing keys of at most 30 characters remain unchanged;
longer private keys use a short deterministic hash. The API silently truncates
persisted client `external_id` to 30 characters, even when its POST response echoes
the full input; the HTTP client rejects oversized keys before making a request.

**The sole company identifier is the full purchase NIP.** Company lookup always
uses `tax_no`, with exact matching after removing formatting separators. The name,
email, RS account ID and `external_id` are not company identity criteria. Returned
NIPs are compared after removing formatting separators and an optional PL prefix.
For new
production company cards, `external_id` is simply the NIP. Existing company external
labels are left untouched, including truncated labels from the former implementation.
If the account contains several cards for one full NIP, reuse the oldest matching
card consistently. This does not merge cards, rewrite historical documents or create
another duplicate. A card whose NIP was changed to another number no longer identifies
the former company, irrespective of its old external label.

DEV labels documents/shortcuts with **TEST — <host>** and sends only `MAIL_TO`.
A company is still identified by NIP; a test-only environment marker restricts
eligible cards to the current host/instance. New markers contain the full NIP and
fit in 30 characters. Former full/truncated markers remain recognized, but only
after the full NIP matched. PROD excludes marked test cards. DEV never adopts
unmarked cards or cards from another test host. Fiscalization restrictions are unchanged.

For private clients the model searches by exact `external_id`; ambiguous matches
require review. Production may adopt one unclaimed generic legacy private card by
exact email and no NIP. Lost client-create responses are recovered on the next
attempt. Advisory locks serialize lookup, card updates and document POST per private
account or company NIP; competing buyers cannot interleave changes to a shared card.

Changed profile display fields do not block a purchase through comparison with old
card values: they are overwritten from the purchase. The returned document is checked
for meaningful differences in kind, currency, amount, department, recipient, NIP,
buyer name and explicitly supplied address/note. Whitespace, email case and NIP
formatting are normalized; first/last-name storage layout and a different provider
`client_id` alone are not evidence of a different document buyer. Real differences
retain the document ID/link and list the differing fields under `document_review`.
Post-creation checks cannot stop fiscalization triggered by Fakturownia itself.
The client shortcut is updated separately; a rejected, ignored or lost shortcut
update does not block creation, add a log/panel warning or change client identity.
A subsequent purchase retries an outdated shortcut. Completed documents are never
reissued just to repair a label.

Migration `20260910090000_add_user_billing_details.sql` adds only nullable user
columns and must run before the code. It is repeatable and compatible with the
previous release. Existing rows start empty. Old `customer_json` snapshots without
`billing` retain their former generic buyer name and are never enriched from
the current profile. Before a new provider create, missing address/note fields
are explicitly cleared to prevent inheritance from a shared card. Existing
issued documents remain unchanged. Recovery only links and verifies an existing document; it does not modify it. No historical backfill, bulk rename or document rewrite is performed.
Reverting the code can leave the optional columns in place.

Scheduling or editing an individual lesson for a person outside the system
requires a valid email, saved in `solo_event.receipt_email`, irrespective of the
integration switch. Registered participants still use their account/parent email.
The email is collected at scheduling, not in the payment form. Old guest lessons
without email must be completed in the lesson editor before a receipt-bearing
payment can be recorded. Voucher redemption for zero value is unaffected. Receipt
capture uses this stored email on production and the configured MAIL_TO on DEV;
subsequent lesson edits never rewrite a captured recipient.

### Initial other-purchase entry

The administrative interface calls this operation **Inny zakup**. Internally, an
unlinked payment has no purchase references in either direction, including
historical reverse-only links. Active instructors and administrators record new
payments through the existing `zarzadzanie/wplata_kwota/<id_user>/` form and
`Payment::recordUnlinkedPayment()`. The model checks the cashier's current
permissions, active participant, allowed payment type, valid amount and nonfuture
received date/time before saving. Instructors retain cash access; administrators
retain the existing wider manual payment types. The cashier comes from the
authenticated user, not submitted form data.

Positive cash and traditional bank-transfer entries require **Getry**, **Odzież**
or **Inna opłata**. The form submits `receipt_item_key`, restricted to the three
unlinked dictionary keys, and displays the selected category's configured receipt
description. **Inna opłata** initially prints **Opłata Rollschool**, which the
administrator can rename in the dictionary. Missing/arbitrary keys and missing,
inactive or invalid dictionary entries are rejected before payment creation.
`payment.receipt_item_key` preserves the category and `payment.receipt_name`
preserves its description at entry, including when receipt integration is
disabled. Neither value can change on an already-paid payment. Existing
`invoice_json` snapshots and retries retain the originally prepared description
and unit after dictionary edits.

The entry form shows the customer's saved account email, or the parent's email
when receiving payment for a child, and does not ask the cashier to type another
recipient. With integration enabled, the model validates that email and the
resolved delivery address before saving, as well as any requested NIP. DEV uses
configured `MAIL_TO` for delivery; production uses the account/parent address.
The internal payment comment is never used as the receipt description or copied
into the document payload. Validation errors retain safe form input, including
the selected position, date/time, comment and NIP.

The shared confirmation modal asks whether the entered
data are correct and warns: **Po zatwierdzeniu system niezwłocznie wystawi i wyśle
paragon fiskalny do klienta.** It asks the cashier to check amount, position, email
and any NIP; its buttons are **Potwierdź i zaksięguj wpłatę** and **Wróć i popraw
dane**. This message applies to positive cash/bank sales with integration enabled.
The integration-off message explicitly says the payment will be saved without
automatic receipt creation or delivery. Donation and technical entries
hide/disable the position and email context. A zero-value cash or bank-transfer
entry keeps that context visible but does not require a position. All three cases
use ordinary payment confirmation and do not create receipt obligations.
Switching the payment type does not clear a previously selected position.

For a classified new sale, the central save event captures a `ready` snapshot with
the selected configured position and unit, entire paid amount, recipient, optional NIP and
cashier (`id_user_who_prepared`). Processing is queued for the end of the same
request without another approval. Payment/receipt transaction boundaries and
failure recovery follow [One payment event](#one-payment-event). Fakturownia must
have both automatic API fiscalization and automatic e-receipt email delivery
enabled for this warning to describe the complete production flow; see
[Fiscalization managed by Fakturownia](#fiscalization-managed-by-fakturownia).

The disabled integration stores the position without capture or automatic
creation. Enabling the switch later or editing an already-paid record does not
backfill a document. The unlinked-payment migrations add the nullable name/key
columns and seed the three dictionary entries without inferring historical
choices; see [Server configuration and deployment](#server-configuration-and-deployment).

### Historical preparation and recovery

Only administrators can prepare an active, paid, positive unlinked sale through
`zarzadzanie/paragon_wystaw/<id_payment>/`. This remains the fallback for legacy
NULL-key payments and uncaptured/unprepared records. A captured unclassified
payment remains `manual_required` and is not queued for provider processing.
Direct retries stop before provider requests while it is still unlinked and no
payload is prepared. The list classifies older missing-purchase errors the same
way, hides their obsolete error and retry action, and excludes them from the
error filter. The register alert and **Pokaż wpłaty** retain this recovery work
alongside failed or uncertain automatic creation attempts. Assigning an
unclassified payment to a purchase can resume the central receipt flow.

The recovery form requires one of the same three category keys and the recipient
email, with optional NIP prefilled from the payment. It shows each category's
current configured description and uses its configured quantity unit. Empty,
arbitrary or non-unlinked keys and invalid dictionary entries are rejected before
receipt writes or provider calls. A valid selection survives validation errors.
Historical NULL-key payments are not backfilled from their old description;
already-captured invoices retain their original payload.
The form displays **Komentarz do wpłaty** immediately above the position
selector, using the current saved `payment.comment` as read-only context for the
administrator. The text is HTML-escaped, preserves line breaks and wraps long
words; a missing or whitespace-only comment displays **Brak komentarza.** This
also applies to historical payments and to redisplaying the form after validation
errors. The comment is not copied into the receipt position, its snapshot or the
provider payload, and opening the form does not modify the payment. The
**Szczegóły wpłaty** link remains available for inspecting the original record.
VAT is always ZW. Quantity is one and gross amount is the entire
recorded payment; the form cannot change it. The source snapshot identifies the
payment directly and `id_user_who_prepared` records the administrator. A payment
row lock and the processor's existing advisory lock protect preparation;
prepared/issued documents cannot be overwritten by repeated submissions. The
same process/retry code and environment guards apply. A custom receipt can
coexist with a later assignment of that same payment
without generating a second document. Fiscalization/email remain provider steps.

The administrative payment report adds an accessible receipt/no-receipt icon
linking to the filtered receipt list. It indicates provider document creation,
not fiscalization or email delivery. A payment with a stored unlinked receipt
category is shown as **Inny zakup** with a link to payment details. The red
classification warning is limited to historical positive cash/bank payments
without a purchase, settlement or stored category; donations, technical entries
and zero-value payments are not warnings. Excel includes the same issued flag and
provider link. No payment totals or report eligibility rules change.

Migration `20260907002000_add_receipt_buyer_details.sql` adds nullable email,
NIP and preparation-author columns. It performs no backfill, is repeatable and
needs no new configuration or external files. Apply through the standard backup
and migration runner before serving the new code. Code rollback can leave these
additive columns in place; existing capture data remains intact.

Verification: `tests/receipt_administration_test.php` exercises scheduling,
automatic initial unlinked-payment capture for all three positions, legacy
admin-only preparation, rejection of names outside the list, duplicate prevention and
123-row pagination/filtering with temporary tables and a fake provider.
`tests/przelewy24_registration_test.php valid --with-nip` checks the actual
registration/capture path; `--invalid-nip` rejects the request before HTTP and
`--invalid-nip --excluded` verifies dictionary exclusion. Confirm the checkbox
shows/hides its input and that validation returns to the payment form retaining
the NIP and discount data. Provider field contract:
[Fakturownia API](https://github.com/fakturownia/API).

The September 2026 reliability audit and real API evidence are recorded in
[receipt integration audit](fakturownia-audit-20260911.md).

## Fulfillment, duplicates and dates

Before preparation, the payment must be active, paid, a supported sale payment,
and have the captured amount. A classified unlinked receipt uses the payment as
its source and requires no separate purchase. For linked receipts, every purchase
must be assigned to that payment;
passes and course registrations must be active. Both component passes must be
activated and linked for CLASSIC 4+4. A gift voucher must have completed its code
issuance. Incomplete fulfillment blocks the document even when `is_paid=1`.

A MySQL advisory lock serializes processing per receipt. Before creating, the
client searches by OID; `oid_unique=yes` also protects a timeout or crash after
provider creation. Before POST the model persists `creation_pending` and its exact
payload. A failed/interrupted POST results in `creation_uncertain`; subsequent
processing first performs GET-only reconciliation. A failure before the invoice
POST is `preparation_failed`, so an operator can correct the cause and issue again.
Old `blocked` rows with a payload and recorded attempts are treated as uncertain.

Once a response/recovered document has a valid ID and the matching OID, its ID and
number are saved **before** checking content. A content mismatch becomes
`document_review`, never a missing document. Refresh uses the saved ID where known,
otherwise OID, and never calls POST/PUT, fiscalization or delivery endpoints. It can
clear the review state after a provider-side correction. A failed GET preserves
uncertainty/existence and cannot authorize another create. A successful empty lookup
only makes an eligible purchase `ready` for a separate issuance request.

Reconciliation precedes current payment/fulfillment guards for possibly created
documents: refunds, zeroing a payment, changing its type or unlinking a purchase
cannot hide a document created earlier. These guards still apply before any new
creation. Legacy fiscalization modes do not control processing; `issued` is terminal. Corrections,
refunds, changes to already-receipted payments and invoice conversion require
operator handling in Fakturownia; changing a payment does not update its receipt.

Fakturownia rejects an OID longer than 40 characters. New OIDs combine the
environment prefix, the first 16 hexadecimal characters of SHA-256 of the stable
instance key, and the payment ID. This fits the limit even with the longest
configured instance key and maximum signed MySQL INT payment ID. The client
checks the limit before creating a document.

For already blocked receipts with the exact previous oversized OID format,
retry first looks up the old OID. An existing document is recovered unchanged.
Only if absent is the frozen OID replaced with the short form; the short OID is
then looked up before creation, also protecting recovery after a lost response.
Every other frozen invoice field is preserved. Issued documents, arbitrary OIDs
and previous OIDs of at most 40 characters remain unchanged. This repair happens
through normal administrator retry; it needs no migration or manual SQL.

API errors expose bounded validation details from JSON `message`, `errors` or
`error` fields, including the affected field name. Tokens and email addresses are
redacted; raw provider response bodies and HTML error pages are not logged or
displayed. Transport failures remain distinguishable from provider rejection.

Cash uses provider payment method `cash`; P24 and bank transfer use `transfer`.
For manual entries, the saved payment time supplies the sale/paid date, including
a backdated cash date accepted by the existing form. P24 uses confirmation time.
Issue date is the date of document preparation. Retrying a prepared document
preserves all dates and values.

## Fiscalization managed by Fakturownia

Rollschool has no fiscalization mode setting and makes no `/invoices/fiscal_print`
request. It creates `kind=receipt` documents and finishes at `issued`, regardless
of any previously saved `manual` or `automatic` value. The integration switch and
start date remain in **Ustawienia systemu → Funkcje opcjonalne → Integracja
z Fakturownią**.

Fakturownia's **Automatyczna fiskalizacja paragonów po utworzeniu przez API**
decides whether document creation triggers fiscalization. Printer selection and
e-receipt handling also belong to Fakturownia and its printer module. Production
does not require this automation to be disabled. Rollschool neither reads nor
changes those settings and cannot override or cancel provider automation.
Disabling the Rollschool integration stops creation of further documents; it does
not cancel documents or printer work already queued in Fakturownia.

Email delivery is a separate Fakturownia setting. **Automatyczne wysyłanie
e-paragonów emailem** can send the generated e-receipt to the supplied recipient;
otherwise an operator requests delivery there. Rollschool does not infer
fiscalization or delivery completion from successful document creation. There is
no completion polling or webhook.

Before relying on the fully automatic unlinked-payment flow in production,
enable and verify both provider settings with the intended printer and e-receipt
setup. These are operational prerequisites for automatic issue-and-send behavior;
the payment form does not inspect or change them. The implementation does not
alter provider settings or add a new Rollschool configuration option. Verify
document creation safely on test-next first; DEV printer and email safeguards
remain applicable and must not be bypassed to test the confirmation wording.

The retired `FACTUROWNIA_FISCALIZATION_MODE` database setting and legacy
`payment_receipt.fiscalization_mode` column remain unused for compatibility;
neither can block processing or request fiscalization. Historical attempt
timestamps and `fiscalization_requested` / `fiscalization_review` outcomes remain
unchanged and terminal. Review entries still direct an operator to Fakturownia.
Already issued documents are not resubmitted, and no historical payments are
backfilled by this change.

Retiring application-managed fiscalization needs no additional migration,
production configuration edit or external file operation. A legacy
`FACTUROWNIA_FISCALIZATION_MODE` constant in the
Git-ignored `application/config/config.php` is ignored. The existing
`FACTUROWNIA_MANUAL_FISCALIZATION_CONFIRMED_FOR` connection safeguard is now
required only on DEV and ignored on production; see the configuration section.
The removal does not alter Fakturownia settings or existing documents.

Deploy and verify on test-next before production. Check that the mode selector and
per-document mode labels are absent, while the integration switch and date still
work. Use fake-provider tests to verify creation, recovery and historical modes
without fiscalization calls. Before a code rollback, disable the integration and
reconcile provider automation with the older release's saved mode: that release
may resume explicit fiscalization requests from a retained `automatic` setting.

## Recipients and environment separation

On production, the recipient is the account email. For manual payments for a
child this resolves to the parent's account; P24 retains the paying account
captured at checkout. Guest vouchers use the order's buyer email.

On DEV (local and test-next), receipt capture, the P24 registration email and
outgoing application emails use the same `MAIL_TO` setting. The address is
configured exclusively in the server-owned `application/config/config.php`
before database settings load. The administration panel and database do not
control it. See [environment-specific mail and SMS configuration](test-email-routing.md)
for required constants, environment templates, and deployment order.
No administrator session is required, including guest purchases and impersonation.
There is no fallback to a copied customer address. Empty or invalid `MAIL_TO`
stops P24 registration before the HTTP request and shows the exact setting to fix.
The configured email is captured with the purchase; changing it does not rewrite
existing receipts. The older `id_testing_user` column remains for compatibility
but new records do not populate it. No schema migration is needed for this change.

`ENVIRONMENT` and `APP_PUBLIC_URL` are server settings, never the request Host
header. Production requires PROD and `rollschool.pl`. Account environment must
be `production` on PROD, and `test` on DEV. Use separate Fakturownia accounts for
real and test data, and separate tokens for local, test-next and production.
A token is bound to the account where it was created: a token labelled production
on the current account will not authorize a different future production account.

DEV documents use OID `TEST-<instance-hash>-<payment>`, buyer name
`TEST — <origin host>` and a visible `DOKUMENT TESTOWY` description identifying the
origin and absence of real sale. Production uses `ROLLSCHOOL-...` and a normal
payment description. The oversized legacy OID repair is described above.

A receipt must match the current origin, environment, account URL, instance key
and integration mode. A copied production database cannot replay its receipts on
DEV. Keep instance keys stable; give a cloned database a
new key. `preview` is an optional DEV-only local preview without API calls.
Preview records cannot be promoted to real documents by changing configuration.

## Server configuration and deployment

Apply the tracked migrations through the backed-up deployment runner:

- `database/migrations/20260906183000_add_fakturownia_receipts.sql` creates receipt
  tables, initial dictionary and the disabled switch.
- `database/migrations/20260906223000_limit_receipt_dictionary_to_current_products.sql`
  deactivates obsolete/duplicate dictionary entries, retaining their data.
- `database/migrations/20260906233000_add_receipt_fiscalization_setting.sql` historically
  added the now-retired panel setting and durable fiscalization attempt timestamp.
  The migration remains immutable; historical metadata is retained.
- `database/migrations/20260907002000_add_receipt_buyer_details.sql` adds the guest
  lesson email, payment/receipt NIP and custom receipt author, without backfilling history.
- `database/migrations/20260907003000_add_receipt_quantity_unit.sql` adds
  `receipt_item.quantity_unit` with default `szt.` for existing and new dictionary
  entries. Replay preserves subsequent administrator edits. Apply before the new
  code; no server configuration or file operations are needed. Prepared and issued
  receipt payloads are not backfilled. A code rollback can retain the column.
- `database/migrations/20260907004000_add_receipt_integration_start_date.sql` adds
  the required `FACTUROWNIA_START_DATE` database setting. Run before deploying the
  code, through the backed-up migration runner. On each environment, its default
  is `CURRENT_DATE()` at migration execution, interpreted by the list as midnight.
  No `config.php` entry, secret or server file operation is required. Verify the
  populated date under the integration switch and the same boundary on the list.
  A code rollback can retain the extra setting; migration replay preserves edits.
- `database/migrations/20260908120000_add_receipt_customer_snapshot.sql` adds the
  nullable private buyer snapshot. Apply before the new code. It is repeatable,
  preserves all existing rows and requires no new configuration, token or server
  file operation. Code rollback can leave the extra column in place.
- `database/migrations/20260915120000_add_unlinked_payment_receipt_name.sql` adds
  nullable `payment.receipt_name VARCHAR(100)` for the description captured at
  initial entry. This migration has already run locally and remains unchanged.
- `database/migrations/20260915130000_configure_unlinked_receipt_items.sql` adds
  nullable `payment.receipt_item_key` and seeds the three unlinked dictionary
  rows with policy `include`, unit `szt.` and the default descriptions listed
  above. Replay preserves existing dictionary edits. No historical payment
  classifications or prepared/issued receipt payloads are backfilled or rewritten.
  Apply both unlinked-payment migrations in order through the standard backed-up
  runner before serving the new code, first on test-next and then production.
  Verify all three editable descriptions and units in **Konfiguracja paragonów**
  and in both position-selection forms. No external file operation or environment
  configuration edit is required; code rollback may retain the added columns,
  dictionary rows and saved values.
- `database/migrations/20260915230000_add_skate_covers_receipt_key.sql` renames
  the Getry dictionary key while preserving its ID, configuration and audit
  fields. It also converts `payment.receipt_item_key` and top-level source
  `item_key` fields in `payment_receipt.source_json`. Quantities, prices, other
  source fields and `invoice_json` are preserved; source JSON formatting may
  change. If neither dictionary key exists, defaults are inserted. The
  transactional migration is repeatable and stops before writes on conflicting
  dictionary rows, malformed legacy JSON or multiple cover items in one source.
  No legacy alias remains in the runtime or migrated identifier fields.
  Apply before serving the new code, suspending writers that still use the old
  key. A code-only rollback to an old-key version is not compatible with the
  converted data: use a reviewed inverse key conversion or a compatible runtime.
  Never change prepared/issued invoice payloads to perform a rollback.

Previously deployed migrations remain immutable. The unused retry-time column
and index are retained for compatibility; processing runs at the end of the
payment request, without a scheduled worker.

In the server-owned, Git-ignored `application/config/config.php` PHP block, before
loading database settings, configure the following. Do not store secrets in Git.

```php
if (!defined('FACTUROWNIA_MODE')) define('FACTUROWNIA_MODE', 'documents'); // off | preview (DEV) | documents
if (!defined('FACTUROWNIA_ACCOUNT_URL')) define('FACTUROWNIA_ACCOUNT_URL', 'https://YOUR_ACCOUNT.fakturownia.pl');
if (!defined('FACTUROWNIA_ACCOUNT_ENVIRONMENT')) define('FACTUROWNIA_ACCOUNT_ENVIRONMENT', 'test'); // production on PROD
if (!defined('FACTUROWNIA_API_TOKEN')) define('FACTUROWNIA_API_TOKEN', 'YOUR_PRIVATE_TOKEN');
if (!defined('FACTUROWNIA_DEPARTMENT_ID')) define('FACTUROWNIA_DEPARTMENT_ID', 123);
if (!defined('FACTUROWNIA_INSTANCE_KEY')) define('FACTUROWNIA_INSTANCE_KEY', 'YOUR_UNIQUE_STABLE_KEY');
// DEV only; not required on production.
if (!defined('FACTUROWNIA_MANUAL_FISCALIZATION_CONFIRMED_FOR')) define('FACTUROWNIA_MANUAL_FISCALIZATION_CONFIRMED_FOR', 'https://YOUR_ACCOUNT.fakturownia.pl');
```

Instance keys contain 8–64 ASCII letters/digits/underscore/hyphen; use distinct
local-, test-next- and production- prefixes plus unique suffixes. The panel owns
`FACTUROWNIA_ENABLED` and `FACTUROWNIA_START_DATE`; do not define either in this
file. They are loaded from `application_setting` during normal startup. The
retired `FACTUROWNIA_FISCALIZATION_MODE` is ignored even if still defined.

On DEV, `FACTUROWNIA_MANUAL_FISCALIZATION_CONFIRMED_FOR` must equal the exact
account URL and records the operator's confirmation that **Automatyczna fiskalizacja
paragonów po utworzeniu przez API** is disabled on that test account. It does not
require disabling email delivery. Changing test accounts requires a new
confirmation. Production ignores this constant and follows Fakturownia's settings.

This is an operator assertion, not verification of the current provider setting:
an authenticated `/account.json` check on 2026-09-06 exposed neither automation
flag. Rollschool cannot detect later provider changes. Local and test-next must
use an isolated test account without a real fiscal printer, or DEV `preview`
mode with no API calls. A technological printer can be used for agreed test
scenarios. A test label or the confirmation string alone cannot make a physical
printer non-fiscal or prevent provider-side automation.

Historical setup note (not a statement of current settings): on 2026-09-06
automatic API fiscalization was disabled and saved on
`https://fundacja-rolki-dla-kazdego.fakturownia.pl`; email automation was already off.
Authenticated read-only API preflight identified department 212458. Separate
local/test-next/production-labelled tokens were created on this account; their
private backup is outside the repository. No real fiscalization was performed.

### Separate account for local and test-next

From 2026-09-10, local and test-next use
`https://michal-machowski90.fakturownia.pl` (account ID `2757535`), with separate
API tokens and department `2050321`, named `TEST — Fundacja Rolki dla każdego`.
The seller name, tax number and address were copied from the foundation at the
owner's request; the test department uses ZW. Production retains its own account
and configuration.

Replace the existing `FACTUROWNIA_ACCOUNT_URL`, `FACTUROWNIA_API_TOKEN`,
`FACTUROWNIA_DEPARTMENT_ID` and `FACTUROWNIA_MANUAL_FISCALIZATION_CONFIRMED_FOR`
definitions in the ignored `application/config/config.php` before loading database
settings. Use the URL above for both URL settings, department `2050321`, and the
token assigned to that environment. Keep `FACTUROWNIA_ACCOUNT_ENVIRONMENT=test`,
`FACTUROWNIA_MODE=documents`, the existing stable `FACTUROWNIA_INSTANCE_KEY`, and
the environment's `MAIL_TO`. Replace definitions instead of appending duplicates.
The local configuration is updated directly; test-next requires this server-side
configuration replacement. No SQL migration or application deployment is required.

The private token backup is
`/Users/radek/.config/rollschool/fakturownia-tokens.json` (mode `0600`, outside Git).
It contains only the new local/test-next credentials; the owner revoked the old
test credentials, and their saved records were removed. A ready test-next PHP
fragment is stored alongside it as `fakturownia-test-next-config.php`, also mode
`0600`. Never copy either private file into tracked documentation or deployment
artifacts.

On the new account, receipt documents and e-receipts are enabled; automatic API
fiscalization is disabled. No fiscal printer is connected. This allows testing
document creation without a fiscal operation. Actual electronic fiscal receipt
generation requires an active Paragony.pl entitlement and a technological printer
connected specifically to this test account. Do not connect the production printer.
When testing manual issuance later, select the e-receipt action in Fakturownia.
The offered free Paragony.pl trial was not activated, per the owner's decision.
The e-receipt feature checkbox alone does not activate that entitlement. Automatic
e-receipt email delivery is also disabled on this account. Current verification
covers both token permissions and a read-only request through the configured PHP
client; no document was created and no fiscalization was requested during setup.

Verify each token with an authenticated read-only `GET /departments.json`, checking
department `2050321`; validate the local configuration and inspect the saved
provider settings. Test using a new payment after the account change. Historical
receipt rows keep their original account URL and document links; the existing
instance check prevents retrying them against the new account. Do not rewrite
historical rows or replay old payments. If setup must be paused, disable the
integration switch or use DEV `preview`; the revoked old test credentials are
not a usable rollback.

Deploy and verify test-next first: configure its token/account/department and
provider settings, set a valid `MAIL_TO` in its ignored `application/config/config.php`, complete sale
rules, then enable the panel switch and test. VAT is fixed to ZW in code. The same `MAIL_TO` requirement
applies locally. On production this test recipient is ignored. Verify the address
shown at checkout and in a newly captured receipt; no secret needs to be printed.
On rollback, retain a valid `MAIL_TO` because the earlier P24 DEV code also uses it.
Promote the reviewed code and production configuration only after test acceptance.
There are no `Files` assets, worker scripts or cron entries to install. Rollback:
disable integration first, retain the additive tables and all provider documents,
and restore the prior code/configuration. Disabling cannot cancel requests already
sent to Fakturownia; an offline printer may process them after reconnecting. Do not
delete accounting documents.

## Verification

The receipt test also runs `tests/fixtures/receipt_customer_scenarios.php` and
`tests/fixtures/receipt_billing_scenarios.php` against
temporary tables and a fake API. It covers legacy client adoption, repeat purchases,
name/email changes, corrections of the RS surname, edits to the linked Fakturownia
profile, frozen buyer identity, parent vs. actual payer, guests, and alternating
private/company purchases. Different NIPs use distinct clients; the same NIP
across different accounts and guests reuses one company client. Tests preserve
each purchase's recipient, private client and earlier documents. They also cover
legacy NIP adoption across email changes, shared NIP locking through document
creation, lookup by full NIP independently of external ID, recovery after a later
purchase, lost client-create responses, duplicate external IDs and review of
returned buyer data that differs from the frozen purchase. A company card whose
NIP has changed no longer identifies the original company; an old external ID
cannot override that decision. An incorrect NIP on a returned document retains
the document link and requires review, without offering issuance again.
Validation after creation does not control provider fiscalization.
Shortcut failure cases (rejection, ignored value, lost response and wrong response
ID) cover existing, legacy and new clients, including retained automatic-mode metadata.
All still create one receipt, retain the correct client ID and permit the next
purchase to repair the label without duplicating the customer. Billing cases cover
validation and atomic saving, clearing/partial addresses, frozen name/address/company
on delayed processing, different checkout NIPs, private purchases with a saved
company, client names/shortcuts, legacy payloads, and unchecked NIP defaults versus
user-entered retry values. These fake-transport tests are not evidence of the
actual provider template or fiscal printer output.
Local and test-next identities remain separate even with the same test email or
copied instance key. Before promotion, check private and NIP purchases using the
actual Fakturownia API, without fiscalization: repeat a private purchase, use two
NIPs from that account, use the first NIP from another account/guest, then make
another private purchase. Check client IDs, shortcuts, purchase recipients and
unchanged earlier documents. Private shortcuts show the TEST origin/name/RS ID;
company shortcuts show the TEST origin and the stored company name when it matches
the purchase NIP, falling back to the NIP. Document buyers retain the TEST label.
Check the document preview and e-receipt display without requesting fiscalization
from either DEV environment. Provider rendering is not simulated by the PHP tests.

Payment-writing tests (`fakturownia_receipts_test.php`,
`receipt_administration_test.php`, and `przelewy24_registration_test.php`)
require `RP_TEST_DATABASE=codex_rollpoints_<name>` naming an isolated DEV schema
prepared with `scripts/prepare-rollpoints-tests.php`. The shared test bootstrap
loads database settings, including pass prices, and redirects default model
connections to that schema. Their fixture connections also shadow every
RollPoints table, so payment hooks cannot leave events in the underlying schema.
Verify this with `tests/payment_test_isolation_test.php` on the same isolated DB.
`php tests/other_purchase_form_test.php` separately checks the initial category
visibility, positive-amount requirement, payment-type switching and the public
**Inny zakup** heading without accessing the database.

`php tests/fakturownia_receipts_test.php` uses connection-local temporary MySQL
tables and a fake provider. `--disabled` checks the switch; `--preview` ensures no
API calls. `php tests/fakturownia_settings_test.php` checks removal of the mode
selector on DEV and production, ignored legacy form values, atomic form validation
and database-backed runtime loading using a
temporary settings table. Tests cover migration replay, dictionary, current products, central
payment events, transaction rollback, all sale methods, fulfillment, balance
amounts, recipients, environment isolation, duplicate prevention and recovery
from a lost create response. They require local DEV `rollschool.localhost`.
The default run also checks the payment notice, the actual recipient, dictionary
changes, mixed-balance exclusions, editable section policy and zero payments. It
checks that the form contains neither a consent checkbox nor hidden consent fields. The
disabled run verifies that integration-off hides the notice.

The settings test also checks start-date seeding, replay preservation, date-field
nesting and atomic rejection of empty, impossible or malformed dates.
`php tests/receipt_administration_test.php` verifies the inclusive midnight
boundary, exclusion of older payments and obligations across all filters and
searches, and consistent pagination/counts. In the local/test admin UI, verify
that changing the start date changes list visibility on the next request without
creating a receipt; returning the date to its earlier value reveals older entries.
Check that the unlinked-payment alert retains its global eligible count after
applying an unrelated filter, search, date range or page, and that **Pokaż wpłaty**
opens the full unlinked work list within the integration start date. Confirm the
alert is absent when that count is zero and is still visible with the integration
disabled. In the legacy administrator preparation form, verify that a saved
multiline comment appears above
the position selector, HTML is displayed as text and an empty comment has the
fallback message. Validation errors must retain the saved comment and the chosen
position; the resulting provider payload must contain only the selected position
name, with no payment comment.

For new unlinked entries, test all three position choices, an instructor's cash
payment, an administrator's bank transfer, parent/child recipient resolution and
optional NIP. Each positive sale must store its stable key and configured name
and queue a ready snapshot without opening administrator preparation. Rename
each description and change its unit through **Konfiguracja paragonów**; new
payments and historical preparation must use those values, while prepared
snapshots and retries retain the earlier values. **Inna opłata** initially uses
**Opłata Rollschool**. Confirm dictionary saves reject `exclude`/`pending` for
these categories, while linked categories retain their existing policy choices.
Replay the new seed after editing a row and verify the edits survive. Reject
missing/arbitrary/non-unlinked keys, inactive/missing/invalid dictionary entries,
malformed amounts/dates/recipient/NIP, inactive participants/cashiers and payment
types unavailable to the cashier before saving. Check the confirmation warning,
both modal buttons and retained form values after validation errors. Changing
to donation, technical or zero-value payment must hide and disable receipt
controls and switch to ordinary payment confirmation. Integration-off entries
must retain the choice, explain that no automatic receipt will be issued, and
produce no capture or provider call. Enabling integration and editing that paid
payment must not backfill a receipt or change the stored key/name. Verify caller
transaction rollback leaves no provider calls, repeated payment events retain
one frozen receipt, and a provider failure leaves the payment recorded with
recoverable receipt state. Historical NULL-key rows must still allow the
administrator fallback.

For the Getry key migration, test a customized legacy configuration, an already
configured canonical row, an empty dictionary and repeated execution. Rename the
existing dictionary row while preserving its ID, settings and audit fields;
convert payment classifications and matching source item keys to `skate_covers`.
Source JSON formatting may change, but quantities, amounts, item order and other
values must remain intact. Keep `invoice_json` byte-for-byte unchanged, including
retries of already prepared receipts. Reject the old key at runtime. Conflicting
dictionary rows, malformed legacy JSON and multiple matching source entries must
stop the migration before updates without discarding data.

Fake-provider tests verify that creation, recovery after a lost create response,
repeat processing, legacy manual/automatic values and historical fiscalization
outcomes never request fiscalization. They also verify that production no longer
requires the DEV account confirmation. These tests do not call the connected
Fakturownia account or printer.

The receipt tests also cover donation exclusion for every purchase category and
unlinked payments, regular section bank transfers, legacy donation retries, fixed
ZW despite tampered form values or stale dictionary taxes, and blocked non-ZW
prepared snapshots. `php tests/przelewy24_registration_test.php valid --section
--invalid-nip` verifies successful P24 registration for a section donation without
creating a receipt or requiring receipt-only NIP. In the manual payment form,
switching to **Darowizna** must hide/disable the NIP controls; switching back to
ordinary bank transfer restores them.

Zero-value payments in every supported channel and technical settlements never
capture a receipt. Tests exercise the actual 100% promo-code calculation for group
and individual lessons and both pass purchase paths, then the central paid-payment
event. They also verify that even a previously prepared receipt cannot be sent
after its payment becomes zero or technical. No provider request is made in these
cases; free checkout returns after its technical settlement before P24 registration.

On test-next: configure a rule, complete P24 and record cash/bank payments for it,
including a parent's purchase, impersonation and a guest voucher. Verify one TEST
receipt per sale, correct gross amount/method/date/recipient, and no fiscalization
or email requests from Rollschool. Check donation exclusion and an incomplete purchase. Retry after a
provider failure and after replaying a payment event: there must be one document.
Disable the switch and confirm no new capture or creates; retry existing records
manually after re-enabling. Inspect the test printer settings separately before
any human fiscalization experiment.
For P24, verify the notice email matches the configured `MAIL_TO`. Set every
source to `exclude` and verify no notice appears; with a mixed balance, one
included source must restore it. The notice has no separate acceptance control.

`php tests/przelewy24_registration_test.php valid` exercises the actual P24
registration method with a fake HTTP response and temporary payment/receipt
tables. Cases `false`, `empty` and `invalid` verify that a missing address never
reaches P24 and produces a configuration error. `--disabled` repeats the check
with receipts disabled. The valid case verifies that P24 and the captured receipt
share one configured address without an administrator session and retain the
gross amount. Production recipient resolution is checked independently.

`php tests/fakturownia_client_test.php` checks the 40-character boundary and the
actual HTTP 422 validation response structure. Receipt workflow tests cover
maximum-length keys/IDs, recovery of old OIDs, repairing missing legacy documents
and losing a creation response during repair. No fiscalization request is used.
On 2026-09-06, the test account accepted an unfiscalized diagnostic receipt for
PLN 55 with `tax=zw`, while the original test-next OID was rejected with the exact
40-character validation error. The tax exemption was not the cause of that failure.

Official contracts: [Fakturownia API](https://github.com/fakturownia/API),
[e-receipt API](https://github.com/e-paragony/api),
[automatic e-receipt email delivery](https://pomoc.fakturownia.pl/179433234-Automatyczne-wysylanie-e-paragonow-e-mailem),
[automatic API fiscalization](https://pomoc.fakturownia.pl/179236547-Automatyczna-fiskalizacja-paragonow-po-utworzeniu-przez-API).

## Ordered skate covers

Online checkout type `g` and manual payments linked by `id_skate_cover_order`
automatically use the canonical Getry dictionary entry `skate_covers`.
Its configured receipt name, quantity unit, fixed ZW tax and inclusion policy
also apply to ordered covers. The identifier migration preserves the previous
configuration and converts historical payment and source item keys. Prepared
and issued invoice payloads remain unchanged; no runtime alias is retained.
No provider configuration change is required.
Ordinary unlinked Getry payments still use quantity one.

For an order, `source_json` freezes `quantity` (number of pairs),
`unit_price_cents` and the full `amount_cents` before an online transaction is
registered, or when a manual payment is accepted. Positions send that actual
quantity and full gross total. The order's optional customer note and the
payment's internal comment are never copied into the provider payload.

Receipt fulfillment requires the same active paid payment to be assigned to the
approved order and the order's quantity and unit price to match the captured
source. Cancellation, a changed quantity, or a duplicate payment blocks automatic
issuance pending office resolution. Existing prepared provider payloads and
retries remain frozen. Verify cash and online orders of several pairs, a replayed
callback, an order changed after checkout, and a late payment after cancellation.
