From f8825064f5ca5180abf4d04dae7851afc9dc634c Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 11 Feb 2026 14:26:24 +0100 Subject: [PATCH 1/8] feat: implement klaviyo tracking services --- src/services/klaviyo.service.ts | 70 ++++++++++++++++++++++ src/webhooks/handleSubscriptionCanceled.ts | 9 ++- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/services/klaviyo.service.ts diff --git a/src/services/klaviyo.service.ts b/src/services/klaviyo.service.ts new file mode 100644 index 00000000..3d19af0f --- /dev/null +++ b/src/services/klaviyo.service.ts @@ -0,0 +1,70 @@ +import axios from 'axios'; + +interface KlaviyoEventOptions { + email: string; + eventName: string; +} + +export class KlaviyoTrackingService { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor( + apiKey: string | undefined = process.env.KLAVIYO_API_KEY, + baseUrl: string | undefined = process.env.KLAVIYO_BASE_URL + ) { + if (!apiKey) { + throw new Error("Klaviyo API Key is required."); + } + + this.apiKey = apiKey; + this.baseUrl = baseUrl ?? ""; + } + + private async trackEvent(options: KlaviyoEventOptions): Promise { + const { email, eventName } = options; + + const payload = { + data: { + type: 'event', + attributes: { + profile: { + data: { + type: 'profile', + attributes: { email }, + }, + }, + metric: { + data: { + type: 'metric', + attributes: { name: eventName }, + }, + }, + }, + }, + }; + + try { + await axios.post(`${this.baseUrl}/events/`, payload, { + headers: { + Authorization: `Klaviyo-API-Key ${this.apiKey}`, + 'Content-Type': 'application/json', + revision: '2024-10-15', + }, + }); + + console.log(`[Klaviyo] ${eventName} tracked for ${email}`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Klaviyo] ${eventName} failed for ${email}:`, message); + throw error; + } + } + + async trackSubscriptionCancelled(email: string): Promise { + await this.trackEvent({ + email, + eventName: 'Subscription Cancelled', + }); + } +} \ No newline at end of file diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index 56a27986..6d8624ed 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -12,6 +12,7 @@ import { TierNotFoundError, TiersService } from '../services/tiers.service'; import { Service } from '../core/users/Tier'; import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; import { Customer } from '../infrastructure/domain/entities/customer'; +import { KlaviyoTrackingService } from '../services/klaviyo.service'; function isObjectStorageProduct(meta: Stripe.Metadata): boolean { return !!meta && !!meta.type && meta.type === 'object-storage'; @@ -60,7 +61,8 @@ export default async function handleSubscriptionCanceled( const productId = subscription.items.data[0].price.product as string; const { metadata: productMetadata } = await paymentService.getProduct(productId); const customer = await stripePaymentsAdapter.getCustomer(customerId); - + const klaviyoService = new KlaviyoTrackingService('pk_9c5b5074b318bb02fc7f575102379c25b1'); + if (isObjectStorageProduct(productMetadata)) { await handleObjectStorageSubscriptionCancelled(customer, subscription, objectStorageService, paymentService, log); return; @@ -101,6 +103,11 @@ export default async function handleSubscriptionCanceled( } catch (error) { const err = error as Error; log.error(`[SUB CANCEL/ERROR]: Error canceling tier product. ERROR: ${err.stack ?? err.message}`); + try { + await klaviyoService.trackSubscriptionCancelled(customer.email); + } catch (error) { + log.error(`[KLAVIYO] Failed to track cancellation for ${customerId}`); + } if (!(error instanceof TierNotFoundError)) { throw error; } From 5406b9de4c4bce28216cf8e5f42124bfb94ae976 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 11 Feb 2026 14:58:00 +0100 Subject: [PATCH 2/8] Update handleSubscriptionCanceled.ts --- src/webhooks/handleSubscriptionCanceled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index 6d8624ed..e75a3ee6 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -61,7 +61,7 @@ export default async function handleSubscriptionCanceled( const productId = subscription.items.data[0].price.product as string; const { metadata: productMetadata } = await paymentService.getProduct(productId); const customer = await stripePaymentsAdapter.getCustomer(customerId); - const klaviyoService = new KlaviyoTrackingService('pk_9c5b5074b318bb02fc7f575102379c25b1'); + const klaviyoService = new KlaviyoTrackingService(process.env.KLAVIYO_API_KEY); if (isObjectStorageProduct(productMetadata)) { await handleObjectStorageSubscriptionCancelled(customer, subscription, objectStorageService, paymentService, log); From 6d8fedcbe94f627d95c49f229a90fb9c38bd0588 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 16 Feb 2026 10:51:41 +0100 Subject: [PATCH 3/8] feat: review changes --- src/services/klaviyo.service.ts | 36 +++++++++++++--------- src/webhooks/handleSubscriptionCanceled.ts | 16 +++++----- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/services/klaviyo.service.ts b/src/services/klaviyo.service.ts index 3d19af0f..a8aeac67 100644 --- a/src/services/klaviyo.service.ts +++ b/src/services/klaviyo.service.ts @@ -1,24 +1,28 @@ import axios from 'axios'; +import Logger from '../Logger'; +import { BadRequestError } from '../errors/Errors'; +import config from '../config'; + +export enum KlaviyoEvent { + SubscriptionCancelled = 'Subscription Cancelled', +} interface KlaviyoEventOptions { email: string; - eventName: string; + eventName: KlaviyoEvent; } export class KlaviyoTrackingService { private readonly apiKey: string; private readonly baseUrl: string; - constructor( - apiKey: string | undefined = process.env.KLAVIYO_API_KEY, - baseUrl: string | undefined = process.env.KLAVIYO_BASE_URL - ) { - if (!apiKey) { - throw new Error("Klaviyo API Key is required."); + constructor() { + if (!config.KLAVIYO_API_KEY) { + throw new BadRequestError("Klaviyo API Key is required."); } - this.apiKey = apiKey; - this.baseUrl = baseUrl ?? ""; + this.apiKey = config.KLAVIYO_API_KEY; + this.baseUrl = config.KLAVIYO_BASE_URL ?? ""; } private async trackEvent(options: KlaviyoEventOptions): Promise { @@ -53,18 +57,20 @@ export class KlaviyoTrackingService { }, }); - console.log(`[Klaviyo] ${eventName} tracked for ${email}`); + Logger.info(`[Klaviyo] ${eventName} tracked for ${email}`); } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`[Klaviyo] ${eventName} failed for ${email}:`, message); - throw error; + const message = error instanceof Error ? error.message : 'Unknown error'; + Logger.error(`[Klaviyo] ${eventName} failed for ${email}:`, message); + throw error; } } async trackSubscriptionCancelled(email: string): Promise { await this.trackEvent({ email, - eventName: 'Subscription Cancelled', + eventName: KlaviyoEvent.SubscriptionCancelled, }); } -} \ No newline at end of file +} + +export const klaviyoService = new KlaviyoTrackingService(); \ No newline at end of file diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index e75a3ee6..7dd035b9 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -12,7 +12,8 @@ import { TierNotFoundError, TiersService } from '../services/tiers.service'; import { Service } from '../core/users/Tier'; import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; import { Customer } from '../infrastructure/domain/entities/customer'; -import { KlaviyoTrackingService } from '../services/klaviyo.service'; +import { klaviyoService } from '../services/klaviyo.service'; +import Logger from '../Logger'; function isObjectStorageProduct(meta: Stripe.Metadata): boolean { return !!meta && !!meta.type && meta.type === 'object-storage'; @@ -61,7 +62,6 @@ export default async function handleSubscriptionCanceled( const productId = subscription.items.data[0].price.product as string; const { metadata: productMetadata } = await paymentService.getProduct(productId); const customer = await stripePaymentsAdapter.getCustomer(customerId); - const klaviyoService = new KlaviyoTrackingService(process.env.KLAVIYO_API_KEY); if (isObjectStorageProduct(productMetadata)) { await handleObjectStorageSubscriptionCancelled(customer, subscription, objectStorageService, paymentService, log); @@ -103,11 +103,7 @@ export default async function handleSubscriptionCanceled( } catch (error) { const err = error as Error; log.error(`[SUB CANCEL/ERROR]: Error canceling tier product. ERROR: ${err.stack ?? err.message}`); - try { - await klaviyoService.trackSubscriptionCancelled(customer.email); - } catch (error) { - log.error(`[KLAVIYO] Failed to track cancellation for ${customerId}`); - } + if (!(error instanceof TierNotFoundError)) { throw error; } @@ -125,4 +121,10 @@ export default async function handleSubscriptionCanceled( freeTier.featuresPerService[Service.Drive].foreignTierId, ); } + + try { + await klaviyoService.trackSubscriptionCancelled(customer.email); + } catch (error) { + Logger.error(`[KLAVIYO] Failed to track cancellation for ${customerId}`, error); + } } From 57907b09f741e78e07d1ba005dec287c2498d6ff Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 16 Feb 2026 10:51:45 +0100 Subject: [PATCH 4/8] Create klaviyo.service.test.ts --- tests/src/services/klaviyo.service.test.ts | 102 +++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/src/services/klaviyo.service.test.ts diff --git a/tests/src/services/klaviyo.service.test.ts b/tests/src/services/klaviyo.service.test.ts new file mode 100644 index 00000000..aa674e81 --- /dev/null +++ b/tests/src/services/klaviyo.service.test.ts @@ -0,0 +1,102 @@ +import axios from 'axios'; +import { KlaviyoTrackingService, KlaviyoEvent } from '../../../src/services/klaviyo.service'; +import Logger from '../../../src/Logger'; +import config from '../../../src/config'; +import { BadRequestError } from '../../../src/errors/Errors'; + +jest.mock('axios'); +jest.mock('../../../src/Logger'); +jest.mock('../../../src/config', () => ({ + __esModule: true, + default: { + KLAVIYO_API_KEY: 'pk_test_12345', + KLAVIYO_BASE_URL: 'https://a.klaviyo.com/api', + }, +})); + +const mockedAxios = axios as jest.Mocked; +const mockedLogger = Logger as jest.Mocked; + +describe('KlaviyoTrackingService', () => { + let service: KlaviyoTrackingService; + const mockApiKey = 'pk_test_12345'; + const mockBaseUrl = 'https://a.klaviyo.com/api'; + + beforeEach(() => { + jest.clearAllMocks(); + (config as any).KLAVIYO_API_KEY = mockApiKey; + (config as any).KLAVIYO_BASE_URL = mockBaseUrl; + service = new KlaviyoTrackingService(); + }); + + describe('Initialization', () => { + test('When instantiated without an API Key in config, then it throws an error', () => { + (config as any).KLAVIYO_API_KEY = undefined; + expect(() => new KlaviyoTrackingService()).toThrow(BadRequestError); + }); + + test('When instantiated with valid config, then it initializes correctly', () => { + expect(() => new KlaviyoTrackingService()).not.toThrow(); + }); + }); + + describe('Tracking Subscription Cancelled', () => { + test('When tracking a cancellation, then it sends the correct payload to Klaviyo', async () => { + const email = 'user@example.com'; + const expectedUrl = `${mockBaseUrl}/events/`; + + const expectedPayload = { + data: { + type: 'event', + attributes: { + profile: { + data: { + type: 'profile', + attributes: { email }, + }, + }, + metric: { + data: { + type: 'metric', + attributes: { name: KlaviyoEvent.SubscriptionCancelled }, + }, + }, + }, + }, + }; + + const expectedHeaders = { + headers: { + Authorization: `Klaviyo-API-Key ${mockApiKey}`, + 'Content-Type': 'application/json', + revision: '2024-10-15', + }, + }; + + mockedAxios.post.mockResolvedValue({ data: { status: 'ok' } }); + + await service.trackSubscriptionCancelled(email); + + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith(expectedUrl, expectedPayload, expectedHeaders); + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} tracked for ${email}`) + ); + }); + + test('When axios fails, then the error is logged and re-thrown', async () => { + const email = 'error@example.com'; + const errorMessage = 'Network Error'; + const error = new Error(errorMessage); + + mockedAxios.post.mockRejectedValue(error); + + await expect(service.trackSubscriptionCancelled(email)).rejects.toThrow(errorMessage); + + expect(mockedLogger.error).toHaveBeenCalledWith( + `[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}:`, + errorMessage + ); + }); + }); +}); \ No newline at end of file From 39f3a7dadb82d4c5eafdec256e93c73df6d0b86f Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 16 Feb 2026 14:15:38 +0100 Subject: [PATCH 5/8] Update sonarcloud-analysis.yml --- .github/workflows/sonarcloud-analysis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sonarcloud-analysis.yml b/.github/workflows/sonarcloud-analysis.yml index b8772732..faf4023a 100644 --- a/.github/workflows/sonarcloud-analysis.yml +++ b/.github/workflows/sonarcloud-analysis.yml @@ -35,6 +35,8 @@ jobs: - run: echo CHART_API_URL=api_url >> ./.env - run: echo PAYMENTS_GATEWAY_SECRET=${{secrets.PAYMENTS_GATEWAY_SECRET}} >> ./.env - run: echo PAYMENTS_GATEWAY_PUBLIC_SECRET=${{secrets.PAYMENTS_GATEWAY_PUBLIC_SECRET}} >> ./.env + - run: echo KLAVIYO_BASE_URL=${{secrets.KLAVIYO_BASE_URL}} >> ./.env + - run: echo KLAVIYO_API_KEY=${{secrets.KLAVIYO_API_KEY}} >> ./.env - run: echo PC_CLOUD_TRIAL_CODE=my_code >> ./.env - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc From eff7efb5bac87596e04ccfa689a11f0821772a31 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 16 Feb 2026 14:33:56 +0100 Subject: [PATCH 6/8] Update sonarcloud-analysis.yml --- .github/workflows/sonarcloud-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonarcloud-analysis.yml b/.github/workflows/sonarcloud-analysis.yml index faf4023a..08b30736 100644 --- a/.github/workflows/sonarcloud-analysis.yml +++ b/.github/workflows/sonarcloud-analysis.yml @@ -35,8 +35,8 @@ jobs: - run: echo CHART_API_URL=api_url >> ./.env - run: echo PAYMENTS_GATEWAY_SECRET=${{secrets.PAYMENTS_GATEWAY_SECRET}} >> ./.env - run: echo PAYMENTS_GATEWAY_PUBLIC_SECRET=${{secrets.PAYMENTS_GATEWAY_PUBLIC_SECRET}} >> ./.env - - run: echo KLAVIYO_BASE_URL=${{secrets.KLAVIYO_BASE_URL}} >> ./.env - - run: echo KLAVIYO_API_KEY=${{secrets.KLAVIYO_API_KEY}} >> ./.env + - run: echo KLAVIYO_BASE_URL=test_klaviyo_key_123 >> ./.env + - run: echo KLAVIYO_API_KEY=test_klaviyo_key_123 >> ./.env - run: echo PC_CLOUD_TRIAL_CODE=my_code >> ./.env - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc From 7efb968de7e351a029b41f69150031e5cb031847 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Mon, 16 Feb 2026 15:05:12 +0100 Subject: [PATCH 7/8] Update tests.yaml --- .github/workflows/tests.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 25eb21cd..acfc8ee9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,6 +36,8 @@ jobs: - run: echo PAYMENTS_GATEWAY_SECRET=${{secrets.PAYMENTS_GATEWAY_SECRET}} >> ./.env - run: echo PAYMENTS_GATEWAY_PUBLIC_SECRET=${{secrets.PAYMENTS_GATEWAY_PUBLIC_SECRET}} >> ./.env - run: echo PC_CLOUD_TRIAL_CODE=my_code >> ./.env + - run: echo KLAVIYO_BASE_URL=test_klaviyo_url >> ./.env + - run: echo KLAVIYO_API_KEY=test_klaviyo_key_123 >> ./.env - run: echo "registry=https://registry.yarnpkg.com/" > .npmrc - run: echo "@internxt:registry=https://npm.pkg.github.com" >> .npmrc From af3d39e48192c7aa6a3051d8810e4baacf00240f Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Tue, 17 Feb 2026 15:57:26 +0100 Subject: [PATCH 8/8] feat: update after revision --- src/services/klaviyo.service.ts | 5 +++-- src/webhooks/handleSubscriptionCanceled.ts | 2 +- tests/src/helpers/services-factory.ts | 4 ++++ tests/src/services/klaviyo.service.test.ts | 6 +++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/services/klaviyo.service.ts b/src/services/klaviyo.service.ts index a8aeac67..e19d4c9e 100644 --- a/src/services/klaviyo.service.ts +++ b/src/services/klaviyo.service.ts @@ -12,6 +12,7 @@ interface KlaviyoEventOptions { eventName: KlaviyoEvent; } + export class KlaviyoTrackingService { private readonly apiKey: string; private readonly baseUrl: string; @@ -22,7 +23,7 @@ export class KlaviyoTrackingService { } this.apiKey = config.KLAVIYO_API_KEY; - this.baseUrl = config.KLAVIYO_BASE_URL ?? ""; + this.baseUrl = config.KLAVIYO_BASE_URL || 'https://a.klaviyo.com/api'; } private async trackEvent(options: KlaviyoEventOptions): Promise { @@ -60,7 +61,7 @@ export class KlaviyoTrackingService { Logger.info(`[Klaviyo] ${eventName} tracked for ${email}`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - Logger.error(`[Klaviyo] ${eventName} failed for ${email}:`, message); + Logger.error(`[Klaviyo] ${eventName} failed for ${email}: ${message}`); throw error; } } diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index 7dd035b9..7c0e017b 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -125,6 +125,6 @@ export default async function handleSubscriptionCanceled( try { await klaviyoService.trackSubscriptionCancelled(customer.email); } catch (error) { - Logger.error(`[KLAVIYO] Failed to track cancellation for ${customerId}`, error); + Logger.error(`[KLAVIYO] Failed to track cancellation for ${customerId}: ${(error as Error).message}`); } } diff --git a/tests/src/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index 7c0f0f1b..0f45aee3 100644 --- a/tests/src/helpers/services-factory.ts +++ b/tests/src/helpers/services-factory.ts @@ -25,6 +25,7 @@ import { InvoiceCompletedHandler } from '../../../src/webhooks/events/invoices/I import { getLogger } from '../fixtures'; import { UserFeatureOverridesRepository } from '../../../src/core/users/MongoDBUserFeatureOverridesRepository'; import { UserFeaturesOverridesService } from '../../../src/services/userFeaturesOverride.service'; +import { KlaviyoTrackingService } from '../../../src/services/klaviyo.service'; export interface TestServices { stripe: Stripe; @@ -41,6 +42,7 @@ export interface TestServices { objectStorageWebhookHandler: ObjectStorageWebhookHandler; invoiceCompletedHandler: InvoiceCompletedHandler; userFeaturesOverridesService: UserFeaturesOverridesService; + klaviyoTrackingService: KlaviyoTrackingService; } export interface TestRepositories { @@ -117,6 +119,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe repositories.userFeatureOverridesRepository, ); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); + const klaviyoTrackingService = new KlaviyoTrackingService(); return { stripe, @@ -133,6 +136,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe objectStorageWebhookHandler, invoiceCompletedHandler, userFeaturesOverridesService, + klaviyoTrackingService, ...repositories, }; }; diff --git a/tests/src/services/klaviyo.service.test.ts b/tests/src/services/klaviyo.service.test.ts index aa674e81..afb7fbec 100644 --- a/tests/src/services/klaviyo.service.test.ts +++ b/tests/src/services/klaviyo.service.test.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { KlaviyoTrackingService, KlaviyoEvent } from '../../../src/services/klaviyo.service'; import Logger from '../../../src/Logger'; -import config from '../../../src/config'; import { BadRequestError } from '../../../src/errors/Errors'; +import config from '../../../src/config'; jest.mock('axios'); jest.mock('../../../src/Logger'); @@ -17,6 +17,7 @@ jest.mock('../../../src/config', () => ({ const mockedAxios = axios as jest.Mocked; const mockedLogger = Logger as jest.Mocked; + describe('KlaviyoTrackingService', () => { let service: KlaviyoTrackingService; const mockApiKey = 'pk_test_12345'; @@ -94,8 +95,7 @@ describe('KlaviyoTrackingService', () => { await expect(service.trackSubscriptionCancelled(email)).rejects.toThrow(errorMessage); expect(mockedLogger.error).toHaveBeenCalledWith( - `[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}:`, - errorMessage + expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`) ); }); });