Post-Race Reports Functional Requirements
1. Business Context
After every event the organiser has to account for the money the event brought in and settle the day licences it sold on behalf of Cycling South Africa (CSA). Both are produced by hand today, from the order data and the timing export. This document states what the two reports must contain so that they can be produced the same way for every event.
1.1. Business Drivers
-
Event accounting - State the income an event earned, per product and per price, in a form the organiser can hand to a bookkeeper
-
Discount visibility - Show what was given away, and separate discounts that belong to a product from those that do not
-
CSA settlement - State how many day licences are owed to CSA and name the riders they were issued to
-
Repeatability - Produce the same report for every event without re-deriving the rules
1.2. Scope
This requirements document covers:
-
The Event Income Report
-
The CSA Day Licence Report
-
Draft queries for each sub-report, for a Jasper Reports or custom-screen implementation
Out of scope:
-
Payment processor reconciliation and fees, which are covered by Financial Management
-
Money taken outside the system, such as cash at the registration desk
-
Calculation of points or standings
-
Cross-event and season roll-ups
1.3. Delivery
The reports are first produced on demand as a downloadable workbook. They are intended to move to either a custom screen or Jasper Reports. The queries in Draft Queries are written one per sub-report, which is the shape Jasper Reports needs.
2. Definitions
| Term | Meaning |
|---|---|
Paid order |
A sales order with status |
Event line item |
An order line item linked to an event participant of the event being reported. |
Line amounts |
As defined in Order Line Item Amounts: |
Price step |
A distinct pre-discount unit price at which a product was sold. A product whose price changed during the entry period has one price step per price. |
Line-item discount |
A discount recorded on the line item itself. It belongs to a product and reduces that product’s income. |
Order-level discount |
A discount recorded on the order as a whole, such as a coupon or a gift voucher. It does not belong to any one line item. |
Day licence product |
The product the event names as its day licence ( |
Recorded a result |
The participant started the event: an active result set of the event holds a row for them with a finishing place or a status showing that they rode, such as did not finish, dropped, lapped or disqualified. A participant marked as did not start, or with no result row at all, did not arrive and has not recorded a result. |
3. Event Income Report Requirements
3.1. FR-PR-001: Income per product and price step
| ID | FR-PR-001 |
|---|---|
Title |
Income per Product and Price Step |
Priority |
High |
Description |
The system shall report the income from the event line items of paid orders, broken down per product and, within each product, per price step. |
Acceptance Criteria |
|
3.2. FR-PR-002: Line-item discounts
| ID | FR-PR-002 |
|---|---|
Title |
Line-Item Discounts Grouped with Their Product |
Priority |
High |
Description |
The system shall report line-item discounts on the same row as the product and price step they were given against. |
Acceptance Criteria |
|
3.3. FR-PR-003: Order-level discounts
| ID | FR-PR-003 |
|---|---|
Title |
Order-Level Discounts as a Global Expense |
Priority |
High |
Description |
The system shall report order-level discounts as a single expense for the report, separate from product income. |
Acceptance Criteria |
|
Attribution |
An order can hold line items for more than one event, and an order-level discount then cannot be split between them. The report includes the whole discount of every paid order that has at least one event line item. Where every such order relates to the one event, the discount is treated as an expense of that event. Where an order spans events, or also holds a line that is not an event entry, the supporting list flags the order so that the reader can decide how to treat it. |
3.4. FR-PR-004: Income summary
| ID | FR-PR-004 |
|---|---|
Title |
Income Summary |
Priority |
High |
Description |
The system shall close the report with a summary that takes the reader from line-item income to net income. |
Acceptance Criteria |
|
4. CSA Day Licence Report Requirements
4.1. FR-PR-010: Day licence income
| ID | FR-PR-010 |
|---|---|
Title |
Day Licence Income Collected |
Priority |
High |
Description |
The system shall report the quantity and amount of day licences sold on paid orders for the event. |
Acceptance Criteria |
|
4.2. FR-PR-011: Day licence holders with a result
| ID | FR-PR-011 |
|---|---|
Title |
Day Licence Holders Who Recorded a Result |
Priority |
High |
Description |
The system shall list the day licence holders who recorded a result in the event, with the amount each paid. |
Acceptance Criteria |
|
Rationale |
A day licence is owed to CSA for every rider who arrived and started, including one who was later dropped or disqualified. The amount for riders with a result is therefore normally less than the amount collected, because some buyers never arrive. |
4.3. FR-PR-012: Result source
| ID | FR-PR-012 |
|---|---|
Title |
Results Taken from the Event’s Result Sets |
Priority |
Medium |
Description |
The system shall decide whether a participant recorded a result from the results imported for the event. |
Acceptance Criteria |
|
Notes |
Importing results asks for a points system even when no points are wanted. Where one has to be chosen only to complete the import, the Western Province Cycling Association system is used. |
5. Non-Functional Requirements
5.1. NFR-PR-001: Reproducibility
| ID | NFR-PR-001 |
|---|---|
Title |
Reproducible Figures |
Description |
The same event shall produce the same figures however the report is delivered. |
Acceptance Criteria |
|
5.2. NFR-PR-002: Personal information
| ID | NFR-PR-002 |
|---|---|
Title |
Protection of Personal Information |
Description |
The CSA Day Licence Report carries identity numbers and shall be restricted accordingly. |
Acceptance Criteria |
|
6. Limitations of the Current Data
These are properties of the order data that a reader of either report needs to know.
-
Coupon codes are not held. An order records the amount of its order-level discount, not the coupon that produced it. A breakdown by coupon has to come from the shop.
-
A shop discount is recorded in one of two shapes. The shop spreads a coupon across the lines of its own order. Some orders arrive with the discount at order level only and no line-item discount. Others carry it on the lines as well as on the order, and the order discount then equals the sum of the line discounts. Deducting only the order-level remainder (FR-PR-003) reports either shape once. A report can show an empty Discount column and still be correct.
-
A line price can differ from the shop’s. Where the shop prices a product higher than the line item recorded for it and then discounts the order in full, the order-level discount exceeds the line total. Net income is then understated against the money received, by the difference in price.
-
Money taken outside the system is absent. A late entry or a day licence paid for at the registration desk appears in neither report unless the order is marked paid.
-
The CSA ID has two homes. It is held either on the person record (
wp_users.csa_id) or as person metadata (csa_id). The report reads the first and falls back to the second.
7. Draft Queries
One query per sub-report, in MySQL, with :eventId as the only parameter.
In Jasper Reports each becomes the dataset of one sub-report and :eventId becomes $P{eventId}.
All of them reach an event through order_line_item.event_participant_id.
event_participant.order_id is not populated and must not be used.
7.1. Income per product and price step
Serves FR-PR-001 and FR-PR-002.
Product totals and the grand total are group footers in the report, not rows of the query.
price is already net of any line-item discount, so the pre-discount price adds the discount back, and the sub total reads net, falling back to price where net is not recorded.
SELECT p.id AS product_id,
p.name AS product,
oli.price + COALESCE(oli.discount, 0) AS unit_price,
COUNT(*) AS qty,
SUM(oli.price + COALESCE(oli.discount, 0)) AS price,
SUM(COALESCE(oli.discount, 0)) AS discount,
SUM(COALESCE(oli.net, oli.price)) AS sub_total
FROM order_line_item oli
JOIN sales_order so ON so.id = oli.order_id
JOIN event_participant ep ON ep.id = oli.event_participant_id
JOIN product p ON p.id = oli.product_id
WHERE ep.event_id = :eventId
AND so.status = 'P'
GROUP BY p.id, p.name, oli.price + COALESCE(oli.discount, 0)
ORDER BY p.name, unit_price;
7.2. Order-level discounts
Serves FR-PR-003.
EXISTS rather than a join keeps an order to one row when several of its lines belong to the event.
order_expense is what FR-PR-003 deducts; on_lines is the part of the discount already reported against products.
spans_other_events is 1 when the order also holds a line for another event or for something that is not an event entry, such as a membership; order_lines_total then includes those lines.
SELECT so.id AS order_id,
so.number AS order_number,
so.payment_date_time AS paid_on,
(SELECT SUM(COALESCE(l.net, l.price))
FROM order_line_item l WHERE l.order_id = so.id) AS order_lines_total,
so.discount AS order_discount,
(SELECT SUM(COALESCE(l.discount, 0))
FROM order_line_item l WHERE l.order_id = so.id) AS on_lines,
GREATEST(0, so.discount - (SELECT SUM(COALESCE(l.discount, 0))
FROM order_line_item l WHERE l.order_id = so.id)) AS order_expense,
EXISTS (SELECT 1
FROM order_line_item l
LEFT JOIN event_participant ep ON ep.id = l.event_participant_id
WHERE l.order_id = so.id
AND (ep.event_id IS NULL OR ep.event_id <> :eventId)) AS spans_other_events
FROM sales_order so
WHERE so.status = 'P'
AND COALESCE(so.discount, 0) <> 0
AND EXISTS (SELECT 1
FROM order_line_item l
JOIN event_participant ep ON ep.id = l.event_participant_id
WHERE l.order_id = so.id AND ep.event_id = :eventId)
ORDER BY so.payment_date_time, so.id;
7.3. Income summary
Serves FR-PR-004. Every sum is coalesced to zero, so an event with no paid line items reports zero income rather than an empty total.
SELECT li.paid_orders,
li.line_items,
li.price AS line_price,
li.line_discount,
li.line_net AS line_sub_total,
od.order_discount,
od.refunds,
li.line_net - od.order_discount - od.refunds AS net_income
FROM (SELECT COUNT(DISTINCT so.id) AS paid_orders,
COUNT(*) AS line_items,
COALESCE(SUM(oli.price + COALESCE(oli.discount, 0)), 0) AS price,
COALESCE(SUM(COALESCE(oli.discount, 0)), 0) AS line_discount,
COALESCE(SUM(COALESCE(oli.net, oli.price)), 0) AS line_net
FROM order_line_item oli
JOIN sales_order so ON so.id = oli.order_id
JOIN event_participant ep ON ep.id = oli.event_participant_id
WHERE ep.event_id = :eventId AND so.status = 'P') li
CROSS JOIN
(SELECT COALESCE(SUM(GREATEST(0, so.discount - (SELECT SUM(COALESCE(l.discount, 0))
FROM order_line_item l
WHERE l.order_id = so.id))), 0) AS order_discount,
COALESCE(SUM(so.refund_amount), 0) AS refunds
FROM sales_order so
WHERE so.status = 'P'
AND EXISTS (SELECT 1
FROM order_line_item l
JOIN event_participant ep ON ep.id = l.event_participant_id
WHERE l.order_id = so.id AND ep.event_id = :eventId)) od;
7.4. Day licence income
Serves FR-PR-010.
SELECT COUNT(*) AS qty,
COALESCE(SUM(COALESCE(oli.net, oli.price)), 0) AS amount
FROM order_line_item oli
JOIN sales_order so ON so.id = oli.order_id
JOIN event_participant ep ON ep.id = oli.event_participant_id
JOIN event e ON e.id = ep.event_id
LEFT JOIN series s ON s.id = e.series_id
WHERE ep.event_id = :eventId
AND so.status = 'P'
AND oli.product_id = COALESCE(e.product_day_license_id, s.product_day_license_id);
7.5. Day licence holders with a result
Serves FR-PR-011 and FR-PR-012. The quantity and amount totals are the report footer.
SELECT ep.id AS event_participant_id,
ep.first_name,
ep.last_name,
NULLIF(ep.identity_number, '') AS id_number,
COALESCE(NULLIF(u.csa_id, ''), csa.meta_value) AS csa_id,
so.number AS order_number,
COALESCE(oli.net, oli.price) AS amount_paid
FROM order_line_item oli
JOIN sales_order so ON so.id = oli.order_id
JOIN event_participant ep ON ep.id = oli.event_participant_id
JOIN event e ON e.id = ep.event_id
LEFT JOIN series s ON s.id = e.series_id
LEFT JOIN wp_users u ON u.ID = ep.person_id
LEFT JOIN wp_usermeta csa ON csa.user_id = ep.person_id
AND csa.meta_key = 'csa_id'
AND csa.meta_value <> ''
WHERE ep.event_id = :eventId
AND so.status = 'P'
AND oli.product_id = COALESCE(e.product_day_license_id, s.product_day_license_id)
AND EXISTS (SELECT 1
FROM race_result rr
JOIN result_set rs ON rs.id = rr.result_set_id
WHERE rs.event_id = ep.event_id
AND rs.active = 'Y'
AND rr.event_participant_id = ep.id
AND (rr.position IS NOT NULL OR rr.status IS NOT NULL)
AND COALESCE(rr.status, 'FIN') <> 'DNS')
ORDER BY ep.last_name, ep.first_name;
The first four queries have been run against production data.
This one has been checked for syntax only: no event yet holds both imported results and day licence sales, so its result filter has not been proven on real rows.
It assumes one csa_id metadata row per person, which holds today; a second row would duplicate the licence line.
8. Open Questions
-
Result rows with neither place nor status. A timing export can carry a row with no placing and no status. The definition above treats it as not having started; whether such a rider arrived cannot be told from the row.
-
Orders spanning events. FR-PR-003 flags them; how the discount is then shared is undecided.
-
Desk payments. Whether money taken at the registration desk is captured as paid orders, so that these reports cover it, is undecided.