Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Integration and unit tests on Billing #9317

Merged
merged 10 commits into from
Jan 9, 2025
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)',
],
Comment on lines +99 to +103
Copy link
Contributor

Choose a reason for hiding this comment

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

style: test file patterns could be consolidated into a single pattern like '*.@(spec|integration-spec|test).@(ts|tsx|js|jsx)' for better maintainability

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}"
Copy link
Member

Choose a reason for hiding this comment

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

what is it?

}
}
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
Loading