Skip to content

1.9.79: JVZoo's JVZIPN at jvzoo.php, for affiliates and vendors (stacked on #268) - #272

Merged
tracking202 merged 21 commits into
1.9.79from
1.9.79-jvzoo-ipn
Oct 28, 2026
Merged

tracking202 merged 21 commits into
1.9.79from
1.9.79-jvzoo-ipn

Conversation

@tracking202

@tracking202 tracking202 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #268. This branch was made from 1.9.79 with 1.9.79-paykickstart-ipn (#268) merged in, because JVZoo is built on #268's payment-notification framework. Until #268 merges, this PR's diff also shows #268's commits. Review and merge it after #268. Once #268 has merged, merging 1.9.79 into this branch leaves only this PR's own commits in the diff.

Current state. The branch has #268 merged at 8532b44, its head on 10/28. JVZoo's own commits are:

  • f848166: the JVZoo work;
  • 083e6fe: the 403 names cverify, and a refund after a deleted reversal reverses again;
  • 300f5b0: a notification is dated by ctranstime, read strictly.

The rest are merges of #268 (415cc7e, 90fa1b6, 5654a76).

Backlog: DH99 ("The JVZoo IPN URL points to a file that has never existed")

What changed

tracking202/static/jvzoo.php never existed. Account › API integrations, GET /system/integrations and p202 system integrations all handed it out, so every JVZIPN sent there got a 404. The file now runs #268's Receiver with a JVZoo adapter (202-config/PaymentNotification/JVZoo/).

Source. I read JVZoo's own article, "JVZIPN - How to create your own integrated script" (support.jvzoo.com article 206456857, updated 2024-06-13). The support site sits behind a Cloudflare challenge, but its Zendesk Help Center API is open. I also read the JVZIPN v2 guide, "Custom Variables", "How to set up tracking IDs", "Test Purchase Mode", and the refund articles. Where they differ from the research brief, JVZoo's documents are followed (see the decisions below).

Two URLs, one per role. Every JVZIPN carries two click fields:

  • caffitid is the tid on an affiliate's link.
  • cvendthru holds the variables on the vendor's buy link.

Each field is set by the other party, so neither can be trusted without knowing which party received the notification. Any affiliate can set the tid that lands in a vendor's notification. JVZoo itself asks for the URL in two places, so each place gets its own URL:

  • jvzoo.php?role=affiliate goes in the affiliate's Affiliate IPN (Affiliates › Approved Products › Integrations). It reads only caffitid, which the campaign's offer URL fills with ?tid=[[subid]] on the JVZoo affiliate link.
  • jvzoo.php?role=vendor goes in the product's JVZIPN v1 URL (Advanced Settings). It reads only subid in cvendthru, which the buy link fills with ?subid=[[subid]].

A URL with no role is answered 400.

Verification. The cverify is computed by JVZoo's published validator:

  1. Take every posted value except cverify, in field-name order, each followed by |.
  2. Append the account's JVZIPN secret key.
  3. Take the SHA-1, keep the first 8 hex digits, and upper-case them.

It is compared with hash_equals(). JVZoo's sample uses ==, which would accept +12345678 when the cverify is all digits. It is checked against every account's stored key before anything is read, and the account is the one whose key verifies it (CLAUDE.md #16).

Dating. Every money event is dated by ctranstime, which goes to Notification::$occurredAt. #268's Processor holds that time to the click for a sale, to the sale for a refund, and to the notification's arrival, and the answer says when it did.

  • ctranstime must be a Unix time of 1 to 10 digits, at most Notification::LAST_TIME. Anything else is refused with 400, naming ctranstime.
  • When the field is absent or empty, the row is dated at arrival.
  • JVZoo resends an unanswered notification hourly for 72 hours, so a late notification keeps its own time.

What each ctransaction does:

ctransaction Result
SALE, BILL A conversion (source jvzoo, new) under ctransreceipt, at ctransamount
RFND, CGBK, INSF Reverses, in full, the sale that ctransreceipt names
CANCEL-REBILL, UNCANCEL-REBILL, TEST 200, records nothing
any other value 400, naming it

Duplicates and changed amounts are handled by #268's Processor. A JVZoo refund has no id of its own; it comes under the sale's receipt. So #268 keys it by its body:

  • The same notification sent again is a retry, and changes nothing.
  • A different refund is compared with the reversal that stands, and is a duplicate when it takes back the same amount. Every JVZoo reversal takes back the whole sale, so a chargeback after a refund is a duplicate.
  • When the reversal has been deleted (an undone refund, see below), a different refund reverses the sale again.

The key. JVZooKey is the one rule. It is used by the page, by PUT /users/{id}/preferences (moved from TEXT to its own rule in PreferenceRules) and by the endpoint:

  • the key is stored trimmed;
  • a line break, a control character or bytes that are not UTF-8 are refused;
  • secret_stored is true only for a key the endpoint can sign with.

The panel gives the steps for an affiliate and for a vendor, both URLs, and where JVZoo shows the key (My Account › Account Details › Seller Settings). GET /system/integrations now lists:

  • jvzoo: label "Affiliate IPN URL", …/jvzoo.php?role=affiliate;
  • jvzoo_vendor (new): label "Vendor JVZIPN URL", …/jvzoo.php?role=vendor.

Docs. openapi.yaml (integrations, the preference, the source enum), cli.md, cli-agent.md, documentation/api/{09,10,14,15}, documentation/cli/10-go-cli.md, a JVZoo section with an outcome table in 09-3rd-party-integrations.md, and both changelogs. The Go CLI's --source list now includes jvzoo.

Changes to #268's code

Decisions a reviewer should check

  1. The role is in the URL, and the URL is not signed. A replay works only if someone already holds a signed notification. Replayed to the other role's URL, it reads the other party's field; it cannot invent a value. The docs recommend an https:// URL.
    • I chose this over storing the account's JVZoo id and comparing it with ctransvendor/ctransaffiliate. That would need a new column and a page field, and it matches the two settings JVZoo already has less closely.
  2. Verification uses every account's key, not the click owner's. The adapter does not look up the click's owner and try that owner's key. Most of a vendor's sales name no Prosper202 click and still have to be acknowledged with 200. Also, a click lookup before verification would tell anyone without a key which click ids exist.
  3. INSF reverses the sale; it is not "record nothing" as the brief said. JVZoo defines INSF as "an eCheck chargeback for a standard or recurring product".
  4. A reversal takes back the whole sale and ignores its stated amount. JVZoo refunds a payment whole: its "Partial Refund" still refunds everything paid to the parties. The refund arrives under the sale's receipt; cupsellreceipt is an upsell's parent receipt, not a refund's original.
  5. ctransamount must have a decimal point. JVZoo's field table, and its v2 migration table, say pennies (1000 = $10.00). Its sample POST and real notifications (a production log on GitHub) say dollars (10.00, 0.10). 47.00 is read as dollars. 4700 is refused with 400, naming the conflict, rather than guessed.
  6. Tests. JVZoo marks no notification as a test. A Test Purchase Mode sale is a real sale at a test price, so it is recorded, and refunding it reverses it.
    • JVZoo lists "Tests" among the reasons it posts but names no type for them. JVZIPN copies ClickBank's INS, whose test type is TEST, so TEST is acknowledged. Every other undocumented type is refused.
  7. Only the fields JVZIPN v1 documents are taken. The cverify joins values without their names. An added field shifts every later value along: one built after ctransamount reads ctranstime as the receipt, which would record the sale a second time. The test testValuesMovedBetweenFieldsUnderOneCverifyAreNotRead shows it.
    • JVZoo says v1 is frozen ("future features are planned for v2"). If it ever adds a field, every notification is a 400 naming that field.
  8. JVZIPN v2 is answered 415. Its cverify covers five fields (paykey|customer_email|product_name|transaction_type|date) and covers neither the amount nor the tid.
  9. The cverify is 32 bits. That is JVZoo's algorithm. Forging one online needs about 2^31 requests for each stored key, and there is no rate limit here.
    • A per-address limit would be aggregate behind a proxy. Then anyone could block genuine notifications with a few bad ones an hour.
  10. Text that is not UTF-8 is refused with 400. It is not signed: what JVZoo's validator does with it differs between PHP versions (measured on 8.3: each invalid byte becomes ?).
  11. The click field is read strictly per role:
  • An affiliate tid that is not a click id is 200 no_click, because a tid is general-purpose.
  • A vendor subid that is not a click id is 400, because the panel says to put subid=[[subid]] there; this matches PayKickstart.
  • no_click is answered by the adapter as a Refusal, so its wording names the right field. Its log line has the receipt in the message but not the structured fields.
  1. Upsells. An upsell is credited only when its notification carries the tid or subid. cupsellreceipt is not used to find the parent's click.
  2. A second refund that is identical to the first is read as a retry. This follows from 1.9.79: PayKickstart's product IPN, verified with each campaign's key #268's body keying. Suppose JVZoo's ctranstime on a refund were the original sale's time, rather than the refund's. Then a second genuine refund after a reinstatement would be byte-identical to the first, and it would be answered duplicate. No receiver can tell those two apart. JVZoo's article does not say whose time a refund carries.
  3. GET /users/{id}/preferences and GET /system/integrations can disagree. After 1.9.79: GET /users/{id}/preferences serves no integration secret #270, the preferences read serves the JVZoo key as {"stored": true} whenever the column holds text. secret_stored in the integrations read is true only for a key the endpoint can sign with. So a stored key with a line break in it reads as stored: true in one and secret_stored: false in the other.

Verification

After the merges of #268 (10/28)

Scope report of .claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79-paykickstart-ipn --changed, verbatim. It ran on the tree with #268 at 41bfe59 and the ctranstime change (PHP 8.3.6, MariaDB 10.11, full vendor/).

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 passes here because the ratchet's baseline is HEAD. The tab-indented panel lines explained under the first round were already in HEAD, so nothing was counted as added.

#268's 8532b44 merged cleanly after that run. It touches only PayKickstartKeys and its panel. After it, I ran:

  • tests/PaymentNotification (unit, 111 tests);
  • HandedOutUrlsNameServedFilesTest and ComponentClassIsConsumedTest;
  • the integration suites below.

Integration suites, each on a database recreated for it:

  • JVZooIpnIntegrationTest: 15 tests. New: a late notification, where a sale 3 days late and its refund 2 days late keep their own times, a chargeback stating a time before its sale is dated at the sale, a ctranstime changed after signing is a 403, and one that is not a Unix time is a 400. Also new: the refund-after-delete path (the first refund sent again is a duplicate, a later one reverses) and the 403's cverify wording.
  • PayKickstartIpnIntegrationTest: 20 tests.
  • Before the last merge, also AccountAdministrationIntegrationTest (13) and PreferencesWriteIntegrationTest (7).

Plants, each caught by a named test:

  • ctranstime dropped: caught by testALateNotificationIsDatedByItsCtranstime;
  • an unreadable ctranstime read as none, which is the old lenient read: caught by testAFieldTheNotificationNeedsIsReadExactlyOrRefusedByName;
  • signatureField() answering hash: caught by testNothingIsWrittenForANotificationTheStoredKeyDoesNotVerify.

Live pass on 10/28, without the licence. The licence stub is down. I installed with install-instance.sh's own steps, skipping the licence form. The Go CLI answers 402 on that install, so the setup went through the v3 API with curl:

  • POST /ppc-networks, /ppc-accounts, /campaigns (offer URL https://www.jvzoo.com/c/17/414851/?tid=[[subid]], accumulate) and /trackers;
  • PUT /users/1/preferences with jvzoo_ipn_secret_key: " Live-JVZIPN-Key-0001 ", stored trimmed (20 characters).

GET /system/integrations gave both URLs with secret_stored: true, and the tracker redirected to …?tid=1 and …?tid=2. Notifications were signed by a script independent of this code and POSTed with curl:

  • Click 1:
    • SALE 200 recorded;
    • RFND 200 reversed;
    • SALE again 409, naming conversion 2;
    • DELETE /api/v3/conversions/2 204;
    • SALE again 200 duplicate;
    • the first RFND sent again unchanged 200 duplicate ("was reversed by conversion 2, since deleted; this RFND is that refund sent again");
    • a later RFND 200 reversed, conversion 3.
    • Each row's conv_time is the ctranstime it stated, not when it arrived (about 30 seconds later).
  • Click 2:
    • a SALE stating a time a day before the click was 200, "Dated at the click … is before the click";
    • its RFND was dated at the time it stated;
    • a SALE stating a time a day ahead was 200, "Dated when it arrived … later than this server's clock";
    • ctranstime=2026-10-28 was 400, naming ctranstime.
  • A stranger's key: 403, "The notification's cverify does not verify …".
  • The log: the server log held one line per notification (11 of 11), with no key, email or buyer name.

Could not run on 10/28 (licence stub down):

  • the Go CLI against the instance (402), and with it p202 conversion list --source jvzoo and the agent-eval case;
  • the page's own form and the browser pass. Those ran on 10/23 against a licensed instance, as reported below.
  • The live pass did not repeat the vendor role or the 10/23 outcome table. Neither path changed in this round, except for dating.

First round (10/23)

Scope report of .claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, run on the final tree before committing, verbatim. Environment: PHP 8.3.6, MariaDB 10.11, full vendor/. The unit tier ran 6184 tests with 5 skipped: the 4 documented UserTest DB-singleton skips, and CronEntryPointsFailLoudlyTest, because the live instance's 202-config.php was in the checkout.

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      FAIL
  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".

Why phpcs FAILed in the first round. The phpcs ratchet counts 202-account/api-integrations.php as made worse: 1010 errors, against 930 at #268's tip. All 80 added findings are Generic.WhiteSpace.DisallowTabIndent, from the JVZoo panel's new lines. The file is indented with tabs throughout: 925 of those errors are already in it, and #268's panel just below adds the same kind of lines. Its other findings went down by one, and every new file is PSR-12 clean. I kept the file's indentation rather than mixing spaces into it.

Run by hand, beyond the ladder:

  • Integration suites, each alone on a database created for it:
    • JVZooIpnIntegrationTest (14). It covers: an affiliate's sale; a vendor's sale that ignores the tid; a replay; a changed amount (409); a rebill; refund, refund replay, and chargeback-after-refund (duplicate); CGBK and INSF reversals; a refund with nothing to reverse (404); a sale re-sent after its refund (409, and a duplicate once the reversal is deleted); an unknown key, a tampered amount, a moved tid, a missing cverify and GET (403/405); another account's click and a missing click (404, the same answer); a key shared by two accounts; CANCEL-REBILL, UNCANCEL-REBILL, TEST, no click, no role and a whole-number amount; an unreadable stored key; and the 503 while the upgrade is pending.
    • PayKickstartIpnIntegrationTest (15), with the Processor change.
    • AccountAdministrationIntegrationTest (13) and PreferencesWriteIntegrationTest (7).
  • Signature vectors (JVZooSignatureTest):
    • Two vectors other people published: niteoweb.ipn.jvzoo's and pyramid_bimt's mocked requests, cverify AACDD10E and D1EA7E5A for the key secret. JVZoo's validator, run verbatim, reproduces both.
    • Nine vectors computed for this PR, by JVZoo's validator run verbatim on PHP 8.3 and by a separate Python reading of the article. The two agreed on every one. They cover: the article's sample, the same fields reversed, an affiliate sale, a vendor sale, a rebill (-B002), non-ASCII text, whitespace (not trimmed), a | in a value, and a refund under another key.
  • Planted defects: 24, each caught by a named test failing (FAILURES, not a fatal):
    • the unknown-field check;
    • the vendor reading the tid, and the affiliate reading cvendthru;
    • a loose ==, and a case-blind compare;
    • a whole number read as dollars;
    • INSF ignored;
    • a reversal by its stated amount;
    • TEST recorded as a sale, and an unknown type ignored;
    • v2 not told apart;
    • no role read as vendor;
    • a non-click subid read as none;
    • signature: values trimmed, empty values dropped, unsorted, lower case, non-UTF-8 signed;
    • a key with a line break accepted;
    • secret_stored for any text, and the API writing any text;
    • the reinstatement branch removed;
    • the role read loosely;
    • jvzoo.php deleted (both HandedOutUrlsNameServedFilesTest tests).
  • Live pass on an instance installed from this branch with install-instance.sh and seeded with seed.sh:
    • The page. Signed in, I submitted the JVZoo panel's own form, with its token. A key with a line break was refused under the field. A blank key was refused. Live-JVZIPN-Key-0001 was saved, stored trimmed, and the pill turned Connected. The panel showed both URLs.
    • The CLI. p202 system integrations listed jvzoo and jvzoo_vendor with secret_stored true. A second account's key went in through p202 user prefs update 2 --jvzoo-ipn-secret-key, and a two-line key was refused there with 422.
    • The clicks. Campaign B's offer URL was https://www.jvzoo.com/c/17/414851/?tid=[[subid]], and tracker B redirected to …?tid=7. Then it was the buy link https://www.jvzoo.com/b/0/414851/1?subid=[[subid]], and the tracker redirected to …?subid=8.
    • Thirty notifications, signed by a script independent of this code and POSTed with curl. The affiliate sale was 200 recorded on click 7: the vendor subid in the same body was ignored. Then:
      • replay 200 duplicate; another amount 409;
      • rebill 200 recorded;
      • refund 200 reversed; refund replay 200 duplicate; chargeback after it 200 duplicate;
      • sale again after the refund 409;
      • refund not on file 404;
      • INSF of the rebill 200 reversed;
      • unknown key 403; tampered amount 403; no cverify 403;
      • no tid 200 no_click; tid fbweek1 200 no_click;
      • missing click 404; the other account's key on click 7 404;
      • no role 400; 4700 400; an extra field 400;
      • TEST, CANCEL-REBILL and UNCANCEL-REBILL 200;
      • JSON 415; a v2 body 415; GET 405;
      • vendor: sale on subid=8 200 (the affiliate tid in the same body ignored), no subid 200 no_click, subid=[[subid]] 400, refund 200 reversed.
    • "Add them up". On a fresh click 9, a 47 sale, a 9 rebill and the sale's refund left the click at 9. After 409 and p202 conversion delete 9 (the reversal), the sale again was 200 duplicate and the click was at 56.
    • Read back. p202 conversion list --source jvzoo and p202 click conversions 7, 8 and 9 read the rows back. The server log held one line per notification (36 for 36), with no key, email or buyer name.
  • Browser pass. I ran node tests/browser/run.js --spec account-pages with a new JVZoo scenario: two URLs to copy, in order; Not connected, then Connected; the disclosure closed; the key saved trimmed. 591 checks passed, including the API integrations baseline at 1280px and 390px, light and dark.
    • One scenario failed: "Users: … an empty list says so". The cause was the second user I had made for the live pass. Rerun after deleting that user, it passed 10/10.
  • Agent eval. New case admin-006-a-jvzoo-affiliate-gets-the-affiliate-url, with a reference-agent branch.
    • With the reference agent, all mechanical expectations passed. I graded the rubric by hand from the judge's input, because no judge runs here.
    • A planted agent that stores nothing, repeats the key and pastes the vendor URL with subid failed all six mechanical expectations.
    • The case is listed in searchKnownMisses: two tasks in one ask, as with admin-005.

Could not run

  • CI has not been observed.
  • No real JVZoo notification was available. Every cverify here comes from JVZoo's published validator or from two published third-party vectors. A difference between that article and JVZoo's real signer would only show in production.
  • I have not seen an Affiliate IPN body. JVZoo's affiliate screenshot shows the setting, but the 2015 announcement article is gone. I assumed it carries the same JVZIPN v1 fields as the vendor's. If it carries others, it is answered 400, naming them.
  • The full integration runner (tests/run-integration-suites.sh) was not run; only the four suites this change touches were.
  • tests/live/upgrade-equals-install.sh was not run: this change makes no schema change. The jvzoo source fits the existing varchar(32), and the key column exists.
  • The pass ran on MariaDB 10.11; CI runs MySQL 8.0.

Found outside this item

🤖 Generated with Claude Code

https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

Account › API integrations handed out tracking202/static/paykickstart.php
as PayKickstart's Affiliate IPN URL under "No setup needed", and GET
/system/integrations and `p202 system integrations` listed it. The file
never existed, so every IPN sent there was a 404. The Affiliate IPN could
not have worked anyway: it is unsigned JSON with PayKickstart's own
tracking id and no custom variables, so nothing in it names a click.

The URL now takes PayKickstart's product (vendor) IPN. A vendor puts
subid=[[subid]] on the checkout link that is the campaign's offer URL,
pastes the URL into each product's IPN Integration tab and stores each
PayKickstart campaign's IPN secret key (202_users_pref, one per line, at
most 20, never served back). Each IPN is verified against every account's
stored keys with PayKickstart's published HMAC-SHA1 algorithm before
anything in it is read, and is recorded for the account whose key
verifies it, on a click of that account (another account's click reads
as no click). sales records a conversion (source paykickstart, pixel type
2) under its transaction_id at its amount; refund reverses it; test mode,
subscription-payment and every other event record nothing. A retry under
a transaction id on file is a duplicate only when it states the same
amount, and is refused (409) when it states another. Every outcome is a
status PayKickstart's log shows, with the reason, and one log line
without a key or buyer data.

The receiving side is provider-neutral (Prosper202\PaymentNotification:
a ProviderAdapter that verifies and parses, one Processor that records or
reverses through p202RecordLegacyConversion(), one Response mapping), so
JVZoo and ClickBank can follow as adapters.

HandedOutUrlsNameServedFilesTest holds every script a served literal
names, and every integration GET /system/integrations lists, to a file in
the tree; jvzoo.php, zpn.php and slack.php are listed as known dead until
their own changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
202-config/changelogs.json kept both: #258's updated lines, then the
PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#258 made the API answer a 5xx while the files are deployed and the
upgrade not run with a 503 naming the upgrade. The IPN endpoint is not in
the API's tree, and between those two moments its keys' column is not
there yet: it answered 500. The Receiver now asks
SchemaVersion::pendingUpgrade() on a failure and answers 503 with its
sentence (OutcomeKind::UPGRADE_PENDING); PayKickstart's retry lands once
the upgrade has run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ey is "the key"

admin-005 asks an agent to store a campaign's PayKickstart IPN secret key
and give the URL to paste: the key is stored with user prefs update, the
URL is the one system integrations answers, and neither the preferences
read nor the reply repeats the key. The reference agent answers it; an
agent that stores nothing and repeats the key fails all four checks.
Search puts system integrations first for the ask (two tasks in one ask),
so it is listed in searchKnownMisses rather than taught by a phrase lifted
from the ask.

A refusal of a single pasted key says "The key is 5 characters long"
rather than "Key 1"; a list still names the key by its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…what the reversal on file took back

A refund with no amount (or 0) takes back the whole sale. Its comparison
with the reversal on file was skipped when the amount was null, so a full
refund after a partial one was answered 200 duplicate: PayKickstart stopped
retrying while the rest of the sale stayed credited (CLAUDE.md #15). The
Processor now reads the reversal on file with the sale it reverses and
compares what the refund takes back, a full refund read as the sale's
amount. Any other refund of a sale already reversed is a 409 naming both:
"already reversed by 10.5, and 36.5 of its 47 is still credited; this
refund states its full amount, 47". A sale is still reversed once.

The other duplicate decision, a sale or rebill under a transaction id on
file, compares two amounts that are never null (the sale's is required,
the stored one NOT NULL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Account › API integrations, GET /system/integrations and p202 system
integrations handed out tracking202/static/jvzoo.php as JVZoo's IPN URL,
and the file never existed: every JVZIPN sent there was a 404. It now runs
the payment-notification Receiver (#268) with a JVZoo adapter.

JVZoo's own article ("JVZIPN - How to create your own integrated script",
read through its Help Center API) is the source. The cverify is its
published validator's: every posted value but cverify's, in field-name
order, each followed by "|", the account's JVZIPN secret key, SHA-1, the
first 8 hex digits upper-cased; compared with hash_equals(), where the
sample's == would take "+12345678" for an all-digit cverify. It is checked
against every account's stored key before anything is read, and the account
is the one whose key verifies it (CLAUDE.md #16), so a vendor's sale that
names no click is still acknowledged.

A JVZIPN carries two click fields, caffitid (the tid on an affiliate's
link) and cvendthru (the variables on the vendor's buy link), and each is
the other party's choice to the one who did not set it. JVZoo asks for the
URL in two places, an affiliate's Affiliate IPN and a product's JVZIPN v1
URL, so each gets its own URL: ?role=affiliate reads only caffitid,
?role=vendor only cvendthru's subid; a URL with no role is a 400.

SALE and BILL record a conversion (source jvzoo, new) under ctransreceipt
at ctransamount; RFND, CGBK and INSF (JVZoo: an eCheck chargeback, so a
reversal, not nothing) reverse the whole sale ctransreceipt names, since
JVZoo refunds a payment whole; CANCEL-REBILL, UNCANCEL-REBILL and TEST
record nothing. JVZoo marks no test notification: a Test Purchase Mode sale
is a real sale at a test price. ctransamount must have a decimal point: the
field table says pennies and the notifications say dollars. Only the fields
JVZIPN v1 documents are taken, because the cverify joins values without
names and an added field moves every later value along (a receipt read from
ctranstime would record the sale twice). JVZIPN v2, whose cverify covers
neither the amount nor the tid, is a 415.

One change to #268's Processor: a sale already on file that a live
reversal nets (JVZoo sends RFND, then SALE again, when a refund is undone)
is a 409 naming the reversal to delete, where it was answered 200
duplicate with the sale still reversed.

The key is read and written through JVZooKey (trimmed; a line break or a
control character refused) by the page, PUT /users/{id}/preferences and
the endpoint, so GET /system/integrations secret_stored says whether a key
the endpoint can sign with is stored. The panel gives both sets of steps.

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 23, 2026 22:01
@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-23T22:01:21.776940Z
We couldn’t complete this review. Comment @codex review to try again.
f848166 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.

…er a deleted reversal reverses again

A sale stated again under a transaction id whose sale a refund took back
was answered 200 duplicate while the sale stayed reversed. JVZoo sends an
undone refund as RFND and then SALE again; the sale is now a 409 naming
the reversal to delete to count it (ported from the JVZoo branch).

Deleting that reversal did not make a later refund work: a deleted row
keeps its dedupe key, every refund without an id of its own shared
rev:<sale>:1, so the next refund of the sale found the deleted row and was
answered 200 duplicate with nothing reversed. Such a refund is now keyed
by its body, so only the same notification sent again unchanged finds a
reversal already on file, live or deleted; any other is either compared
with the reversal that stands (the writer refuses a second one under its
lock, and the Processor answers duplicate when it takes back the same) or,
when none stands, reverses the sale again. A refund with its own id is
still told apart by that id.

The 403 names the provider's signature field (ProviderAdapter::
signatureField(); PayKickstart's is hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
changelogs.json: 1.9.79's lines, then the PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…JVZoo branch

#268 ported the reinstatement 409 from this branch, keyed a refund with no
id of its own by its body (so a refund after a deleted reversal reverses
again), named the signature field in the 403 through a new
ProviderAdapter::signatureField(), and merged 1.9.79 (#262).

changelogs.json conflicted: 1.9.79's lines are kept, the JVZoo line after
them. The JVZoo adapter gains signatureField() ("cverify") in this commit,
since without it the class no longer satisfies the interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ppened

p202RecordLegacyConversion() dated every row time(), and the Processor
dropped the time a notification states, so an IPN PayKickstart delivered
days late (it retries a failed delivery up to 13 times) was filed on the
day it arrived in every view by conversion time: the conversion list's
time filter, attribution, the click-to-conversion time. The recorder now
takes a conv_time, and the Processor passes the stated time, held between
the earliest it can be (the click a sale credits; for a refund, the sale
it takes back, so the sale keeps its own day) and its arrival (a clock
ahead of this server's). The answer and the log line say when a time was
held. With no time stated, the row is dated when it arrives, as before.

transaction_time is signed (every non-empty field is); one that is not a
Unix time is now a 400 naming it, where it was read as no time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…start IPN branch

GET /users/{id}/preferences now serves every integration secret as its
stored state (#270), and the PayKickstart IPN keys are one more:
paykickstart_ipn_secret_keys is on ServedPreferences::SECRET, answered
{"stored": true}, and the count this branch served in its place
(paykickstart_ipn_keys_stored, PayKickstartKeys::withoutKeys()) is gone.
GET /system/integrations keys_stored still says how many. The
PreferenceSecretsAreNotServedIntegrationTest round trip stores the keys
with the other secrets.

Conflicts: changelogs (1.9.79's lines, then this branch's), the
preferences docs and the OpenAPI schemas (1.9.79's, with the keys added
as a StoredSecret), and the controller (1.9.79's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ending

The page's Remove form named a key by its place and the four characters
it shows (…AB12). Another tab could remove that key and add a different
one ending in the same four characters at its place; the first tab's
form then matched the new key and removed it (measured on the pushed
class: removed(['campaign-new-AB12', …], '1', '…AB12') dropped
campaign-new-AB12). The form now carries an HMAC-SHA256 of the whole key
under a secret this session holds server-side
(PayKickstartKeys::removalToken()): the page shows nothing a reader
could match a guess against, and a form made for one key cannot match
another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ersal reverses again

With #268's body-keyed refunds, a JVZoo refund sent after the reversal
was deleted reverses the sale again, and the first refund sent again
unchanged is a retry. The integration test now walks that path (SALE, RFND,
SALE 409, delete, the RFND again, a later RFND) and pins the 403's wording
to cverify; the unit test pins signatureField(). The guide and the
changelog say what a refund does after the reversal is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…JVZoo branch

#268 now dates a notification's row by the provider's own time, held to the
click for a sale and to the sale for a refund, and brings 1.9.79's #270
(ServedPreferences: GET preferences serves each secret as {"stored": ...}),
#280, #281 and #282.

Conflicts: both changelogs keep the base's lines with the JVZoo line after
them; the OpenAPI preferences update takes #270's oneOf shape, with the
JVZoo key's rule in its string branch; the eval cases keep #268's admin-005
notes and add admin-006 after it. jvzoo_ipn_secret_key is already on
ServedPreferences::SECRET, and JVZoo adds no other column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#268's Processor now dates a row by the provider's own time and holds it to
the click (a sale) or the sale (a refund) and to arrival. JVZoo's
ctranstime is that time: it was read leniently and only logged, so an
unreadable one became "no time" and the row was dated when it arrived. It
is now a Unix time of 1 to 10 digits, at most Notification::LAST_TIME, or
the notification is refused with a 400 naming ctranstime; absent or empty,
the row is dated when it arrives.

JVZooIpnIntegrationTest gains a late notification: a sale three days late
and its refund two days late keep their own times, a chargeback stating a
time before its sale is dated at the sale, a ctranstime changed after
signing is a 403, and one that is not a Unix time is a 400. The guide and
the changelog say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…JVZoo branch

#268's stale-Remove fix for PayKickstart's keys (PayKickstartKeys and its
panel); it merged cleanly beside the JVZoo panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For a6c5723: 8 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, 202-config/functions-upgrade.php, 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 paykickstart-ipn (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
PayKickstartAdapter::storedSecrets() read every 202_users_pref row, and a
deleted account keeps its row and the keys in it. Its key kept verifying
IPNs, so a sale on a deleted account's click was recorded for it, and a key
the deleted account shared with a live one verified both. ClickBank's and
JVZoo's adapters, on the branches above, read their keys the same way, and
ClickBank refuses a key two accounts store with a 409: a deleted account
holding a live account's ClickBank key refused every notification of the
live account, with nothing in the UI to clear the deleted copy.

AccountSecrets::stored() is now the one read of a provider's secret column
that decides the accounts a notification can belong to: it joins
202_users and leaves out user_deleted = 1. A user switched off in Account ›
Users (user_active = 0) still owns its keys: a sale PayKickstart reports
for that account is not that person acting. ProviderAdapter documents it.

StoredSecretsReadThroughAccountSecretsTest holds every ProviderAdapter's
storedSecrets() to a call of AccountSecrets::stored() with no statement of
its own naming 202_users_pref, and every other statement in
202-config/PaymentNotification and the payment endpoints that names
202_users_pref to a listed reason (the Processor's currency read, for the
account already decided).

Tests: PayKickstartIpnIntegrationTest's new
testADeletedAccountsStoredKeyVerifiesNothing seeds a deleted account that
stores a live account's key and one of its own: the live account's sale is
recorded for it, the deleted account's click is a 404 and its own key a
403, and a switched-off account's sale is recorded. Planted: the
user_deleted filter removed from AccountSecrets fails it ("Recorded
transaction PK-TX-D2 on click 99040131"), and so does a user_active = 1
filter added (the switched-off account's IPN unverified); storedSecrets()
reading 202_users_pref itself fails both structural tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in #268's AccountSecrets: a deleted account's stored keys verify
nothing. The conflict was in changelogs.txt, where both sides changed
lines of 1.9.79: #268's PayKickstart line is kept as it is and this
branch's JVZoo line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
JVZooAdapter::storedSecrets() read jvzoo_ipn_secret_key from every
202_users_pref row, and a deleted account keeps its row and its key. Its
key kept verifying JVZIPN notifications: a sale on a deleted account's
click was recorded for it, and a key it shared with a live account verified
both.

storedSecrets() reads the key through #268's AccountSecrets::stored(),
which leaves out deleted accounts; a switched-off account (user_active = 0)
still owns its key. StoredSecretsReadThroughAccountSecretsTest, merged from
#268, now finds JVZooAdapter and holds it to that read (it failed on the
merge until this change).

Tests: JVZooIpnIntegrationTest's new
testADeletedAccountsStoredKeyVerifiesNothing: with a deleted account storing
the account's key, the account's sale is recorded for it; the deleted
account's click is a 404, a key only it stores a 403; a switched-off
account's sale is recorded. Planted: storedSecrets() reading
202_users_pref itself, and AccountSecrets without its user_deleted filter,
each fail it ("Recorded transaction D2 on click 99050141"); a
user_active = 1 filter fails it with a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 70209f1 into 1.9.79 Oct 28, 2026
20 checks passed
tracking202 added a commit that referenced this pull request Oct 28, 2026
…ike the others

#272's JVZooAdapter read 202_users_pref itself, which the branches that made
AccountSecrets::stored() the one read (a deleted account owns no key) did not
know of; StoredSecretsReadThroughAccountSecretsTest holds every adapter to it.
Also: #323's session runner refuses a web request first (#306).
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