Skip to content

1.9.79: the LTV customer reads answer money as numbers - #305

Merged
tracking202 merged 2 commits into
1.9.79from
1.9.79-ltv-customer-numbers
Oct 28, 2026
Merged

tracking202 merged 2 commits into
1.9.79from
1.9.79-ltv-customer-numbers

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Backlog: RKE4

What changed and why

GET /ltv/customers/{id} answered mysqli's DECIMAL strings beside integer ids, while GET /ltv/summary (and the rest of the API, since 17305ee) answers money as JSON numbers: one field, two types, depending on the endpoint an agent read. Measured on an instance built from origin/1.9.79 and seeded with a customer holding two sales with line items, a refund, two subscriptions and engagement:

Field before (origin/1.9.79) after (this branch)
data.total_revenue / refunded_amount / mrr string "54.12500" / "5.00000" / "39.99000" number 54.125 / 5 / 39.99
data.subscriptions[].amount / mrr string "120.00000" / "10.00000" number 120 / 10
data.recent_events[].amount string "-5.00000" number -5
data.recent_events[].items[].quantity / unit_price / amount string "1.500" / "24.50000" (or null) / "10.12500" number 1.5 / 24.5 (null stays null) / 10.125
GET /ltv/customers rows: total_revenue / refunded_amount / mrr strings numbers
GET /ltv/customers/{id}/engagement: browsing[].conversions / events[].event_value string "1" / "12.500" integer 1 / number 12.5 (null stays null)
GET /ltv/customers/{id}/next-offer already numbers unchanged
data.custom_fields of a customer with no value [] (a list; {...} with a value) {}

Counts other than conversions (order_count, active_subscription_count, billing_interval_count, clicks) and every id were already integers (mysqlnd's binary protocol types integer columns) and stay so; text that may be all digits (primary_ref, postal_code, phone, alias_value, external_sub_id, transaction_id, sku) stays a string.

Where the casts are. 17305ee has no shared helper: it cast the LTV summary inside MysqlLtvRepository::summary() ("so the Customer LTV page reads the same"), the CRUD base in numericDecimals(), and the click reads at the edge. These reads follow the summary, in the same repositories, so every reader gets one type: MysqlLtvRepository::customers() (the summary's loop), MysqlCustomerCrmRepository::get() (a decimals() helper with 17305ee's edge rule, is_string && is_numeric -> float, so a NULL unit_price stays null) and MysqlEngagementRepository::customerEvents() / customerEngagement(). That also covers every write that answers a customer (POST and PATCH /ltv/customers, merge, alias, all through customerDetail()) and DELETE /ltv/customers/{id}?dry_run=1's record. LtvController changes only customerDetail() and the erasure preview, for custom_fields (below); the webhook code the three open LTV branches change is untouched.

custom_fields was {...} for a customer with a value and [] without (PHP's empty array). It is converted at the API edge (LtvController::presentCustomer()), not in the repository, because the customer page reads it as an array.

Readers swept by field name (CLAUDE.md #28).

  • PHP pages that read the same repositories (ltv_customer.php modal, sort_ltv.php table, ltv_download.php export, ltv_merge_search.php): all cast already. Captured signed in on the instance before and after: byte-identical (tokens masked), and they show the seeded values ($54.13, ×1.50, 75%).
  • Go CLI: no struct decodes these answers (ltv customers, ltv engagement render generically; --all re-encodes rows). TestStructsDecodeAPIMoneyAsJSONNumber now also names refunded_amount, mrr and unit_price; the --all fake sent "total_revenue":"10" (the old contract) and now sends numbers, with an assertion that the amount survives the re-encode as a number. Tables: the list showed 54.125 before and after; the detail's key-value view showed 39.99000, now 39.99; --csv writes 39.99 where it wrote 39.99000.
  • PHP CLI (ltv:customers, ltv:engagement): generic Formatter, casts to string for display.
  • MCP (go-cli p202 mcp and the hosted engine): the catalog has no LTV tool, so no LTV reader there.
  • Reference agent and eval checks (tests/fixtures/agent-eval/): the ltv cases read amounts through jq tonumber, which takes a number or a numeric string; nothing to change.
  • @group instance suites and tests/live/: none reads an LTV customer.
  • The one unit fake that leaves out a selected column (EngagementEventTest, no event_value) now sends it as the server does and asserts the number.

Docs. docs/openapi.yaml had additionalProperties: true for these answers (no field typed as string, nor as anything else); it now types them: LtvCustomer, LtvCustomerDetail (allOf LtvCustomer) with LtvCustomerAlias, LtvCustomerSubscription, LtvCustomerRevenueEvent, LtvCustomerLineItem, and LtvCustomerEngagement with LtvBrowsedCampaign, LtvEngagementEvent. documentation/api/20-ltv.md gains a "Numbers" paragraph. Both changelogs: a Changed: line.

Tests

tests/Ltv/LtvDatabaseIntegrationTest.php (@group integration, real server: the types are the server's, which a fake connection would only repeat), extending the file's existing "numbers, not DECIMAL strings" cohort check rather than adding a parallel one:

  • testCustomerReadsServeNumbers: summary, list, detail, engagement, next-offer and the erasure preview through LtvController, each walked whole for a numeric string outside a listed text field (so a DECIMAL column added later is seen), plus exact values (54.125, 1.5, a null unit_price, a null event_value, conversions 1) and digit-only text staying text. This also holds the summary 17305ee changed, which had no test.
  • testCustomerAnswersHaveTheSpecsKeysAndTypes: each answer's keys equal the OpenAPI schema's properties, and each value's JSON type is the one the schema states (a list or an object judged by json_encode, so [] for an object fails).

Planted and caught (each restored and cmp-checked): list rollups uncast; summary amounts uncast; detail customer row, subscriptions, events, line-item quantity each uncast; a NULL unit_price cast to 0; conversions uncast; event_value uncast; the spec stating mrr as string, leaving a served key out, listing a key not served, unit_price not nullable; the custom_fields {} conversion removed. Go: the --all fake sending a string, and a struct decoding mrr into a string, each fail their test.

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, on the committed branch, with a scratch MariaDB 10.11 database for the schema tier and no 202-config.php in the checkout (so the cron entry-point test ran rather than skipping):

Scope report
------------
  go         PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  golangci   PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Report the lines above verbatim. Do not write "tests pass".

phpcs: 6 files examined, 0 made worse. unit: 6104 tests, 4 skipped (the documented "User class requires DB singleton" skips). The --changed run on the working tree before committing reported phpcs FAIL (two test files over HEAD's warning and error counts); the new lines were wrapped and the run above is after that.

Also run, outside the ladder:

  • The LTV integration suites, each alone in a database created for the run, on the final code: LtvDatabaseIntegrationTest (45 tests), LtvBodyValuesIntegrationTest (10), NestedBodyValuesIntegrationTest (8), ForeignRecordNamesIntegrationTest (10), ReClickCountsOnceIntegrationTest (3), LtvProductPredictionIntegrationTest (6): all OK.
  • Live instance from this branch (MariaDB 10.11, PHP 8.3, php -S on 8153): the four endpoints' JSON types before (on origin/1.9.79's code) and after, as in the table above; the pages before/after; p202 ltv customers, ltv customers <id>, ltv engagement <id>, --csv, --all --json through the built Go CLI.
  • Eval cases that read customers (tests/fixtures/agent-eval/cases/ltv.json, 6 cases, reference agent, no judge): before, on an instance of origin/1.9.79's code seeded for the first time, 6/6 needs_judge (their deterministic checks pass); after, on a fresh instance of the final branch, 6/6 needs_judge, the same command counts.

What could not run, and how the instance ran without the licence

The licence stub is down. The instance was installed without the licence form: install.php refuses an unverified key server-side, so the schema, the account and its API key were made by the installer's own classes from a script (INSTALL::install_databases(), InstallAccount::create(), an INSERT into 202_api_keys, partitions), not through the web form. It was then served with outbound HTTP(S) pointed at a dead proxy, so every licence check got no answer and failed open as designed (nothing reached my.tracking202.com): with that, sign-in to the pages and the Go CLI both worked, so the live CLI path did run (p202 ltv ... and the eval cases through it). Not exercised: the licence gate itself, and the web installer's licence step.

No judge ran for the eval rubrics (needs_judge): only their deterministic halves were checked.

For the reviewer

  • Money is now a PHP float: a DECIMAL(16,5) value beyond about 15 significant digits loses its last digits in JSON, as 17305ee accepted for the summary and the click reads.
  • git merge-tree against 1.9.79: LTV webhooks go through the export webhooks' guard and sender #278 (1.9.79-ltv-webhook-guard), 1.9.79: a dead LTV webhook can be revived, and is sent what it missed in the last 7 days #289 (1.9.79-ltv-webhook-revive) and 1.9.79: LTV Settings reads the row a button names exactly #296 (1.9.79-ltv-delete-webhook-id): only changelogs.txt and 202-config/changelogs.json conflict (every 1.9.79 PR appends there); LtvController.php, docs/openapi.yaml and documentation/api/20-ltv.md merge cleanly.
  • quantity is DECIMAL(12,3) and the API accepts 1.5, so it is a number, not an integer.
  • The repository approach changes what the pages receive (floats); I checked every page reader casts and diffed their output rather than assuming.
  • Outside this item, the same DECIMAL/SUM() strings remain in other LTV answers: measured, GET /ltv/subscriptions (amount, mrr, customer_ltv) and GET /ltv/breakdown?by=product (total_revenue, orders, units, aov, mrr, repeat_rate, avg_revenue_per_customer); by reading (fetchAll() with no cast), /ltv/breakdown by campaign, traffic source account and landing page, /ltv/products (price), /ltv/companies, /ltv/abm and /ltv/abm/company (whose contacts carry the customer fields order_count, total_revenue, mrr). After this PR GET /ltv/subscriptions answers a subscription's amount/mrr as strings while the customer detail's embedded subscriptions answer numbers. I left them for a follow-up item rather than widen this one.

Generated by Claude Code

GET /ltv/customers/{id} answered mysqli's DECIMAL strings beside integer
ids: "total_revenue": "54.12500", "mrr": "0.00000", each subscription's
amount and mrr, each revenue event's amount, and each line item's
quantity ("1.500"), unit_price and amount. GET /ltv/customers did the
same for the rollups, and the engagement read answered SUM()'s "1" for a
campaign's conversions and "75.000" for an event's value, while
GET /ltv/summary has answered numbers since 17305ee: one field, two types,
depending on the endpoint read.

The casts are where 17305ee put the summary's, in the repositories, so
every reader gets the same types: MysqlLtvRepository::customers(),
MysqlCustomerCrmRepository::get() (a NULL unit_price stays null; quantity
is a number, DECIMAL(12,3), 1.5 as well as 2) and
MysqlEngagementRepository's customer reads. The pages that read them
(the customer modal, the customer table, the download, the merge search)
already cast; their output is byte-identical before and after on a seeded
instance. Every write that answers a customer (POST/PATCH, merge, alias)
and the erasure's dry_run record answer the same, through customerDetail()
and crm->get(). next-offer already answered numbers.

The same endpoint answered custom_fields as {} with a value and [] with
none (PHP's empty array): it is an object either way now, at the API edge,
since the page reads it as an array.

docs/openapi.yaml types the answers (LtvCustomer, LtvCustomerDetail and
its parts, LtvCustomerEngagement). LtvDatabaseIntegrationTest holds them on
a real server: every customer read and the summary walked whole for a
numeric string outside a text field (a DECIMAL column added later is seen
too), the values exact, and each answer's keys and JSON types equal to
the spec's schema. Each defect was planted and caught. The Go CLI decodes
no struct from these answers; its money floor now names refunded_amount,
mrr and unit_price, and the --all fake answers numbers as the server does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 28, 2026 10:09
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T10:09:58.902496Z
We couldn’t complete this review. Comment @codex review to try again.
82b51c8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For 82b51c8: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For f7d314e: 3 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 3db1f65 into 1.9.79 Oct 28, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants