# RollPoints

## Scope and entry points

RollPoints is an opt-in account-level loyalty ledger. The rules come from the
supplied `Regulamin_RollPoints.pdf` and the approved implementation decisions.
There is no workflow for changing the terms or closing the program. Future
changes require a separate code change and communications outside this module.

- `/rollpoints/informacje/`: public summary, full HTML terms and PDF download.
- `/rollpoints/`: enrollment, balances, lot history, referral code, rewards and
  withdrawal; authenticated account owner only. The page uses the shared account
  navigation and the same primary card layout as the other account sections.
- `/rollpoints/regulamin/`: downloadable terms. Former/current members retain
  access when public availability is disabled.
- `/rollpoints_admin/`: participants, independent email/SMS consent filters
  and XLSX export; administrators only.
- `/rollpoints_admin/uczestnik/{id_user}`: ledger, corrections, freezes,
  issued-code history and audit history.
- `/rollpoints_admin/obozy`: preview and commit a camp import.
- `/rollpoints_admin/nagrody/`: list of temporary reward offers.
- `/rollpoints_admin/nagroda/` and `/rollpoints_admin/nagroda/{id}/`: separate
  pages for adding and editing one temporary reward.
- `/rollpoints_admin/promocje/`: list of promotional earning periods.
- `/rollpoints_admin/promocja/` and `/rollpoints_admin/promocja/{id}/`: separate
  pages for adding and editing one earning period.
- `/rollpoints_admin/rozliczenia/`: pending, failed and ambiguous payment grants.

`ROLLPOINTS_ENABLED` is a Boolean database setting under system features.
Migration inserts `0` only if absent. Enable it using the existing configuration
screen after verification. Do not add it to the ignored `config.php` file.
Disabling it hides public/client menu links, the public information page,
sitemap entry and referral registration field, and refuses enrollment and
exchange. It does not stop grants already due to members, remove history,
prevent withdrawal, cancel membership, or invalidate issued codes. The admin
panel and authenticated history/PDF URLs remain available. Direct account and
administrative URLs use `noindex, nofollow`.

## Acceptance and delivery

The current terms identifier is `v1`. Before the program launch, its HTML and
tracked PDF were revised together to clarify discount coupons as possible
catalog rewards and include accelerated courses, equipment rental, Flex Open
90-day passes, sports-section payments and other purchases. The fixed reward
table now lives in the public catalog linked from section 4; favorable additions
and lower point costs follow the catalog rules in section 10. The service
prepayment rule remains distinct from payment for merchandise and other goods.
This is an authorized correction of the first version,
not a new terms version or an acceptance/notification workflow. Public descriptions
and terms state participant-facing eligibility and deadlines; internal payment
categories and import mechanics belong in this technical documentation only.
The tracked PDF is `application/Resources/documents/rollpoints-v1.pdf`. SHA-256:
`a60dbd65a2a37df11622629a69653a5003b720032e9fb3f0d8dbe126848334a0`.
The matching HTML is `application/view/rollpoints/terms_v1.php`. The five-page
PDF was regenerated from that HTML using ReportLab with embedded Carlito fonts;
its complete extracted text matches the HTML, excluding running headers and
footers. The catalog URL is clickable in both formats. All pages were visually
checked. Subsequent prelaunch text corrections must update both files and the
checksum assertion in `tests/rollpoints_interface_test.php` together.
Existing test enrollment evidence is not rewritten by this correction.

Enrollment requires a separate unchecked acceptance and a confirmed email.
It stores the terms version, PDF checksum, enrollment time, email at enrollment
and confirmation queue ID. Account/membership creation, audit and `CommQueue`
insertion share a transaction. Failure queues no orphan confirmation and
creates no membership. Existing main terms acceptance and marketing consents
are untouched.

The editable `StaticText` email template `mail_rollpoints_joined` supports
`[[user]]`, `[[joined_datetime]]`, `[[terms_version]]`, `[[rollpoints_url]]`.
Its description and placeholder explanations in administration are Polish;
`20260914110300_translate_rollpoints_email_metadata.sql` updates those fields
without replacing the editable email body.
The URL token is a plain escaped URL, used in the template's link destination.
The PDF is attached through the existing email queue. Queue retry and DEV mail
routing use the existing configuration; no new SMTP settings or direct mail
sender are introduced. A missing attachment fails the send attempt rather than
sending a confirmation without its document. After launch, retain the exact
accepted PDF while any membership or queued message refers to it; future terms
changes must preserve those copies. The prelaunch correction keeps the same
download route and attachment path. It requires no migration, configuration
change or manual file copy outside normal tracked-code deployment. Verify the
download checksum and a new enrollment's queued attachment against the file
above. Reverting this prelaunch correction requires restoring its HTML, PDF,
summary, FAQ and checksum assertion together.

The PDF delivery is an intentional durable-copy implementation. It avoids
depending on whether a mutable web page alone satisfies a particular legal
classification of the free program. This does not introduce a terms-change
notification mechanism.

## Ledger and concurrency

`RollPoints` contains the constants, catalog and ledger rules. `RollPointsRecord`
reuses MINI3 `load()`, `getSet()`, `set()` and `save()` for allowlisted records.
Administration delegates transaction handling to `RollPoints::transaction()`;
there is one savepoint/error-mode implementation. All cooperating models use the framework's injected PDO connection; no module
opens its own connection directly. Custom parameterized SQL stays in models
for joins, aggregates, reports, locking and atomic updates. Controllers and
views do not execute queries. Mutations lock the paying account, and nested
operations use savepoints. The tables are InnoDB.

Reads after an account lock use current locked records where a prior
REPEATABLE READ snapshot could otherwise survive the wait. Automatic payment
and referral grants carry the expected membership identifier, so a concurrent
withdrawal and rejoining cannot move an old entitlement into a new membership.
The shared `Mini\Core\Model::update()` also uses a locking read when already
inside a transaction before its immediate update of the same row. This keeps
the standard `save()` path able to update a row committed by another
transaction after the caller's earlier snapshot. Referral corrections pass
their known account owner, verified after locking, to preserve account-before-lot
lock order while handling such newly committed lots.

- `rollpoints_account`: persistent account/referral code and outstanding debt.
- `rollpoints_membership`: each enrollment period and its acceptance evidence.
- `rollpoints_lot`: one idempotent grant per `(source_type, source_key)`.
- `rollpoints_reward`: immutable issue snapshot with the literal code, reward
  label, point cost, issue/expiry dates and unique exchange request key. It has
  no reference to a discount-code row or later code-use status.
- `rollpoints_audit`: before/after balance changes, actor and reason, exchange
  lot usage and explicit payment recall history.
  The participant admin view labels self-service enrollment, exchange, referral
  registration and self-service withdrawal with the user ID; administrative
  actions retain the administrator label. Historical actor IDs are unchanged.
- `rollpoints_payment`: durable payment state and the original eligibility
  snapshot, including the settlement or recall intent.
- `rollpoints_promotion`: scheduled earning-rate promotions, their conditions,
  availability window and administration metadata.
- `rollpoints_referral` and `rollpoints_referral_purchase`: registration link
  and first-purchase candidates per person.
- Camp, temporary offer and administrative audit tables implement
  their corresponding workflows without changing the permanent catalog.

Every lot distinguishes effective payment/settlement date from actual addition.
Its ordinary expiry is 12 calendar months from addition, inclusive through the
last second of the date in Europe/Warsaw; February 29 maps to February 28.
Imported/backdated effective dates do not shorten this period. History shows
granted, reward-used, debt-used, remaining, revoked, expired and frozen points.
Expiry is settled for the affected account when reading its balance/history
and before an exchange or other ledger mutation. The exchange checks expiry
even if no account page has been opened. Frozen points expire on the same
terms. There is no scheduled expiry sweep of inactive accounts.
The account lists its nearest expiry dates and a combined history of point
grants and reward exchanges, ordered by actual addition/issue time, newest
first. Exchanges show the saved reward label, issue time and negative point
cost. Grants retain their receipt/settlement date, addition time, original
points, per-lot usage, remaining points, corrections, freezes and expiry.
The grant's positive value is the original award; current per-lot counters are
shown separately. The table is not a running-balance reconstruction of every
administrative adjustment.
`activityHistory()` merges lot and issued-reward records before applying a
single 50-row pagination. Equal timestamps sort exchanges before grants and
then use descending record IDs. `history()` remains the lot-only API used by
administration and ledger operations. Issuance snapshots provide exchange
entries even when older audit entries have no lot breakdown or an ordinary
promo code is later edited/deleted. No additional database writes or tables
are needed, and history remains accessible after withdrawal or flag disablement.
It has no separate feed of administrative correction/freeze reasons. The
detailed operation log, reasons and authors remain available in administration;
removing the participant section does not delete audit data.
The available-points card is always visible. Frozen-points and negative-balance
cards appear only while their respective frozen/debt amounts are positive;
point-lot history remains accessible after resolution.
Each reward shows a Bootstrap progress bar based on available points only,
capped at its cost. Fractional percentages round down so 100% appears only
when the full cost is covered. Current/required points remain visible as text;
frozen points do not advance progress. Percent widths use SCSS classes rather
than inline styles.

Exchange consumes unfrozen lots with the earliest expiry first. Existing debt
repayments stay assigned to their original lots: a new short-lived grant cannot
replace a completed repayment and free older points again. Reversing spent
entitlement creates debt; new grants cover outstanding debt before becoming
available. Expired unused points never repay a newly created debt. Issued-code
changes do not restore spent points. Previously expired or withdrawn points do
not become available again merely because the user joined again.

Freezes reserve part of the remaining lot and do not extend expiry. An explicit
expiry extension is a separate audited compensation. Technical grants and
corrections require an administrator and reason. Withdrawal expires unused
entitlement, retains debts/audit and leaves issued codes valid. Deactivation
performs withdrawal before scrubbing the account. Automatic account merging is
blocked for accounts or beneficiaries with RollPoints/referral history, actual
payment-linked point lots, or unresolved payment events attributable to membership
or a referral at receipt time. A technical event for an ordinary nonmember
payment does not block merging, including pending/error events.

## Payments and referrals

`payment.id_user_payer` and `payment.received_datetime` record the actual payer
and receipt time independently of Fakturownia. Manual cash/bank payments accept
a validated nonfuture receipt date and time, defaulting to now. P24 sets the
time on the first successful confirmation, not checkout creation. Repeated or
stale callbacks preserve the original receipt time and payer. Existing paid
records with no such timestamp are not backfilled.

The base rate grants 1 RP per complete 1,000 groszy of each qualifying payment.
An eligible promotional period can award additional points as described below.
The receipt must fall within participation. Included: single group lessons,
Classic, Flex, Flex Open (including the 90-day `ok` variant), individual lessons,
accelerated courses, equipment rental for classes booked through the Rollschool
system, sports-section period payments, gift-voucher purchase and other purchases,
such as merchandise. Directly purchased sports-section passes (`sek`) also
qualify. All monetary payment methods are eligible: cash, P24, bank transfer,
on-site card and receipts recorded as donations, regardless of the linked
service or purchase. This does not reintroduce retired payment methods into
administrative forms or recalculate historical payments.

Single group and individual lessons require receipt strictly before the lesson
start. Passes, courses, rentals and sports-section payments do not add a separate
start-date comparison. A prepaid pass posted late earns points for the full paid
amount; entries are not prorated. Explicit arrears/balance settlement is excluded
regardless of its linked service. Other exclusions are technical or zero-money
settlements, legacy paper-voucher settlement, value covered by vouchers/codes
and pre-membership receipts. Retired or technical pass types outside the eligible
list earn no points. Discounts reduce the eligible cash amount. Camp payments use
administrator-calculated imports instead of automatic grants.

Other purchases use a `target_type = other` snapshot with the payment ID as
`id_target` and the participant-facing description `Inny zakup`. Classification
uses `Payment::isUnlinked()`: neither the payment's explicit purchase references
nor any purchase's reverse `id_payment` link may exist. This prevents an existing
linked service with a missing forward reference from being credited as an
additional purchase. Genuine unlinked purchases are settled without waiting for
a service assignment. The same monetary payment methods qualify, including
donations and on-site card; technical entries, voucher-funded value, arrears
and zero amounts remain excluded. The receipt-time membership, promotional rate,
one-grant-per-payment key and explicit
recall rules are shared with other purchases. The freeform payment comment is
not copied into the participant-facing point description. The public summary
and terms call these `inne zakupy`, without exposing linkage or payment-record
mechanics, and do not require merchandise to be purchased before its receipt.
New other-purchase payments capture this snapshot when first saved as paid.
When an older pending/error event has no snapshot, recovery as `other` retains
zero eligible amount and `fully_prepaid = false`; editing an old unlinked
payment therefore cannot award newly eligible historical points. This does not
prevent an incomplete recognized service payment from completing its original
assignment and receiving its due grant.

The expanded service scope applies to new payment settlements through the same
captured-payer, receipt-time, promotional-rate, idempotency and explicit-recall
workflow. Existing processed payment snapshots and awarded points are unchanged;
there is no historical recalculation, migration, scheduled scan or backfill.
The expansion does not add course, rental, sports-section or other purchases to
referral bonuses. Flex Open 90-day passes qualify for the existing pass referral bonus.

Children's and other beneficiaries' purchases accrue to the captured payer.
A guest voucher purchase matches an active owner by normalized email only
when the match is unique and the owner participated at receipt time. Ambiguous
matches enter `review` for staff explanation, not automatic reassignment.
The administrator verifies identity/participation and resolves the payer on the
review screen with a reason. Resolution grants the canonical payment-linked
lot atomically so later payment recall still applies to it. A reasoned
no-points resolution is available when the purchase does not qualify.
Unmatched nonmembers earn no RP.

Payment save records settlement data in its transaction. Once a targeted payment
is fully linked to its purchase and committed, processing runs at the end of
the same PHP request. Other purchases are ready after the paid, genuinely
unlinked payment is committed. Only IDs saved or explicitly corrected in that
request are processed, without a batch limit or a scan of older pending/error records.
Idempotency is enforced by payment and lot keys. A later link saves the payment
again and keeps the actual receipt date. Payments with an incomplete service
assignment remain pending until that assignment; they are not picked up by
unrelated payments. Operational errors remain recorded for manual investigation,
with no periodic recovery or
automatic retry across requests. Ambiguous payer matches require staff action.

The existing payment-edit form is unchanged by RollPoints. Editing its amount,
channel, comment or visibility neither recalculates nor revokes points and does
not write a RollPoints audit entry. It exposes no receipt-date correction,
change-kind, reason or partial-refund fields. The receipt timestamp and original
purchase snapshot remain frozen. Partial refunds are not registered in RollPoints.

Only explicit payment recall (`Payment::recall()`) records a full loyalty
reversal and revokes the original and referral RP. Rescheduling or retaining
payment credit does not revoke points. Restoring visibility or editing accounting
data after recall does not restore RP or referral bonuses, including when the
edit precedes deferred reversal processing. An independent audited point
correction remains available in participant administration. Restoring visibility
does not reattach purchase links removed by payment recall.

Each recall writes a `payment_change` entry in `rollpoints_audit` in the payment
transaction. Its `before_json` preserves the previous intent, reason, actor and
processing status; `after_json` stores `id_payment` and `change_kind = full_reversal`.
The audit stores the reason, actor and operation time. The frozen payer determines
the audit account; an unresolved guest remains unattributed (`id_user = 0`).
There is no separate payment-change table or processing-attempt counter.

Referral codes use the first given name and surname initial, normalized to
ASCII uppercase letters, followed by a hyphen and five random letters, e.g.
`RADOSLAWG-KMRTX`. Polish diacritics are converted to their basic letters.
The given-name portion is capped at 13 letters to fit the existing 20-character
column; names without usable letters use a `ROLL` prefix. The random suffix
omits I and O. The unique database index rejects collisions, and account
creation retries with a new suffix inside the enrollment transaction.
The code remains stable after a name change or withdrawal/rejoining. Registration
ignores letter case and surrounding whitespace. This format is introduced
during local development before referral codes have been distributed.

Referral codes are accepted only at new owner registration. A referred owner
does not need membership. The referrer receives 20 RP for a first fully prepaid
single group purchase or 50 RP for a pass, separately for each beneficiary on
the referred account. Candidates use actual full-payment time; late posting
can replace the earlier decision through correction rather than a second
bonus. A genuine cancellation remains the first decision and cannot award a
replacement bonus on the next purchase. Corrections to erroneous eligibility
are reversible. Referrers must qualify at the relevant purchase time.
Shared surname/phone alone is not disqualifying. Available account history
guards repeat registration/self-referral, with the adult former-child exception.
Registration is serialized by normalized email as well as the referrer lock.
Email availability is checked against active independent accounts only.
Child profiles contain names and a birth date, without their own email or phone;
contact goes through the parent. A former child registers a first independent
account with their own email through the normal flow. Registration does not
match child profiles by email, names or age, require a special child-profile
verification, or automatically link the new account to a child record.
Owner login/reset lookups exclude dependent child profiles.
Pre-deployment deleted identities cannot reliably be reconstructed; ambiguous
historical claims require administrative evidence and a reasoned adjustment.

## Promotional earning rates

`RollPointsPromotion` selects the rate using the actual receipt time in
`payment.received_datetime`, not the processing time or the date when the points
appear. A period includes `valid_from` and excludes `valid_to`, in Europe/Warsaw.
For example, a promotion ending at November 30, 00:00 applies through November
29 and does not include a receipt at midnight on November 30. Past periods
remain stored so that a late-booked payment receives its original applicable
rate. No scheduled worker activates or ends a promotion: the date-range lookup
selects it when needed.

The configured amount is the total effective conversion: 800 groszy means
`floor(eligible_amount_cents / 800)` RP, not the base points plus that result.
A 100 PLN payment therefore earns 12 RP at 8 PLN or 20 RP at 5 PLN, instead
of the standard 10 RP. The extra entitlement is the difference from the base
rate. Each payment is rounded down separately, using integers in groszy.
This affects ordinary eligible payments only; referral bonuses remain 20/50 RP,
and camp imports continue to accept the administrator's already calculated RP.

The existing `rollpoints_payment.snapshot_json` stores `earning_rate`, including
`amount_cents`, `id_promotion`, `label`, `conditions`, `valid_from` and `valid_to`.
The applied rate is retained with the receipt's original eligibility snapshot.
Automatic grants and administrative resolution of ambiguous voucher payers use
the same rate-selection/calculation rules. A payment awaiting review can
therefore receive promotional points after the promotion ends. Already granted
lots are not recalculated when a promotion is created, edited or finished;
ordinary payment edits continue to have no loyalty effect. Full payment recall
reverses the original lot, including its additional promotional points.

Participant point history shows the recorded campaign name and conversion below
the awarded RP, for example `Promocja „Black Friday” — 1 RP za każde pełne 5 zł`.
The same annotation appears on the lot in administration. Both history APIs
load the payment snapshots for the displayed lots in one batch and expose only
the campaign label and amount as `earning_promotion`. The recorded payer must
match the lot owner. The annotation uses the original snapshot rather than
current campaign definitions, so it also appears for earlier promotional grants,
ended campaigns, recalled or expired lots, former members, and a disabled program.
Base-rate grants, nonpayment entries and records without valid saved promotional
metadata have no annotation. No historical values are recalculated or rewritten,
and this display change needs no migration.

Administrators schedule future periods with a name, start/end date and time,
conditions, and an amount from 0.01 PLN through 9.99 PLN per RP. Overlapping
enabled periods are rejected. Scheduled periods may be corrected normally before
they start. Editing or disabling an existing period whose start is in the past
requires explicit emergency confirmation and a reason, including ended periods
and disabled drafts. Emergency editing also permits moving an existing period's
start into the past or reactivating it. Newly created active periods must still
start in the future; emergency mode cannot create a new period. Overlap checks,
administrator authorization and CSRF protection apply in both modes. Saves retain
the actor, reason, previous and new values, and emergency-mode marker in the
administration audit, within the same transaction.

An emergency change affects subsequent rate selection for receipts without a
saved eligibility snapshot, including late-booked payments from the edited
period. Disabling a period restores the standard rate for those receipts unless
another enabled period applies. Existing receipt snapshots, pending receipts
with a saved rate, and granted lots retain their original conversion and points.
There is no automatic historical re-award or reversal; any necessary adjustment
to awarded points uses the separate audited lot-correction operation. The admin
form and shared confirmation modal explain these effects before saving.

Promotion and temporary-reward lists contain summaries and links to add or edit;
they contain no embedded or expandable forms. Each editor has a back/cancel link
to its own list. Every save, including a new definition, opens the shared
confirmation modal after native form validation. The confirmation uses the
currently selected enabled state: enabled definitions ask to save and launch
within the configured period; disabled definitions ask to save as disabled.
Canceling the modal keeps the completed form without a request. The form's
external JavaScript supplies only the confirmation wording; domain validation
and authorization remain on the server.

Each editor's POST and CSRF scope are bound to its route record (or `new`), and
a mismatched hidden record ID is rejected. Validation errors preserve safe
submitted values on the same editor; successful saves redirect with Flash to
the corresponding list. Legacy POST requests to a list do not save: they
redirect to the list with instructions to open the separate editor. All four
admin routes have private page titles and remain excluded from indexing and
the sitemap. This interface separation introduces no data migration and does
not change campaign earning, reward issuance or existing point balances.

While a promotion is active, both the public information page and the account
page show its name, effective total rate, exact period and escaped conditions.
The public summary and enrollment introduction use the same active rate.
Conditions must be announced before the promotion starts; staff remain
responsible for announcing future campaigns through their chosen channels.
The standard 1 RP/10 PLN rule remains in `terms_v1.php` and its matching PDF.
Promotional information shows the effective rate in place of the base rate,
without suggesting that the two rates stack. This does not introduce a terms-change
workflow or replacement acceptance. Turning `ROLLPOINTS_ENABLED` off hides
the promotional information; due grants still use the rate applicable at
receipt time. Reward costs, existing points, expiry, balances and issued codes
remain independent of the earning conversion.

## Rewards

| Reward | Free service (RP) | 50% discount (RP) | 20% discount (RP) | Promo target |
| --- | ---: | ---: | ---: | --- |
| Single group lesson | 250 | 150 | 100 | `z` |
| Individual hour, one participant | 700 | 400 | 250 | `i` |
| Classic 4h | 800 | 500 | 300 | `k`, `4he` |
| Flex 4h | 850 | 510 | 320 | `k`, `4h` |

`RollPoints::baseCatalog()` is shared by the public summary and exchange logic.
Terms section 4 allows a reward to be a code for a free service or a fixed/percentage
discount coupon, with its benefit and conditions disclosed before exchange.
The complete catalog point cost is required; RP cannot directly pay for a
purchase and missing RP cannot be supplemented with money. Issuing a reward
and the value covered by its code earn no RP; an actual payment after a coupon
discount may qualify under section 2. The public summary and FAQ use this
distinction. The permanent catalog has twelve entries: four existing free-service
rewards and their 50%/20% discount variants. The original keys remain stable;
new keys append `_discount_50` or `_discount_20` to the service key. Every entry
defines its point cost and `percent` (the discount, not the remaining price).
`RollPoints::rewardServices()` supplies the four canonical service definitions;
temporary-reward administration uses these once each, with a separate percent
selector. `baseCatalog()` adds the configured permanent discount costs. Permanent
and temporary rewards share code issuance, benefit display, confirmation and
immutable issue-label logic. Existing codes, exchanges and point balances are
unchanged. The additional catalog entries require no schema or configuration
change and do not create temporary offers or earning promotions.

The informational catalog is a four-row comparison table at
`https://rollschool.pl/rollpoints/informacje/#katalog-nagrod`, with free-service,
50% and 20% columns populated from the same catalog as exchanges. Temporary
offers appear separately below with their period and conditions. The table
scrolls horizontally on small screens. The participant exchange catalog at
`#katalog-wymiany` groups the permanent variants into four service cards, with
one shared service heading and compact "Bezpłatnie", "Rabat 50%" and
"Rabat 20%" headings in the normal text color. Each card compares the three variants side by side on
larger screens and stacks them on phones, without nesting extra cards.
Each variant retains its own point price, available-points progress, shortfall
and exchange form. Disabled exchange buttons retain their label and show a
decorative closed-lock icon; enabled buttons have no lock. The form still submits the exact reward key, CSRF token and
exchange request key and confirms the selected benefit and point cost before
submission. Available temporary offers appear in a separate
`#nagrody-specjalne` section, with the green special-reward badge, explicit
discount, full offer name, availability dates and conditions. An empty special
section is omitted. The permanent and temporary variants reuse the same
exchange-form partial; grouping does not change eligibility, balances, code
issuance or existing rewards. Both sections require active membership and an
enabled program, while already issued codes remain in their existing history.
The existing public page metadata and feature-flag gating apply to the catalog.

Terms section 4 links to this catalog rather than repeating names and prices.
Section 10 permits favorable additions and point-cost reductions without
changing the main terms, provided existing reward conditions are not worsened.
Other changes remain subject to its reasons, notice and participant-protection
rules; moving the table does not authorize unrestricted changes. Any future
terms or catalog-change communications remain manual, outside the application.

An exchange atomically debits the points, records their lot usage in the audit, creates
an ordinary `PromoCode`, and saves the issued code as literal text in the reward
history. A repeated request returns the same issue; concurrent requests cannot
overdraw. Issued codes provide the selected benefit for the specified service,
are transferable, single-use, and valid for 12 months. No point/cash top-up or
direct part-payment with RP exists. A fully code-covered purchase earns no new
RP because no qualifying money was paid. An eligible advance payment after a
50%/20% discount earns points from the actual amount paid through the existing
payment logic.
The single `exchange` audit entry references `id_reward`; its `after_json`
contains `points` and `lots: [{id_lot, points}, ...]` in consumption order.
The live per-lot `used_points` counters remain the source for balances and
participant history. No separate allocation table or code-use tracking is
required. An audit-write failure rolls back the point debit and code issuance.

RollPoints ends its reward workflow at issue. Participant and administrator
history shows the saved code, label, cost, issue time and original expiry;
there is no later usage, cancellation or expiry status, code synchronization,
or automatic point restoration. The original issue record remains unchanged
when the code is used or edited. Redemption and further changes use the existing
discount-code functionality and its ordinary editor. The records have no
bidirectional database relationship. Any independent point correction still
requires the standard audited administrative operation and reason.

Temporary offers provide their own availability window, conditions and cost.
Administration selects the service and its `percent`: existing 100% coverage,
or a 50%/20% discount. These are the only supported values, independent of the
point cost; adding variants does not create any offers automatically. Missing
legacy input defaults to 100%. Model validation rejects other values, including
arrays and decimal strings. The public/account catalogs and exchange confirmation
show the discount and service from model data, independently of the administrator's
free-text description. Issued history and the ordinary code comment retain the
reward name with its discount suffix. To fit the existing 200-character snapshot,
50%/20% offer names allow up to 188 characters; full-service names still allow 200.
The percent is audited and, like other offer terms, cannot change after the offer
starts. Disabling an offer preserves issued codes and history.

`20260914110500_add_rollpoints_reward_percent.sql` adds only
`rollpoints_temporary_reward.percent`, defaulting existing rows to 100. Run it
before serving the new code; it is additive and repeatable. No new settings or
files outside tracked code are needed. For local/test verification, check all
all twelve permanent entries at their configured point costs and all four
temporary service types at 100%, 50% and 20%, ordinary code applicability and
discount calculation, repeated exchanges, published-offer immutability and
unchanged existing 100% rewards.
Run `tests/rollpoints_reward_discount_test.php` on an isolated RollPoints database.
Before rolling back to code that always issues 100% codes, disable any 50%/20%
offers through administration; leave the added column and already issued ordinary
codes in place. Old code must not issue new rewards from active partial-discount
offers because it would incorrectly generate full-service codes.
Both participant reward cards and the public catalog mark them with a subtle
green "Nagroda specjalna" badge and clock icon; permanent catalog rewards have no such marker.
Disabling an offer prevents new exchanges and leaves previous issue records and
the already-created ordinary codes unchanged.

## Camps

TXT imports accept UTF-8, optional BOM and CRLF, one `email integer_points`
record per line. Maximum 1 MiB / 5,000 rows. Staff supply the stable trip code,
name, trip end date and effective settlement date, and are responsible for
payment/installment and membership qualification. Preview shows matches,
membership dates, errors, totals and earlier imports. More than 14 days after
trip end requires acknowledgement. The commit revalidates the preview and
membership under locks; one invalid row rolls back the complete batch.

Unique `(camp, account)` prevents duplicate grants. Exact repeats do not add
points. Changed amounts must use the participant's audited lot correction.
File/preview hashes, request ID, actor, dates and result are retained. Preview
expires after 30 minutes and is not committed by merely uploading a file.

Participant history shows only the camp name, without its administrative import
code. `activityHistory()` resolves camp names through the account's camp-entry
and lot links in batch for the displayed page. This also covers existing lots
whose saved descriptions include a code; stored descriptions, administrative
history and audit remain unchanged. Parentheses belonging to the actual name
are preserved, and the name is escaped by the view. If a valid account-scoped
link is missing, the row retains its generic trip label without exposing the
raw import description. This presentation change requires no data migration.
`tests/rollpoints_camp_history_test.php` verifies imported names, account-scoped
links, missing/ambiguous links, pagination and unchanged administrative data in
an isolated transaction. Pure interface fixtures check name escaping.

Complaints are handled outside the application using the contact details and
deadlines in the terms. RollPoints has no complaint register, receipt dates,
response deadlines or resolution workflow. Administrators can independently
freeze, release or correct points with a mandatory reason and audit history.
The participant's complaint instructions and the immutable terms remain intact.

## Deployment

1. Deploy the tracked code and PDF through the normal release process, first to
   test-next. The additive migrations are `20260914110000_add_rollpoints.sql`,
   `20260914110100_add_rollpoints_payment_tracking.sql`,
   `20260914110200_add_rollpoints_administration.sql`,
   `20260914110300_translate_rollpoints_email_metadata.sql`,
   `20260914110400_add_rollpoints_promotions.sql`, and
   `20260914110500_add_rollpoints_reward_percent.sql`. Migration `104` adds only
   the promotion table; payment rate snapshots use the existing JSON column.
   Migration `105` adds the temporary reward percentage, defaulting existing
   offers to 100%. No promotion is created or activated by migration. The
   additional eligible service types require no further migration. The standard runner makes
   a complete backup before applying them. Do not edit applied migration files.
2. Verify the flag is `0`, PDF checksum matches, template exists, and the
   existing email worker/DEV recipient routing are configured. No files in
   ignored `Files/` are required, and there are no manual PDF copies or new
   config.php constants. Ordinary code checkout supplies the PDF.
3. Verify payment-linked grants after the request completes, then check expiry
   through balance/history reads and an exchange attempted without an earlier
   balance read. Do not install a RollPoints worker or cron entry. The existing
   email queue handles confirmation delivery separately.
4. Enable the feature through configuration only when ready. On local, this
   is a manual UI step after migration; defaults remain disabled for other
   environments. Production promotion requires its own authorization.

Rollback can disable new enrollment/exchange immediately using the flag while
preserving due grants and existing codes. A code rollback keeps all additive
tables/columns and PDF. There is no RollPoints scheduler configuration to add
or remove. Do not delete the ledger or restore a stale database over payments
to undo a code release.

### Local pre-release schema cleanup (2026-09-13)

The owner confirmed that RollPoints migrations had run only on local DEV and
explicitly authorized revising the original administration migration. This
one-time pre-release revision removes the entire `rollpoints_complaint` table
definition from `20260914110200_add_rollpoints_administration.sql`. No complaint
columns exist in other RollPoints tables. Fresh deployments create only the
remaining administration tables; automatic migrations contain no destructive
cleanup statements. Applied migrations remain immutable after release.

For the existing `rollschool.localhost` database, cleanup was completed after a
full verified backup using `scripts/database-migrations.php backup rollschool.localhost`.
The DEV dump required `DEPLOY_DATABASE_DUMP_SKIP_TLS_VERIFY=1`, which the backup
runner rejects outside DEV. The backup is
`/var/www/sites/deployment-backups/rollschool.localhost/rollschool.localhost_20260913_230553_f04ea7ec3e68.sql.gz`
(container path), SHA-256
`b04967ca3123d6fec877606d36c6f1b365f40fccdcd423b9fc046f34ba893926`.

The local-only maintenance operation, guarded by the DEV host, verified backup,
expected old checksum and migration advisory lock, dropped `rollpoints_complaint`
(one local record) and removed one `rollpoints_admin_audit` row whose
`entity_type` was `complaint`. All point lots, memberships, reward allocations,
point audit entries and unrelated administrative audit entries were preserved.
The single local `deployment_migration.checksum_sha256` for the revised file
was updated from
`86d37b3c32924a2161ffd104d26d787edaa9fe90932ab67ab7335d0c895b64df`
to `1edb92e4e1d409de0dcd6dbda5662b83a5a624f6ca245abf35c41b5e81574c4a`.
The standard migration status then reported 26 applied and zero pending files.
Do not run this cleanup or rewrite migration history on test or production.

Verification uses a fresh isolated schema without the removed table, the
administration and ledger tests, and the local participant/report screens.
If the removed local register must be recovered, extract only its table and
the corresponding audit rows from the verified dump, restore the prior local
code/migration and its original checksum together. Do not overwrite newer
payments or ledger data by restoring the complete database.

## Verification

### Local pre-release simplification to code issuance (2026-09-13)

Before release, the owner reduced rewards to ordinary code issuance and
confirmed the migrations were local-only. The original
`20260914110000_add_rollpoints.sql` now stores a literal `rollpoints_reward.code`
instead of `id_promo_code`, `status`, `target_type` and `pass_type`; code target
restrictions remain ordinary `promo_code` fields. Allocation rows no longer
have `is_reversed`. `20260914110100_add_rollpoints_payment_tracking.sql` no
longer adds `promo_code.id_rollpoints_reward` or its unique index. Neither
automatic migration contains a destructive cleanup statement.

The existing local schema was changed separately after verifying that no
reward or allocation rows existed and no promo code referenced a reward.
Before cleanup the standard backup runner created
`/var/www/sites/deployment-backups/rollschool.localhost/rollschool.localhost_20260913_232300_f04ea7ec3e68.sql.gz`
(container path), SHA-256
`6cdde16bc274125a4994763fa40baaf7a37b18fa3cf6faa1acd2601e7534b1d4`.
The local maintenance command required DEV, the exact local host, the verified
backup, both old migration checksums and the deployment advisory lock. It
replaced only the empty reward-history fields, removed the unused allocation
flag and promo-code relationship, and verified that existing promo codes,
point lots, frozen points, memberships, payment events and audits were unchanged.

Only the two corresponding local migration-history checksums were updated:

| Migration | Previous SHA-256 | Final SHA-256 |
| --- | --- | --- |
| `20260914110000_add_rollpoints.sql` | `ebbda8fba51f73ee9da749891b5d974091f8cbf53e8e1c55249e0873a2613bbc` | `f9bff9f941bb648afce708721c6a7fad56791de83633b5597ab5b8677d5d1a6b` |
| `20260914110100_add_rollpoints_payment_tracking.sql` | `c1ac84ea1e1c043ff8f6b91f758a9e0947d3233de90dd9fda84c0ea1e8e96b43` | `a48921800d97f05be7c28399ff58431d0f577373563118164838025b826068e3` |

Fresh migration verification also starts with empty configuration/template
tables and a pre-RollPoints payment schema. All four migrations run twice:
the initial feature flag is `0`, a later manual `1` survives replay, and the
removed complaint/lifecycle fields are not recreated.

Migration status reports 26 applied files and zero pending files. This
pre-release cleanup is not a test/production deployment step. After any
external deployment, retain the normal immutable-migration rule. Recovery of
the previous local schema requires the matching prior code, schema definitions
from the verified backup, and the original two checksums together; preserve
any data created after the backup instead of restoring the entire database.

### Local pre-release audit consolidation (2026-09-14)

The owner approved removing the separate `rollpoints_allocation` and
`rollpoints_payment_change` tables and the unused `rollpoints_payment.attempts`
column. Their definitions were removed from the two original local-only
migrations. Fresh deployments create 13 RollPoints tables. Automatic migrations
contain no destructive statements; no test/production cleanup is required.
New exchanges and payment changes retain their details in `rollpoints_audit`
as described above. Existing audit records remain valid without rewriting.

Before local cleanup, the standard backup runner created and verified
`/var/www/sites/deployment-backups/rollschool.localhost/rollschool.localhost_20260914_001350_f04ea7ec3e68.sql.gz`
(container path), SHA-256
`70fb0a2494e41678163438c2eb94231b0b6bec36605868d1a69647890df0b26a`.
The one-time maintenance operation required DEV, the exact local host, this
verified backup, the previous migration checksums and the deployment advisory
lock. Both removed tables were empty, so no historical rows needed transfer.
It dropped only those tables and the unused column, then compared record
fingerprints for every retained RollPoints table, `payment` and `promo_code`.
All retained records were unchanged.

Only the two corresponding local migration-history checksums were updated:

| Migration | Previous SHA-256 | Current SHA-256 |
| --- | --- | --- |
| `20260914110000_add_rollpoints.sql` | `f9bff9f941bb648afce708721c6a7fad56791de83633b5597ab5b8677d5d1a6b` | `9fecfb6b5d515bad76a9adb633e883e0ce75eaadb6d72609dbd77c7034886ff8` |
| `20260914110100_add_rollpoints_payment_tracking.sql` | `a48921800d97f05be7c28399ff58431d0f577373563118164838025b826068e3` | `b8eef55b4476a3b56554560ca172c3e7a03dad2a2a923eca33cd58fe2e1dfc18` |

This cleanup is complete on local DEV only, not an external deployment step.
Recovery requires the prior schema definitions from the verified backup and
matching prior code/checksums. Preserve newer data; do not restore the entire
database over later payments. Any exchanges or payment edits made after this
cleanup remain in the consolidated audit if the old schema is restored.

### Test suites

Use `scripts/prepare-rollpoints-tests.php` with `RP_TEST_DATABASE` naming a new
`codex_rollpoints_*` database on DEV. It clones schema and configuration/template
rows only, not user/payment data. RollPoints tables are created by the current
migrations rather than copied from the local schema. Each
RollPoints integration test requires that explicit isolated database; default
model connections also use it. Never run them against the application's main DB.
The legacy receipt, receipt-administration, individual-lesson payment/confirmation
and P24 registration tests use the same guarded `database_test_bootstrap.php`.
They require the explicit isolated database, load database settings (including
pass prices), and shadow all RollPoints tables alongside their temporary payment
fixtures. `payment_test_isolation_test.php` rejects missing/unsafe database names
and checks that those subprocesses leave the underlying tables unchanged.
`rollpoints_review_regression_test.php` covers edits without loyalty effects,
explicit recall, visibility restoration before/after reversal processing,
and merges with and without actual loyalty history.
Run `tests/rollpoints_schema_test.php` immediately after preparation on its own
fresh schema. It checks the absence of both removed tables and the attempts
column, the required tables, and schema/feature-setting preservation on replay.

Run the ledger, payment eligibility/workflow, referral, administration and
interface tests. They cover rounding, receipt timing, owner/child attribution,
exclusions, refunds, late posting, FEFO/freeze/debt/expiry, replay and independent
process exchange races, atomic code issue, immutable issued-code history after
ordinary code edits, stale membership snapshots including
withdrawal/rejoining during a grant, rollback, membership and email queue,
referrals, imports and permission/CSRF contracts. Referral regressions also
create child profiles through `User::saveChild()` without invented contact
details, allow a former child's first independent account, and reject an
existing owner email with or without a referral code. Payment tests cover more
than 25 saved payments in one request and ensure unrelated older pending/error
records are not processed. Ledger tests verify expiry on balance/history reads
and on a direct exchange without a previous read. They also assert exact audited
lot usage for FEFO and repeated/concurrent exchanges. Payment workflow tests
assert full-reversal audit entries and unchanged points after ordinary edits,
the original payer, guest attribution and rollback on audit-write failure.
Referral tests also cover
a newly committed first-purchase candidate/lot, refund followed by a
stale correction, and registration after the referrer withdraws. Camp imports
recheck metadata after locking, so a concurrent use of the same code for a
different camp is rejected without partial grants. Also run existing payment,
gift-voucher, promo-code and authentication regressions. Inspect changed PHP
syntax and `git diff --check`. Build Sass only when requested for local visual
verification; never hand-edit its output.

`tests/rollpoints_referral_code_test.php` verifies name normalization, code
length, case-insensitive entry, stability after account changes, and real
unique-index collisions with retry or full enrollment rollback.

Run `tests/rollpoints_additional_services_test.php` on the same prepared isolated
schema to verify accelerated-course, rental, sports-section and Flex Open
90-day payment save/link/flush flows. It commits synthetic fixtures only in the
isolated schema so processing runs after the actual transaction commits. It
checks parent-payer attribution, replay, expiry, membership, promotional rates,
donation/card earning across eligible services, technical exclusions and the
unchanged referral scope. Fixtures remain in that test schema; the fixture promotion is
disabled after the run. No application data is used. For example, after preparing
`codex_rollpoints_additional_services`:

```bash
docker compose -f /Users/radek/Docker/localdev/docker-compose.yml exec -T --workdir /var/www/sites/rollschool.localhost -e RP_TEST_DATABASE=codex_rollpoints_additional_services web-rollschool php tests/rollpoints_additional_services_test.php
```

`tests/rollpoints_standard_code_test.php` covers ordinary checkout for all four
rewards with RollPoints disabled, a different recipient and the issuer's
remaining points frozen. It verifies normal code editing/copying, service
restrictions, no points for the free service, failed-claim rollback inside an
existing transaction, concurrent single-use and once-per-user claims, unlimited
reuse and expiry. It runs only on the isolated schema without sending messages
or contacting a payment provider.

`tests/rollpoints_other_purchase_test.php` uses the same prepared isolated schema
and verifies the generic `Inny zakup` description, cash/bank/P24/card/donation confirmation,
parent attribution, per-payment thresholds, promotions, replay, explicit recall,
ordinary edits, membership boundaries and earning with the feature flag off.
It rejects broken direct service references and reverse-linked payments as other
purchases, and verifies that editing an older pending event without a snapshot
does not backfill RP. For a prepared `codex_rollpoints_other_purchase_20260914`:

```bash
docker compose -f /Users/radek/Docker/localdev/docker-compose.yml exec -T --workdir /var/www/sites/rollschool.localhost -e RP_TEST_DATABASE=codex_rollpoints_other_purchase_20260914 web-rollschool php tests/rollpoints_other_purchase_test.php
```

Browser checks: disabled menus/public route/referral field, manual flag enable,
desktop/mobile summary and account views, separate acceptance, administrator
participant/ledger/import/review screens, PDF response and private-page SEO.
Create fixtures and test writes only locally in isolated schemas. Test-next is
shared with testers: deployment verification must not create, modify or remove
campaigns, rewards or other application data without explicit authorization for
the particular operation.
No historical points are automatically granted by migration or by enabling.

Promotion verification covers receipts before, at and after both boundaries,
late booking and manual payer resolution, unchanged earlier grants and full
recall. Check rejection of overlaps, invalid rates and edits after the start
without emergency confirmation, and successful audited emergency edits.
Run `tests/rollpoints_promotion_test.php` and
`tests/rollpoints_promotion_payment_test.php` against a newly prepared isolated
`RP_TEST_DATABASE`, after the new migration has been included. The first covers
the rate and scheduling rules; the second exercises payment-linked grants.
`tests/rollpoints_promotion_emergency_payment_test.php` verifies that existing
granted lots and pending receipt snapshots keep their original conversion across
emergency rate changes and disabling, while newly captured receipts use the
updated or standard rate. It creates committed synthetic fixtures only in an
isolated `RP_TEST_DATABASE`. Emergency editing requires no schema migration.
Run `php tests/rollpoints_interface_test.php` for pure public/account/admin fixtures;
it checks both feature states without a database connection.
The pure interface fixtures cover current/base rates, integer and fractional
PLN formatting, existing/new participants, dates, escaped campaign text, and
hidden promotions while the feature is disabled. The immutable PDF checksum
continues to be checked.
The account-catalog fixtures also verify the four service groups and twelve
variant-to-price/key mappings, visible discount headings, exchange route,
confirmation and token preservation, point-based button availability, subset
catalogs and separately displayed temporary offers. Disabled and withdrawn
participants cannot see exchange groups even if catalog data is supplied.

The admin interface fixtures also verify separate lists and editors, safe values
after validation errors, record-specific actions and confirmation attributes.
Run `php tests/rollpoints_admin_editor_flow_test.php` for isolated controller
flows covering permissions, CSRF, route/hidden-ID consistency, validation and
redirects without a database connection. Run
`node tests/rollpoints_admin_form_test.js` for confirmation wording after changes
to the current name and enabled checkbox, including the final submit event.
Browser verification should cover opening Add/Edit from each list, canceling a
confirmation without saving, and the responsive editor layout. Perform write
tests only locally or on an isolated test database, never on shared test-next.

`tests/rollpoints_promotion_history_test.php` verifies historical campaign
annotations, pagination and account isolation on an isolated `RP_TEST_DATABASE`.
The interface fixtures verify placement under the credited RP, Polish amount
formatting, escaped campaign names and history visibility with the flag disabled.

The promotion migration was applied on local DEV after a verified backup on
2026-09-14. The isolated scheduling/concurrency and payment-promotion suites,
payment workflow, review regressions, administration, ordinary reward-code
checkout, and legacy payment-isolation suites passed. The public and admin
views were inspected using static fixtures with the application's existing
stylesheet at desktop and 390 px mobile width, without horizontal overflow.
Verification did not enable sample campaigns in the local application database.
Campaign definitions are environment-specific and are not copied by deployments.

The local flag was enabled through the configuration UI after the backed-up
migrations; migrations still default it to disabled elsewhere. Verification of
the current issue-only reward flow uses a newly prepared isolated schema so
removed lifecycle columns cannot hide accidental dependencies. Payment grants
and point expiry are verified without a separate RollPoints scheduled task.
