Repository navigation
1.9.79: PayKickstart's product IPN, verified with each campaign's key - #268
tracking202 wants to merge 12 commits into
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
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 658a28e74e
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…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
|
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aab39091fe
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…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
…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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41bfe59e82
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…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
…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
…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
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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 80d1bc4742
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $pkPosted('paykickstart_key_token'), | ||
| $pkSecret | ||
| ); | ||
| updateUserPreference(PayKickstartKeys::COLUMN, PayKickstartKeys::serialize($pkKeys), $_SESSION['user_id'], $db); |
There was a problem hiding this comment.
When two sessions for the same account submit overlapping key changes, both requests can derive $pkKeys from the same $pkOld and then blindly replace the whole column, so the last write discards the other session's addition or removal; for example, concurrent additions of keys A and B can leave only B stored, causing IPNs signed with A to be rejected. Fresh evidence beyond the earlier stale-token issue is that the full-list write here has no row lock or compare-and-swap check; update the list transactionally or condition the update on the previously read value.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The lost update is real. Two sessions of one account that save the key list at the same moment each write the list they read, so the later save drops the other's change.
I'm not fixing it only here, because every settings form on API integrations and the other Account pages works the same way: it posts whole values and rewrites them, so any two concurrent saves of one account's settings lose one. A compare-and-swap on this one column would leave every sibling form with the same shape (CLAUDE.md #5). The proportionate fix is page-wide: each settings form carries the version of what it showed and refuses a save over a newer one. I'm filing that as one follow-up for the owner rather than widening this PR.
The window is one account saving its own PayKickstart key list from two places within the same request round-trip. The visible outcome is an IPN refused for an unknown key, and that is logged with the key's prefix.
Generated by Claude Code
There was a problem hiding this comment.
Correction to my last line: an IPN signed with a dropped key isn't logged with the key's prefix. When no stored key verifies the hash, the adapter answers the notification as unverified and logs no key at all. So the visible sign is a run of unverified IPNs for that product, not a named key.
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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 #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
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
Backlog: 1YEB ("PayKickstart IPN URL points to a file that does not exist")
This changes what was decided: read this first
The decision was "Build a PayKickstart IPN handler with signature check", and the panel it fixes promised PayKickstart's Affiliate IPN. That cannot be built as the panel describes. PayKickstart's Affiliate IPN is unsigned JSON (amount, buyer names, PayKickstart's own
tracking_id, the affiliate's commission). It has no signature, no event type and no transaction id. PayKickstart allows no custom variables in it, and the affiliate's?tid=value shows only in the affiliate's own PayKickstart account. So nothing in it names a Prosper202 click, and nothing in it can be verified.What this PR builds instead is the vendor (product) IPN. That is the reading of the decision that has a signature to check:
subid=[[subid]]to the PayKickstart checkout link that is the campaign's offer URL, pastetracking202/static/paykickstart.phpinto each product's IPN Integration tab, and store each PayKickstart campaign's IPN secret key in Prosper202. Each campaign has its own key. PayKickstart sends the checkout link's query variables back ascustom_<name>, socustom_subidis the click.415and says why.Until now the URL was a 404 for everyone: the file never existed.
jvzoo.php,zpn.phpandslack.phpare the same kind of dead URL. They are separate 1.9.79 items, and they are listed as known dead in the new check.What changed
tracking202/static/paykickstart.php(POST only, bootstrapped likecb202.php) runsProsper202\PaymentNotification\Receiverwith the PayKickstart adapter.hashis checked against every account's stored keys with PayKickstart's published HMAC-SHA1 algorithm (hash_equals), before any field is read. The account is the one whose key verifies, never one the request names (CLAUDE.md [Maintenance] Remove deprecated PHP features #16).salesrecords a conversion (pixel type 2, new ledger sourcepaykickstart) undertransaction_id, atamount. The amount must be an exact decimal that fitsdecimal(11,5). Rebills are recorded too, each under its own transaction id.refundreverses the sale itstransaction_idnames, through the writer's reversal path.mode=test,subscription-payment(salesalready fires for every payment), every other event, and a sale whose checkout carried no subid (no_click).transaction_time(decision 6).200 duplicateonly when it states the same amount. Stating another amount is409, never answered as the first ([Fix] Return 404 status #15).error_logline, never with a key or the buyer's details.The shared part is
202-config/PaymentNotification/, shaped by the coordinator's guidance:ProviderAdapter: stored secrets, verify, parse.Notification: kind sale|rebill|refund|chargeback|test|ignored, the provider's event, transaction id, the original transaction id, amount, currency, click id, occurred-at.Processor: records or reverses throughp202RecordLegacyConversion().Response: the one mapping from outcome to status and body.Receiver: method, verify, parse, process, log.PayKickstart is
PaymentNotification\PayKickstart\{PayKickstartAdapter, PayKickstartSignature, PayKickstartKeys}. There is no request log, queue, secrets store or UI framework.Keys:
202_users_pref.paykickstart_ipn_secret_keys(text, one key per line), in the table definition and the 1.9.78 → 1.9.79 rung.usersPref()is added to the rung's single_upgrade_measurement_tables()call, because UpgradeLadderTest refuses a second call.PayKickstartKeys: at most 20 keys, each 8 to 128 characters of visible ASCII other than the comma.""clears them. A bad key is refused by its place in the list, never by its text.PUT /users/{id}/preferencespaykickstart_ipn_secret_keysreplaces the whole list. The Go CLI'suser prefs update --paykickstart-ipn-secret-keyssends it.GET /users/{id}/preferencesanswers the column as{"stored": true}, as it does every integration secret (ServedPreferences::SECRET). A GET body sent back keeps the keys. The count this PR first served in its place (paykickstart_ipn_keys_stored) is gone: one shape for every secret, and no second key for one column that PUT would have to accept back.GET /system/integrationsstill haskeys_stored(how many) and a realsecret_stored. Staging a write that carries the keys is refused (pinned inStagedChangesControllerTest). There is also a Slack notice when the keys change.The panel gives the vendor steps, including setting the campaign's "When a click converts more than once" to "Add them up" for subscriptions. In the live pass, "Keep the latest" made each rebill replace the sale's value. The panel also has the affiliate note.
Docs and changelogs:
docs/openapi.yaml(integrations, preferences, the ledger source enum),docs/cli.md,docs/cli-agent.md,documentation/api/{09,10,14,15},documentation/cli/{10,12}, a PayKickstart section with an outcome table indocumentation/tutorials-and-guides/09-3rd-party-integrations.md,changelogs.txtandchangelogs.json.The machine check for the defect
tests/Release/HandedOutUrlsNameServedFilesTestchecks that every string literal in a served PHP file naming a script undertracking202/or a202-*directory names a file that exists, and that every integrationAdministrationController::INTEGRATIONSlists does too.KNOWN_DEAD(jvzoo.php, zpn.php, slack.php) only shrinks: a listed file that appears, or that nothing names any more, fails the test. Planted and caught: a dead script in INTEGRATIONS, a dead script on the page, and a dead entry removed from the list.Other structural tests taught about the new code:
$_POSTand is authenticated by HMAC.ipis the provider's server, as in cb202.Receiveras recording, and the endpoint is listed as exempt. A planted removal of the exemption fails.Decisions a reviewer should check
custom_subid(a test ping, an untracked sale) can still be verified and acknowledged.licenseswith several licences) are left out of the signed string. This was executed, not inferred: PayKickstart's PHP validator, run verbatim on PHP 7.4 (unpacked from the ondrej PPA), drops an array value and gives the same hash as the payload without it. On PHP 8 the validator throws TypeError.iconv(), whose//IGNOREvaries by C library. It equals glibc iconv on every valid string tried (3,000 random strings in the test, 20,000 in the scratch run).200 duplicate.200 duplicatewhen it takes back what that reversal took back; a refund with no amount (or 0) counts as the sale's whole amount. Otherwise it is a 409 naming both amounts, so a full refund after a partial one says what was reversed and how much is still credited (Codex's P1, cf1bd2a). A sale is reversed once: nothing in PayKickstart's IPN documentation, as read for this PR, describes cumulative refunds.rev:<sale>:1, which the deleted row keeps, so the next refund was answered200 duplicateand reversed nothing (measured on the JVZoo branch).duplicateand409. A refund with an id of its own (none from PayKickstart) is still told apart by that id.time().Signed.
transaction_timeis covered by the signature, like every non-empty field. Decision 2 still applies: a holder of a signed IPN can move values between fields, but cannot set a time of their own.Unreadable. A time that is not a Unix time is a 400 naming it (subid missing in pixel url #4), where it used to read as no time. With none stated, the row is dated when it arrives.
Bounds. The time is held between the earliest it can be and its arrival, and the answer and the log line say so:
These are held rather than refused: refusing would lose the payment over a clock, and PayKickstart would resend the same time.
Repeats. A repeat is not compared by its time, only by its amount. The stored time may be a bound rather than the time sent, so the comparison would refuse a genuine retry.
Reports.
p202 reportcounts a conversion on its click's day and is unchanged by this. What moves is everything by conversion time: the conversion list and its time filter, attribution, and the click-to-conversion time.p202RecordLegacyConversion()has nonotify_traffic_source).Processorrefuses a notification that states a currency other than the account's (422). No adapter sends one yet; this is the default for the next adapter, tested by building aNotificationdirectly.salesIPN for a reversed sale is either a late retry of a sale already answered, or a sale PayKickstart says counts again.200 duplicate.ProviderAdapter::signatureField()(hashfor PayKickstart).signatureField();Notification::$occurredAt, refusing one they cannot read (400 naming the field) rather than leaving it null. The Processor does the rest.Verification
Scope report of
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, on 8532b44, after the merge of1.9.79at 915a367 (#270, #280, #281, #282) into this branch (PHP 8.3.6, MariaDB 10.11, full vendor/; the unit tier ran 6164 tests), verbatim:Run by hand, beyond the ladder:
tests/PaymentNotification/PayKickstartIpnIntegrationTest(20 at 41bfe59, 13 at first): sale, replay, a replay stating another amount, rebill,subscription-payment, full and partial refund, refund replay, a second refund (409), a refund with nothing to reverse (404), a bad hash, a tampered amount, a missing hash, GET, another account's click, a click that does not exist, a key shared by two accounts, test mode, an ignored event, no subid, a malformed amount, an unreadable stored key, a currency the account does not count in, and the 503 while the upgrade is pending; since cf1bd2a and b000000, a full refund after a partial one, a repeat however its amount is spelled, a sale stated again after its refund, a refund after its reversal was deleted, a refund with an id of its own, a sale and a refund days late (dated when they happened, a refund before its sale dated at the sale, a changed time a 403), and a time before the click or ahead of the clock (held, and said).tests/User/PreferencesWriteIntegrationTest(6),tests/Api/V3/AccountAdministrationIntegrationTest(13), and 1.9.79: GET /users/{id}/preferences serves no integration secret #270'stests/User/PreferenceSecretsAreNotServedIntegrationTest(5), whose round trip now stores the PayKickstart keys with the other secrets.tests/run-integration-suites.shon the merged tree: 142 suites, 1340 tests passed, and 2 suites failed. Each of the 2 was then re-run alone on a fresh database:AttributionWorkerIntegrationTestpassed 3 runs of 3. In the full run, its dead-connection lock test (the dead session took its lock with it) had failed while other sessions were using the same server.MysqlLookupRepositoriesIntegrationTestfails one test,testTheFactoryCachesThroughTheBootstrapHelpers, withClass "Memcached" not found: this sandbox has no memcached extension, and CI's job installs it.is_rebillread loosely;withoutKeys()(since removed for 1.9.79: GET /users/{id}/preferences serves no integration secret #270's shape), GET preferences serving the keys, integrations counting no keys;TestPrefFlagsMatchThePreferenceRules).1again;conv_time;removed()(which compared the ending), and a token computed from the ending alone.1.9.79-jvzoo-ipnwith no conflicts (git merge-tree). On that merge, withsignatureField()added to the JVZoo adapter, JVZoo's four suites passed (14 IPN integration tests, 54 unit tests), and so did PayKickstart's IPN suite. At aab3909, which includes 1.9.79: the findings of the 1.9.78 release's adversarial review #262, the only conflict ischangelogs.json, between 1.9.79: the findings of the 1.9.78 release's adversarial review #262's lines and JVZoo's line. Not rechecked at 41bfe59.tests/live/upgrade-equals-install.sh(1.9.55 on PHP 7.4 → this branch, against a fresh install): 25 passed, 0 failed. 162 tables are identical, and the upgrade logged nothing.upgrade.phpwith this branch.202_versionreads 1.9.79,paykickstart_ipn_secret_keys text NULLwas added, and no upgrade log line appeared.install-instance.shand seeded withseed.sh:Setup. Campaign B's offer URL was set to
https://app.paykickstart.com/checkout/123?subid=[[subid]](p202 campaign update). Two keys were stored withp202 user prefs update 1 --paykickstart-ipn-secret-keys ….user prefs get(before 1.9.79: GET /users/{id}/preferences serves no integration secret #270, a count) andsystem integrationsshowed the count and never the keys.A click. Tracker B redirected to
…/checkout/123?subid=7.Again on the final tree. The IPN list below was run again on a fresh click,
subid=9, after the merge of 1.9.79: the review findings of the re-click restore and the ledger lock #258 and the 503 commit, and gave the same answers.IPNs. Signed with the published algorithm (a script independent of this code) and POSTed with curl. Results: sale 200 recorded, replay 200 duplicate, another amount 409, rebill 200,
subscription-payment200 ignored, test mode 200, full refund 200 reversed, its replay 200, a second refund 409, a partial refund 200, a refund of a sale not on file 404, a stranger's key 403, a tampered amount 403, no subid 200no_click,custom_subid=[[subid]]400,1,000.00400, another account's key on this click 404, the Affiliate IPN's JSON 415, GET 405.Read back.
p202 conversion list --source paykickstartandp202 click conversions 7and8read the rows back. In accumulate mode, 47 + 9 − 9 = 47 was the click's value, and the campaign breakdown showed income 47. The server log held one line per IPN and no key, email or buyer name.An undone refund, end to end (port 8230, b000000), each IPN signed and posted to
paykickstart.php:p202 conversion delete 2 --force, then the sale again: 200 duplicate;hash.p202 click conversionsread the rows back as +47, −47 (deleted), −47, and the click's value as 0.A late IPN, end to end (port 8230, 41bfe59), on an instance set up without the licence stub (below) and driven by curl:
https://app.paykickstart.com/checkout/123?subid=[[subid]], a tracker, and the key;dl.php, then backdated five days in the click tables (a fixture edit);GET /users/1/preferencesanswered the keys as{"stored":true}, and/system/integrationskeys_stored1.The IPNs, each signed and posted to
paykickstart.php:transaction_time=2026-10-25: 400 naming it.The read-backs:
GET /conversions?click_id=(behindp202 conversion list) showed the four rows on 10-25, 10-26, 10-23 and 10-28;GET /conversionsfor the sale's UTC day listed the sale alone;GET /reports/timeseries?interval=day(behindp202 report timeseries) counted the conversion on the click's day, 10-23, as before.The page. A second account signed in and, through the page's own forms (token included), added a key, was refused a 5-character key ("The key is 5 characters long; …") and a duplicate, had a stale remove refused, and removed a key. The key's text never appeared on the page.
admin-005-a-paykickstart-key-is-stored-never-repeated: store a campaign's key and give the URL to paste.user prefs updatewas run,keys_storedis 1, the preferences read leaves out the key, and the reply has the URL andsubid=[[subid]]but not the key. The rubric was graded by hand from the judge's input, because no judge runs here.p202 searchputssystem integrationsfirst for this ask anduser prefs updatesecond: the ask holds two tasks. It is listed insearchKnownMissesrather than taught by a phrase taken from the ask.node tests/browser/run.js --spec account-pages, new scenario added): the PayKickstart scenario passed all 8 checks, and every Account page passed its baseline at 1280px and 390px in light and dark. One existing scenario failed, "a DNI network is removed only when confirmed", because the pass signed in as a second admin. The page lists user 1's DNI networks but deletes only the session user's. That is not caused by this PR; see below.Could not run
install-instance.shfails at the licence step:install_databases(),InstallAccount::create(), a REST key) without the licence form.p202 report,p202 conversion listand the eval case did not run against it. Their API endpoints did, by curl. The earlier passes in this PR ran with the stub.{"stored":true}(updated for 1.9.79: GET /users/{id}/preferences serves no integration secret #270) and has not been run since.api-key-required.php), so 8532b44's Remove form was not driven in a browser or over HTTP.PayKickstartKeysTestcovers the stale form, both before and after, and the page's own change is the form field and the handler's argument.Found outside this item
/users/{id}/preferencesserved the older secret columns in plain text (cb_key,jvzoo_ipn_secret_key,zaxaa_api_signature,ipqs_api_key, the Slack URL,lpo_bridge_config); 1.9.79: GET /users/{id}/preferences serves no integration secret #270 has since fixed it.202_dni_networks WHERE user_id = '1'but removesWHERE user_id = <session user>. An admin other than user 1 sees user 1's networks, and their Remove answers "not found".Amount::toUnits()accepts 13 whole digits while conversion columns aredecimal(11,5). MariaDB under the emptysql_modethatconnect.phpsets clamps 5000000 to 999999.99999 with only a warning; that was measured on the column, not through an endpoint.p202RecordLegacyConversion()has nonotify_traffic_source), unlike gpb.php.Generated by Claude Code