# Discount codes

## Domain boundary

RollPoints creates ordinary discount codes within the same transaction that
debits points and records the exchange. At issue they cover the specified
service in full, are transferable and single-use, and expire after 12 months.
The loyalty history retains the literal code and the original issue details;
there is no database relationship or later status synchronization between its
reward record and `promo_code`. These codes remain editable through the regular
discount-code panel. Their use or later edits do not automatically restore RP
or rewrite the exchange history. They are separate from purchased gift
vouchers; see [RollPoints](rollpoints.md#rewards).

Codes settled by the reservation system are represented by
`Mini\Model\PromoCode` and the `promo_code` database table. In the user-facing
payment form the same input accepts a discount code or a code printed on a gift
voucher. This does not make `PromoCode` the model of a gift voucher: issuing,
personalizing, delivering and tracking gift vouchers is a separate domain and
can use a future `GiftVoucher` model without conflicting terminology.

Payment promo type `1` means a discount code
(`Payment::PROMO_TYPE_DISCOUNT_CODE`). Existing `payment.promo_type` and
`payment.promo_code` values retain their meaning; no historical payment is
rewritten. Codes generated for a paid gift voucher additionally store its
identifier in `promo_code.id_gift_voucher`, which makes their origin verifiable
without changing redemption rules.

## Payment reservation

The shared promo-code payment path locks and refreshes the ordinary code before
checking its active state, validity and previous use. Saving the payment and
reserving a single-use code run in one transaction (or a savepoint when the
caller already owns one), so concurrent claims cannot create two discounted
payments and a failed reservation rolls back the payment. Per-user reusable
codes keep their existing user-usage lock. These checks apply to ordinary codes
from every source; there is no RollPoints-specific redemption path.

The standard pass-type selector includes Classic 4 (`4he`) as well as Flex 4
(`4h`), so both code restrictions can be maintained through the ordinary editor.

## Gift-voucher visual preview

The ROOT-only route `/zarzadzanie/podglad_vouchera/`, available from the
`Narzędzia ROOT` section of the management menu, provides a standalone visual
prototype without the normal application header or footer. It renders
directly with the application's existing background, Bootstrap build, fonts,
cards, alerts, badges and glass styling. It uses mock voucher data and does not
issue, persist, send or redeem a gift voucher. The lower section demonstrates
one blank area sized for one to five future promo codes. Recipient, lesson type,
number of hours, voucher number, issue date, validity date and code values
deliberately remain empty in the template, without dotted or lined placeholders.
The visual part also reserves a full-width blank area for variant-specific
conditions and instructions, such as separate terms for group and individual
lesson vouchers.
The implemented issuance workflow and its lifecycle are documented in
`docs/gift-vouchers.md`; the visual preview remains a template-design aid.

## Targets and values

The discount-code editor supports five targets:

- `k`: passes;
- `z`: single group lessons;
- `a`: passes and single group lessons;
- `ia`: any individual lesson, regardless of participant count;
- `i`: an individual lesson for exactly one participant.

The individual targets are deliberately separate. Existing `z` and `a` codes do
not discount individual lessons, while neither `ia` nor `i` discounts group
lessons or passes. The existing technical value `i` now explicitly represents
the one-participant restriction, including all historical codes that already
carry this value. The participant count comes from `solo_event.participant_count`
and is checked both during the payment preview and during final payment creation.
Every target supports either a fixed PLN reduction or a percentage reduction. If
both values exist on a historical record, the fixed amount takes precedence. A
reduction never makes the payment amount negative.

Target labels are defined once by `PromoCode`, while pass names come from the
shared `Pass` type dictionary. `PromoCode` only selects which pass types may be
used as code restrictions. The administration form, HTML list, XLSX exports and
the existing `Tagger` presentation use these dictionaries, so a target or pass
type cannot acquire a different label in each output.

Single-code and batch-generation workflows use the same targets, values,
validity and usage rules. A batch generates unique codes and exports them to
XLSX.

## Usage modes

An active discount code can be configured in one of three modes:

- single-use (`multiple_use = 0`),
- reusable without a per-user limit (`multiple_use = 1`, `multiple_use_per_user = 0`),
- reusable once per user (`multiple_use = 1`, `multiple_use_per_user = 1`).

The once-per-user rule is based on the participant assigned to the payment, not
the account submitting the form. A parent therefore uses the child's allowance
when starting a payment for that child.

Starting a discounted payment consumes a reusable-once-per-user code. The
payment does not need to be paid: any active payment with the same `id_user`,
promo type and code counts as usage. Previewing the discount without confirming
the payment does not consume it. An abandoned or failed payment continues to
count while the payment remains active. Making the payment inactive releases
the allowance; there is no automatic expiry of an unpaid reservation.

If a code reduces the amount to zero, the existing technical-payment flow marks
and assigns the payable item without contacting the online payment provider.

## Karta Dużej Rodziny

Karta Dużej Rodziny is separate from generated discount codes. A non-empty card
number applies a 10% reduction only to a direct advance payment for a single
group or individual lesson. It is not applied to whole-balance payments and is
not combined with another discount. The payment stores promo type `2` and the
entered card number for reporting.

Karta Warszawiaka is no longer offered for new payments. Historical promo type
`4` remains readable in administrative reports.

## Concurrent payment creation

Eligibility shown on the confirmation page is only a preview. The final
usage check runs again while the payment is saved. The shared save path locks
the code first. For once-per-user codes it also locks the participant's `user`
row, checks for an existing active payment using the same code, and saves before
committing. The locks are held only
for this short database operation, not while the customer completes an online
payment.

Migration `20260811123000_add_payment_voucher_usage_index.sql` keeps its deployed
historical filename. The rename migration changes the active index name to
`payment_promo_code_user_usage_idx`; the indexed columns and locking rule do not
change.

## Schema rename

Migration `20260813120000_rename_voucher_table_to_promo_code.sql` directly
renames the existing `voucher` table to `promo_code` and the supporting payment
index to `payment_promo_code_user_usage_idx`. It does not copy or alter rows and
does not leave a compatibility model, view, constant, method or route using the
old domain name. Re-execution is a no-op once the new names exist.

The migration runner creates and verifies the normal full database backup before
executing this migration. No additional manual database step is required. The
rename must be verified on test-next before promoting the same application SHA
to production. A code-only rollback to a version that still queries `voucher`
is not compatible with the renamed schema; if such a rollback is required, the
table must first be renamed back under the deployment maintenance procedure.

## Payment entry and traditional transfers

Payable-item screens expose one primary action labelled `Opłać`. It opens the
payment screen, where a supported discount code or voucher code can be applied. For items that
previously displayed a separate traditional-transfer button (`z`, `i`, `k`,
`b`, and `p`), the payment screen now shows the alternative below a separator.
It warns that traditional transfers are posted with a delay and opens the
existing bank-account instructions through a POST form protected by a CSRF
token scoped to the payable type and record ID.

Traditional-transfer details are resolved from the payable record identified by
the submitted type prefix and ID. The participant is derived from that record
and access is allowed only to the participant or their parent account. Direct
GET links and requests with a missing, invalid or mismatched CSRF token are
rejected.

## Verification

1. Run the rename migration twice on an isolated database and verify that
   `promo_code` is the only base table and retains the original row count.
2. Create and edit a temporary record through `PromoCode` after the rename.
3. Generate and edit single and batch discount codes in the new administration
   screens, including XLSX download.
4. Apply fixed and percentage `i` codes to a one-participant individual lesson,
   verify that they are rejected for two to four participants, and verify that
   an `ia` code remains valid for each supported participant count. Confirm that
   `z` and `a` codes are rejected for individual lessons.
5. Confirm one reusable-once-per-user discounted payment and leave it unpaid;
   another payment for the participant must reject the code until the first
   payment is made inactive.
6. Submit two confirmations concurrently for the same participant and code;
   exactly one discounted payment must be created.
7. Verify every former two-button screen now shows one `Opłać` action and that
   the payment page opens the correct traditional-transfer instructions through
   POST with a valid scoped CSRF token.
8. Verify that direct GET, missing-token and mismatched-token requests to the
   transfer instructions are rejected.
9. Verify a child-owned payable item shows the child's transfer title and that
   an unrelated account cannot open its transfer details.
10. Run `tests/rollpoints_standard_code_test.php` on a prepared isolated
    `codex_rollpoints_*` schema. It covers ordinary single-use races between
    different accounts, once-per-user limits, unlimited reuse, expiry and
    rollback when the usage marker cannot be saved, as well as the four normal
    checkout paths for codes issued by RollPoints.
