diff --git a/.github/workflows/sonarcloud-analysis.yml b/.github/workflows/sonarcloud-analysis.yml index b8772732..08b30736 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=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 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 diff --git a/src/services/klaviyo.service.ts b/src/services/klaviyo.service.ts new file mode 100644 index 00000000..e19d4c9e --- /dev/null +++ b/src/services/klaviyo.service.ts @@ -0,0 +1,77 @@ +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: KlaviyoEvent; +} + + +export class KlaviyoTrackingService { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor() { + if (!config.KLAVIYO_API_KEY) { + throw new BadRequestError("Klaviyo API Key is required."); + } + + this.apiKey = config.KLAVIYO_API_KEY; + this.baseUrl = config.KLAVIYO_BASE_URL || 'https://a.klaviyo.com/api'; + } + + 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', + }, + }); + + 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}`); + throw error; + } + } + + async trackSubscriptionCancelled(email: string): Promise { + await this.trackEvent({ + email, + eventName: KlaviyoEvent.SubscriptionCancelled, + }); + } +} + +export const klaviyoService = new KlaviyoTrackingService(); \ No newline at end of file diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index 56a27986..7c0e017b 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -12,6 +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 { klaviyoService } from '../services/klaviyo.service'; +import Logger from '../Logger'; function isObjectStorageProduct(meta: Stripe.Metadata): boolean { return !!meta && !!meta.type && meta.type === 'object-storage'; @@ -60,7 +62,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); - + if (isObjectStorageProduct(productMetadata)) { await handleObjectStorageSubscriptionCancelled(customer, subscription, objectStorageService, paymentService, log); return; @@ -101,6 +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}`); + if (!(error instanceof TierNotFoundError)) { throw error; } @@ -118,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 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 new file mode 100644 index 00000000..afb7fbec --- /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 { BadRequestError } from '../../../src/errors/Errors'; +import config from '../../../src/config'; + +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( + expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`) + ); + }); + }); +}); \ No newline at end of file