Skip to content

1.9.79: ClickBank rebills are recorded, and refunds and chargebacks take a sale back - #288

Merged
tracking202 merged 28 commits into
1.9.79from
1.9.79-clickbank-rebills-refunds
Oct 28, 2026
Merged

tracking202 merged 28 commits into
1.9.79from
1.9.79-clickbank-rebills-refunds

Conversation

@tracking202

@tracking202 tracking202 commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #276 (ClickBank per-account, branch 1.9.79-clickbank-per-account), which is stacked on #268 (PayKickstart IPN). Review and merge this after #276 (and #268). Until they merge, this diff includes their commits. This PR's own change is 0395bc3, 801e5fc and e6a29f0; e6fb649 merges #276 in. To see it alone: git diff origin/1.9.79-clickbank-per-account...1.9.79-clickbank-rebills-refunds.

Backlog: 851K ("ClickBank rebills are never recorded and refunds never reverse a sale")

What was wrong

cb202.php acted on TEST and SALE only, and answered every other INS notification 200 with nothing done:

  • a BILL (a subscription's rebill) recorded no revenue;
  • an RFND (refund) or CGBK (chargeback) left the sale counted.

What changed

ClickBankAdapter now reads every transactionType that ClickBank's INS article lists (TYPES). Recording and reversing go through #268's Processor and p202RecordLegacyConversion(), as PayKickstart and JVZoo do.

Type What ClickBank says it is What Prosper202 does Status
SALE a purchase, or a subscription's first payment a conversion under its receipt, at totalAccountAmount 200 recorded
BILL a subscription's rebill another conversion under its own receipt (ClickBank: the first order's, a dash and four digits, ABCD1234-0001) 200 recorded
RFND a refund a reversal of the conversion under its receipt, by what totalAccountAmount says the account lost (sign dropped); none or 0 takes back the whole payment 200 reversed
CGBK a chargeback the same 200 reversed
INSF an eCheck chargeback (the reports' "Bounced": the buyer's bank returned the check) the same 200 reversed
CANCEL-REBILL a subscription cancelled (a refunded subscription also sends one) nothing: no money moves 200 ignored, logged with its account
UNCANCEL-REBILL a cancellation undone nothing 200 ignored, logged
SUBSCRIPTION-CHG, ABANDONED_ORDER, CUSTOMER_AUTH_FAILURE, CUSTOMER_EMAIL_UPDATE, CUSTOMER_UPDATE_CC_NOTIFICATION, PURCHASE_DETAILS_EMAIL_RESPONSE a product change, a cart left, a rebill's card declined, the buyer's details or receipt email nothing 200 ignored, logged
TEST, TEST_SALE, TEST_BILL, TEST_RFND, CANCEL-TEST-REBILL, UNCANCEL-TEST-REBILL tests the key is marked Verified; nothing is recorded 200 test
any other not in the article nothing 400, naming it

Reversals (#268's rules, with one change for ClickBank in e6a29f0):

  • A payment is reversed once. Only ClickBank's retry of the same notification (the same order, attemptCount aside) is 200 duplicate. Any other reversal of a payment that already has one is a 409 naming the reversal on file and how much is still credited, whatever amount it states. That includes a second partial refund of the same amount and a chargeback of a refunded payment.
    • Until e6a29f0 the Processor compared the amount only, so a second 10.00 refund after a 10.00 refund was answered 200 duplicate ("is already reversed; nothing was changed"). ClickBank never retries a 200, so the refund was lost without trace (CLAUDE.md subid missing in pixel url #4, [Fix] Return 404 status #15). Found by a self-review after the adversarial review marked this PR BLOCKING.
    • PayKickstart keys refunds by its form fields and keeps the amount-only rule.
    • A second partial refund is now refused loudly rather than recorded. Recording several partial reversals of one payment is a change to the shared Processor, filed separately.
  • A SALE sent again while its refund stands is a 409 that names the reversal to delete.
  • A refund with no id of its own is keyed by its body, so after a reversal is deleted the same notification still changes nothing, while another one reverses the sale again.
  • A refund is dated by its own transactionTime, held at its sale when it is earlier.

A shared fix this needed. The Processor keys that body by hashing $request->fields. ClickBank's notification is a JSON body with no form fields, so every ClickBank refund had the same key. Driven before the fix: a sale was refunded, an operator deleted the reversal, then a chargeback arrived. It was answered 200 "this CGBK is that refund sent again" and the sale stayed counted.

  • Fix: a new optional interface, FingerprintsNotifications. The Processor asks it when the adapter implements it. This is one instanceof in Processor::reversal(); PayKickstart and JVZoo are untouched.
  • ClickBank's answer is the decrypted order, with its keys in order and without attemptCount. ClickBank raises attemptCount on each retry of the same notification, and a retry's ciphertext and IV need not repeat.

Docs and changelogs. The ClickBank outcome table in documentation/tutorials-and-guides/09-3rd-party-integrations.md now covers every type. The setup steps say to set a subscription campaign's When a click converts more than once to Add them up. There are entries in changelogs.txt and changelogs.json, after 1.9.79's own.

Checked against ClickBank's spec

The type names and meanings were re-read from support.clickbank.com "Instant Notification Service (INS)" (article 10535147), not from memory. The receipts came from the transaction-reporting articles.

  • Verified:
    • the 17 types and their descriptions in the table above;
    • "Refunded recurring products also generate a CANCEL-REBILL";
    • rebill receipts are "the same receipt number as the initial order, followed by a dash and four-digit number";
    • partial refunds exist: the Orders API's product change refunds "a portion of the old product's last recurring payment".
  • Not verified:
    • The refund's receipt. No article says whether an RFND, CGBK or INSF carries the receipt of the payment it takes back. This PR reverses the conversion under the notification's own receipt.
    • The amount's sign. No article says whether totalAccountAmount is negative on a refund; both signs are read. A third-party affiliate guide (affiliatedegen.com) records refunds as negative amounts against the same tracking value, which also suggests that refunds carry the order's tracking codes.
    • No real ClickBank notification was available.

The ledger rules (CLAUDE.md #13, #15, #34, #36)

Decisions a reviewer should check

  1. A type the article does not list is a 400, not 200 ignored. The brief said "other types are acknowledged and logged". Every type the article lists is acknowledged and logged. An unlisted one is refused by name, as JVZoo (1.9.79: JVZoo's JVZIPN at jvzoo.php, for affiliates and vendors (stacked on #268) #272) refuses: acknowledging it would lose a type that moves money (the older API's SALE-RB/RFND-RB, say) with no trace in ClickBank's log. ClickBank retries a 400 five times over 20 hours.
  2. Differences from JVZoo:
    • Partial reversals. JVZoo reverses in full because JVZoo refunds whole; ClickBank refunds part of a payment too.
    • Unknown fields are not refused, as 1.9.79: ClickBank's INS works for every account, decrypted with each account's own key #276 explains: the order is encrypted whole, and its fields grow by version.
    • Same as JVZoo: INSF reverses; CANCEL-REBILL and UNCANCEL-REBILL are acknowledged with nothing recorded; the account is the one whose key verifies.
  3. TEST_SALE and the other test types mark the key Verified, as TEST does, because each one decrypted with that account's key.
  4. SUBSCRIPTION-CHG records nothing. The article describes it by its old and new products, not by a payment.
  5. A chargeback of a refunded payment for the same amount is a 409 (since e6a29f0), where it was 200 duplicate. Two notifications that each say the account lost the money then both show in ClickBank's log, and neither is dropped unseen.

Verification

Scope report of .claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed on 801e5fc (PHP 8.3.6, MariaDB 10.11, full vendor/). The schema tier had a scratch database, and 202-config.php was moved aside. Unit tier: 6170 tests, 4 skipped (the documented User DB-singleton skips). Verbatim:

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

For e6a29f0, verify.sh --changed on the working tree before the commit gave patterns, phpcs, phpstan, syntax and unit PASS.

Integration suites, each alone on a database created for it:

Suite Tests
ClickBankInsIntegrationTest 24 OK (after e6a29f0)
PayKickstartIpnIntegrationTest 20 OK
LegacyPostbackCampaignIntegrationTest 5 OK
ConversionBridgeIntegrationTest 5 OK
ConversionLedgerIntegrationTest 26 OK

The integration tests cover:

  • a rebill beside its sale;
  • a refund and ClickBank's retries of it;
  • a second refund of the same amount, refused;
  • a partial refund, and the full one refused after it;
  • a chargeback and a returned eCheck in full;
  • a refund of a rebill alone;
  • a sale sent again after its refund;
  • a reversal deleted and then a chargeback;
  • refund dating;
  • another account's click;
  • cancellations and an unlisted type.

testAnotherRefundOfTheSameAmountIsRefusedNotAnsweredAsTheFirst fails without e6a29f0's Processor change.

Planted defects, 19 of 19 caught, each by a named test:

  • BILL, RFND, CGBK or INSF dropped;
  • CANCEL-REBILL reversing;
  • TEST_SALE recorded;
  • an unlisted type acknowledged;
  • every reversal in full;
  • a stated 0 reversing nothing;
  • a reversal naming no receipt;
  • a negative rebill accepted;
  • a retry's attemptCount kept in the fingerprint;
  • the Processor fingerprinting the form fields;
  • the fingerprint not in key order;
  • a time read without its offset;
  • a 13th month read as January;
  • a reversal not dated, a sale not dated;
  • an unreadable time read as none.

Live pass, tests/live/clickbank-ins.sh: 85 passed, 0 failed on 801e5fc. After e6a29f0: 87 passed, 0 failed. The two new steps are a retry sent as the identical order and a second refund of the same amount, now a 409. With the Processor change taken out, those two fail.

Two accounts, notifications encrypted by the openssl command line, posted with curl, rows read back from 202_conversion_logs and GET /conversions. Beyond #276's checks:

  • a BILL under RB…-0001 recorded and replayed as a duplicate;
  • account 2's RFND reverses its sale by 12.50 (reverses_conv_id set), and its replay is a duplicate;
  • a CGBK of the refunded sale for another amount is a 409;
  • account 1's key cannot refund account 2's rebill (404);
  • a CGBK stating 0 takes the whole rebill back, and account 2's click nets 0;
  • an INSF takes back account 1's sale;
  • a refund of a receipt not on file is a 404;
  • CANCEL-REBILL, UNCANCEL-REBILL and SUBSCRIPTION-CHG are 200 ignored, and TEST_SALE is 200 test;
  • RFND-RB is a 400 naming it;
  • GET /conversions?source=clickbank lists the four rows to account 2 and none to account 1;
  • a reversal deleted through DELETE /conversions/{id}: ClickBank's retry of the same refund is 200 "that refund sent again", a chargeback then reverses the sale again, and the SALE sent again is a 409;
  • the server log has one line per notification, naming its account, with no key and no buyer detail.

Before and after. The same pass against #276's adapter: 61 passed, 24 failed, with every BILL, RFND, CGBK, INSF, TEST_SALE and unlisted type answered 200 ignored.

Could not run

  • The installer's licence step. The licence stub at my.tracking202.com is down. The instance was installed by running what 202-config/install.php does once its licence check passes: INSTALL::install_databases(), InstallAccount::create(), a REST API key in 202_api_keys, and the partitions. Then php -S was started with the CI router, as install-instance.sh does. Skipped: the licence check itself, and the AutoCron and daily-email registrations. Everything else (the API, dl.php, cb202.php, DELETE /conversions/{id}) was driven directly.
  • CI has not been observed on e6a29f0 yet.
  • A real ClickBank notification was not available.
  • The full integration-suite set was not run. The run was on MariaDB 10.11; CI runs MySQL 8.0.

Found outside this item

  • A reversal larger than its sale is recorded as stated. A refund of a 20.00 sale stating 37.62 leaves the click at −17.62. This is the shared Processor's behaviour (PayKickstart too). For a ClickBank vendor it may even be right, if ClickBank's refund costs more than the sale's net. It needs a decision (backlog 3E5N).
  • The shared Processor's no_click and unknown_click messages are worded for PayKickstart ("the checkout link carried no subid", "signed"). ClickBank and JVZoo answer no_click from their adapters to avoid this. 1.9.79: payment notifications answer no_click and unknown_click in each provider's own field names #319 fixes the rest.

Generated by Claude Code

claude added 20 commits October 23, 2026 19:49
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
…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
cb202.php read the ClickBank secret key and the Slack webhook of account 1
and of no other account, so a second account could not use ClickBank's
Instant Notification Service at all. It also decrypted with AES-128-CBC,
while ClickBank's INS article says notifications are encrypted with
CBC-AES-256 and its PHP and Python samples decrypt them so: AES-128 reads
16 of the derived key's 32 bytes, and a notification encrypted as the
article says did not decrypt for any account (executed: the openssl command
line's AES-256 ciphertext, which Python's cryptography decrypts keyed as
ClickBank's Python sample, is refused by the old code, which accepts only
the AES-128 its own tests encrypted with).

cb202.php now runs the shared payment-notification Receiver with a
ClickBank adapter (#268's shape):

- ClickBankCipher decrypts as ClickBank's samples do: AES-256-CBC keyed
  with the first 32 hex digits of SHA-1(secret), trailing control bytes
  trimmed, the order a JSON object naming its transactionType.
- ClickBankAdapter tries every account's stored cb_key; the account is the
  one whose key decrypts the notification. A key two accounts store is
  refused (a new outcome, ambiguous_account, 409) and the log names the
  accounts. A ciphertext that decrypts to something other than an order
  answers as one no key decrypts: the old code answered the two with
  different 400s, which is what a padding-oracle attack needs.
- The envelope is read before any key: a legacy cverify form (4.0 and
  earlier) is a 415 naming the version to choose, anything else that is
  not {"notification","iv"} a 400.
- A SALE is recorded under its receipt at totalAccountAmount, stated as
  USD (ClickBank: "Total you received for the transaction in USD"), so an
  account counting in another currency is refused by the Processor. The
  amount is read exactly whether 8.0 sends it as a string or 7.0 as a
  number. The click is the first tracking code (tid=[[subid]]), or, with
  none, the affiliate's extclid that 8.0 sends; no click is 200 no_click
  rather than the 400 ClickBank resent five times.
- TEST marks that account's cb_verified through a new optional interface,
  ConfirmsTestNotifications, which the Processor calls for a test one
  account verified; a failure answers 500 so ClickBank's Test URL shows
  it. cb202.php then posts to that account's own Slack webhook, after the
  answer (ClickBank waits 3 seconds).
- Every other type is acknowledged as before (851K handles them).

Shared code: InboundRequest carries the raw body; the Processor's test and
ignored outcomes name the account when one verified; the Receiver's
refusal no longer says "hash" (ClickBank's is a cipher). The page's
ClickBank panel says to put tid=[[subid]] on the HopLink and asks for
OpenSSL alone (mcrypt was never read).

Tests: ClickBankAdapterTest (a known-answer vector from the openssl
command line, the old cipher refused, the padding-oracle case, the
envelope, every field's reading) and ClickBankInsIntegrationTest (two
accounts and a third sharing a key, a real server and the real writer).
PostbackHandlerTest is removed: it re-implemented the AES-128 decryption
in the test and exercised nothing in the tree. tests/live/clickbank-ins.sh
is the two-account live pass, encrypting with the openssl command line;
legacy-pixels.sh encrypts as ClickBank does now.

Backlog: 7PJV

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

The Processor's no_click answer names PayKickstart's checkout link and its
subid, which is the wrong advice for a ClickBank HopLink. The adapter now
answers no_click itself, as JVZoo's does, naming the type, the receipt and
tid=[[subid]].

Backlog: 7PJV

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
… take a sale back

cb202.php acted on TEST and SALE only and answered every other INS
notification 200 with nothing done: a BILL (a subscription's rebill)
recorded no revenue, and an RFND or a CGBK left the sale it refunded
counted.

ClickBankAdapter now reads every transactionType ClickBank's INS article
lists (TYPES), through #268's Processor, as PayKickstart and JVZoo do:

- BILL is a rebill: another conversion on the click under its own receipt
  (ClickBank's reporting: the first order's receipt, a dash and four
  digits), at its totalAccountAmount.
- RFND (a refund), CGBK (a chargeback) and INSF (an eCheck returned
  unpaid) reverse the conversion under their receipt, by what
  totalAccountAmount says the account lost, sign dropped; none or 0 takes
  back the whole payment. JVZoo reverses in full because JVZoo refunds
  whole; ClickBank refunds part of a payment too (the Orders API's product
  change refunds "a portion of the old product's last recurring payment").
  A payment is reversed once: the Processor answers a repeat stating the
  same amount as a duplicate and another amount as a 409 naming what is
  still credited.
- CANCEL-REBILL and UNCANCEL-REBILL (a subscription cancelled, a
  cancellation undone), SUBSCRIPTION-CHG, ABANDONED_ORDER and the customer
  notifications move no money: 200 ignored, logged with the account.
- TEST_SALE, TEST_BILL, TEST_RFND, CANCEL-TEST-REBILL and
  UNCANCEL-TEST-REBILL are tests, as TEST is.
- A type the article does not list is a 400 naming it, rather than
  acknowledged: one that moves money would otherwise be lost unseen.

Tests: the type table, rebills and reversals in ClickBankAdapterTest, and
in ClickBankInsIntegrationTest on a real server with the real writer: a
rebill beside its sale, a refund and its replays, a partial refund and the
full one refused after it, a chargeback and a returned eCheck in full, a
refund of a rebill alone, a refund of nothing on file, another account's
click, the cancellations and an unlisted type. tests/live/clickbank-ins.sh
drives each over HTTP for both accounts.

Backlog: 851K

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

The Receiver's 403 now names the provider's signature field
(ProviderAdapter::signatureField()); ClickBank's is its encryption.

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

# Conflicts:
#	202-config/changelogs.json
…ClickBank branch

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

# Conflicts:
#	202-config/changelogs.json
…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
…79's #270, #280, #281, #282) into the ClickBank branch

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

# Conflicts:
#	202-config/changelogs.json
#	changelogs.txt
#268's Processor now dates a conversion by the time its notification
states (held between the click and the arrival). ClickBankAdapter reads
transactionTime with the offset it carries: RFC 3339 as 6.0 and 8.0 send
it (2023-10-05T13:47:51-06:00) and ISO 8601's basic form as 7.0's notes
describe (20200819T144359-0700). A time that is neither, or names no
real moment (a 13th month, hour 24, beyond a conv_time), is a 400 naming
the field rather than the time it arrived; none at all is dated on
arrival, as before.

Backlog: 7PJV

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…32b44 and 1.9.79) into the rebills branch

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

# Conflicts:
#	202-config/PaymentNotification/ClickBank/ClickBankAdapter.php
#	changelogs.txt
#	tests/live/clickbank-ins.sh
…al is dated

#268's Processor now keys a refund that has no id of its own by a hash of
its body, so the same notification sent again finds its reversal, live or
deleted, and any other one reverses the sale again once a deleted
reversal no longer stands. It hashes the form fields, and ClickBank's
notification is a JSON body with no form fields: every ClickBank refund
had the same key. A chargeback that arrived after an operator deleted a
refund's reversal was answered 200 "this CGBK is that refund sent again"
and the sale stayed counted (ClickBankInsIntegrationTest, before this).

A provider whose notification is not its form fields now says what one
notification is (FingerprintsNotifications, which the Processor asks when
the adapter implements it). ClickBank's is the decrypted order with its
keys in order and without attemptCount, which ClickBank raises on each
retry of the same notification; its ciphertext and IV need not repeat
either.

Also on the merged base: a SALE sent again while its refund stands is a
409 naming the reversal to delete; a refund, chargeback or returned eCheck
is dated by its transactionTime, held at its sale when earlier. Tests
for both, and tests/live/clickbank-ins.sh deletes a reversal through
DELETE /conversions/{id} and drives the retry and the chargeback after it.

Backlog: 851K

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 05:56
@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-28T05:56:03.890812Z
We couldn’t complete this review. Comment @codex review to try again.
801e5fc 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 801e5fc: 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 5e7e990: 6 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
Brings in clickbank-per-account (this branch's stacked base), and through it 1.9.79's new commits.

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

Copy link
Copy Markdown
Owner Author

adversarial-review failed on f750f9d, the merge of 1.9.79 into this branch: verdict BLOCKING, 6 findings. The findings are encrypted for maintainers, so I can't read which ones block.

I've started an independent adversarial review of the same diff: this branch's commits, #276's and #268's beneath it, and the merge resolutions. Every defect it can show with a failing test gets fixed on the lowest branch that owns the code, then merged up. If a maintainer can share which findings were blocking, that would point the fixes directly.


Generated by Claude Code

…ed as the first

ClickBank's retry of a notification is the same order (its attemptCount
aside), and ClickBankAdapter::fingerprint() keys a refund by exactly that,
so a refund whose fingerprint finds no reversal on file is another
notification. When a reversal already stood against the payment, the
Processor still compared amounts only and answered one stating the same
amount 200 "is already reversed; nothing was changed": a second partial
refund of 10.00 after one of 10.00 (ClickBank refunds part of a payment,
which is why this branch reads totalAccountAmount at all) was dropped with
a status ClickBank never retries, while the account lost it too (CLAUDE.md
#4, #15: the request was compared by amount when the code knew it was
another one).

For a provider that says what one notification is
(FingerprintsNotifications), only that notification sent again is a
duplicate; any other reversal of a payment one already stands against is
a 409 naming the reversal on file and what is still credited, whatever it
states, since a sale is reversed once. A chargeback of a refunded payment
for the same amount is now that 409 too, where it was 200 duplicate: two
notifications that each say the account lost the money are shown in
ClickBank's log rather than one of them dropped unseen. PayKickstart, which
keys refunds by its form fields, keeps its rule.

Tests: ClickBankInsIntegrationTest's new
testAnotherRefundOfTheSameAmountIsRefusedNotAnsweredAsTheFirst fails
without the Processor change ("is already reversed; nothing was
changed"), and the refund test's "same refund again" is ClickBank's retry
of it (the same order, attemptCount raised) rather than a notification
with another time or spelling. tests/live/clickbank-ins.sh sends the
retry as the same order and a second refund of the same amount: 87
passed, 0 failed here; 85 passed, 2 failed (those two) with the
Processor change taken out.

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

Copy link
Copy Markdown
Owner Author

This follows up the BLOCKING adversarial review on f750f9d. Its report is encrypted, so we can't confirm that this is the same finding, but a self-review of this PR's own delta found one defect and e6a29f0 fixes it.

  • Defect: a second ClickBank refund of a payment for the same amount was answered 200 duplicate and dropped, and ClickBank never retries a 200. The Processor compared amounts even when the adapter's fingerprint had already shown it was a different notification.
  • Fix: only ClickBank's retry of the same order is a duplicate. Any other reversal of a payment that already has one is a 409 naming what is still credited.
  • Behaviour change: a chargeback of a refunded payment for the same amount is now a 409 too, where it was 200 duplicate.
  • Test: testAnotherRefundOfTheSameAmountIsRefusedNotAnsweredAsTheFirst fails without the change.
  • Live pass: 87 passed, 0 failed with the change. With the Processor change taken out, the two new steps fail.

The description above is updated to match. The review re-runs on this push.


Generated by Claude Code

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 ClickBank line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
ClickBankAdapter::storedSecrets() read cb_key from every 202_users_pref row,
and a deleted account keeps its row and its key. A notification that the
key decrypts names every account that stores it, and a key two accounts
store is refused (409, ambiguous_account): so a deleted account that still
held the key a live account stores now made every one of the live
account's notifications a 409, and the deleted account's copy cannot be
cleared from the UI. A key only a deleted account stored still recorded
sales on its clicks.

storedSecrets() reads the keys through #268's AccountSecrets::stored(),
which leaves out deleted accounts; a switched-off account (user_active = 0)
still owns its key. StoredSecretsReadThroughAccountSecretsTest now holds
ClickBankAdapter too, and lists the two statements it makes on
202_users_pref for the account a TEST already decided (confirmTest(),
slackWebhook()).

Tests: ClickBankInsIntegrationTest's new
testADeletedAccountsStoredKeyIsNoSecondAccount: with a deleted account
storing the first account's key, the first account's sale is recorded and
its TEST marks only it verified; a key only the deleted account stores is
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 with the 409 ("The notification decrypts
with a ClickBank secret key that more than one Prosper202 account
stores"); 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
Brings in #276 with #268's AccountSecrets: a deleted account's stored
ClickBank and PayKickstart keys verify nothing, so a deleted account no
longer makes a live account's ClickBank key ambiguous. The conflict was in
changelogs.txt, where both sides changed lines of 1.9.79: the incoming
PayKickstart and ClickBank lines are kept as they are and this branch's
rebills line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 76b394b into 1.9.79 Oct 28, 2026
20 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