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 P (paid). Draft, unpaid, pending and cancelled orders are never included.

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 is the amount charged after any line-item discount, discount is the reduction granted on the line, and net is the revenue recognised, equal to price. The pre-discount price of a line is therefore price plus discount.

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 (event.product_day_license_id), or, where the event names none, the one its series names (series.product_day_license_id). The event-level value wins; this is the same resolution registration uses when it adds a day licence to an order.

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

  • Only event line items on paid orders are included

  • One row per product per price step, ordered by product then price

  • Each row shows the pre-discount unit price, the quantity, and three amounts: Price, Discount and Sub Total

  • Price is the sum of the pre-discount prices of the row’s line items, which is the unit price multiplied by the quantity

  • Each product has a total row, and the report has a grand total row

  • The price shown is taken from the line item as recorded at the time of the order, not from the product’s current price

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

  • Discount is the sum of the line-item discounts of the row

  • Sub Total is the revenue of the row’s line items (net), which equals Price less Discount

  • A line-item discount is subtracted once: price already excludes it, so Sub Total is never derived by subtracting Discount from price

  • A line item with no discount contributes zero, and the row still appears

  • Line-item discounts are not repeated in the order-level discount section

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

  • The expense is the part of each order’s discount not already carried on its line items: the order discount less the sum of that order’s line-item discounts, never below zero

  • The total of that expense over the paid orders is shown as one expense line, with the number of orders it came from

  • A supporting list shows each discounted order with its number, payment date, line total, order discount, the part already on its lines, and the resulting expense, so that the full discount stays visible even where none of it is deducted here

  • No discount is deducted twice: a discount recorded on the lines is reported under FR-PR-002 and contributes nothing to this expense

  • An order is counted once, however many of its line items belong to the event

  • The report states that order-level discounts cannot be attributed to a specific product, event or participant, because they may be gift vouchers or other interventions

  • Order-level discounts are never spread across products or price steps

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

  • The summary shows, in order: income from line items, less the order-level discount expense of FR-PR-003, less refunds, net income

  • The number of paid orders and of line items is shown

  • The report states the event, the date and time it was produced, and that it covers paid orders only

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

  • Only line items for the event’s day licence product on paid orders are counted

  • The quantity and total amount are shown

  • The figure includes every licence sold, whether or not the holder recorded a result

  • An event for which neither the event nor its series names a day licence product produces an empty report rather than an error

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

  • One row per day licence line item whose participant recorded a result

  • Each row shows first name, last name, identity number if provided, CSA ID if available, and amount paid

  • A missing identity number or CSA ID leaves the cell empty; it does not exclude the row

  • A participant who started is included whatever the outcome: placed, did not finish, dropped or disqualified

  • A participant who did not start, or who has no result, is excluded

  • The list is ordered by last name then first name

  • The list is totalled: quantity of licences and total amount paid

  • The report shows this total beside the total collected under FR-PR-010, so that the difference is visible

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

  • Only active result sets of the event are considered

  • The report does not require points to have been calculated

  • If the event has no imported results, the report says so rather than listing nobody

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

  • The workbook, the custom screen and the Jasper report use the same selection rules as Draft Queries

  • The grand total of FR-PR-001 less the order-level discounts of FR-PR-003 equals the net income of FR-PR-004

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

  • The report is available only to users authorised to administer the organising organisation

  • The Event Income Report contains no personal information

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.