Skip to content

Commit

Permalink
Add Integration and unit tests on Billing (#9317)
Browse files Browse the repository at this point in the history
Solves [ twentyhq/private-issues#214 ]

**TLDR**
Add unit and integration tests to Billing. First approach to run jest
integration tests directly from VSCode.

**In order to run the unit tests:**
Run unit test using the CLI or with the jest extension directly from
VSCode.

**In order to run the integration tests:**
Ensure that your database has the billingTables. If that's not the case,
migrate the database with IS_BILLING_ENABLED set to true:
` npx nx run twenty-server:test:integration
test/integration/billing/suites/billing-controller.integration-spec.ts`

**Doing:**
- Unit test on transformSubscriptionEventToSubscriptionItem
- More tests cases in billingController integration tests.

---------

Co-authored-by: Félix Malfait <[email protected]>
Co-authored-by: Weiko <[email protected]>
Co-authored-by: Charles Bochet <[email protected]>
  • Loading branch information
4 people authored Jan 9, 2025
1 parent 4ed1db3 commit c39af5f
Show file tree
Hide file tree
Showing 49 changed files with 2,155 additions and 333 deletions.
6 changes: 5 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ module.exports = {
rules: {},
},
{
files: ['*.spec.@(ts|tsx|js|jsx)', '*.test.@(ts|tsx|js|jsx)'],
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
env: {
jest: true,
},
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ jobs:
- name: Install dependencies
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/workflows/actions/yarn-install
- name: Update .env.test for billing
if: steps.changed-files.outputs.any_changed == 'true'
run: |
sed -i '$ a\
IS_BILLING_ENABLED=true\
BILLING_STRIPE_API_KEY=test-api-key\
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id\
BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret' .env.test

- name: Server / Restore Task Cache
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/workflows/actions/task-cache
Expand Down
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@
"files.associations": {
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
}
}
1 change: 1 addition & 0 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"!{projectRoot}/**/tsconfig.spec.json",
"!{projectRoot}/**/*.test.(ts|tsx)",
"!{projectRoot}/**/*.spec.(ts|tsx)",
"!{projectRoot}/**/*.integration-spec.ts",
"!{projectRoot}/**/__tests__/*"
],
"production": [
Expand Down
5 changes: 4 additions & 1 deletion packages/twenty-server/jest-integration.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';

const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tsConfig = require('./tsconfig.json');

Expand All @@ -9,7 +10,9 @@ const jestConfig: JestConfigWithTsJest = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testRegex: '.integration-spec.ts$',
testRegex: isBillingEnabled
? 'integration-spec.ts'
: '^(?!.*billing).*\\.integration-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalSetup: '<rootDir>/test/integration/utils/setup-test.ts',
globalTeardown: '<rootDir>/test/integration/utils/teardown-test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,27 @@ import {
} from '@nestjs/common';

import { Response } from 'express';
import Stripe from 'stripe';

import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service';
import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service';
import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service';
import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
@Controller('billing')
@UseFilters(BillingRestApiExceptionFilter)
export class BillingController {
protected readonly logger = new Logger(BillingController.name);

constructor(
private readonly stripeService: StripeService,
private readonly stripeWebhookService: StripeWebhookService,
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
private readonly billingSubscriptionService: BillingSubscriptionService,
Expand All @@ -48,72 +49,63 @@ export class BillingController {

return;
}
const event = this.stripeService.constructEventFromPayload(
const event = this.stripeWebhookService.constructEventFromPayload(
signature,
req.rawBody,
);

if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) {
await this.billingSubscriptionService.handleUnpaidInvoices(event.data);
}

if (
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED ||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED
) {
const workspaceId = event.data.object.metadata?.workspaceId;
try {
const result = await this.handleStripeEvent(event);

if (!workspaceId) {
res.status(200).send(result).end();
} catch (error) {
if (error instanceof BillingException) {
res.status(404).end();

return;
}

await this.billingWebhookSubscriptionService.processStripeEvent(
workspaceId,
event.data,
);
}
if (
event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED
) {
try {
await this.billingWebhookEntitlementService.processStripeEvent(
}

private async handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case BillingWebhookEvent.SETUP_INTENT_SUCCEEDED:
return await this.billingSubscriptionService.handleUnpaidInvoices(
event.data,
);
case BillingWebhookEvent.PRICE_UPDATED:
case BillingWebhookEvent.PRICE_CREATED:
return await this.billingWebhookPriceService.processStripeEvent(
event.data,
);
} catch (error) {
if (
error instanceof BillingException &&
error.code === BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND
) {
res.status(404).end();
}
}
}

if (
event.type === WebhookEvent.PRODUCT_CREATED ||
event.type === WebhookEvent.PRODUCT_UPDATED
) {
await this.billingWebhookProductService.processStripeEvent(event.data);
}
if (
event.type === WebhookEvent.PRICE_CREATED ||
event.type === WebhookEvent.PRICE_UPDATED
) {
try {
await this.billingWebhookPriceService.processStripeEvent(event.data);
} catch (error) {
if (
error instanceof BillingException &&
error.code === BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND
) {
res.status(404).end();
case BillingWebhookEvent.PRODUCT_UPDATED:
case BillingWebhookEvent.PRODUCT_CREATED:
return await this.billingWebhookProductService.processStripeEvent(
event.data,
);
case BillingWebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED:
return await this.billingWebhookEntitlementService.processStripeEvent(
event.data,
);

case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED:
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED:
case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: {
const workspaceId = event.data.object.metadata?.workspaceId;

if (!workspaceId) {
throw new BillingException(
'Workspace ID is required for subscription events',
BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND,
);
}

return await this.billingWebhookSubscriptionService.processStripeEvent(
workspaceId,
event.data,
);
}
default:
return {};
}

res.status(200).end();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export class BillingException extends CustomException {
export enum BillingExceptionCode {
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
Expand All @@ -23,12 +23,13 @@ export class BillingResolver {
constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
private readonly stripeService: StripeService,
private readonly stripePriceService: StripePriceService,
) {}

@Query(() => ProductPricesEntity)
async getProductPrices(@Args() { product }: ProductInput) {
const productPrices = await this.stripeService.getStripePrices(product);
const productPrices =
await this.stripePriceService.getStripePrices(product);

return {
totalNumberOfPrices: productPrices.length,
Expand Down Expand Up @@ -63,7 +64,7 @@ export class BillingResolver {
requirePaymentMethod,
}: CheckoutSessionInput,
) {
const productPrice = await this.stripeService.getStripePrice(
const productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan,
recurringInterval,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';

interface SyncCustomerDataCommandOptions
Expand All @@ -23,7 +23,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly stripeService: StripeService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
@InjectRepository(BillingCustomer, 'core')
protected readonly billingCustomerRepository: Repository<BillingCustomer>,
) {
Expand Down Expand Up @@ -71,7 +71,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne

if (!options.dryRun && !billingCustomer) {
const stripeCustomerId =
await this.stripeService.getStripeCustomerIdFromWorkspaceId(
await this.stripeSubscriptionService.getStripeCustomerIdFromWorkspaceId(
workspaceId,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.service';
import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util';
import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util';
import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util';
Expand All @@ -30,7 +32,9 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner {
private readonly billingProductRepository: Repository<BillingProduct>,
@InjectRepository(BillingMeter, 'core')
private readonly billingMeterRepository: Repository<BillingMeter>,
private readonly stripeService: StripeService,
private readonly stripeBillingMeterService: StripeBillingMeterService,
private readonly stripeProductService: StripeProductService,
private readonly stripePriceService: StripePriceService,
) {
super();
}
Expand Down Expand Up @@ -92,7 +96,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner {
}
await this.upsertProductRepositoryData(product, options);

const prices = await this.stripeService.getPricesByProductId(
const prices = await this.stripePriceService.getPricesByProductId(
product.id,
);

Expand Down Expand Up @@ -133,11 +137,11 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner {
passedParams: string[],
options: BaseCommandOptions,
): Promise<void> {
const billingMeters = await this.stripeService.getAllMeters();
const billingMeters = await this.stripeBillingMeterService.getAllMeters();

await this.upsertMetersRepositoryData(billingMeters, options);

const billingProducts = await this.stripeService.getAllProducts();
const billingProducts = await this.stripeProductService.getAllProducts();

const billingPrices = await this.processBillingPricesByProductBatches(
billingProducts,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export enum WebhookEvent {
export enum BillingWebhookEvent {
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
response,
404,
);
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
404,
);
default:
return this.httpExceptionHandlerService.handleError(
exception,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Logger, Scope } from '@nestjs/common';

import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
Expand All @@ -18,7 +18,7 @@ export class UpdateSubscriptionQuantityJob {

constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly stripeService: StripeService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly twentyORMManager: TwentyORMManager,
) {}

Expand All @@ -41,7 +41,7 @@ export class UpdateSubscriptionQuantityJob {
data.workspaceId,
);

await this.stripeService.updateSubscriptionItem(
await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
);
Expand Down
Loading

0 comments on commit c39af5f

Please sign in to comment.