Skip to content

Commit 0238ab9

Browse files
author
op-simoneromeo
authored
Implement Tradedoubler affiliate adapter (#319)
1 parent 10007d7 commit 0238ab9

2 files changed

Lines changed: 306 additions & 25 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,135 @@
11
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import adapter from './index.js';
34

45
smokeTest(adapter, { idPrefix: 'affiliate' });
6+
7+
const ctx = (secrets: Record<string, string> = { TRADEDOUBLER_API_TOKEN: 'td-token' }) => ({
8+
secret: (key: string) => secrets[key],
9+
log: () => {},
10+
});
11+
12+
describe('Tradedoubler affiliate adapter', () => {
13+
afterEach(() => {
14+
vi.unstubAllGlobals();
15+
});
16+
17+
it('requires a Products API token before making requests', async () => {
18+
await expect(adapter.connect(ctx({}), {})).rejects.toThrow('TRADEDOUBLER_API_TOKEN not in vault');
19+
});
20+
21+
it('probes product feeds during connect and preserves configured publisher id', async () => {
22+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
23+
feeds: [
24+
{
25+
feedId: 19750,
26+
currencyISOCode: 'GBP',
27+
programs: [{ programId: 41305, name: 'Merchant UK' }],
28+
},
29+
],
30+
}));
31+
vi.stubGlobal('fetch', fetchMock);
32+
33+
await expect(adapter.connect(ctx(), { accountId: '2038177' })).resolves.toEqual({
34+
accountId: '2038177',
35+
});
36+
const [url, request] = fetchMock.mock.calls[0]!;
37+
expect(String(url)).toBe('https://api.tradedoubler.com/1.0/productFeeds.json?token=td-token');
38+
expect(request.headers.accept).toBe('application/json');
39+
});
40+
41+
it('can read publisher id from the vault when config omits accountId', async () => {
42+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ feeds: [] })));
43+
44+
await expect(adapter.connect(ctx({
45+
TRADEDOUBLER_API_TOKEN: 'td-token',
46+
TRADEDOUBLER_PUBLISHER_ID: '2038177',
47+
}), {})).resolves.toEqual({
48+
accountId: '2038177',
49+
});
50+
});
51+
52+
it('builds a direct Tradedoubler tracking URL with EPI values', async () => {
53+
await expect(adapter.getTrackingLink?.(
54+
ctx(),
55+
'41305',
56+
'https://merchant.example/path?q=coffee grinder',
57+
{
58+
accountId: '2038177',
59+
adId: '2468',
60+
clickRef: 'launch-1',
61+
clickRef2: 'newsletter',
62+
},
63+
)).resolves.toEqual({
64+
url: 'https://clk.tradedoubler.com/click?a(2038177)p(41305)g(2468)epi(launch-1)epi2(newsletter)url(https%3A%2F%2Fmerchant.example%2Fpath%3Fq%3Dcoffee%20grinder)',
65+
});
66+
});
67+
68+
it('requires publisher id and an absolute destination URL for tracking links', async () => {
69+
await expect(adapter.getTrackingLink?.(ctx(), '41305', 'https://merchant.example', {}))
70+
.rejects.toThrow('publisher id is required');
71+
await expect(adapter.getTrackingLink?.(ctx(), '41305', '/relative', { accountId: '2038177' }))
72+
.rejects.toThrow('must be an absolute URL');
73+
});
74+
75+
it('uses matrix syntax to scope product-feed stats by program', async () => {
76+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
77+
feeds: [
78+
{
79+
feedId: 19750,
80+
currencyISOCode: 'GBP',
81+
programs: [{ programId: 41305, name: 'Merchant UK' }],
82+
},
83+
{
84+
feedId: 20782,
85+
currencyISOCode: 'EUR',
86+
programs: [{ programId: 99999, name: 'Other Merchant' }],
87+
},
88+
],
89+
}));
90+
vi.stubGlobal('fetch', fetchMock);
91+
92+
await expect(adapter.stats?.(ctx(), '41305', {})).resolves.toEqual({
93+
publishers: 1,
94+
clicks: 0,
95+
conversions: 0,
96+
revenue: 0,
97+
commissionsPaid: 0,
98+
currency: 'GBP',
99+
});
100+
const [url] = fetchMock.mock.calls[0]!;
101+
expect(String(url)).toBe(
102+
'https://api.tradedoubler.com/1.0/productFeeds.json;programId=41305?token=td-token',
103+
);
104+
});
105+
106+
it('falls back to configured currency when no matching feeds are returned', async () => {
107+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ feeds: [] })));
108+
109+
await expect(adapter.stats?.(ctx(), '41305', { currency: 'SEK' })).resolves.toEqual({
110+
publishers: 0,
111+
clicks: 0,
112+
conversions: 0,
113+
revenue: 0,
114+
commissionsPaid: 0,
115+
currency: 'SEK',
116+
});
117+
});
118+
119+
it('redacts the API token from provider errors', async () => {
120+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
121+
ok: false,
122+
status: 401,
123+
text: async () => 'token td-token is invalid',
124+
}));
125+
126+
await expect(adapter.connect(ctx(), {})).rejects.toThrow('Tradedoubler 401: token [redacted] is invalid');
127+
});
128+
});
129+
130+
function jsonResponse(body: unknown) {
131+
return {
132+
ok: true,
133+
json: async () => body,
134+
};
135+
}
Lines changed: 175 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,196 @@
1-
import { defineAffiliate, tokenSetup } from '@profullstack/sh1pt-core';
1+
import { defineAffiliate, tokenSetup, type AffiliateConnectContext } from '@profullstack/sh1pt-core';
22

33
interface Config {
44
accountId?: string;
5+
adId?: string;
6+
baseUrl?: string;
7+
clickRef?: string;
8+
clickRef2?: string;
9+
currency?: string;
10+
trackingBaseUrl?: string;
511
}
612

13+
const DEFAULT_API_BASE = 'https://api.tradedoubler.com';
14+
const DEFAULT_TRACKING_BASE = 'https://clk.tradedoubler.com';
15+
const PRODUCT_TOKEN_KEY = 'TRADEDOUBLER_API_TOKEN';
16+
const PUBLISHER_ID_KEY = 'TRADEDOUBLER_PUBLISHER_ID';
17+
718
export default defineAffiliate<Config>({
8-
id: "affiliate-tradedoubler",
9-
label: "Tradedoubler",
10-
side: "both",
19+
id: 'affiliate-tradedoubler',
20+
label: 'Tradedoubler',
21+
side: 'publisher',
1122

1223
async connect(ctx, config) {
13-
const token = ctx.secret("TRADEDOUBLER_API_TOKEN");
14-
if (!token) throw new Error("TRADEDOUBLER_API_TOKEN not in vault — run `sh1pt promote affiliates setup`");
15-
return { accountId: config.accountId ?? "affiliate-tradedoubler" };
16-
},
17-
18-
async createProgram(ctx, program) {
19-
ctx.log(`[stub] ${"affiliate-tradedoubler"} createProgram name=${program.name} commission=${program.commissionRate}${program.commissionType === 'percentage' ? '%' : ''}`);
24+
const feeds = await tradedoublerGet(ctx, config, '/1.0/productFeeds.json');
25+
const firstFeed = collectItems(feeds, ['feeds'])[0];
2026
return {
21-
programId: `stub-${Date.now()}`,
22-
marketplaceUrl: "https://www.tradedoubler.com",
27+
accountId:
28+
config.accountId
29+
?? ctx.secret(PUBLISHER_ID_KEY)
30+
?? stringField(firstFeed, ['siteId', 'publisherId'])
31+
?? 'affiliate-tradedoubler',
2332
};
2433
},
2534

26-
async getTrackingLink(ctx, programId, destinationUrl) {
27-
ctx.log(`[stub] ${"affiliate-tradedoubler"} getTrackingLink program=${programId}`);
28-
return { url: destinationUrl };
35+
async getTrackingLink(ctx, programId, destinationUrl, config) {
36+
ctx.log(`tradedoubler tracking link · program=${programId}`);
37+
const publisherId = config.accountId ?? ctx.secret(PUBLISHER_ID_KEY);
38+
if (!publisherId) throw new Error('Tradedoubler accountId / publisher id is required');
39+
if (!destinationUrl) throw new Error('Tradedoubler destinationUrl is required');
40+
try {
41+
new URL(destinationUrl);
42+
} catch {
43+
throw new Error('Tradedoubler destinationUrl must be an absolute URL');
44+
}
45+
const parts = [
46+
matrixPart('a', publisherId),
47+
matrixPart('p', programId),
48+
];
49+
if (config.adId) parts.push(matrixPart('g', config.adId));
50+
if (config.clickRef) parts.push(matrixPart('epi', config.clickRef));
51+
if (config.clickRef2) parts.push(matrixPart('epi2', config.clickRef2));
52+
parts.push(matrixPart('url', destinationUrl, true));
53+
return {
54+
url: `${trimSlash(config.trackingBaseUrl ?? DEFAULT_TRACKING_BASE)}/click?${parts.join('')}`,
55+
};
2956
},
3057

31-
async stats(ctx, programId) {
32-
ctx.log(`[stub] ${"affiliate-tradedoubler"} stats program=${programId}`);
33-
return { publishers: 0, clicks: 0, conversions: 0, revenue: 0, commissionsPaid: 0, currency: 'USD' };
58+
async stats(ctx, programId, config) {
59+
ctx.log(`tradedoubler product-feed stats · program=${programId}`);
60+
const data = await tradedoublerGet(ctx, config, '/1.0/productFeeds.json', { programId });
61+
const feeds = collectItems(data, ['feeds']);
62+
const matchingFeeds = feeds.filter((feed) => feedMatchesProgram(feed, programId));
63+
const scopedFeeds = matchingFeeds.length > 0 ? matchingFeeds : feeds;
64+
return {
65+
publishers: scopedFeeds.length > 0 ? 1 : 0,
66+
clicks: 0,
67+
conversions: 0,
68+
revenue: 0,
69+
commissionsPaid: 0,
70+
currency:
71+
firstString(scopedFeeds, ['currencyISOCode', 'currency'])
72+
?? config.currency
73+
?? 'EUR',
74+
};
3475
},
3576

3677
setup: tokenSetup<Config>({
37-
secretKey: "TRADEDOUBLER_API_TOKEN",
38-
label: "Tradedoubler",
39-
vendorDocUrl: "https://www.tradedoubler.com",
78+
secretKey: PRODUCT_TOKEN_KEY,
79+
label: 'Tradedoubler',
80+
vendorDocUrl: 'https://dev.tradedoubler.com/products/publisher/',
4081
steps: [
41-
"Log into tradedoubler.com → My Account → API",
42-
"Generate a token",
43-
"Paste below",
82+
'Open the Tradedoubler publisher interface and create a Products API token',
83+
'Paste the Products API token below',
84+
'Optionally store TRADEDOUBLER_PUBLISHER_ID or set accountId for direct click tracking links',
85+
],
86+
fields: [
87+
{
88+
key: 'accountId',
89+
message: 'Optional Tradedoubler publisher id used in click tracking URLs:',
90+
},
91+
{
92+
key: 'clickRef',
93+
message: 'Optional EPI value to attach to tracking links:',
94+
},
95+
{
96+
key: 'clickRef2',
97+
message: 'Optional EPI2 value to attach to tracking links:',
98+
},
4499
],
45100
}),
46101
});
102+
103+
type TdRecord = Record<string, unknown>;
104+
105+
async function tradedoublerGet(
106+
ctx: AffiliateConnectContext,
107+
config: Config,
108+
path: string,
109+
matrix: Record<string, string | number | undefined> = {},
110+
): Promise<unknown> {
111+
const token = ctx.secret(PRODUCT_TOKEN_KEY);
112+
if (!token) throw new Error(`${PRODUCT_TOKEN_KEY} not in vault`);
113+
const url = new URL(`${trimSlash(config.baseUrl ?? DEFAULT_API_BASE)}${withMatrix(path, matrix)}`);
114+
url.searchParams.set('token', token);
115+
const res = await fetch(url, {
116+
headers: {
117+
accept: 'application/json',
118+
},
119+
});
120+
if (!res.ok) {
121+
const text = await res.text();
122+
throw new Error(`Tradedoubler ${res.status}: ${redact(text, token).slice(0, 200)}`);
123+
}
124+
return res.json();
125+
}
126+
127+
function withMatrix(path: string, matrix: Record<string, string | number | undefined>): string {
128+
const suffix = Object.entries(matrix)
129+
.filter(([, value]) => value !== undefined && String(value).length > 0)
130+
.map(([key, value]) => `;${key}=${encodeURIComponent(String(value))}`)
131+
.join('');
132+
return `${path}${suffix}`;
133+
}
134+
135+
function matrixPart(key: string, value: string, encodeFullUrl = false): string {
136+
return `${key}(${encodeFullUrl ? encodeURIComponent(value) : encodeMatrixValue(value)})`;
137+
}
138+
139+
function encodeMatrixValue(value: string): string {
140+
return encodeURIComponent(value).replace(/%2F/gi, '/');
141+
}
142+
143+
function feedMatchesProgram(feed: TdRecord, programId: string): boolean {
144+
if (stringField(feed, ['programId', 'id']) === programId) return true;
145+
const programs = feed.programs;
146+
if (!Array.isArray(programs)) return false;
147+
return programs
148+
.filter(isRecord)
149+
.some((program) => stringField(program, ['programId', 'id']) === programId);
150+
}
151+
152+
function collectItems(data: unknown, keys: string[]): TdRecord[] {
153+
if (Array.isArray(data)) return data.filter(isRecord);
154+
if (!isRecord(data)) return [];
155+
for (const key of keys) {
156+
const value = data[key];
157+
if (Array.isArray(value)) return value.filter(isRecord);
158+
}
159+
if (Array.isArray(data.items)) return data.items.filter(isRecord);
160+
if (Array.isArray(data.data)) return data.data.filter(isRecord);
161+
return [data];
162+
}
163+
164+
function isRecord(value: unknown): value is TdRecord {
165+
return typeof value === 'object' && value !== null;
166+
}
167+
168+
function stringField(item: TdRecord | undefined, keys: string[]): string | undefined {
169+
if (!item) return undefined;
170+
for (const key of keys) {
171+
const value = item[key];
172+
if (typeof value === 'string' && value.length > 0) return value;
173+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
174+
}
175+
return undefined;
176+
}
177+
178+
function firstString(items: TdRecord[], keys: string[]): string | undefined {
179+
for (const item of items) {
180+
const value = stringField(item, keys);
181+
if (value) return value;
182+
}
183+
return undefined;
184+
}
185+
186+
function redact(text: string, ...values: Array<string | undefined>): string {
187+
let redacted = text;
188+
for (const value of values) {
189+
if (value) redacted = redacted.split(value).join('[redacted]');
190+
}
191+
return redacted;
192+
}
193+
194+
function trimSlash(value: string): string {
195+
return value.replace(/\/+$/, '');
196+
}

0 commit comments

Comments
 (0)