Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can assign here directly the variables if you want, no need to pass it as props. Also, use config file instead of process.env.

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<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,
};
};
102 changes: 102 additions & 0 deletions tests/src/services/klaviyo.service.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the logger can be spied: jest.spyOn(Logger, 'error)`

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<typeof axios>;
const mockedLogger = Logger as jest.Mocked<typeof Logger>;


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();
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can instantiate the service in the service-factory.

});

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}`)
);
});
});
});
Loading