Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,17 @@ through a comparison page and buys a fortnight later after typing the domain is
credited to the comparison page rather than to "direct". A visit with neither is
not recorded at all, precisely so it cannot take that slot.

The one exception is a link from the Mac app (`?ref=app-…`). That reader was
acquired when they installed the app, and the screen they clicked from, such as
a paid feature's gate, is what brought them to buy, so it replaces the record.

The record lives in `localStorage` under `tablepro:attribution`, because this
app has no session and sets no cookies. It is disclosed on `/privacy`.

What the backend does with it today is **nothing**: the platform ignores the
field and passes none of it to Polar or Lemon Squeezy. The privacy policy says
exactly that. The `checkout_started`
analytics event is the only part of this that reports anything.
The backend passes `source`, `medium`, `campaign`, `term`, `content` and
`referrer` to the payment provider as checkout metadata (`utm_source` and so on,
the keys tablepro-cloud reads), so they come back on the order webhook and stay
with the order. `landing_page` and `first_seen_at` are not kept.

If the backend ever starts keeping it, three things hold, and the privacy
policy changes first:
Expand Down
16 changes: 16 additions & 0 deletions resources/js/lib/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,31 @@ function hasExpired(attribution: Attribution, now: Date): boolean {
return now.getTime() - first > TTL_MS;
}

/**
* The `ref` the Mac app puts on its links: `app-support`, `app-about`,
* `app-gate-compare-sync` and the like (`SupportLinks.swift`).
*/
const APP_REF = /^app-[a-z0-9-]+$/;

/**
* First-touch resolution: a stored record inside the window is returned
* unchanged, and only a lapsed or absent one gives way to this visit.
*
* The one exception is a link from the Mac app. Its reader was acquired when
* they installed the app; the screen they clicked from, such as a paid
* feature's gate, is what brought them to buy, so it replaces the stored record.
*
* Returns the same object reference it was given when nothing changed, which is
* what lets the caller skip a pointless write on every page load.
*/
export function resolveAttribution(stored: Attribution | null, visit: Visit): Attribution | null {
if (stored !== null && ! hasExpired(stored, visit.now)) {
const current = readVisit(visit);

if (current?.source !== undefined && APP_REF.test(current.source)) {
return current;
}

return stored;
}

Expand Down
18 changes: 18 additions & 0 deletions tests/js/attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ test('keeps the first touch when the reader returns direct', () => {
assert.equal(resolveAttribution(first, visit('https://tablepro.app/')), first);
});

test('lets a link from the Mac app replace the first touch', () => {
const first: Attribution = { source: 'hn', landing_page: '/blog/tablepro-0-69', first_seen_at: daysBefore(NOW, 14) };

const resolved = resolveAttribution(first, visit('https://tablepro.app/?ref=app-gate-compare-sync#pricing', 'https://www.google.com/'));

assert.equal(resolved?.source, 'app-gate-compare-sync');
assert.equal(resolved?.landing_page, '/');
assert.equal(resolved?.first_seen_at, NOW.toISOString());
});

test('keeps the first touch for a ref that only looks like the app', () => {
const first: Attribution = { source: 'hn', landing_page: '/', first_seen_at: daysBefore(NOW, 1) };

assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=getapps.cafe')), first);
assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=app')), first);
assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=APP-SUPPORT')), first);
});

test('lets a lapsed first touch give way to the current visit', () => {
const stale: Attribution = { source: 'hn', landing_page: '/', first_seen_at: daysBefore(NOW, ATTRIBUTION_TTL_DAYS + 1) };

Expand Down