fix(financial): attribute revenue to what was actually sold
Measured against the live oc-sport database, subscription revenue read
457,970 EGP against a genuine 345,257 — overstated by 32.6% — while the
per-programme breakdown summed to 39,873, about 12% of reality.
Three distinct causes:
POSService::buildInvoiceItems() discarded the item_type/item_id it was
handed, so every POS line landed with a NULL itemable_type. Reporting
reads NULL as "programme subscription", which moved 102,000 EGP of
product sales into subscription revenue — 90% of the error — and meant
no product-ownership check could ever pass. Lines now carry their
Product or Kit. A migration backfills history by matching invoice lines
to their POS lines, filling only NULL rows and only where the match is
unambiguous; a production dry run matched 44 of 45 with 0 ambiguous.
Pro-rata allocation divided by invoices.total_amount, but line totals
sum to subtotal_amount — total_amount also carries discount, tax and
service fees. Every bundled invoice was therefore split on the wrong
denominator (10,713 EGP).
topPrograms joined enrolments to invoices and dropped anything without
an invoice_id. Only 88 of 350 enrolments have one, so 75% of programmes
reported zero. Now a UNION: the exact link where it exists, participant
fallback where it does not, split evenly across a participant's
programmes. Reconciles at 333,105 EGP.
Also: the mounted revenue widgets and the receptionist dashboard omitted
direction='inbound', counting refunds as income, and the widgets' raw
queries bypassed SoftDeletes and cancelled invoices.
EnrollExistingWizard read BasePrice directly, ignoring membership type
and every pricing rule, so it quoted a different figure than the
registration wizard for the same player. Both now go through
PricingService.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing