Skip to content

1.9.79: every LTV read answers money as numbers and counts as integers - #313

Merged
tracking202 merged 4 commits into
1.9.79from
1.9.79-ltv-numbers-everywhere
Oct 28, 2026
Merged

tracking202 merged 4 commits into
1.9.79from
1.9.79-ltv-numbers-everywhere

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Backlog: N9MJ

Stacked on #305 (1.9.79-ltv-customer-numbers, backlog RKE4): this branch starts from #305's head (82b51c8) and must merge after it. Until #305 merges, this PR's diff against 1.9.79 also shows #305's commit; the change here is the second commit, d58d841.

What changed and why

After #305 the customer reads answered numbers and the rest of /ltv still answered mysqli's text, so a subscription's amount was 29.99 in GET /ltv/customers/{id} and "29.99000" in GET /ltv/subscriptions (CLAUDE.md #28: a field's JSON type is part of the contract). I enumerated the reads from api/v3/index.php's /ltv read group (18 GET routes) and measured every answer on a real server. These still served text:

Read Fields that were text
GET /ltv/subscriptions amount, mrr, customer_ltv
GET /ltv/breakdown by campaign, ppc_account, landing_page total_revenue, avg_ltv ("44.562500000"), aov, repeat_rate ("0.5000"), mrr, spend, cac, ltv_cac, and total_orders ("4")
GET /ltv/breakdown?by=product the amounts, units ("1.500"), orders
GET /ltv/products, PATCH /ltv/products/{id}, its delete's dry_run record price
GET /ltv/companies total_revenue, mrr, order_count, last_activity_time
GET /ltv/abm, /ltv/abm/company total_revenue, mrr, avg_time_on_page/avg_scroll_depth/avg_video_pct ("30.0000000"), and abm/company's last_activity (a GREATEST() that MariaDB answers as text)

The summary, cohorts, mrr, predict, fields, webhooks, deliveries and integrations already answered numbers.

  • One helper. 1.9.79: the LTV customer reads answer money as numbers #305's private decimals() is now Prosper202\Ltv\LtvNumbers. It has decimals() for money and measures, and integers() for counts and unix times.
  • Where the casts are. Each read goes through it where its SQL is, as 1.9.79: the LTV customer reads answer money as numbers #305 did: in the repositories, and in the controller for products, because that SQL lives there.
  • Every column is named. Each read lists every money and count column, not only the ones MariaDB happens to answer as text, because MySQL and MariaDB type COALESCE()/GREATEST() differently.
  • What is never converted. integers() leaves a value with a fraction unchanged rather than truncating it. NULL stays null: a product with no price, a subscription whose customer row is gone. Names, skus, product keys and references made of digits stay strings.

docs/openapi.yaml now gives every /ltv read a typed components schema:

  • LtvSummary, LtvAcquisitionBreakdownRow, LtvProductBreakdownRow
  • LtvCohort, LtvMrr/LtvChurnInputs, LtvPrediction/LtvPredictionInputs/LtvPredictionByGroup/LtvPredictionGroup
  • LtvProduct, LtvCompanyRollup, LtvCompany, LtvSubscription
  • LtvAbmAccount, LtvAbmContact, LtvField, LtvWebhook, LtvWebhookDelivery, LtvIntegration, LtvNextOffer

Before this, all but the customer reads were additionalProperties: true, and GET /ltv/abm's list was documented as an object.

Tests: #305's type tests now cover every read

All in tests/Ltv/LtvDatabaseIntegrationTest.php, on a real server.

testEveryLtvReadServesNumbers

  • The reads it covers are the GET routes parsed out of api/v3/index.php's /ltv group. A route added there fails the test until it is held to the same rule.
  • Each answer is walked for a numeric string outside a text field.
  • The newly numeric values are checked exactly: units 1.5, a null price, a company with no customer, a sku and an account name of digits that stay strings.

testEveryLtvAnswerHasTheSpecsKeysAndTypes

  • Each answer, plus the product/company writes and dry runs, must have its schema's keys and JSON types.
  • The check recurses into every schema a property $refs. 1.9.79: the LTV customer reads answer money as numbers #305's spec reader learned {$ref: ...}, items: {$ref: ...} and required: [...], the last so optional keys like fallback_reason and options_invalid are allowed.
  • The spec's 200 answer for each path must name that schema.

Plants. Each plant below made a named test fail, and each was restored with a verified backup:

Plant Caught by
Subscription amount cast removed both tests
Product price cast removed both tests
Company order_count cast removed both tests
ABM last_activity cast removed both tests
LtvSubscription.amount typed string in the spec spec test
An undocumented key added to LtvAbmAccount spec test (key mismatch)
GET /ltv/abm's answer put back to additionalProperties: true spec test (path does not name the schema)
A GET route added to the router group both tests (route set mismatch)
All of this PR's code put back to #305's both tests

AccountAdministrationIntegrationTest compared the PATCHed price as the string '12.50000'; it now asserts 12.5.

Readers swept by field name (#28)

  • Go structs and their fakes: no Go struct decodes these answers, and no LTV fake answers money as text. TestStructsDecodeAPIMoneyAsJSONNumber's money names now include customer_ltv, avg_ltv, aov, spend, cac and avg_revenue_per_customer; no struct field carries one.
  • MCP tools: the catalogue has no LTV tool.
  • Eval checks: ltv.json and the administration product case read amounts with tonumber, which reads both types.
  • @group instance suites, tests/live/: none read /ltv.
  • PHP pages: they cast every value they read (diffed below).
  • Go and PHP CLI tables: these change; see below.

Live, before (#305 head 82b51c8) and after (this branch)

The licence stub is down. I installed with install.php's steps without the licence form, into p202_n9mj_live, and served php -S on 8163 with every proxy variable pointed at the closed port 127.0.0.1:9. The licence check failed open; nothing reached the licence service. Then:

  • I seeded with tests/fixtures/agent-eval/seed.sh (Go CLI).
  • I added LTV data over the API: two click conversions with customer refs at company Acme, revenue with line items, a refund, two subscriptions, a priced product, a second company, engagement events, a select field, an integration, and a webhook with a delivery inserted by SQL.

Results:

  • API answers (23 reads + the PATCH/dry-run answers): every leaf's JSON type changed exactly where the table above says and nowhere else. With numeric strings read as numbers on both sides, every answer has the same values before and after.
  • Pages: byte-identical before and after. These were sort_ltv.php by campaign, ppc_account, landing_page, product and abm; ltv_subscriptions.php, ltv_companies.php, ltv_company.php, ltv_products.php and ltv_customer.php, all signed in.
  • Go CLI tables (p202 ltv subscriptions|products|companies|abm|breakdown): values the same. An amount now shows to the cent (9.50 / 27.50 where they showed 9.5 / 27.5), a zero repeat_rate shows 0 (was 0.0000), and units shows 2 / 1.50 (was 2.000 / 1.500).
  • PHP CLI tables (ltv:subscriptions|companies|abm|breakdown): the decimal text's trailing zeros are gone (29.99 where they showed 29.99000, 13.75 where they showed 13.750000000).
  • Agent-eval LTV cases (ltv.json, 6 cases) and ltv-product-001, on the after instance with the reference agent: 0 fail; every case reports needs_judge, because without --judge-cmd the rubric is not graded. Their mechanical checks passed.
  • Not exercised live: the landing_page breakdown answered [] there, because the seed's clicks come through the direct-link tracker. The integration test covers that read with a real row.

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed (scratch DB p202_n9mj_schema):

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
  • Unit: 6104 tests, 5 skipped. The fifth skip is CronEntryPointsFailLoudlyTest's "this checkout has a 202-config.php", from the live instance's config in the worktree.
  • Integration group, run by hand against a scratch DB:
    • tests/Ltv/LtvDatabaseIntegrationTest.php: OK, 47 tests.
    • tests/Api/V3/AccountAdministrationIntegrationTest.php: OK, 13.
    • LtvBodyValuesIntegrationTest: OK, 10.
    • NestedBodyValuesIntegrationTest: OK, 8.
  • Did not run: UpgradePendingAnswerServedIntegrationTest skipped itself (it needs a setup this environment lacks; it does not read /ltv).
  • CI: not read yet.

For a reviewer to check


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
After 82b51c8 the customer reads answered numbers and the rest of /ltv
still answered mysqli's text, so a subscription's amount was 29.99 in
GET /ltv/customers/{id} and "29.99000" in GET /ltv/subscriptions. Measured
on a real server, the reads that still served text were:

- GET /ltv/subscriptions: amount, mrr, customer_ltv
- GET /ltv/breakdown by campaign, ppc_account and landing_page: every
  amount and rate (avg_ltv "44.562500000", repeat_rate "0.5000", spend,
  cac, ltv_cac) and total_orders ("4")
- GET /ltv/breakdown?by=product: the amounts, units ("1.500"), orders
- GET /ltv/products, PATCH /ltv/products/{id} and its delete's dry run:
  price
- GET /ltv/companies: total_revenue, mrr, order_count, last_activity_time
- GET /ltv/abm and /ltv/abm/company: total_revenue, mrr, the three
  engagement averages ("30.0000000"), and the company view's
  last_activity ("1793183200", a GREATEST() MariaDB answers as text)

#305's private helper is now Prosper202\Ltv\LtvNumbers, with decimals()
for money and measures and integers() for counts and unix times, and each
read above goes through it where its SQL is: the repositories, and the
controller for the products (whose SQL is there). Each read names every
such column, not only the ones this server answers as text, since MySQL
and MariaDB type COALESCE() and GREATEST() differently. integers() leaves
a value with a fraction as it is, for the type tests to see, rather than
cutting it. A NULL stays null (a product with no price, a subscription
whose customer row is gone); a name, sku, product key or reference of
digits stays a string.

docs/openapi.yaml types every /ltv read with a components schema
(LtvSummary, LtvAcquisitionBreakdownRow, LtvProductBreakdownRow,
LtvCohort, LtvMrr, LtvPrediction, LtvProduct, LtvCompanyRollup,
LtvCompany, LtvSubscription, LtvAbmAccount, LtvAbmContact, LtvField,
LtvWebhook, LtvWebhookDelivery, LtvIntegration, LtvNextOffer); they were
additionalProperties: true, and GET /ltv/abm's list was documented as an
object.

LtvDatabaseIntegrationTest holds every read on a real server: the reads
are the GET routes of api/v3/index.php's /ltv group, so a route added
there fails until it is held too; each answer is walked for a numeric
string outside a text field; the new numbers are checked for value; and
each answer has its schema's keys and JSON types, into every schema a
property refers to, and the spec's 200 answer for the path names that
schema. Planted, each failed a named test: the subscription amount cast,
the product price cast, the company order_count cast and the ABM
last_activity cast each removed; LtvSubscription.amount typed string; an
undocumented key in LtvAbmAccount; GET /ltv/abm's answer untyped again; a
GET route added to the group; and the whole change's code reverted to
82b51c8's.

On an instance installed from 82b51c8 and then from this branch, every
/ltv answer has the same values with the types above changed and no
other; the Customer LTV pages (the breakdowns, ABM, subscriptions,
companies, company, products, customer) are byte-identical; the agent-eval
LTV cases' checks pass. p202's tables now show these amounts to the cent
(9.50 where they showed 9.5) and a zero rate as 0 (it showed 0.0000), and
the PHP CLI's 29.99 where it showed 29.99000.

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:58
@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:58:13.812763Z
We couldn’t complete this review. Comment @codex review to try again.
d58d841 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.

@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.

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.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For d58d841: 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 83a6814: 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
Brings in ltv-customer-numbers (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 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 fa85a2b 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