Repository navigation
1.9.79: ClickBank rebills are recorded, and refunds and chargebacks take a sale back - #288
Conversation
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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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
|
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
|
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.
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
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
TESTandSALEonly, and answered every other INS notification200with nothing done:BILL(a subscription's rebill) recorded no revenue;RFND(refund) orCGBK(chargeback) left the sale counted.What changed
ClickBankAdapternow reads everytransactionTypethat ClickBank's INS article lists (TYPES). Recording and reversing go through #268'sProcessorandp202RecordLegacyConversion(), as PayKickstart and JVZoo do.SALEreceipt, attotalAccountAmountBILLABCD1234-0001)RFNDtotalAccountAmountsays the account lost (sign dropped); none or 0 takes back the whole paymentCGBKINSFCANCEL-REBILLUNCANCEL-REBILLSUBSCRIPTION-CHG,ABANDONED_ORDER,CUSTOMER_AUTH_FAILURE,CUSTOMER_EMAIL_UPDATE,CUSTOMER_UPDATE_CC_NOTIFICATION,PURCHASE_DETAILS_EMAIL_RESPONSETEST,TEST_SALE,TEST_BILL,TEST_RFND,CANCEL-TEST-REBILL,UNCANCEL-TEST-REBILLReversals (#268's rules, with one change for ClickBank in e6a29f0):
attemptCountaside) is200 duplicate. Any other reversal of a payment that already has one is a409naming 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.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.409that names the reversal to delete.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 answered200 "this CGBK is that refund sent again"and the sale stayed counted.FingerprintsNotifications. The Processor asks it when the adapter implements it. This is oneinstanceofinProcessor::reversal(); PayKickstart and JVZoo are untouched.attemptCount. ClickBank raisesattemptCounton 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.mdnow 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 inchangelogs.txtandchangelogs.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.
receipt.totalAccountAmountis 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.The ledger rules (CLAUDE.md #13, #15, #34, #36)
409. A reversal is compared by its fingerprint, the decrypted order withoutattemptCount, rather than by amount, so only a retry of the same notification is a duplicate.500and ClickBank retries in 4 hours. The writer keys the row by the receipt (and the reversal by sale and body), so a retry cannot double it.p202RecordLegacyConversion(), whose writer locks the click before its first plain read and owns its transaction. The Processor catches nothing inside a transaction.Decisions a reviewer should check
SALE-RB/RFND-RB, say) with no trace in ClickBank's log. ClickBank retries a 400 five times over 20 hours.TEST_SALEand the other test types mark the key Verified, asTESTdoes, because each one decrypted with that account's key.SUBSCRIPTION-CHGrecords nothing. The article describes it by its old and new products, not by a payment.409(since e6a29f0), where it was200 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 --changedon 801e5fc (PHP 8.3.6, MariaDB 10.11, full vendor/). The schema tier had a scratch database, and202-config.phpwas moved aside. Unit tier: 6170 tests, 4 skipped (the documented User DB-singleton skips). Verbatim:For e6a29f0,
verify.sh --changedon the working tree before the commit gavepatterns,phpcs,phpstan,syntaxandunitPASS.Integration suites, each alone on a database created for it:
ClickBankInsIntegrationTestPayKickstartIpnIntegrationTestLegacyPostbackCampaignIntegrationTestConversionBridgeIntegrationTestConversionLedgerIntegrationTestThe integration tests cover:
testAnotherRefundOfTheSameAmountIsRefusedNotAnsweredAsTheFirstfails without e6a29f0's Processor change.Planted defects, 19 of 19 caught, each by a named test:
attemptCountkept in the fingerprint;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 a409. 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_logsandGET /conversions. Beyond #276's checks:RB…-0001recorded and replayed as a duplicate;reverses_conv_idset), and its replay is a duplicate;409;404);404;200 ignored, and TEST_SALE is200 test;RFND-RBis a400naming it;GET /conversions?source=clickbanklists the four rows to account 2 and none to account 1;DELETE /conversions/{id}: ClickBank's retry of the same refund is200 "that refund sent again", a chargeback then reverses the sale again, and the SALE sent again is a409;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
202-config/install.phpdoes once its licence check passes:INSTALL::install_databases(),InstallAccount::create(), a REST API key in202_api_keys, and the partitions. Thenphp -Swas started with the CI router, asinstall-instance.shdoes. 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.Found outside this item
no_clickandunknown_clickmessages are worded for PayKickstart ("the checkout link carried no subid", "signed"). ClickBank and JVZoo answerno_clickfrom 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