diff --git a/docs/decisions.md b/docs/decisions.md index f2f3da6..c563dbd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1438,3 +1438,115 @@ Node analogue and would blur what that file is for. **Credit.** Found downstream against a real deployment and reported upstream; independently reproduced here, including the sign-flip case, before the fix was applied. + +## 30. Matching consumed the click, so reinstalls lost attribution and repeat matches re-attributed silently — Done + +**Problem, reported downstream from real end-to-end device testing and +reproduced here before anything was changed.** `/match` did not only read — +it locked the winning click to the device, and the candidate query only ever +considered unmatched clicks (`WHERE matched = 0` in PHP, +`eq(referralClicks.matched, false)` in Node). A matched click therefore +became invisible to everyone, *including the device that had just matched +it*. `ClickStore::lockToDevice()`'s own docblock stated the original intent +plainly: "so it can never be matched twice." That intent was the bug. + +**Symptom one — a reinstall recovers nothing.** One click, three install +cycles. On iOS `identifierForVendor` is cleared when the last vendor app is +uninstalled, so a reinstalled app presents a *new* device id: + +``` +install #1 (device-after-install-1): got FRIEND99 at 100.00 +install #2 (device-after-install-2): NO CODE RECOVERED +install #3 (device-after-install-3): NO CODE RECOVERED +``` + +It compounded at `/claim`, which verifies the click is bound to the claiming +device — so even a click that could still be found would have been rejected +as unverified after a reinstall. + +**Symptom two — a repeat match credits a different referrer.** Same device, +two identical calls seconds apart, two clicks in the window. The device's +real referrer is `ALICE01`: + +``` +attempt 1 (SAME device): attributed to ALICE01 at 100.00 +attempt 2 (SAME device): attributed to BOB0002 at 85.00 +``` + +Worse than symptom one, because nothing looks like an error. A retry, or a +relaunch before signup completes, silently re-attributes the user to someone +else. **The selection logic was never at fault and this was not +non-determinism** — `match()` scans every candidate and keeps the maximum, +with `ORDER BY created_at DESC` only settling ties. The second call correctly +picked the best of what it could still see; the pool had changed underneath +it. That misdiagnosis cost real time downstream, so it is written down here. + +**Decision.** Matching does not consume a click. `matched` now means "last +matched by", not "used up" — guarding against a referral code being redeemed +twice belongs in whatever records signups, not in making the click invisible +to matching. The `matched` filter is gone from both backends' candidate +queries. + +Removing the filter alone would have been wrong in one direction, though: a +device whose fingerprint later stops clearing the threshold (IP moved, say) +would lose an attribution it had already been given. So the resolution is: + +| existing binding | new candidate above threshold | result | +| --- | --- | --- | +| no | yes | best candidate — unchanged from before | +| yes | no | keep the existing binding | +| yes | yes | whichever **click** is newer | + +Two constraints that are easy to get wrong, both learned the hard way: + +- **Compare by click time, not confidence.** A stale click that happens to + score higher is still the wrong answer. Attribution is last-click-wins. +- **A newer click below the confidence threshold must not take over**, or an + unrelated recent click could steal an attribution merely by being recent. + +An earlier attempt downstream stopped at "return the device's existing lock +first." That was rejected, correctly: it binds a device to the first click it +ever matched, so a user who clicks a *newer* referral link keeps being +credited to the older referrer for the whole window. + +**A second method rather than a looser one.** `lockToDevice()`'s +`AND matched = 0` is a security guard on the `/claim` path — the +deterministic tier's first real use of a click, where losing the race must +reject rather than proceed (#21). Matching has the opposite requirement, so +it got its own `bindMatch()` which rebinds unconditionally. Loosening +`lockToDevice()` itself would have quietly removed a claim-time guard while +appearing to fix an unrelated matching bug. + +**Ordering by the click's time, not the lock's.** A device can now hold more +than one binding, so the newest has to win. That ordering keys on +`created_at` rather than `matched_at`: `matched_at` is written by +`UTC_TIMESTAMP()` into a plain `TIMESTAMP`, both at one-second resolution, so +two locks taken in the same second tie and the winner falls out of arbitrary +storage order. `created_at` is stable *and* semantically right under +last-click-wins. + +**A parity divergence fixed along the way.** PHP filtered candidates on both +`expires_at > UTC_TIMESTAMP()` and the `created_at` window; Node filtered +only on the window, with no expiry check at all. Harmless while expiry and +the match window are the same duration, and a real difference between two +backends documented as interchangeable the moment either is configured +independently. Node now checks expiry too. + +**Verification.** The scoring suite could never have caught any of this — the +bug was never in scoring, it was in which rows the query could see, and a +pure-scoring suite has no database. `tests/run.php` now carries a +database-backed section using in-memory SQLite with `UTC_TIMESTAMP()` +shimmed via `sqliteCreateFunction()`, so it still provisions nothing: five +reinstall cycles under five different device ids, three repeat matches +against two competing referrers, takeover by a newer qualifying click, +refusal of takeover by a newer non-qualifying one, and an unrelated device +still matching nothing. Counter-checked by reintroducing only the +`matched = 0` clause: 7 of the new assertions fail and reproduce both +reported symptoms exactly, including the `ALICE01` → `BOB0002` flip. Worth +noting that "a newer qualifying click takes over" *passes* against the buggy +code — the old click is filtered out, so the new one wins by default — which +is precisely why it isn't load-bearing on its own. + +**Credit.** Found downstream by real end-to-end device testing and reported +upstream, along with the analysis that the selection logic was not the +culprit. Independently reproduced here before the fix was written. diff --git a/packages/referral-sdk-node/src/routes/referral.ts b/packages/referral-sdk-node/src/routes/referral.ts index c38bd4e..87feedd 100644 --- a/packages/referral-sdk-node/src/routes/referral.ts +++ b/packages/referral-sdk-node/src/routes/referral.ts @@ -139,16 +139,16 @@ export function referralRouter(db: Db, config: ReferralConfig): Router { timezone: fingerprint.timezone, language: fingerprint.language, deviceId: device_id, - }); + }, storedDeviceId); if (!result) { return res.json({ matched: false, referral_code: null }); } - // Lock atomically. If another request won the race, report no match - // rather than handing the same click to two devices. - if (!(await clicks.lockToDevice(result.clickId, storedDeviceId, 'fingerprint', result.confidence))) { - return res.json({ matched: false, referral_code: null }); - } + // Record the binding. Unlike the claim path this rebinds rather than + // refusing when the click is already matched — a returning device must + // be able to recover a click it (or a previous install of it) already + // matched. See decisions.md #30. + await clicks.bindMatch(result.clickId, storedDeviceId, result.confidence); const token = signClickToken(result.clickId, result.expiresAt, getClickTokenSecret()); return res.json({ diff --git a/packages/referral-sdk-node/src/services/clickStore.ts b/packages/referral-sdk-node/src/services/clickStore.ts index a4d6e6b..b2b218d 100644 --- a/packages/referral-sdk-node/src/services/clickStore.ts +++ b/packages/referral-sdk-node/src/services/clickStore.ts @@ -62,6 +62,35 @@ export class ClickStore { return { clickId, expiresAt }; } + /** + * Record the current fingerprint match on a click, rebinding it if it was + * already bound to some other device. + * + * Separate from lockToDevice() on purpose. That method's + * `matched = false` predicate is a security guard on the /claim path — the + * deterministic tier's first real use of a click, where losing the race + * must reject rather than proceed (decisions.md #21). Matching has the + * opposite requirement: a click has to be re-bindable, or a reinstall can + * never recover it, because on iOS the returning device presents a + * brand-new device id (decisions.md #30). Loosening lockToDevice() itself + * would have quietly removed that claim-time guard. + * + * Last write wins, which is exactly "last matched by" — and the caller has + * already decided this device should hold the attribution. + */ + async bindMatch(clickId: string, deviceId: string, confidence: number): Promise { + await this.db + .update(referralClicks) + .set({ + matched: true, + matchedDeviceId: deviceId, + matchedAt: new Date(), + matchMethod: 'fingerprint', + matchConfidence: confidence, + }) + .where(eq(referralClicks.clickId, clickId)); + } + /** * Atomically lock a click to a device so it can never be matched twice. * Returns true only if this call is the one that won the lock. Records diff --git a/packages/referral-sdk-node/src/services/fingerprintMatcher.ts b/packages/referral-sdk-node/src/services/fingerprintMatcher.ts index c39dc3e..ccfe6ae 100644 --- a/packages/referral-sdk-node/src/services/fingerprintMatcher.ts +++ b/packages/referral-sdk-node/src/services/fingerprintMatcher.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte } from 'drizzle-orm'; +import { and, desc, gt, gte } from 'drizzle-orm'; import type { ReferralConfig } from '../config.js'; import type { Db } from '../db/client.js'; import { referralClicks, referralMatchAttempts } from '../db/schema.js'; @@ -135,11 +135,34 @@ export class FingerprintMatcher { return weight * (1 - elapsedMs / windowMs); } - /** Find the best matching unclaimed click for a device, within the match window. */ - async match(incoming: IncomingFingerprint): Promise { + /** + * Find the best matching click for a device, within the match window. + * + * Matching deliberately does **not** consume a click. `matched` means + * "last matched by", not "used up" — see docs/decisions.md #30. An + * already-matched click stays a candidate, which is what lets a reinstall + * recover (on iOS the device id itself changes when the last vendor app is + * uninstalled, so the returning device is a *new* device as far as this + * table is concerned) and what stops a second match from silently falling + * through to a runner-up and re-attributing the user to a different + * referrer. Guarding against a code being redeemed twice belongs in + * whatever records signups, not in making the click invisible here. + * + * `deviceId` (already hashed) lets a device keep an attribution it has + * already been given if its fingerprint later stops clearing the + * confidence threshold. + */ + async match(incoming: IncomingFingerprint, deviceId?: string): Promise { const windowStart = new Date(Date.now() - this.config.matchWindowSeconds() * 1000); - // Only fresh, unmatched clicks. Newest first: last-click-wins on ties. + // No `matched = false` filter: that single clause was the whole of #17. + // The expiry check mirrors the PHP backend, which has always had it — + // the two are meant to be interchangeable, and without it this would + // diverge the moment expiry and the match window are configured to + // different durations. + // Newest first so ties resolve last-click-wins, and so the first row seen + // for this device is its most recent binding — ordering by matchedAt + // instead would be unstable at one-second storage resolution. const rows = await this.db .select({ clickId: referralClicks.clickId, @@ -152,20 +175,28 @@ export class FingerprintMatcher { language: referralClicks.language, createdAt: referralClicks.createdAt, expiresAt: referralClicks.expiresAt, + matchedDeviceId: referralClicks.matchedDeviceId, }) .from(referralClicks) .where( and( - eq(referralClicks.matched, false), gte(referralClicks.createdAt, windowStart), + gt(referralClicks.expiresAt, new Date()), ), ) .orderBy(desc(referralClicks.createdAt)); let best: (typeof rows)[number] | null = null; let bestScore = 0; + let existing: (typeof rows)[number] | null = null; for (const row of rows) { + // Rows arrive newest-first, so the first one bound to this device is + // its newest binding; later (older) ones are superseded. + if (existing === null && deviceId !== undefined && row.matchedDeviceId === deviceId) { + existing = row; + } + const s = this.score(row, incoming); if (s > bestScore) { bestScore = s; @@ -173,22 +204,43 @@ export class FingerprintMatcher { } } - const matched = best !== null && bestScore >= this.config.minConfidence; + // Only a candidate clearing the threshold may take an attribution over. + // Without this a newer but unrelated click could steal one purely by + // being recent. + const bestQualifies = best !== null && bestScore >= this.config.minConfidence; + const candidate = bestQualifies ? best : null; + + let winner: (typeof rows)[number] | null; + if (candidate !== null && existing !== null) { + // Last-click-wins, compared by the *click's* time rather than by + // confidence: a stale click that happens to score higher is still the + // wrong answer. + winner = candidate.createdAt.getTime() > existing.createdAt.getTime() ? candidate : existing; + } else { + // Falling back to `existing` keeps an attribution the device already + // has when nothing clears the threshold this time — a retry or a + // relaunch on a weaker signal must not silently lose it. + winner = candidate ?? existing; + } + await this.logAttempt(incoming, { - matched, + matched: winner !== null, candidateCount: rows.length, bestScore: rows.length > 0 ? bestScore : null, bestClickId: best?.clickId ?? null, }); - if (!matched) return null; + if (winner === null) return null; + + // Re-score the winner rather than reusing bestScore, which belongs to + // `best` and would be wrong whenever `existing` won. + const confidence = winner === best ? bestScore : this.score(winner, incoming); return { - // best is non-null here — matched only becomes true when it is. - clickId: best!.clickId, - referralCode: best!.referralCode, - confidence: Math.round(bestScore * 100) / 100, - expiresAt: best!.expiresAt, + clickId: winner.clickId, + referralCode: winner.referralCode, + confidence: Math.round(confidence * 100) / 100, + expiresAt: winner.expiresAt, }; } diff --git a/packages/referral-sdk/src/Controllers/MatchController.php b/packages/referral-sdk/src/Controllers/MatchController.php index 2c13e68..d43c45d 100644 --- a/packages/referral-sdk/src/Controllers/MatchController.php +++ b/packages/referral-sdk/src/Controllers/MatchController.php @@ -52,7 +52,7 @@ public function __invoke(Request $request): JsonResponse $fingerprint = $data['fingerprint']; $fingerprint['platform'] = $data['platform']; - $result = $this->matcher->match($fingerprint, (string) $request->ip()); + $result = $this->matcher->match($fingerprint, (string) $request->ip(), $storedDeviceId); if ($result === null) { return response()->json([ @@ -61,14 +61,11 @@ public function __invoke(Request $request): JsonResponse ]); } - // Lock atomically. If another request won the race, report no match - // rather than handing the same click to two devices. - if (!$this->clicks->lockToDevice($result['click_id'], $storedDeviceId, 'fingerprint', $result['confidence'])) { - return response()->json([ - 'matched' => false, - 'referral_code' => null, - ]); - } + // Record the binding. Unlike the claim path this rebinds rather than + // refusing when the click is already matched — a returning device + // must be able to recover a click it (or a previous install of it) + // already matched. See docs/decisions.md #30. + $this->clicks->bindMatch($result['click_id'], $storedDeviceId, $result['confidence']); $token = ClickToken::sign($result['click_id'], $result['expires_at'], $this->config->requireClickTokenSecret()); diff --git a/packages/referral-sdk/src/Services/ClickStore.php b/packages/referral-sdk/src/Services/ClickStore.php index 484a3dd..4adef9f 100644 --- a/packages/referral-sdk/src/Services/ClickStore.php +++ b/packages/referral-sdk/src/Services/ClickStore.php @@ -67,6 +67,41 @@ public function store(string $referralCode, array $fingerprint, string $ip): arr return ['click_id' => $clickId, 'expires_at' => $expiresAt]; } + /** + * Record the current fingerprint match on a click, rebinding it if it was + * already bound to some other device. + * + * Separate from lockToDevice() on purpose. That method's + * `AND matched = 0` is a security guard on the /claim path — the + * deterministic tier's first real use of a click, where losing the race + * must reject rather than proceed (docs/decisions.md #21). Matching has + * the opposite requirement: a click has to be re-bindable, or a reinstall + * can never recover it, because on iOS the returning device presents a + * brand-new device id (docs/decisions.md #30). Loosening lockToDevice() + * itself would have quietly removed that claim-time guard. + * + * Last write wins, which is exactly "last matched by" — and the caller + * has already decided this device should hold the attribution. + */ + public function bindMatch( + string $clickId, + string $deviceId, + float $confidence, + ): void { + $stmt = $this->pdo->prepare( + 'UPDATE referral_clicks + SET matched = 1, matched_device_id = :device_id, matched_at = UTC_TIMESTAMP(), + match_method = :method, match_confidence = :confidence + WHERE click_id = :click_id' + ); + $stmt->execute([ + ':device_id' => $deviceId, + ':method' => 'fingerprint', + ':confidence' => $confidence, + ':click_id' => $clickId, + ]); + } + /** * Atomically lock a click to a device so it can never be matched twice. * Returns true only if this call is the one that won the lock. Records diff --git a/packages/referral-sdk/src/Services/FingerprintMatcher.php b/packages/referral-sdk/src/Services/FingerprintMatcher.php index 852289b..dcdfaad 100644 --- a/packages/referral-sdk/src/Services/FingerprintMatcher.php +++ b/packages/referral-sdk/src/Services/FingerprintMatcher.php @@ -41,13 +41,28 @@ public function __construct( } /** - * Find the best matching unclaimed click for a device. + * Find the best matching click for a device. * - * @param array $incoming Fingerprint sent by the app on first launch. + * Matching deliberately does **not** consume a click. `matched` means + * "last matched by", not "used up" — see docs/decisions.md #30. An + * already-matched click stays a candidate, which is what lets a + * reinstall recover (on iOS the device id itself changes when the last + * vendor app is uninstalled, so the returning device is a *new* device + * as far as this table is concerned) and what stops a second match from + * silently falling through to a runner-up and re-attributing the user to + * a different referrer. Guarding against a referral code being redeemed + * twice belongs in whatever records signups, not in making the click + * invisible here. + * + * @param array $incoming Fingerprint sent by the app on first launch. * @param string $requestIp Server-observed IP of the match request. + * @param string|null $deviceId Already-hashed device id, when known. Lets a + * device keep an attribution it has already been + * given if its fingerprint later stops clearing + * the confidence threshold. * @return array{click_id: string, referral_code: string, confidence: float, expires_at: \DateTimeImmutable}|null */ - public function match(array $incoming, string $requestIp): ?array + public function match(array $incoming, string $requestIp, ?string $deviceId = null): ?array { if ($this->pdo === null) { throw new \LogicException('FingerprintMatcher::match() requires a PDO connection.'); @@ -57,14 +72,17 @@ public function match(array $incoming, string $requestIp): ?array $windowStart = gmdate('Y-m-d H:i:s', time() - $this->config->matchWindowSeconds()); - // Only consider fresh, unmatched clicks. Newest first: last-click-wins on ties. + // No `matched = 0` filter: that single clause was the whole of #17. + // Newest first so ties resolve last-click-wins, and so the first row + // seen for this device is its most recent binding — ordering by + // matched_at instead would be unstable, since UTC_TIMESTAMP() has + // one-second resolution and two locks taken in the same second tie. $stmt = $this->pdo->prepare( 'SELECT click_id, referral_code, ip_address, user_agent, screen_width, screen_height, timezone, language, platform, - created_at, expires_at + created_at, expires_at, matched_device_id FROM referral_clicks - WHERE matched = 0 - AND expires_at > UTC_TIMESTAMP() + WHERE expires_at > UTC_TIMESTAMP() AND created_at >= :window_start ORDER BY created_at DESC' ); @@ -73,9 +91,20 @@ public function match(array $incoming, string $requestIp): ?array $best = null; $bestScore = 0.0; + $existing = null; $now = time(); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + // Rows arrive newest-first, so the first one bound to this device + // is its newest binding; later (older) ones are superseded. + if ($existing === null + && $deviceId !== null + && $row['matched_device_id'] !== null + && hash_equals((string) $row['matched_device_id'], $deviceId) + ) { + $existing = $row; + } + $score = $this->score($row, $incoming, $now); if ($score > $bestScore) { $bestScore = $score; @@ -83,15 +112,44 @@ public function match(array $incoming, string $requestIp): ?array } } - if ($best === null || $bestScore < $this->config->minConfidence) { + // Only a candidate that clears the threshold may take an attribution + // over. Without this a newer but unrelated click could steal one + // purely by being recent. + if ($best !== null && $bestScore < $this->config->minConfidence) { + $best = null; + } + + if ($best !== null && $existing !== null) { + // Attribution is last-click-wins, compared by the *click's* time + // rather than by confidence: a stale click that happens to score + // higher is still the wrong answer. A newer click takes over; an + // older one leaves the existing binding alone. + $winner = strtotime((string) $best['created_at']) > strtotime((string) $existing['created_at']) + ? $best + : $existing; + } else { + // Falling back to $existing keeps an attribution the device has + // already been given when nothing clears the threshold this time + // — a retry or a relaunch on a weaker signal must not silently + // lose it. + $winner = $best ?? $existing; + } + + if ($winner === null) { return null; } + // Re-score the winner rather than reusing $bestScore, which belongs + // to $best and would be wrong whenever $existing won. + $confidence = $winner === $best + ? $bestScore + : $this->score($winner, $incoming, $now); + return [ - 'click_id' => (string) $best['click_id'], - 'referral_code' => (string) $best['referral_code'], - 'confidence' => round($bestScore, 2), - 'expires_at' => new \DateTimeImmutable((string) $best['expires_at'], new \DateTimeZone('UTC')), + 'click_id' => (string) $winner['click_id'], + 'referral_code' => (string) $winner['referral_code'], + 'confidence' => round($confidence, 2), + 'expires_at' => new \DateTimeImmutable((string) $winner['expires_at'], new \DateTimeZone('UTC')), ]; } diff --git a/packages/referral-sdk/src/Support/DeviceId.php b/packages/referral-sdk/src/Support/DeviceId.php index a740477..9f553d3 100644 --- a/packages/referral-sdk/src/Support/DeviceId.php +++ b/packages/referral-sdk/src/Support/DeviceId.php @@ -7,8 +7,9 @@ /** * Applies `hash_device_ids` consistently everywhere a device_id is * persisted — `referral_clicks.matched_device_id` (see - * ClickStore::lockToDevice, called from MatchController/ClaimController) - * and `referral_conversions.device_id` (ConversionTracker) both need to + * ClickStore::bindMatch from MatchController and ClickStore::lockToDevice + * from ClaimController) and `referral_conversions.device_id` + * (ConversionTracker) both need to * agree on the same stored form, or a lock-ownership check compares a * hash against a raw value and never matches. See decisions.md #21 (the * bug this was originally caught fixing). diff --git a/packages/referral-sdk/tests/run.php b/packages/referral-sdk/tests/run.php index fdd5c39..ec7c236 100644 --- a/packages/referral-sdk/tests/run.php +++ b/packages/referral-sdk/tests/run.php @@ -3,15 +3,22 @@ declare(strict_types=1); /** - * Zero-dependency sanity check for the scoring engine. + * Zero-dependency sanity check for the scoring engine and for the + * attribution rules around it. * Run with: php tests/run.php * No composer install / PHPUnit required. + * + * The attribution section at the bottom needs a database, which it gets + * from in-memory SQLite with UTC_TIMESTAMP() registered as a custom + * function — so it still provisions nothing and installs nothing. */ require __DIR__ . '/../src/Support/UserAgentParser.php'; require __DIR__ . '/../src/Support/ReferralConfig.php'; require __DIR__ . '/../src/Services/FingerprintMatcher.php'; +require __DIR__ . '/../src/Services/ClickStore.php'; +use BlynkDeferlink\Referral\Services\ClickStore; use BlynkDeferlink\Referral\Services\FingerprintMatcher; use BlynkDeferlink\Referral\Support\ReferralConfig; @@ -142,5 +149,134 @@ } date_default_timezone_set($tzBefore); +// --------------------------------------------------------------------------- +// Attribution rules (docs/decisions.md #30, issue #17) +// +// These need a database, because the bug they guard was never in the scoring +// function — scoring was always correct. It was in which rows the candidate +// query could see. A pure-scoring suite structurally cannot reach it. +// --------------------------------------------------------------------------- + +echo "\nAttribution rules\n"; + +$assertSame = function (string $name, ?string $expected, ?string $actual) use (&$pass, &$fail): void { + if ($expected === $actual) { + echo " ✓ {$name}\n"; + $pass++; + } else { + echo " ✗ {$name} (expected " . var_export($expected, true) + . ', got ' . var_export($actual, true) . ")\n"; + $fail++; + } +}; + +$freshDb = function (): PDO { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + // The SQL is MySQL-flavored; shim the one function it depends on. + @$pdo->sqliteCreateFunction('UTC_TIMESTAMP', static fn () => gmdate('Y-m-d H:i:s'), 0); + $pdo->exec( + 'CREATE TABLE referral_clicks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, click_id TEXT NOT NULL, + referral_code TEXT NOT NULL, ip_address TEXT, user_agent TEXT, + screen_width INTEGER, screen_height INTEGER, pixel_ratio REAL, + timezone TEXT, language TEXT, platform TEXT, referrer_url TEXT, + matched INTEGER DEFAULT 0, matched_device_id TEXT, matched_at TEXT, + match_method TEXT, match_confidence REAL, created_at TEXT, expires_at TEXT)' + ); + return $pdo; +}; + +$phoneUa = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15'; +$phoneClick = [ + 'user_agent' => $phoneUa, 'screen_width' => 390, 'screen_height' => 844, + 'timezone' => 'Africa/Lagos', 'language' => 'en-NG', +]; +$phoneDevice = [ + 'device_model' => 'iPhone', 'platform' => 'ios', + 'screen_width' => 390, 'screen_height' => 844, + 'timezone' => 'Africa/Lagos', 'language' => 'en-NG', +]; + +// Reinstalling changes the device id on iOS (identifierForVendor is cleared +// when the last vendor app is removed), so each cycle arrives looking like a +// device the table has never seen. Five cycles because the original bug +// returned the code on the first and nothing on every one after. +$pdo = $freshDb(); +$matcher = new FingerprintMatcher($pdo, new ReferralConfig()); +$store = new ClickStore($pdo, new ReferralConfig()); +$store->store('FRIEND99', $phoneClick, '102.89.1.1'); + +for ($cycle = 1; $cycle <= 5; $cycle++) { + $deviceId = "device-install-{$cycle}"; + $result = $matcher->match($phoneDevice, '102.89.1.1', $deviceId); + $assertSame("reinstall #{$cycle} still recovers the clicked code", 'FRIEND99', $result['referral_code'] ?? null); + if ($result !== null) { + $store->bindMatch($result['click_id'], $deviceId, $result['confidence']); + } +} + +// Two referrers in the window. Repeating the match must keep returning the +// device's actual referrer, not fall through to the runner-up. +$pdo = $freshDb(); +$matcher = new FingerprintMatcher($pdo, new ReferralConfig()); +$store = new ClickStore($pdo, new ReferralConfig()); +$store->store('ALICE01', $phoneClick, '102.89.1.1'); +$store->store('BOB0002', [ + 'user_agent' => $phoneUa, 'screen_width' => 430, 'screen_height' => 932, + 'timezone' => 'Africa/Lagos', 'language' => 'en-NG', +], '102.89.1.1'); + +for ($attempt = 1; $attempt <= 3; $attempt++) { + $result = $matcher->match($phoneDevice, '102.89.1.1', 'stable-device'); + $assertSame("repeat match #{$attempt} keeps the same referrer", 'ALICE01', $result['referral_code'] ?? null); + if ($result !== null) { + $store->bindMatch($result['click_id'], 'stable-device', $result['confidence']); + } +} + +// Attribution is last-click-wins: a newer click that clears the threshold +// takes over an existing binding. +$pdo = $freshDb(); +$matcher = new FingerprintMatcher($pdo, new ReferralConfig()); +$store = new ClickStore($pdo, new ReferralConfig()); +$store->store('ALICE01', $phoneClick, '102.89.1.1'); +$first = $matcher->match($phoneDevice, '102.89.1.1', 'dev-1'); +$store->bindMatch($first['click_id'], 'dev-1', $first['confidence']); +sleep(1); // created_at has one-second resolution; the new click must be newer +$store->store('CAROL77', $phoneClick, '102.89.1.1'); +$second = $matcher->match($phoneDevice, '102.89.1.1', 'dev-1'); +$assertSame('a newer qualifying click takes the attribution over', 'CAROL77', $second['referral_code'] ?? null); + +// ...but only if it qualifies. An unrelated click must not steal an +// attribution merely by being recent. +$pdo = $freshDb(); +$matcher = new FingerprintMatcher($pdo, new ReferralConfig()); +$store = new ClickStore($pdo, new ReferralConfig()); +$store->store('ALICE01', $phoneClick, '102.89.1.1'); +$first = $matcher->match($phoneDevice, '102.89.1.1', 'dev-1'); +$store->bindMatch($first['click_id'], 'dev-1', $first['confidence']); +sleep(1); +$store->store('MALLORY1', [ + 'user_agent' => 'Mozilla/5.0 (Linux; Android 14; Pixel 7 Build/AP1A) AppleWebKit/537.36', + 'screen_width' => 412, 'screen_height' => 915, + 'timezone' => 'Europe/Berlin', 'language' => 'de', +], '8.8.8.8'); +$third = $matcher->match($phoneDevice, '102.89.1.1', 'dev-1'); +$assertSame('a newer non-qualifying click does not steal the attribution', 'ALICE01', $third['referral_code'] ?? null); + +// A device with no binding and nothing worth matching gets nothing — the +// threshold still has to mean something. +$pdo = $freshDb(); +$matcher = new FingerprintMatcher($pdo, new ReferralConfig()); +$store = new ClickStore($pdo, new ReferralConfig()); +$store->store('NOBODY1', [ + 'user_agent' => 'Mozilla/5.0 (Linux; Android 14; Pixel 7 Build/AP1A) AppleWebKit/537.36', + 'screen_width' => 412, 'screen_height' => 915, + 'timezone' => 'Europe/Berlin', 'language' => 'de', +], '8.8.8.8'); +$none = $matcher->match($phoneDevice, '102.89.1.1', 'unknown-device'); +$assertSame('an unrelated device still matches nothing', null, $none['referral_code'] ?? null); + echo "\n{$pass} passed, {$fail} failed\n"; exit($fail === 0 ? 0 : 1);