# Class equipment orders and skate-cover purchases

## Customer and office workflow

The class list offers two independent actions: **Wypożycz sprzęt** for a rental
and **Zamów getry** for buying textile covers pulled over skates to protect the
floor. The customer owns the purchased covers. Collection is at the selected
class after the office confirms availability. A participant may have both
orders for the same enrollment, with separate decisions and payments.

The purchase form takes a number of pairs and an optional customer note. It
does not offer size selection or require acceptance of rental terms. There is
no inventory reservation or stock-management module.
The office makes the availability decision.

The office uses the shared **Wypożyczenia i getry** list, with an order-kind
filter and the existing status filters. It can create and edit unpaid orders,
approve requests, reject them with a reason, and retry notifications. Customers
can edit or cancel their own pending request; a parent can act for their child.
Creation requires an active enrollment and an active, non-cancelled occurrence.
Customer requests must concern a future occurrence.

Explicit office reopening clears the cancellation marker and the instructor
notification markers so a newly approved, paid order can be prepared again.
Earlier settlement notes and settlement audit fields remain available.

`ClassOrder` implements shared reads, ownership checks, decisions, cancellation,
notification dispatch and manual payments. `RentalOrder` retains its original
public methods and URLs. `SkateCoverOrder` implements the purchase fields,
calculation and naming. New account and office actions use the `getry_*` prefix.
The factory accepts only `r` and `g`; SQL table and payment-relation names are
selected from this fixed mapping.

The two models use separate tables with one order per
`id_user_event_occurence` in each table. Equal numeric order IDs do not identify
the same order: routes, payment relations, UI row anchors and CSRF scopes also
include the order kind. Shared read results provide `order_type`, `order_route`,
`order_label` and `equipment_summary`.

## Price and shared restrictions

`ApplicationSetting` exposes `SKATE_COVER_PRICE_PER_PAIR` in **Wypożyczenia i
getry**, next to rental prices. The migration inserts **30 PLN** if the key is
missing. The setting accepts positive whole-PLN prices, consistent with the
existing rental price settings. It is loaded from the database; no addition to
the ignored local `application/config/config.php` is required.

Purchases store `quantity`, `unit_price` and `amount`. The quantity must be a
positive integer from 1 to 999 and the total may not exceed 999999.99 PLN. Notes
are limited to 1000 characters. The server computes the amount, ignoring any
submitted price. The unit price is captured when the order is first created;
quantity changes and office reopening retain that historical price. Changing
the setting affects only new orders. The displayed FAQ price uses the same
setting. Existing rental prices and historical payments are not recalculated.

Both order kinds use `RENTAL_MAX_ORDER_DAYS_AHEAD`,
`RENTAL_MIN_HOURS_BEFORE`, `RENTAL_WEEKEND_CUTOFF_HOUR`,
`RENTAL_ALLOW_LATE_ORDERS` and `RentalExclusionPeriod`. Weekend cutoffs use the
preceding Friday. Existing pending requests remain editable after a creation
cutoff; new requests must satisfy the current restrictions. The existing office
override for late requests remains available. `RENTAL_OFFICE_EMAIL` and
`RENTAL_OFFICE_PHONE` receive operational notifications for both kinds.

## Decisions, payments and cancellation

Approval queues an email and, when selected, an SMS to the participant or their
parent. Purchase confirmation includes the number of pairs, total, collection
date and a link to the order with payment options. The shared attendance list
shows the order and its paid state. An authorized instructor or substitute can
accept payment for an approved unpaid purchase; the office can use its existing
payment controls. Manual purchase payments must equal the frozen order total.

On the attendance page, staff use **Przyjmij wpłatę za getry** beside the order in
**Wypożyczenia i getry na ten termin**. It opens checkout type `g` with that order
ID, links the payment to `id_skate_cover_order` and stores its ID on the order.
After acceptance the order remains visible as **zapłacone — wydaj getry** and
the payment button disappears. **Inny zakup** does not settle an
existing purchase request; the attendance page explicitly directs staff to the
order's payment control.

Expanding a participant on the attendance list also shows their cover-order
summary and amount above the ordinary payment actions. An approved unpaid order
has its own **Przyjmij wpłatę za zamówione getry** button, linked by the exact
enrollment and purchase ID. It uses the same checkout and attendance return URL
as the control below the list, independently of whether the class fee is paid.
Pending orders show the office-approval state, paid orders show collection
instructions, and read-only viewers do not receive a payment button.

`Payment` uses checkout type `g` and `id_skate_cover_order` for purchases, while
rentals retain `r` and `id_rental_order`. Przelewy24 registration, callback
verification, payment administration and reporting use those separate
relations. Ordered purchases are not available as loose payments. The canonical
`skate_covers` receipt-item configuration supplies the customer-facing **Getry**
label and unit for both orders and unlinked cover payments. New payments and
receipt-source snapshots use that key. The identifier migration also converts
historical classifications and source keys; the runtime has no legacy alias. The receipt stores the real pair quantity and total. Customer
notes are not sent as receipt-item content. See
[receipt integration](fakturownia-receipts.md) and
[payment administration](payment-administration.md).

Saving requests locks the enrollment, occurrence and existing order so
concurrent forms cannot create duplicate records or overwrite a newer approval.
Class cancellation marks the occurrence cancelled before processing its
orders; the occurrence lock prevents a new request from racing that sweep. Decisions,
manual payment acceptance and online fulfillment lock the order before related
payment rows. Repeated cashier submissions do not insert another payment.
Superseded unpaid online attempts are deactivated, but a verified later payment
is still recorded as money received and directed to manual settlement.

Explicitly reported absence, enrollment withdrawal and class cancellation
process both order kinds. Merely marking a participant absent on the attendance
list retains the existing rental behavior and does not automatically cancel an
order. Unpaid pending or approved orders become `cancelled`. Paid orders become
`settlement_required`; the system does not automatically refund money. An
attendance cancellation during an existing settlement also records the
cancellation marker, preventing a later payment recall from restoring delivery.

If online payment arrives after cash was accepted, the original linked payment
remains the primary payment and the additional receipt of money is listed for
settlement. With multiple active paid payments, the final settlement action is
blocked: staff must refund and explicitly recall the additional payment first.
Recalling an additional payment restores approval only when one valid paid
primary remains and the order was not cancelled. Ordinary cancellation or
payment edits do not reverse RollPoints. Explicit payment recall and the final
refund-and-withdraw settlement record the corresponding reversal. Duplicate
payments do not earn a second points allocation.

The regular administration list covers occurrences from the last 30 days
onwards. Older orders appear in the archive; unresolved `settlement_required`
orders remain in their dedicated queue regardless of age.

## Migration and deployment

`database/migrations/20260915140000_add_skate_cover_orders.sql` creates the
purchase table and indexes, adds nullable `payment.id_skate_cover_order` with an
index, and seeds the setting without replacing an existing value. That schema
migration is additive and safe to repeat after partial execution, and does not
rewrite historical rental or payment rows. There are no files to copy into `Files`, no
external asset operations and no new server secrets or configuration settings.

`database/migrations/20260915230000_add_skate_covers_receipt_key.sql` renames the
existing Getry dictionary key from `unlinked_leggings` to `skate_covers`, retaining
its row ID, configured name, unit, policy, active state and audit fields. It also
updates `payment.receipt_item_key` and matching top-level `item_key` fields in
`payment_receipt.source_json`. Quantities, amounts, other source fields and
`invoice_json` remain unchanged. Source JSON may be normalized by MySQL while
changing the key. There is no retained legacy dictionary row or runtime alias.
If neither dictionary key exists, the migration inserts the default Getry row.

The migration is transactional and safe to repeat. It stops before updates if
both dictionary keys exist, legacy source JSON is malformed, or one snapshot
contains multiple cover-order entries (the payment model permits one cover
order). Its JSON-validation guard fails closed on MySQL 8.0 and 8.4; in that case
review the preflight counts in the migration and resolve conflicting data before
retrying. Never discard either customized dictionary row to bypass the guard.

Run the normal migration runner on test before production. It creates and
checks the database backup before executing the migrations in filename order.
New application code requires the table, column, setting and canonical dictionary
row to exist before serving requests. Check migration status, confirm the price
and preserved receipt configuration, and verify one purchase plus
one rental for the same test enrollment. Production deployment requires its
normal separate authorization.

The identifier data conversion intentionally requires coordinated deployment:
apply it before serving this version and suspend writes from code expecting the
old key during the change. A code-only rollback to that earlier version cannot
resolve the migrated categories. Such a rollback requires a separately reviewed
inverse conversion of dictionary, payment and source keys, or a runtime that
supports the new key. Never rewrite prepared/issued invoice payloads or restore
an old database over newly received payments. Earlier code without cover-order
support also cannot process outstanding cover-order callbacks.

## Verification

Run the pure domain and rental regressions without a database:

```sh
php tests/skate_cover_order_test.php
php tests/rental_equipment_measurements_test.php
```

Prepare a new isolated `codex_rollpoints_*` schema using
`scripts/prepare-rollpoints-tests.php`, then apply the new migrations to that
isolated schema before loading database settings. Never point test fixtures at
the application database. With `RP_TEST_DATABASE` naming the prepared schema:

```sh
php tests/skate_cover_receipt_key_migration_test.php
php tests/class_order_controller_test.php
php tests/skate_cover_payment_workflow_test.php
php tests/fakturownia_receipts_test.php
php tests/rollpoints_payment_eligibility_test.php
```

The tests cover parent/child authorization and cross-kind CSRF, independent
rental and purchase creation, quantity and price validation, stale forms,
approval, exact manual payments, repeat acceptance, Przelewy24 replay and
cash/online collision, cancellation during settlement, quantity-preserving
receipts and loyalty eligibility. Payment-provider calls are replaced with test
responses. Controller tests queue only isolated messages; they do not run the
delivery worker. Inspect both mobile and desktop class lists, customer details,
office list and instructor payment form before release.
