# Individual lessons

This document defines the current scheduling, pricing, payment, balance, and
reporting rules for individual lessons. It is the durable domain reference for
changes affecting `SoloEvent`, individual-lesson payments, and related reports.

The August 2026 commit-by-commit change record is available in
[August 2026 individual-lesson changes](changes/2026-08-individual-lessons.md).

## Lesson setup

An instructor schedules an individual lesson by selecting:

- the participant, including a participant outside the system;
- the instructor and location, including the existing alternative-location
  option;
- the lesson date and start time;
- the lesson type: rollers, roller skates, or a lesson in English/Spanish;
- the participant count, from one to four;
- an optional comment.

The date field for a new lesson defaults to the next weekday. The instructor
does not enter the lesson price manually. `SoloEvent::saveSoloEvent()` derives
and stores the aggregate advance-payment amount from the lesson type,
participant count, and current application settings.

The technical lesson type values are:

| Value | Meaning |
| --- | --- |
| `rollers` | Roller lessons |
| `rollerskates` | Roller-skating lessons |
| `foreign_language` | Lessons in English or Spanish |

## Scheduling confirmation email

Creating a new individual lesson for a participant with an account queues one
confirmation email. The creation decision is made before the first save assigns
the lesson ID. Editing a saved lesson does not send another scheduling
confirmation, even if its date, location, participant count or price changes.
Existing historical lessons do not receive retroactive messages.

For an adult, the recipient is the participant's account email. For a child,
the recipient is the parent account, while the message identifies the child as
the participant. The message follows the existing enrollment-notification
convention through `User::sendNotificationEmail()`, including the system
subject prefix and standard contact footer. Participants outside the system
(`id_user = 0`) do not receive this confirmation; their `receipt_email` remains
the receipt-delivery address only. Guest creation and editing retain their
existing behavior.

The lesson insert and communication-queue insert use the same PDO connection
and transaction on the existing InnoDB tables. The operation temporarily uses
PDO exception mode so failed inserts cannot be mistaken for success by the
legacy model layer, then restores the caller's error mode. A caller-owned
transaction is preserved through a scheduling savepoint. A missing/inactive template, invalid recipient or failed queue
insert rolls back the new lesson, so the instructor can retry without leaving
a lesson whose required confirmation was never queued. Queueing is the save
boundary; actual email delivery uses the existing asynchronous `CommQueue`
processing and its retry behavior. No email is sent while the transaction is
still being committed.

The editable template is `mail_individual_lesson_scheduled`, added by
`20260913180000_add_individual_lesson_confirmation.sql`. This migration depends
on the code/type/tags columns from
`20260913120000_classify_editable_texts.sql`. Its insert is guarded by the code
and does not overwrite an existing body or metadata when replayed. Both
migrations are additive and preserve existing lessons and other templates.
The new template is in the email section of the existing text editor; its
description and tag instructions are maintained separately from the editable
body. See [Editable texts](editable-texts.md).

The confirmation includes the participant, lesson type, instructor, registered
location name and address, date and start time, participant count, and both
aggregate price variants. A location outside the system is described as
`Miejsce uzgodnione z instruktorem`; the internal comment is never copied to the
email. Dynamic names, location data and links are escaped before insertion into
the HTML template.

Price values come from the lesson's stored `amount` and
`getPaymentPriceOptions()`, without multiplying an already aggregate price
again. The deadline and required advance-payment hours come from the existing
pricing methods and settings. A payment at the exact deadline still qualifies
for the advance price. When a new booking is created after the deadline, the
message still lists both requested price variants and adds a clear statement
that the deadline has passed and the later price applies. This statement is
generated with the final formatted price, without nested template tokens.
The second variant is labelled `Przy płatności po tym terminie`, which also
describes the rule correctly when the configured threshold is zero hours.

The `Zobacz zajęcia i opłać` link uses `Helper::buildPublicUrl()` and leads to
`/konto/moje_zajecia_indywidualne/` for an adult, or
`/konto/moje_zajecia_indywidualne/<id_child>/` for a child. After signing in, the
customer can see the scheduled lesson and use its existing payment button.
The email does not promise online date changes or cancellation; changes to the
agreed date are arranged with the instructor. It does not introduce a guest
access token or a public payment route.

Verification should cover:

1. A new adult booking and a child booking: one queue record each, correct
   recipient, participant details and account/child link, all 12 template tags
   substituted, with both prices shown as totals.
2. New bookings before, exactly at and after the advance-payment deadline,
   including a zero-hour threshold, and multiple participants in each lesson
   type. Verify the late-price statement only after the deadline.
3. A registered location and the alternative-location option; verify the
   internal comment is absent from both emails.
4. Editing an existing lesson and saving a guest lesson: no new scheduling
   confirmation; existing guest receipt-email validation still applies.
5. A missing template or forced queue failure: neither the new lesson nor a
   queue record remains; a subsequent successful retry creates one of each.
6. Replaying the template migration after an administrator edits its body:
   exactly one template remains, with the edited body unchanged.

Use controlled local/test queue data for verification rather than sending
messages to real customers. No new configuration, server file operation or
public FAQ instruction is required because customer operations are unchanged.
`tests/individual_lesson_confirmation_test.php` exercises scheduling and queued
HTML using synthetic connection-local temporary tables, including failures in
PDO warning mode and retries within a caller-owned transaction.
Both this test and `tests/individual_lesson_payment_amount_test.php` require
an explicit isolated `RP_TEST_DATABASE=codex_rollpoints_<name>` prepared with
`scripts/prepare-rollpoints-tests.php`. All default connections use that schema;
temporary fixture connections also shadow the RollPoints ledger.

## Management authorization

An instructor may view and manage only individual lessons whose
`solo_event.id_instructor` matches the instructor account linked to the logged-in
user. Administrators may manage every existing individual lesson.

The ownership rule is enforced for every direct instructor-management route,
including editing, confirming realization, cancelling, opening the manual-payment
form, and saving a manual payment. Filtering the "My individual lessons" list is
not treated as an authorization check for those routes.

Scheduling and manual-payment forms require action-specific CSRF tokens.
Confirmation and cancellation are POST operations with separate per-lesson CSRF
tokens and retain the shared confirmation modal in the instructor interface.

When an individual lesson is created, its instructor is taken from the logged-in
instructor account. When it is edited, the existing owner is preserved. The save
operation accepts only the participant, location, date and time, lesson type,
participant count, and comment from the scheduling flow; request data cannot
directly reassign ownership or change payment and realization state.

The scheduling form displays the leading instructor's first and last name.
For an existing lesson, it loads the instructor from `solo_event.id_instructor`,
including when an administrator opens another instructor's lesson. For a new
lesson, it displays the current instructor. This is informational and does not
allow reassignment.

For an existing lesson linked to an active, paid payment, the scheduling form
also shows "Zajęcia opłacone" and the payment's recorded date and time. This uses
`solo_event.id_payment`, including payments that settle a shared balance and
zero-value voucher settlements. Pending, inactive, or missing payments do not
produce a paid confirmation. Historical payments without a date show an explicit
missing-date label. Administrators additionally receive an "Edytuj wpłatę" link
to that payment; instructors see the payment status without the admin-only link.
This display requires no migration or historical backfill. Verify it with a paid
lesson as an administrator and its owning instructor, and with an unpaid lesson.

This authorization rule requires no schema migration and does not modify
historical individual-lesson records.

## Price sources

All prices and payment thresholds are managed in the `solo` section of the
application settings panel. They are loaded from `application_setting` and are
required during application bootstrap.

| Setting | Meaning |
| --- | --- |
| `PRICE_SOLO_1` | Rollers, one participant, price per person |
| `PRICE_SOLO_2` | Rollers, two participants, price per person |
| `PRICE_SOLO_3` | Rollers, three or four participants, price per person |
| `PRICE_SOLO_ROLLERSKATES_1` | Roller skates, one participant, price per person |
| `PRICE_SOLO_ROLLERSKATES_2` | Roller skates, two participants, price per person |
| `PRICE_SOLO_ROLLERSKATES_3` | Roller skates, three or four participants, price per person |
| `PRICE_SOLOEN_1` through `PRICE_SOLOEN_4` | English/Spanish lesson, aggregate price for the selected participant count |
| `SOLO_ADVANCE_PAYMENT_MIN_HOURS` | Minimum number of hours before lesson start for the advance-payment price |
| `SOLO_PRICE_DIFFERENCE_PER_PERSON` | Amount added per participant for a later payment |

The migrations seed `SOLO_ADVANCE_PAYMENT_MIN_HOURS` with `6` and
`SOLO_PRICE_DIFFERENCE_PER_PERSON` with `30`. These are initial values, not
hard-coded domain constants; administrators can change them in application
settings.

## Price calculation

`solo_event.amount` stores the aggregate price applicable to an advance
payment. For rollers and roller skates, the configured per-person price is
multiplied by the participant count. For English/Spanish lessons, the selected
setting already contains the aggregate price.

The payment deadline is:

```text
lesson start - SOLO_ADVANCE_PAYMENT_MIN_HOURS
```

A payment made at or before that deadline uses `solo_event.amount`. A payment
made after the deadline uses:

```text
solo_event.amount
  + participant_count * SOLO_PRICE_DIFFERENCE_PER_PERSON
```

The distinction is based on payment time, not payment method. Cash and online
payments follow the same price rule. The public price list and the instructor
scheduling form show both price variants without describing the lower amount
as a discount or rebate.

## Realization and when payment becomes due

An individual lesson becomes due immediately when an instructor confirms its
realization and `solo_event.is_done` changes to `1`. This is intentionally
different from group lessons:

- individual lessons do not use the group-lesson 75-minute delay;
- an active, confirmed, unpaid individual lesson is immediately included in
  the account balance;
- an unconfirmed individual lesson is not included in the balance, even when
  its scheduled time is in the past;
- an inactive, paid, or zero-price individual lesson is not an outstanding
  balance item.

Confirmation recalculates the owning account balance immediately. For a child,
the parent account is recalculated.

## Account balance and whole-balance payment

`User::calculateBalance()` returns one combined list of outstanding group and
individual lessons for the account owner and their children. Individual items
are identified with `balance_item_type = solo`; group items retain their own
time and payment rules.

The customer account screens use this combined result for:

- the outstanding-lessons list;
- the displayed balance total;
- `oldest_unpaid_event_datetime`;
- the online action that pays the whole account balance.

When a whole-balance online payment is confirmed, all group and individual
items from the matching balance calculation are assigned to that payment in a
single database transaction. Each individual lesson stores its own allocated
required amount, so a payment covering several lessons does not lose the
per-lesson price history. The stored account balance is recalculated after the
assignment.

## Historical amount at payment

`solo_event.required_amount_at_payment` is the historical amount allocated to
that lesson when it was paid. It must not be recalculated from current settings
after payment.

For payments assigned after migration `20260912120000_add_solo_expected_amount_at_payment.sql`,
`solo_event.expected_amount_at_payment` separately stores the amount due at
payment time. The legacy `required_amount_at_payment` name and its paid-allocation
meaning remain unchanged for historical and older-code compatibility.

For manual cash and bank transfers, the expected amount is calculated once,
server-side, from the locked lesson's advance price, participant count, lesson
time and the recorded payment time. Changing the entered amount cannot change
this expected amount. The two amounts are saved in the payment-assignment
transaction. Backdated cash uses its recorded receipt date.
Online checkout stores its calculated payable amount after accepted discounts;
balance settlement stores each lesson's calculated allocation. Paper vouchers
and redeemed gift-voucher codes store an expected amount of zero. Other explicit
technical or donation settlements use their accepted settlement amount, without
an automatic comparison against the lesson tariff.

Historical rows remain NULL in the new column and never receive an inferred
expected amount. Lesson edits do not recalculate it. Payment recall clears both
amount snapshots along with the payment relation. The report compares currency
amounts to cents without new per-row queries or recalculating prices: direct
payments use the actual linked `payment.amount` (including later admin corrections),
while shared payments use the stored allocation. Only active, paid lessons with
a known expected amount and a difference show a warning with the amount due.

The value is written for:

- direct online individual-lesson payments;
- direct manual payments entered by an instructor or administrator;
- zero-value technical payments;
- an individual lesson included in a whole-balance payment.

The instructor's "My individual lessons" screen shows this historical value
for paid lessons as `Wpłacona kwota`. For a whole-balance payment, this is the
part of the payment allocated to that lesson. The advance-payment description
is shown only while the lesson remains unpaid.

Recalling a payment clears `solo_event.id_payment`,
`solo_event.required_amount_at_payment` and `solo_event.expected_amount_at_payment`. The lesson then becomes unpaid again
and its currently applicable price is calculated from the payment deadline and
current settings.

## Manual instructor settlements

An instructor normally records an individual-lesson payment as `Gotówka`.
`Rozliczenie/techn.` and `Darowizna` are not available at instructor access
level, and server-side validation rejects a submitted payment type that is not
allowed for the current access level. Administrators retain the wider manual
payment-type list used by administrative workflows.

Individual lessons additionally expose payment type `7`, labelled
`Voucher papierowy starszego typu bez indywidualnych kodów`. It represents the historical paper vouchers whose
usage count is not stored by the application. Selecting it forces both the
payment and the lesson's historical paid amount to `0`, regardless of the
submitted amount. The instructor records the old voucher number or code in the
internal payment comment and remains responsible for checking its previous
uses. This type is rejected for group lessons, passes, courses, rentals,
unlinked payments and gift-voucher purchases.

Like every manual-payment form, the individual-lesson form requires the cash
receipt date when `Gotówka` is selected. The shared rule, including default,
validation, reporting and visibility, is documented in
[Payment and pass administration](payment-administration.md).

The payment and individual-lesson assignment are saved in one transaction after
locking the lesson. This prevents a second payment from being attached between
validation and assignment. The recorded payment amount is preserved as the
lesson's historical required amount when the payment is assigned.

### Exceptional redemption of a new gift-voucher code

The preferred process for a new gift voucher remains participant self-service:
the participant should redeem the code from their account, and a person without
an account should register first. The instructor payment screen presents this
as the normal instruction and keeps instructor redemption behind a separate,
collapsed exceptional action rather than listing it as an ordinary payment
type.

The exceptional action can settle the selected individual lesson even when its
participant is outside the system (`solo_event.id_user = 0`). It accepts only a
code which:

- originates from a paid `GiftVoucher` (`promo_code.id_gift_voucher` is set);
- targets individual lessons for one participant and provides a 100% reduction;
- is active, currently valid, single-use and not previously used.

The selected lesson must have `solo_event.participant_count = 1`. Voucher codes
cannot settle individual lessons booked for two to four participants.

The lesson and code are locked in the same database transaction. A successful
redemption creates a paid zero-value technical payment linked to the lesson,
stores the code in `payment.promo_code` with promo type `1`, updates
`promo_code.last_used`, and records `required_amount_at_payment = 0`. A failed
validation or concurrent claim changes neither record. The endpoint repeats
individual-lesson ownership authorization and uses a token scoped to the
selected lesson.

## Participant discounts and discount codes

The participant can enter a discount code while starting a direct payment for
an individual lesson. A code with `promo_code.type = ia` applies to any supported
participant count, while the existing `promo_code.type = i` applies only when
`solo_event.participant_count = 1`. Gift-voucher codes use the restricted `i`
target. A fixed-amount code subtracts at most the full lesson price; a percentage
code subtracts the configured percentage up to 100%. A code that reduces the
amount to zero creates a paid technical payment and immediately links it to the
lesson without opening the payment provider.

A Karta Dużej Rodziny number gives a 10% discount on a direct advance payment
for an individual lesson. This discount is not available when paying the whole
account balance. Discount-code and Karta Dużej Rodziny data continue to use the
existing `payment.promo_type` and `payment.promo_code` fields; the relation-name
migration does not change the payment schema. Full lifecycle and deployment
rules are documented in [Discount codes](discount-codes.md).

## Reports

### Instructor report

The instructor report counts completed individual lessons in the selected
period and separately counts how many of them remain unpaid. The same summary
and payment status are included in the XLSX export. Every cash-payment row that
is linked to an individual lesson also shows the lesson date separately from
the payment date, both in the page report and in XLSX.

### Fresh-session instructor reminder

After a standard instructor login, or after a remember-me token restores an
expired session, the first rendered page shows a one-time modal when the
instructor has active individual lessons that require attention. The modal
reports separate counts for unconfirmed lessons and for confirmed but unpaid
lessons, and links directly to the instructor's individual-lessons screen.
Unconfirmed future lessons are excluded because they are still planned. A
confirmed unpaid lesson is included regardless of its scheduled date because
confirmation makes its payment due immediately. An unconfirmed lesson is not
also counted as unpaid. The reminder applies to every account with instructor
access and a linked active instructor profile, including accounts with higher
administrative access.

### Individual-lesson report

Administrators can open the report from `Zarządzanie` → `Oferta i harmonogram`
→ `Zajęcia indywidualne`, as well as from the reports list.

The administrator-only `Raport zajęć indywidualnych` lists all lessons by default,
including cancelled lessons and dates outside the current month. Both date fields
start empty. Either inclusive date bound may be supplied independently; invalid
dates and reversed ranges are rejected. Results are paginated in SQL, 50 per page,
ordered by lesson date descending and ID descending for stable ties. Page links
preserve filters, submitting the filter form starts at page 1, and `Wyczyść filtry`
returns to the unfiltered first page. Out-of-range page numbers are clamped.
The optional instructor selector defaults to `Wszyscy instruktorzy` and includes
only instructors referenced by at least one individual lesson, including
inactive instructors with historical lessons. Instructors without any individual
lessons are omitted. It filters by
`solo_event.id_instructor` before counting and pagination, combines with both
date bounds and the unpaid filter, and is preserved in page links. Invalid
instructor IDs are rejected; an unknown positive ID returns no lessons.
The report provides:

- participant profile links when the participant belongs to the system;
- an `Edytuj` action for each lesson, opening the existing scheduling form with
  the lesson ID and its participant ID (`0` for a guest); the form retains the
  existing administrator authorization and scoped CSRF protection on save;
- a `Rozlicz` action for active lessons without an assigned payment, opening
  `zarzadzanie/wplata_start/i/<id>` with the report as the return destination.
  The existing manual-payment form provides the administrator's payment types
  and voucher options. Cancelled lessons and lessons with an assigned payment
  do not offer another payment action;
- instructor and location details;
- realization status: cancelled for inactive records, `Zrealizowane` when `is_done`
  is set, past without confirmation when the lesson time has passed, otherwise
  planned; a past date alone does not mean the lesson was completed;
- the `Kwota należna` column displays `expected_amount_at_payment` for paid
  lessons, never the advance-payment price from `amount`. Paid historical
  lessons without this snapshot show `Brak danych`; unpaid lessons show a dash
  because no payment-time snapshot exists yet. The report does not infer or
  recalculate missing expected amounts;
- the paid amount followed by the payment type, and the payment date. Expected
  and paid amounts use the same format (`150,00`, two decimal places and a comma),
  without a currency suffix and without wrapping within the number. The paid
  amount comes from the direct payment or the stored allocation for a shared
  settlement. The difference warning compares it with the expected snapshot;
- discount code or Karta Dużej Rodziny number used for a direct payment;
- an emphasized unpaid status;
- cancelled lessons without a confirmed active payment display `Nie dotyczy`
  without an unpaid warning and are excluded from the unpaid filter. If a
  cancelled lesson has a confirmed active payment, its payment details remain
  visible; this report does not change or refund that payment;
- an optional unpaid filter applied before counting and pagination, submitted
  with `Pokaż`, so it covers every matching record rather than the current page.

The report navigation and filters do not modify historical records. The expected
amount snapshot requires the migration listed below. Verify
an unfiltered visit with lessons from multiple months and inactive records,
consecutive pages without overlaps, each date bound separately and together,
the unpaid filter across pages, an empty result and invalid date input. Check
instructor selection alone and combined with dates and unpaid status, including
an inactive instructor's historical lessons. Confirm
that page links retain filters and `Edytuj` still opens the exact lesson.

The report uses the shared responsive Bootstrap table presentation. The shared
theme gives Bootstrap tables a semi-transparent glass background in both light
and dark modes; report-specific code does not define a separate table style.

An empty location identifier is handled as the existing alternative location
instead of causing a PHP warning.

## Database migrations

The feature depends on these additive migrations:

| Migration | Purpose |
| --- | --- |
| `20260810223914_add_solo_payment_timing.sql` | Adds `participant_count` and `lesson_type`, and seeds the payment timing settings |
| `20260810231501_add_solo_required_amount_at_payment.sql` | Adds the per-lesson historical amount and backfills already paid lessons |
| `20260912120000_add_solo_expected_amount_at_payment.sql` | Adds the nullable expected amount snapshot for new payments, without backfilling history |

The historical backfill uses the linked payment amount for direct
individual-lesson payments. For older links where one payment cannot be
unambiguously allocated to one lesson, it preserves the amount stored on the
lesson.

These migrations are executed by the standard deployment migration runner after
a verified database backup. Do not edit either migration after it has run on an
environment; use a new additive migration for corrections.

## Verification checklist

Before promoting a change affecting individual lessons:

1. Confirm that the migration runner reports no pending migrations after the
   test deployment and that both new settings exist.
2. Schedule each lesson type and verify aggregate prices for one to four
   participants.
3. Verify the exact deadline boundary: the advance price applies at the
   deadline and the later price applies immediately after it.
4. Confirm that an unconfirmed past lesson is absent from the balance.
5. Confirm realization and verify that the lesson enters the owner or parent
   balance immediately.
6. Pay one lesson directly and verify the historical amount in the instructor
   and administrator views.
7. Pay a balance containing both group and individual lessons and verify every
   item is linked to the payment with the correct per-item amount.
8. Recall the payment and verify that the payment link and historical lesson
   amount are cleared.
9. Verify the instructor report summary and XLSX output, including the separate
   lesson date on individual-lesson cash payments; then verify the administrator
   date range and unpaid-only filter.
10. Verify an individual lesson using the alternative location.
11. As an instructor, verify that direct URLs cannot edit, confirm, cancel, view
    payment details for, or attach a manual payment to another instructor's
    lesson; then verify that the owner and an administrator retain access.
12. Verify that scheduling, realization confirmation, cancellation, and manual
    payment reject missing or invalid CSRF tokens and accept the token generated
    for that exact action and lesson.
13. Start a new instructor session by password and by remember-me restoration.
    Verify that outstanding past lessons produce one actionable modal on the
    first rendered page and that refreshing the page does not show it again.
14. Generate fixed-amount and percentage discount codes targeted at individual
    lessons. Verify that the participant can use each code only for an
    individual-lesson payment and that a full discount settles the lesson for
    0 PLN.
15. Enter a Karta Dużej Rodziny number during a direct individual-lesson payment
    and verify a 10% discount and reporting of the number. Verify that no Karta
    Dużej Rodziny option or discount is available when paying account balance.
16. Verify that the instructor payment form contains only the cash-payment
    workflow and does not accept a discount code.

## Guest receipt email

Scheduling/editing an individual lesson without a registered participant requires
a valid email in `solo_event.receipt_email`. It is collected in the lesson form,
not when taking payment. Registered participants use their account/parent email.
Old guest lessons need this field completed before receipt-bearing cash/bank
payment, while zero-value voucher redemption is unchanged. Email is frozen at
payment receipt capture and redirected to MAIL_TO in DEV. The nullable column is
added by `20260907002000_add_receipt_buyer_details.sql`; no historical addresses
are inferred. See [receipt details and verification](fakturownia-receipts.md).
