Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/sonarcloud-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions src/services/klaviyo.service.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export the class here directly, e.g.: const klaviyoService = new KlaviyoService(); so you can export it directly without instantiating it every time (you can use the api key directly).

Original file line number Diff line number Diff line change
@@ -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;
}

private async trackEvent(options: KlaviyoEventOptions): Promise<void> {
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<void> {
await this.trackEvent({
email,
eventName: KlaviyoEvent.SubscriptionCancelled,
});
}
}

export const klaviyoService = new KlaviyoTrackingService();
11 changes: 10 additions & 1 deletion src/webhooks/handleSubscriptionCanceled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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}`);
}
}
4 changes: 4 additions & 0 deletions tests/src/helpers/services-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +42,7 @@ export interface TestServices {
objectStorageWebhookHandler: ObjectStorageWebhookHandler;
invoiceCompletedHandler: InvoiceCompletedHandler;
userFeaturesOverridesService: UserFeaturesOverridesService;
klaviyoTrackingService: KlaviyoTrackingService;
}

export interface TestRepositories {
Expand Down Expand Up @@ -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,
Expand All @@ -133,6 +136,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe
objectStorageWebhookHandler,
invoiceCompletedHandler,
userFeaturesOverridesService,
klaviyoTrackingService,
...repositories,
};
};
108 changes: 108 additions & 0 deletions tests/src/services/klaviyo.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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';
import { createTestServices } from '../helpers/services-factory';

jest.mock('axios');
jest.mock('../../../src/config', () => ({
__esModule: true,
default: {
KLAVIYO_API_KEY: 'pk_test_12345',
KLAVIYO_BASE_URL: 'https://a.klaviyo.com/api',
STRIPE_SECRET_KEY: 'sk_test_12345',
},
}));

const mockedAxios = axios as jest.Mocked<typeof axios>;


describe('KlaviyoTrackingService', () => {
let service: KlaviyoTrackingService;
let loggerInfoSpy: jest.SpyInstance;
let loggerErrorSpy: jest.SpyInstance;

const mockApiKey = 'pk_test_12345';
const mockBaseUrl = 'https://a.klaviyo.com/api';

beforeEach(() => {
jest.clearAllMocks();
loggerInfoSpy = jest.spyOn(Logger, 'info').mockImplementation();
loggerErrorSpy = jest.spyOn(Logger, 'error').mockImplementation();

(config as any).KLAVIYO_API_KEY = mockApiKey;
(config as any).KLAVIYO_BASE_URL = mockBaseUrl;
service = createTestServices({ stripe: {} as any }).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(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).toThrow(BadRequestError);
});

test('When instantiated with valid config, then it initializes correctly', () => {
expect(() => createTestServices({ stripe: {} as any }).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(loggerInfoSpy).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(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`[Klaviyo] ${KlaviyoEvent.SubscriptionCancelled} failed for ${email}: ${errorMessage}`)
);
});
});
});
Loading