diff --git a/packages/affiliates/digistore24/src/index.test.ts b/packages/affiliates/digistore24/src/index.test.ts index ea8fd599..348c0e7b 100644 --- a/packages/affiliates/digistore24/src/index.test.ts +++ b/packages/affiliates/digistore24/src/index.test.ts @@ -1,4 +1,160 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'affiliate' }); + +const ctx = (secrets: Record = { DIGISTORE24_API_KEY: 'ds24-key' }) => ({ + secret: (key: string) => secrets[key], + log: () => {}, +}); + +function response(body: unknown, status = 200) { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('Digistore24 affiliate adapter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('requires an API key on connect', async () => { + await expect(adapter.connect(ctx({}), {})).rejects.toThrow(/DIGISTORE24_API_KEY/); + }); + + it('resolves a configured Digistore24 product', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response({ + product: { + id: 4242, + name: 'Launch Kit', + url: 'https://example.com/launch-kit', + }, + })); + + const result = await adapter.createProgram!(ctx(), { + name: 'Launch Kit', + destinationUrl: 'https://example.com/launch-kit', + commissionType: 'percentage', + commissionRate: 40, + }, { productId: '4242' }); + + expect(result).toEqual({ + programId: '4242', + marketplaceUrl: 'https://example.com/launch-kit', + }); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://www.digistore24.com/api/call/getProduct?product_id=4242'), + { + method: 'GET', + headers: { + 'content-type': 'application/json', + 'X-DS-API-KEY': 'ds24-key', + }, + }, + ); + }); + + it('finds an existing product by name when no product id is configured', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(response([ + { id: 11, name: 'Other Product' }, + { id: 22, name: 'Existing Program', marketplace_url: 'https://digistore24.com/product/22' }, + ])); + + const result = await adapter.createProgram!(ctx(), { + name: 'Existing Program', + destinationUrl: 'https://example.com/existing', + commissionType: 'flat', + commissionRate: 10, + currency: 'USD', + }, {}); + + expect(result).toEqual({ + programId: '22', + marketplaceUrl: 'https://digistore24.com/product/22', + }); + }); + + it('creates a tracked Digistore24 buy URL for an affiliate', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response({ + url: 'https://www.digistore24.com/product/4242?ds24tr=abc', + })); + + const link = await adapter.getTrackingLink!( + ctx(), + '4242', + 'https://example.com/launch-kit', + { + affiliate: 'jane-affiliate', + campaignKey: 'launch', + trackingKey: 'newsletter', + }, + ); + + expect(link.url).toBe('https://www.digistore24.com/product/4242?ds24tr=abc'); + const url = fetchMock.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe('/api/call/createBuyUrl'); + expect(url.searchParams.get('product_id')).toBe('4242'); + expect(JSON.parse(url.searchParams.get('tracking') ?? '{}')).toEqual({ + affiliate: 'jane-affiliate', + campaignkey: 'launch', + trackingkey: 'newsletter', + }); + expect(JSON.parse(url.searchParams.get('urls') ?? '{}')).toEqual({ + fallback_url: 'https://example.com/launch-kit', + }); + }); + + it('aggregates affiliate stats from Digistore24 statistics and transaction endpoints', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response({ + data: { + top_list: [ + { affiliate_id: 1, affiliate_name: 'Alice', currency: 'EUR', affiliate_amount: 12, payment_amount: 100 }, + { affiliate_id: 2, affiliate_name: 'Bob', currency: 'EUR', affiliate_amount: 8, payment_amount: 50 }, + ], + }, + })) + .mockResolvedValueOnce(response({ + totals: { + funnelVisitors: 75, + orderFormVisitors: 30, + earnings: 150, + }, + })) + .mockResolvedValueOnce(response({ + data: { + summary: { + amounts: { + EUR: { + count: 3, + total_amount: 150, + earned_amount: 20, + }, + }, + }, + transaction_list: [ + { amount: 100, currency: 'EUR', transaction_type: 'payment' }, + { amount: 50, currency: 'EUR', transaction_type: 'payment' }, + ], + }, + })); + + await expect(adapter.stats!(ctx(), '4242', { + from: '2026-05-01T00:00:00.000Z', + to: '2026-05-20T23:59:59.000Z', + fromMonth: '2026-05', + toMonth: '2026-05', + currency: 'EUR', + })).resolves.toEqual({ + publishers: 2, + clicks: 75, + conversions: 3, + revenue: 150, + commissionsPaid: 20, + currency: 'EUR', + }); + }); +}); diff --git a/packages/affiliates/digistore24/src/index.ts b/packages/affiliates/digistore24/src/index.ts index 78469863..7fa6845b 100644 --- a/packages/affiliates/digistore24/src/index.ts +++ b/packages/affiliates/digistore24/src/index.ts @@ -1,46 +1,298 @@ -import { defineAffiliate, tokenSetup } from '@profullstack/sh1pt-core'; +import { + defineAffiliate, + tokenSetup, + type AffiliateConnectContext, + type AffiliateProgram, +} from '@profullstack/sh1pt-core'; interface Config { accountId?: string; + baseUrl?: string; + productId?: string; + affiliate?: string; + campaignKey?: string; + trackingKey?: string; + validUntil?: string; + from?: string; + to?: string; + fromMonth?: string; + toMonth?: string; + currency?: string; +} + +const DEFAULT_BASE_URL = 'https://www.digistore24.com/api/call'; + +interface Digistore24Product { + id: string | number; + name?: string; + tag?: string | null; + marketplace_url?: string; + url?: string; +} + +interface Digistore24BuyUrl { + url?: string; + data?: { + url?: string; + }; +} + +interface Digistore24ToplistItem { + affiliate_id?: string | number; + affiliate_name?: string; + currency?: string; + brutto_amount?: number; + netto_amount?: number; + payment_amount?: number; + affiliate_amount?: number; +} + +interface Digistore24ToplistResponse { + top_list?: Digistore24ToplistItem[]; + data?: { + top_list?: Digistore24ToplistItem[]; + }; +} + +interface Digistore24ClickStats { + totals?: { + funnelVisitors?: number; + orderFormVisitors?: number; + earnings?: number; + }; + data?: { + totals?: { + funnelVisitors?: number; + orderFormVisitors?: number; + earnings?: number; + }; + }; +} + +interface Digistore24Transaction { + amount?: number; + currency?: string; + transaction_type?: string; +} + +interface Digistore24TransactionResponse { + transaction_list?: Digistore24Transaction[]; + data?: { + transaction_list?: Digistore24Transaction[]; + summary?: { + amounts?: Record; + count?: number; + }; + }; } export default defineAffiliate({ - id: "affiliate-digistore24", - label: "Digistore24", - side: "both", + id: 'affiliate-digistore24', + label: 'Digistore24', + side: 'both', async connect(ctx, config) { - const token = ctx.secret("DIGISTORE24_API_KEY"); - if (!token) throw new Error("DIGISTORE24_API_KEY not in vault — run `sh1pt promote affiliates setup`"); - return { accountId: config.accountId ?? "affiliate-digistore24" }; + const token = ctx.secret('DIGISTORE24_API_KEY'); + if (!token) throw new Error('DIGISTORE24_API_KEY not in vault - run `sh1pt promote affiliates setup`'); + return { accountId: config.accountId ?? 'affiliate-digistore24' }; }, - async createProgram(ctx, program) { - ctx.log(`[stub] ${"affiliate-digistore24"} createProgram name=${program.name} commission=${program.commissionRate}${program.commissionType === 'percentage' ? '%' : ''}`); + async createProgram(ctx, program, config) { + ctx.log(`digistore24 - resolve product ${program.name}`); + const product = await resolveProduct(ctx, program, config); return { - programId: `stub-${Date.now()}`, - marketplaceUrl: "https://www.digistore24.com", + programId: String(product.id), + marketplaceUrl: product.marketplace_url ?? product.url ?? program.destinationUrl, }; }, - async getTrackingLink(ctx, programId, destinationUrl) { - ctx.log(`[stub] ${"affiliate-digistore24"} getTrackingLink program=${programId}`); - return { url: destinationUrl }; + async getTrackingLink(ctx, programId, destinationUrl, config) { + ctx.log(`digistore24 - buy url product=${programId}`); + const affiliate = config.affiliate; + if (!affiliate) throw new Error('Digistore24 tracking links require config.affiliate'); + + const buyUrl = await digistore24Request(ctx, config, 'POST', 'createBuyUrl', { + product_id: programId, + tracking: compact({ + affiliate, + campaignkey: config.campaignKey, + trackingkey: config.trackingKey, + }), + urls: compact({ + fallback_url: destinationUrl, + }), + valid_until: config.validUntil ?? 'forever', + }); + + const url = buyUrl.url ?? buyUrl.data?.url; + if (!url) throw new Error(`Digistore24 createBuyUrl did not return a URL for product ${programId}`); + return { url }; }, - async stats(ctx, programId) { - ctx.log(`[stub] ${"affiliate-digistore24"} stats program=${programId}`); - return { publishers: 0, clicks: 0, conversions: 0, revenue: 0, commissionsPaid: 0, currency: 'USD' }; + async stats(ctx, programId, config) { + ctx.log(`digistore24 - stats product=${programId}`); + const now = new Date(); + const from = config.from ?? daysAgoIso(now, 30); + const to = config.to ?? 'today'; + const fromMonth = config.fromMonth ?? from.slice(0, 7); + const toMonth = config.toMonth ?? now.toISOString().slice(0, 7); + + const [toplistResponse, clickStatsResponse, transactionResponse] = await Promise.all([ + digistore24Request(ctx, config, 'GET', 'statsAffiliateToplist', compact({ + from: fromMonth, + to: toMonth, + currency: config.currency, + })), + digistore24Request(ctx, config, 'GET', 'statsClicksAndEarningsByDateAndCampaignKey', compact({ + from, + to, + interval: 'day', + viewRole: 'affiliate,vendor', + currency: config.currency, + })), + digistore24Request(ctx, config, 'GET', 'listTransactions', compact({ + from, + to: config.to ?? 'now', + search: compact({ + role: 'vendor,affiliate', + product_id: programId, + }), + page_size: 1000, + })), + ]); + + const topList = toplistResponse.top_list ?? toplistResponse.data?.top_list ?? []; + const totals = clickStatsResponse.totals ?? clickStatsResponse.data?.totals; + const transactions = transactionResponse.transaction_list ?? transactionResponse.data?.transaction_list ?? []; + const summaryAmounts = Object.values(transactionResponse.data?.summary?.amounts ?? {}); + + const conversionCount = summaryAmounts.reduce((sum, amount) => sum + numberValue(amount.count), 0) + || transactions.filter((transaction) => transaction.transaction_type !== 'refund' && transaction.transaction_type !== 'chargeback').length; + const transactionRevenue = summaryAmounts.reduce((sum, amount) => sum + numberValue(amount.total_amount), 0) + || transactions.reduce((sum, transaction) => sum + numberValue(transaction.amount), 0); + const transactionCommissions = summaryAmounts.reduce((sum, amount) => sum + numberValue(amount.earned_amount), 0); + const toplistRevenue = topList.reduce((sum, affiliate) => { + return sum + numberValue(affiliate.payment_amount ?? affiliate.netto_amount ?? affiliate.brutto_amount); + }, 0); + const toplistCommissions = topList.reduce((sum, affiliate) => sum + numberValue(affiliate.affiliate_amount), 0); + + return { + publishers: topList.length, + clicks: numberValue(totals?.funnelVisitors ?? totals?.orderFormVisitors), + conversions: conversionCount, + revenue: numberValue(totals?.earnings) || transactionRevenue || toplistRevenue, + commissionsPaid: transactionCommissions || toplistCommissions, + currency: config.currency ?? firstCurrency(topList, transactions) ?? 'USD', + }; }, setup: tokenSetup({ - secretKey: "DIGISTORE24_API_KEY", - label: "Digistore24", - vendorDocUrl: "https://www.digistore24.com", + secretKey: 'DIGISTORE24_API_KEY', + label: 'Digistore24', + vendorDocUrl: 'https://digistore24.com/api/docs/index.html', steps: [ - "Log into digistore24.com → Settings → API access", - "Generate an API key", - "Paste below", + 'Log into digistore24.com -> Settings -> API access', + 'Generate an API key with product, buy URL, statistics, and transaction access', + 'Paste below', + 'Set productId for an existing Digistore24 product and affiliate for tracked buy URLs', ], }), }); + +async function resolveProduct( + ctx: AffiliateConnectContext, + program: Pick, + config: Config, +): Promise { + if (config.productId) { + const result = await digistore24Request<{ product?: Digistore24Product; data?: Digistore24Product }>( + ctx, + config, + 'GET', + 'getProduct', + { product_id: config.productId }, + ); + return result.product ?? result.data ?? { id: config.productId, url: program.destinationUrl }; + } + + const productsResponse = await digistore24Request( + ctx, + config, + 'GET', + 'listProducts', + { sort_by: 'name' }, + ); + const products = Array.isArray(productsResponse) + ? productsResponse + : productsResponse.products ?? productsResponse.data ?? []; + const normalizedName = program.name.toLowerCase(); + const match = products.find((product) => { + return product.name?.toLowerCase() === normalizedName || product.tag?.toLowerCase() === normalizedName; + }); + if (match) return match; + if (products.length === 1) return products[0]!; + + throw new Error( + 'Digistore24 API does not expose generic affiliate program creation. Set config.productId for an existing Digistore24 product.', + ); +} + +async function digistore24Request( + ctx: AffiliateConnectContext, + config: Config, + method: 'GET' | 'POST', + operation: string, + params: Record = {}, +): Promise { + const apiKey = ctx.secret('DIGISTORE24_API_KEY'); + if (!apiKey) throw new Error('DIGISTORE24_API_KEY not in vault'); + const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + const url = new URL(`${baseUrl}/${operation.replace(/^\//, '')}`); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + const res = await fetch(url, { + method, + headers: { + 'content-type': 'application/json', + 'X-DS-API-KEY': apiKey, + }, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`Digistore24 ${res.status}: ${text.slice(0, 200)}`); + const body = (text ? JSON.parse(text) : {}) as { result?: string; message?: string; error?: string }; + if (body.result === 'error') { + throw new Error(`Digistore24 API error: ${body.message ?? body.error ?? 'unknown error'}`); + } + return body as T; +} + +function compact>(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => { + return entry !== undefined && entry !== null && entry !== ''; + })) as Partial; +} + +function daysAgoIso(now: Date, days: number): string { + const date = new Date(now); + date.setUTCDate(date.getUTCDate() - days); + return date.toISOString(); +} + +function numberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function firstCurrency( + topList: Digistore24ToplistItem[], + transactions: Digistore24Transaction[], +): string | undefined { + return topList.find((affiliate) => affiliate.currency)?.currency + ?? transactions.find((transaction) => transaction.currency)?.currency; +}