Add Integration and unit tests on Billing (#9317)

Solves [ https://github.com/twentyhq/private-issues/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 <felix.malfait@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Ana Sofia Marin Alexandre
2025-01-09 14:30:41 -03:00
committed by GitHub
parent 4ed1db3845
commit c39af5f063
49 changed files with 2157 additions and 335 deletions

View File

@ -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,
},

View File

@ -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

View File

@ -49,4 +49,6 @@
"files.associations": {
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
}
}

View File

@ -14,6 +14,7 @@
"!{projectRoot}/**/tsconfig.spec.json",
"!{projectRoot}/**/*.test.(ts|tsx)",
"!{projectRoot}/**/*.spec.(ts|tsx)",
"!{projectRoot}/**/*.integration-spec.ts",
"!{projectRoot}/**/__tests__/*"
],
"production": [

View File

@ -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');
@ -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',

View File

@ -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,
@ -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);
}
try {
const result = await this.handleStripeEvent(event);
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;
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,
);
res.status(200).end();
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 {};
}
}
}

View File

@ -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',
}

View File

@ -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';
@ -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,
@ -63,7 +64,7 @@ export class BillingResolver {
requirePaymentMethod,
}: CheckoutSessionInput,
) {
const productPrice = await this.stripeService.getStripePrice(
const productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan,
recurringInterval,
);

View File

@ -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
@ -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>,
) {
@ -71,7 +71,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne
if (!options.dryRun && !billingCustomer) {
const stripeCustomerId =
await this.stripeService.getStripeCustomerIdFromWorkspaceId(
await this.stripeSubscriptionService.getStripeCustomerIdFromWorkspaceId(
workspaceId,
);

View File

@ -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';
@ -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();
}
@ -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,
);
@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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';
@ -18,7 +18,7 @@ export class UpdateSubscriptionQuantityJob {
constructor(
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly stripeService: StripeService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly twentyORMManager: TwentyORMManager,
) {}
@ -41,7 +41,7 @@ export class UpdateSubscriptionQuantityJob {
data.workspaceId,
);
await this.stripeService.updateSubscriptionItem(
await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
);

View File

@ -6,7 +6,8 @@ import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -17,7 +18,8 @@ import { assert } from 'src/utils/assert';
export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
constructor(
private readonly stripeService: StripeService,
private readonly stripeCheckoutService: StripeCheckoutService,
private readonly stripeBillingPortalService: StripeBillingPortalService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@ -52,7 +54,7 @@ export class BillingPortalWorkspaceService {
})
)?.stripeCustomerId;
const session = await this.stripeService.createCheckoutSession(
const session = await this.stripeCheckoutService.createCheckoutSession(
user,
workspace.id,
priceId,
@ -97,10 +99,11 @@ export class BillingPortalWorkspaceService {
}
const returnUrl = frontBaseUrl.toString();
const session = await this.stripeService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
const session =
await this.stripeBillingPortalService.createBillingPortalSession(
stripeCustomerId,
returnUrl,
);
assert(session.url, 'Error: missing billingPortal.session.url');

View File

@ -12,7 +12,9 @@ import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
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 { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -20,7 +22,9 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
constructor(
private readonly stripeService: StripeService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly stripePriceService: StripePriceService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@ -78,7 +82,7 @@ export class BillingSubscriptionService {
});
if (subscriptionToCancel) {
await this.stripeService.cancelSubscription(
await this.stripeSubscriptionService.cancelSubscription(
subscriptionToCancel.stripeSubscriptionId,
);
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
@ -91,10 +95,15 @@ export class BillingSubscriptionService {
);
if (billingSubscription?.status === 'unpaid') {
await this.stripeService.collectLastInvoice(
await this.stripeSubscriptionService.collectLastInvoice(
billingSubscription.stripeSubscriptionId,
);
}
return {
handleUnpaidInvoiceStripeSubscriptionId:
billingSubscription.stripeSubscriptionId,
};
}
async getWorkspaceEntitlementByKey(
@ -127,7 +136,7 @@ export class BillingSubscriptionService {
const billingSubscriptionItem =
await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id);
const productPrice = await this.stripeService.getStripePrice(
const productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan,
newInterval,
);
@ -138,7 +147,7 @@ export class BillingSubscriptionService {
);
}
await this.stripeService.updateBillingSubscriptionItem(
await this.stripeSubscriptionItemService.updateBillingSubscriptionItem(
billingSubscriptionItem,
productPrice.stripePriceId,
);

View File

@ -48,5 +48,9 @@ export class BillingWebhookEntitlementService {
skipUpdateIfNoValuesChanged: true,
},
);
return {
stripeEntitlementCustomerId: data.object.customer,
};
}
}

View File

@ -11,14 +11,14 @@ 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 { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util';
import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util';
@Injectable()
export class BillingWebhookPriceService {
protected readonly logger = new Logger(BillingWebhookPriceService.name);
constructor(
private readonly stripeService: StripeService,
private readonly stripeBillingMeterService: StripeBillingMeterService,
@InjectRepository(BillingPrice, 'core')
private readonly billingPriceRepository: Repository<BillingPrice>,
@InjectRepository(BillingMeter, 'core')
@ -45,7 +45,7 @@ export class BillingWebhookPriceService {
const meterId = data.object.recurring?.meter;
if (meterId) {
const meterData = await this.stripeService.getMeter(meterId);
const meterData = await this.stripeBillingMeterService.getMeter(meterId);
await this.billingMeterRepository.upsert(
transformStripeMeterDataToMeterRepositoryData(meterData),
@ -63,5 +63,10 @@ export class BillingWebhookPriceService {
skipUpdateIfNoValuesChanged: true,
},
);
return {
stripePriceId: data.object.id,
stripeMeterId: meterId,
};
}
}

View File

@ -33,6 +33,10 @@ export class BillingWebhookProductService {
conflictPaths: ['stripeProductId'],
skipUpdateIfNoValuesChanged: true,
});
return {
stripeProductId: data.object.id,
};
}
isStripeValidProductMetadata(

View File

@ -8,7 +8,7 @@ import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billin
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util';
import { transformStripeSubscriptionEventToSubscriptionItemRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util';
import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util';
@ -22,7 +22,7 @@ export class BillingWebhookSubscriptionService {
BillingWebhookSubscriptionService.name,
);
constructor(
private readonly stripeService: StripeService,
private readonly stripeCustomerService: StripeCustomerService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingSubscriptionItem, 'core')
@ -45,7 +45,7 @@ export class BillingWebhookSubscriptionService {
});
if (!workspace) {
return;
return { noWorkspace: true };
}
await this.billingCustomerRepository.upsert(
@ -106,9 +106,14 @@ export class BillingWebhookSubscriptionService {
});
}
await this.stripeService.updateCustomerMetadataWorkspaceId(
await this.stripeCustomerService.updateCustomerMetadataWorkspaceId(
String(data.object.customer),
workspaceId,
);
return {
stripeSubscriptionId: data.object.id,
stripeCustomerId: data.object.customer,
};
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeBillingMeterService {
protected readonly logger = new Logger(StripeBillingMeterService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async getMeter(stripeMeterId: string) {
return await this.stripe.billing.meters.retrieve(stripeMeterId);
}
async getAllMeters() {
const meters = await this.stripe.billing.meters.list();
return meters.data;
}
}

View File

@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeBillingPortalService {
protected readonly logger = new Logger(StripeBillingPortalService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async createBillingPortalSession(
stripeCustomerId: string,
returnUrl?: string,
): Promise<Stripe.BillingPortal.Session> {
return await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url:
returnUrl ?? this.domainManagerService.getBaseUrl().toString(),
});
}
}

View File

@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class StripeCheckoutService {
protected readonly logger = new Logger(StripeCheckoutService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async createCheckoutSession(
user: User,
workspaceId: string,
priceId: string,
quantity: number,
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
plan: BillingPlanKey = BillingPlanKey.PRO,
requirePaymentMethod = true,
): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId,
plan,
},
trial_period_days: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
},
automatic_tax: { enabled: !!requirePaymentMethod },
tax_id_collection: { enabled: !!requirePaymentMethod },
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
payment_method_collection: requirePaymentMethod
? 'always'
: 'if_required',
});
}
}

View File

@ -0,0 +1,33 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeCustomerService {
protected readonly logger = new Logger(StripeCustomerService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async updateCustomerMetadataWorkspaceId(
stripeCustomerId: string,
workspaceId: string,
) {
await this.stripe.customers.update(stripeCustomerId, {
metadata: { workspaceId: workspaceId },
});
}
}

View File

@ -0,0 +1,85 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripePriceService {
protected readonly logger = new Logger(StripePriceService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async getStripePrices(product: AvailableProduct) {
const stripeProductId = this.getStripeProductId(product);
const prices = await this.stripe.prices.search({
query: `product: '${stripeProductId}'`,
});
return this.formatProductPrices(prices.data);
}
async getStripePrice(product: AvailableProduct, recurringInterval: string) {
const productPrices = await this.getStripePrices(product);
return productPrices.find(
(price) => price.recurringInterval === recurringInterval,
);
}
getStripeProductId(product: AvailableProduct) {
if (product === AvailableProduct.BasePlan) {
return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID');
}
} // PD:,will be eliminated after refactoring
formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] {
const productPrices: ProductPriceEntity[] = Object.values(
prices
.filter((item) => item.recurring?.interval && item.unit_amount)
.reduce((acc, item: Stripe.Price) => {
const interval = item.recurring?.interval;
if (!interval || !item.unit_amount) {
return acc;
}
if (!acc[interval] || item.created > acc[interval].created) {
acc[interval] = {
unitAmount: item.unit_amount,
recurringInterval: interval,
created: item.created,
stripePriceId: item.id,
};
}
return acc satisfies Record<string, ProductPriceEntity>;
}, {}),
);
return productPrices.sort((a, b) => a.unitAmount - b.unitAmount);
}
async getPricesByProductId(productId: string) {
const prices = await this.stripe.prices.search({
query: `product:'${productId}'`,
});
return prices.data;
}
}

View File

@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeProductService {
protected readonly logger = new Logger(StripeProductService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async getAllProducts() {
const products = await this.stripe.products.list();
return products.data;
}
}

View File

@ -0,0 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeSubscriptionItemService {
protected readonly logger = new Logger(StripeSubscriptionItemService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
}
async updateBillingSubscriptionItem(
stripeSubscriptionItem: BillingSubscriptionItem,
stripePriceId: string,
) {
await this.stripe.subscriptionItems.update(
stripeSubscriptionItem.stripeSubscriptionItemId,
{
price: stripePriceId,
quantity:
stripeSubscriptionItem.quantity === null
? undefined
: stripeSubscriptionItem.quantity,
},
);
}
}

View File

@ -0,0 +1,59 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeSubscriptionService {
protected readonly logger = new Logger(StripeSubscriptionService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
async cancelSubscription(stripeSubscriptionId: string) {
await this.stripe.subscriptions.cancel(stripeSubscriptionId);
}
async getStripeCustomerIdFromWorkspaceId(workspaceId: string) {
const subscription = await this.stripe.subscriptions.search({
query: `metadata['workspaceId']:'${workspaceId}'`,
limit: 1,
});
const stripeCustomerId = subscription.data[0].customer
? String(subscription.data[0].customer)
: undefined;
return stripeCustomerId;
}
async collectLastInvoice(stripeSubscriptionId: string) {
const subscription = await this.stripe.subscriptions.retrieve(
stripeSubscriptionId,
{ expand: ['latest_invoice'] },
);
const latestInvoice = subscription.latest_invoice;
if (
!(
latestInvoice &&
typeof latestInvoice !== 'string' &&
latestInvoice.status === 'draft'
)
) {
return;
}
await this.stripe.invoices.pay(latestInvoice.id);
}
}

View File

@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class StripeWebhookService {
protected readonly logger = new Logger(StripeWebhookService.name);
private stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
);
}
constructEventFromPayload(signature: string, payload: Buffer) {
const webhookSecret = this.environmentService.get(
'BILLING_STRIPE_WEBHOOK_SECRET',
);
const returnValue = this.stripe.webhooks.constructEvent(
payload,
signature,
webhookSecret,
);
return returnValue;
}
}

View File

@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeSDKMock } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
@Injectable()
export class StripeSDKMockService implements StripeSDKService {
getStripe(stripeApiKey: string) {
return new StripeSDKMock(stripeApiKey) as unknown as Stripe;
}
}

View File

@ -0,0 +1,29 @@
import Stripe from 'stripe';
export class StripeSDKMock {
constructor(private readonly apiKey: string) {}
customers = {
update: (_id: string, _params?: Stripe.CustomerUpdateParams) => {
return;
},
};
webhooks = {
constructEvent: (
payload: Buffer,
signature: string,
_webhookSecret: string,
) => {
if (signature === 'correct-signature') {
const body = JSON.parse(payload.toString());
return {
type: body.type,
data: body.data,
};
}
throw new Error('Invalid signature');
},
};
}

View File

@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';
@Injectable()
export class StripeSDKService {
getStripe(stripeApiKey: string) {
return new Stripe(stripeApiKey, {});
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
@Module({
providers: [StripeSDKService],
exports: [StripeSDKService],
})
export class StripeSDKModule {}

View File

@ -1,11 +1,40 @@
import { Module } from '@nestjs/common';
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 { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.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 { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [DomainManagerModule],
providers: [StripeService],
exports: [StripeService],
imports: [DomainManagerModule, StripeSDKModule],
providers: [
StripeSubscriptionItemService,
StripeWebhookService,
StripeCheckoutService,
StripeSubscriptionService,
StripeBillingPortalService,
StripeBillingMeterService,
StripeCustomerService,
StripePriceService,
StripeProductService,
],
exports: [
StripeWebhookService,
StripeBillingPortalService,
StripeBillingMeterService,
StripeCustomerService,
StripePriceService,
StripeCheckoutService,
StripeSubscriptionItemService,
StripeSubscriptionService,
StripeProductService,
],
})
export class StripeModule {}

View File

@ -1,233 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class StripeService {
protected readonly logger = new Logger(StripeService.name);
private readonly stripe: Stripe;
constructor(
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = new Stripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
{},
);
}
constructEventFromPayload(signature: string, payload: Buffer) {
const webhookSecret = this.environmentService.get(
'BILLING_STRIPE_WEBHOOK_SECRET',
);
return this.stripe.webhooks.constructEvent(
payload,
signature,
webhookSecret,
);
}
async getStripePrices(product: AvailableProduct) {
const stripeProductId = this.getStripeProductId(product);
const prices = await this.stripe.prices.search({
query: `product: '${stripeProductId}'`,
});
return this.formatProductPrices(prices.data);
}
async getStripePrice(product: AvailableProduct, recurringInterval: string) {
const productPrices = await this.getStripePrices(product);
return productPrices.find(
(price) => price.recurringInterval === recurringInterval,
);
}
getStripeProductId(product: AvailableProduct) {
if (product === AvailableProduct.BasePlan) {
return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID');
}
}
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
}
async cancelSubscription(stripeSubscriptionId: string) {
await this.stripe.subscriptions.cancel(stripeSubscriptionId);
}
async createBillingPortalSession(
stripeCustomerId: string,
returnUrl?: string,
): Promise<Stripe.BillingPortal.Session> {
return await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url:
returnUrl ?? this.domainManagerService.getBaseUrl().toString(),
});
}
async createCheckoutSession(
user: User,
workspaceId: string,
priceId: string,
quantity: number,
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
plan: BillingPlanKey = BillingPlanKey.PRO,
requirePaymentMethod = true,
): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId,
plan,
},
trial_period_days: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
},
automatic_tax: { enabled: !!requirePaymentMethod },
tax_id_collection: { enabled: !!requirePaymentMethod },
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
payment_method_collection: requirePaymentMethod
? 'always'
: 'if_required',
});
}
async collectLastInvoice(stripeSubscriptionId: string) {
const subscription = await this.stripe.subscriptions.retrieve(
stripeSubscriptionId,
{ expand: ['latest_invoice'] },
);
const latestInvoice = subscription.latest_invoice;
if (
!(
latestInvoice &&
typeof latestInvoice !== 'string' &&
latestInvoice.status === 'draft'
)
) {
return;
}
await this.stripe.invoices.pay(latestInvoice.id);
}
async updateBillingSubscriptionItem(
stripeSubscriptionItem: BillingSubscriptionItem,
stripePriceId: string,
) {
await this.stripe.subscriptionItems.update(
stripeSubscriptionItem.stripeSubscriptionItemId,
{
price: stripePriceId,
quantity:
stripeSubscriptionItem.quantity === null
? undefined
: stripeSubscriptionItem.quantity,
},
);
}
async updateCustomerMetadataWorkspaceId(
stripeCustomerId: string,
workspaceId: string,
) {
await this.stripe.customers.update(stripeCustomerId, {
metadata: { workspaceId: workspaceId },
});
}
async getMeter(stripeMeterId: string) {
return await this.stripe.billing.meters.retrieve(stripeMeterId);
}
formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] {
const productPrices: ProductPriceEntity[] = Object.values(
prices
.filter((item) => item.recurring?.interval && item.unit_amount)
.reduce((acc, item: Stripe.Price) => {
const interval = item.recurring?.interval;
if (!interval || !item.unit_amount) {
return acc;
}
if (!acc[interval] || item.created > acc[interval].created) {
acc[interval] = {
unitAmount: item.unit_amount,
recurringInterval: interval,
created: item.created,
stripePriceId: item.id,
};
}
return acc satisfies Record<string, ProductPriceEntity>;
}, {}),
);
return productPrices.sort((a, b) => a.unitAmount - b.unitAmount);
}
async getStripeCustomerIdFromWorkspaceId(workspaceId: string) {
const subscription = await this.stripe.subscriptions.search({
query: `metadata['workspaceId']:'${workspaceId}'`,
limit: 1,
});
const stripeCustomerId = subscription.data[0].customer
? String(subscription.data[0].customer)
: undefined;
return stripeCustomerId;
}
async getAllProducts() {
const products = await this.stripe.products.list();
return products.data;
}
async getPricesByProductId(productId: string) {
const prices = await this.stripe.prices.search({
query: `product:'${productId}'`,
});
return prices.data;
}
async getAllMeters() {
const meters = await this.stripe.billing.meters.list();
return meters.data;
}
}

View File

@ -0,0 +1,56 @@
import Stripe from 'stripe';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util';
describe('isStripeValidProductMetadata', () => {
it('should return true if metadata is empty', () => {
const metadata: Stripe.Metadata = {};
expect(isStripeValidProductMetadata(metadata)).toBe(true);
});
it('should return true if metadata has the correct keys with correct values', () => {
const metadata: Stripe.Metadata = {
planKey: BillingPlanKey.PRO,
priceUsageBased: BillingUsageType.METERED,
};
expect(isStripeValidProductMetadata(metadata)).toBe(true);
});
it('should return true if metadata has extra keys', () => {
const metadata: Stripe.Metadata = {
planKey: BillingPlanKey.ENTERPRISE,
priceUsageBased: BillingUsageType.METERED,
randomKey: 'randomValue',
};
expect(isStripeValidProductMetadata(metadata)).toBe(true);
});
it('should return false if metadata has invalid keys', () => {
const metadata: Stripe.Metadata = {
planKey: 'invalid',
priceUsageBased: BillingUsageType.METERED,
};
expect(isStripeValidProductMetadata(metadata)).toBe(false);
});
it('should return false if metadata has invalid values', () => {
const metadata: Stripe.Metadata = {
planKey: BillingPlanKey.PRO,
priceUsageBased: 'invalid',
};
expect(isStripeValidProductMetadata(metadata)).toBe(false);
});
it('should return false if the metadata does not have the required keys', () => {
const metadata: Stripe.Metadata = {
randomKey: 'randomValue',
};
expect(isStripeValidProductMetadata(metadata)).toBe(false);
});
});

View File

@ -0,0 +1,84 @@
import Stripe from 'stripe';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util';
describe('transformStripeEntitlementUpdatedEventToEntitlementRepositoryData', () => {
it('should return the SSO key with true value', () => {
const data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data = {
object: {
customer: 'cus_123',
entitlements: {
data: [
{
lookup_key: 'SSO',
feature: 'SSO',
livemode: false,
id: 'ent_123',
object: 'entitlements.active_entitlement',
},
],
object: 'list',
has_more: false,
url: '',
},
livemode: false,
object: 'entitlements.active_entitlement_summary',
},
};
const result =
transformStripeEntitlementUpdatedEventToEntitlementRepositoryData(
'workspaceId',
data,
);
expect(result).toEqual([
{
workspaceId: 'workspaceId',
key: BillingEntitlementKey.SSO,
value: true,
stripeCustomerId: 'cus_123',
},
]);
});
it('should return the SSO key with false value,should only render the values that are listed in BillingEntitlementKeys', () => {
const data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data = {
object: {
customer: 'cus_123',
entitlements: {
data: [
{
id: 'ent_123',
object: 'entitlements.active_entitlement',
lookup_key: 'DIFFERENT_KEY',
feature: 'DIFFERENT_FEATURE',
livemode: false,
},
],
object: 'list',
has_more: false,
url: '',
},
livemode: false,
object: 'entitlements.active_entitlement_summary',
},
};
const result =
transformStripeEntitlementUpdatedEventToEntitlementRepositoryData(
'workspaceId',
data,
);
expect(result).toEqual([
{
workspaceId: 'workspaceId',
key: BillingEntitlementKey.SSO,
value: false,
stripeCustomerId: 'cus_123',
},
]);
});
});

View File

@ -0,0 +1,94 @@
import Stripe from 'stripe';
import { BillingMeterEventTimeWindow } from 'src/engine/core-modules/billing/enums/billing-meter-event-time-window.enum';
import { BillingMeterStatus } from 'src/engine/core-modules/billing/enums/billing-meter-status.enum';
import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util';
describe('transformStripeMeterDataToMeterRepositoryData', () => {
it('should return the correct data with customer mapping', () => {
const data: Stripe.Billing.Meter = {
id: 'met_123',
object: 'billing.meter',
created: 1719859200,
display_name: 'Meter 1',
event_name: 'event_1',
status: 'active',
customer_mapping: {
event_payload_key: 'event_payload_key_1',
type: 'by_id',
},
default_aggregation: {
formula: 'count',
},
event_time_window: 'day',
livemode: false,
status_transitions: {
deactivated_at: null,
},
updated: 1719859200,
value_settings: {
event_payload_key: 'event_payload_key_1',
},
};
const result = transformStripeMeterDataToMeterRepositoryData(data);
expect(result).toEqual({
stripeMeterId: 'met_123',
displayName: 'Meter 1',
eventName: 'event_1',
status: BillingMeterStatus.ACTIVE,
customerMapping: {
event_payload_key: 'event_payload_key_1',
type: 'by_id',
},
eventTimeWindow: BillingMeterEventTimeWindow.DAY,
valueSettings: {
event_payload_key: 'event_payload_key_1',
},
});
});
it('should return the correct data with null values', () => {
const data: Stripe.Billing.Meter = {
id: 'met_1234',
object: 'billing.meter',
created: 1719859200,
display_name: 'Meter 2',
event_name: 'event_2',
status: 'inactive',
customer_mapping: {
event_payload_key: 'event_payload_key_2',
type: 'by_id',
},
default_aggregation: {
formula: 'sum',
},
event_time_window: null,
livemode: false,
status_transitions: {
deactivated_at: 1719859200,
},
updated: 1719859200,
value_settings: {
event_payload_key: 'event_payload_key_2',
},
};
const result = transformStripeMeterDataToMeterRepositoryData(data);
expect(result).toEqual({
stripeMeterId: 'met_1234',
displayName: 'Meter 2',
eventName: 'event_2',
status: BillingMeterStatus.INACTIVE,
customerMapping: {
event_payload_key: 'event_payload_key_2',
type: 'by_id',
},
eventTimeWindow: undefined,
valueSettings: {
event_payload_key: 'event_payload_key_2',
},
});
});
});

View File

@ -0,0 +1,219 @@
import Stripe from 'stripe';
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util';
describe('transformStripePriceDataToPriceRepositoryData', () => {
const createMockPrice = (overrides = {}): Stripe.Price =>
({
id: 'price_123',
active: true,
product: 'prod_123',
currency: 'usd',
nickname: null,
tax_behavior: null,
type: 'recurring',
billing_scheme: 'per_unit',
unit_amount_decimal: '1000',
unit_amount: 1000,
transform_quantity: null,
recurring: {
usage_type: 'licensed',
interval: 'month',
meter: null,
},
currency_options: null,
tiers: null,
tiers_mode: null,
...overrides,
}) as unknown as Stripe.Price;
it('should transform basic price data correctly', () => {
const mockPrice = createMockPrice();
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result).toEqual({
stripePriceId: 'price_123',
active: true,
stripeProductId: 'prod_123',
stripeMeterId: null,
currency: 'USD',
nickname: undefined,
taxBehavior: undefined,
type: BillingPriceType.RECURRING,
billingScheme: BillingPriceBillingScheme.PER_UNIT,
unitAmountDecimal: '1000',
unitAmount: 1000,
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
currencyOptions: undefined,
tiers: undefined,
tiersMode: undefined,
recurring: {
usage_type: 'licensed',
interval: 'month',
meter: null,
},
});
});
describe('tax behavior transformations', () => {
it.each([
['exclusive', BillingPriceTaxBehavior.EXCLUSIVE],
['inclusive', BillingPriceTaxBehavior.INCLUSIVE],
['unspecified', BillingPriceTaxBehavior.UNSPECIFIED],
])(
'should transform tax behavior %s correctly',
(stripeTaxBehavior, expected) => {
const mockPrice = createMockPrice({
tax_behavior: stripeTaxBehavior as Stripe.Price.TaxBehavior,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.taxBehavior).toBe(expected);
},
);
});
describe('price type transformations', () => {
it.each([
['one_time', BillingPriceType.ONE_TIME],
['recurring', BillingPriceType.RECURRING],
])('should transform price type %s correctly', (stripeType, expected) => {
const mockPrice = createMockPrice({
type: stripeType as Stripe.Price.Type,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.type).toBe(expected);
});
});
describe('billing scheme transformations', () => {
it.each([
['per_unit', BillingPriceBillingScheme.PER_UNIT],
['tiered', BillingPriceBillingScheme.TIERED],
])(
'should transform billing scheme %s correctly',
(stripeScheme, expected) => {
const mockPrice = createMockPrice({
billing_scheme: stripeScheme as Stripe.Price.BillingScheme,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.billingScheme).toBe(expected);
},
);
});
describe('recurring price configurations', () => {
it('should handle metered pricing with meter ID', () => {
const mockPrice = createMockPrice({
recurring: {
usage_type: 'metered',
interval: 'month',
meter: 'meter_123',
},
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.stripeMeterId).toBe('meter_123');
expect(result.usageType).toBe(BillingUsageType.METERED);
});
it.each([
['month', SubscriptionInterval.Month],
['day', SubscriptionInterval.Day],
['week', SubscriptionInterval.Week],
['year', SubscriptionInterval.Year],
])('should transform interval %s correctly', (stripeInterval, expected) => {
const mockPrice = createMockPrice({
recurring: {
usage_type: 'licensed',
interval: stripeInterval as Stripe.Price.Recurring.Interval,
meter: null,
},
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.interval).toBe(expected);
});
});
describe('tiered pricing configurations', () => {
const mockTiers = [
{ up_to: 10, unit_amount: 1000 },
{ up_to: 20, unit_amount: 800 },
];
it.each([
['graduated', BillingPriceTiersMode.GRADUATED],
['volume', BillingPriceTiersMode.VOLUME],
])(
'should transform tiers mode %s correctly',
(stripeTiersMode, expected) => {
const mockPrice = createMockPrice({
billing_scheme: 'tiered',
tiers: mockTiers,
tiers_mode: stripeTiersMode as Stripe.Price.TiersMode,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.tiersMode).toBe(expected);
expect(result.tiers).toEqual(mockTiers);
},
);
});
describe('optional fields handling', () => {
it('should handle transform quantity configuration', () => {
const transformQuantity = {
divide_by: 100,
round: 'up',
};
const mockPrice = createMockPrice({
transform_quantity: transformQuantity,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.transformQuantity).toEqual(transformQuantity);
});
it('should handle currency options', () => {
const currencyOptions = {
eur: {
unit_amount: 850,
unit_amount_decimal: '850',
},
};
const mockPrice = createMockPrice({ currency_options: currencyOptions });
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.currencyOptions).toEqual(currencyOptions);
});
it('should handle null and undefined fields correctly', () => {
const mockPrice = createMockPrice({
nickname: null,
unit_amount: null,
unit_amount_decimal: null,
transform_quantity: null,
tiers: null,
currency_options: null,
});
const result = transformStripePriceDataToPriceRepositoryData(mockPrice);
expect(result.nickname).toBeUndefined();
expect(result.unitAmount).toBeUndefined();
expect(result.unitAmountDecimal).toBeUndefined();
expect(result.transformQuantity).toBeUndefined();
expect(result.tiers).toBeUndefined();
expect(result.currencyOptions).toBeUndefined();
});
});
});

View File

@ -0,0 +1,234 @@
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util';
describe('transformStripePriceEventToPriceRepositoryData', () => {
const createMockPriceData = (overrides = {}) => ({
object: {
id: 'price_123',
active: true,
product: 'prod_123',
meter: null,
currency: 'usd',
nickname: null,
tax_behavior: null,
type: 'recurring',
billing_scheme: 'per_unit',
unit_amount_decimal: '1000',
unit_amount: 1000,
transform_quantity: null,
recurring: {
usage_type: 'licensed',
interval: 'month',
},
currency_options: null,
tiers: null,
tiers_mode: null,
...overrides,
},
});
it('should transform basic price data correctly', () => {
const mockData = createMockPriceData();
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result).toEqual({
stripePriceId: 'price_123',
active: true,
stripeProductId: 'prod_123',
stripeMeterId: undefined,
currency: 'USD',
nickname: undefined,
taxBehavior: undefined,
type: BillingPriceType.RECURRING,
billingScheme: BillingPriceBillingScheme.PER_UNIT,
unitAmountDecimal: '1000',
unitAmount: 1000,
transformQuantity: undefined,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Month,
currencyOptions: undefined,
tiers: undefined,
tiersMode: undefined,
recurring: {
usage_type: 'licensed',
interval: 'month',
},
});
});
it('should handle all tax behaviors correctly', () => {
const taxBehaviors = [
['exclusive', BillingPriceTaxBehavior.EXCLUSIVE],
['inclusive', BillingPriceTaxBehavior.INCLUSIVE],
['unspecified', BillingPriceTaxBehavior.UNSPECIFIED],
];
taxBehaviors.forEach(([stripeTaxBehavior, expectedTaxBehavior]) => {
const mockData = createMockPriceData({
tax_behavior: stripeTaxBehavior,
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.taxBehavior).toBe(expectedTaxBehavior);
});
});
it('should handle all price types correctly', () => {
const priceTypes = [
['one_time', BillingPriceType.ONE_TIME],
['recurring', BillingPriceType.RECURRING],
];
priceTypes.forEach(([stripeType, expectedType]) => {
const mockData = createMockPriceData({ type: stripeType });
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.type).toBe(expectedType);
});
});
it('should handle all billing schemes correctly', () => {
const billingSchemes = [
['per_unit', BillingPriceBillingScheme.PER_UNIT],
['tiered', BillingPriceBillingScheme.TIERED],
];
billingSchemes.forEach(([stripeScheme, expectedScheme]) => {
const mockData = createMockPriceData({ billing_scheme: stripeScheme });
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.billingScheme).toBe(expectedScheme);
});
});
it('should handle all usage types correctly', () => {
const usageTypes = [
['licensed', BillingUsageType.LICENSED],
['metered', BillingUsageType.METERED],
];
usageTypes.forEach(([stripeUsageType, expectedUsageType]) => {
const mockData = createMockPriceData({
recurring: { usage_type: stripeUsageType, interval: 'month' },
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.usageType).toBe(expectedUsageType);
});
});
it('should handle all tiers modes correctly', () => {
const tiersModes = [
['graduated', BillingPriceTiersMode.GRADUATED],
['volume', BillingPriceTiersMode.VOLUME],
];
tiersModes.forEach(([stripeTiersMode, expectedTiersMode]) => {
const mockData = createMockPriceData({ tiers_mode: stripeTiersMode });
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.tiersMode).toBe(expectedTiersMode);
});
});
it('should handle all intervals correctly', () => {
const intervals = [
['month', SubscriptionInterval.Month],
['day', SubscriptionInterval.Day],
['week', SubscriptionInterval.Week],
['year', SubscriptionInterval.Year],
];
intervals.forEach(([stripeInterval, expectedInterval]) => {
const mockData = createMockPriceData({
recurring: { usage_type: 'licensed', interval: stripeInterval },
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.interval).toBe(expectedInterval);
});
});
it('should handle tiered pricing configuration', () => {
const mockTiers = [
{ up_to: 10, unit_amount: 1000 },
{ up_to: 20, unit_amount: 800 },
];
const mockData = createMockPriceData({
billing_scheme: 'tiered',
tiers: mockTiers,
tiers_mode: 'graduated',
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.billingScheme).toBe(BillingPriceBillingScheme.TIERED);
expect(result.tiers).toEqual(mockTiers);
expect(result.tiersMode).toBe(BillingPriceTiersMode.GRADUATED);
});
it('should handle metered pricing with transform quantity', () => {
const mockTransformQuantity = {
divide_by: 100,
round: 'up',
};
const mockData = createMockPriceData({
recurring: {
usage_type: 'metered',
interval: 'month',
meter: 'meter_123',
},
transform_quantity: mockTransformQuantity,
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.stripeMeterId).toBe('meter_123');
expect(result.usageType).toBe(BillingUsageType.METERED);
expect(result.transformQuantity).toEqual(mockTransformQuantity);
});
it('should handle currency options', () => {
const mockCurrencyOptions = {
eur: {
unit_amount: 850,
unit_amount_decimal: '850',
},
};
const mockData = createMockPriceData({
currency_options: mockCurrencyOptions,
});
const result = transformStripePriceEventToPriceRepositoryData(
mockData as any,
);
expect(result.currencyOptions).toEqual(mockCurrencyOptions);
});
});

View File

@ -0,0 +1,86 @@
import Stripe from 'stripe';
import { transformStripeProductDataToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util';
describe('transformStripeProductDataToProductRepositoryData', () => {
it('should return the correct data', () => {
const data: Stripe.Product = {
id: 'prod_123',
name: 'Product 1',
active: true,
description: 'Description 1',
images: ['image1.jpg', 'image2.jpg'],
marketing_features: [
{
name: 'feature1',
},
],
created: 1719859200,
updated: 1719859200,
type: 'service',
livemode: false,
package_dimensions: null,
shippable: false,
object: 'product',
default_price: 'price_123',
unit_label: 'Unit',
url: 'https://example.com',
tax_code: 'tax_code_1',
metadata: { key: 'value' },
};
const result = transformStripeProductDataToProductRepositoryData(data);
expect(result).toEqual({
stripeProductId: 'prod_123',
name: 'Product 1',
active: true,
description: 'Description 1',
images: ['image1.jpg', 'image2.jpg'],
marketingFeatures: [{ name: 'feature1' }],
defaultStripePriceId: 'price_123',
unitLabel: 'Unit',
url: 'https://example.com',
taxCode: 'tax_code_1',
metadata: { key: 'value' },
});
});
it('should return the correct data with null values', () => {
const data: Stripe.Product = {
id: 'prod_456',
name: 'Product 2',
active: false,
description: '',
images: [],
created: 1719859200,
updated: 1719859200,
type: 'service',
livemode: false,
package_dimensions: null,
shippable: false,
object: 'product',
marketing_features: [],
default_price: null,
unit_label: null,
url: null,
tax_code: null,
metadata: {},
};
const result = transformStripeProductDataToProductRepositoryData(data);
expect(result).toEqual({
stripeProductId: 'prod_456',
name: 'Product 2',
active: false,
description: '',
images: [],
marketingFeatures: [],
defaultStripePriceId: undefined,
unitLabel: undefined,
url: undefined,
taxCode: undefined,
metadata: {},
});
});
});

View File

@ -0,0 +1,89 @@
import Stripe from 'stripe';
import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util';
describe('transformStripeProductEventToProductRepositoryData', () => {
it('should return the correct data', () => {
const data: Stripe.ProductCreatedEvent.Data = {
object: {
id: 'prod_123',
name: 'Product 1',
active: true,
description: 'Description 1',
images: ['image1.jpg', 'image2.jpg'],
marketing_features: [
{
name: 'feature1',
},
],
created: 1719859200,
updated: 1719859200,
type: 'service',
livemode: false,
package_dimensions: null,
shippable: false,
object: 'product',
default_price: 'price_123',
unit_label: 'Unit',
url: 'https://example.com',
tax_code: 'tax_code_1',
metadata: { key: 'value' },
},
};
const result = transformStripeProductEventToProductRepositoryData(data);
expect(result).toEqual({
stripeProductId: 'prod_123',
name: 'Product 1',
active: true,
description: 'Description 1',
images: ['image1.jpg', 'image2.jpg'],
marketingFeatures: [{ name: 'feature1' }],
defaultStripePriceId: 'price_123',
unitLabel: 'Unit',
url: 'https://example.com',
taxCode: 'tax_code_1',
});
});
it('should return the correct data with null values', () => {
const data: Stripe.ProductUpdatedEvent.Data = {
object: {
id: 'prod_456',
name: 'Product 2',
object: 'product',
active: false,
description: '',
images: [],
created: 1719859200,
updated: 1719859200,
type: 'service',
livemode: false,
package_dimensions: null,
shippable: false,
marketing_features: [],
default_price: null,
unit_label: null,
url: null,
tax_code: null,
metadata: {},
},
};
const result = transformStripeProductEventToProductRepositoryData(data);
expect(result).toEqual({
stripeProductId: 'prod_456',
name: 'Product 2',
active: false,
description: '',
images: [],
marketingFeatures: [],
defaultStripePriceId: undefined,
unitLabel: undefined,
url: undefined,
taxCode: undefined,
});
});
});

View File

@ -0,0 +1,85 @@
import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util';
describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => {
const mockWorkspaceId = 'workspace_123';
const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC
const createMockSubscriptionData = (overrides = {}) => ({
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'active',
items: {
data: [
{
plan: {
interval: 'month',
},
},
],
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: mockTimestamp,
current_period_start: mockTimestamp - 2592000, // 30 days before end
metadata: {},
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
ended_at: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
...overrides,
},
});
it('should transform basic customer data correctly', () => {
const mockData = createMockSubscriptionData('cus_123');
const result = transformStripeSubscriptionEventToCustomerRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result).toEqual({
workspaceId: 'workspace_123',
stripeCustomerId: 'cus_123',
});
});
it('should work with different subscription event types', () => {
const mockData = createMockSubscriptionData('cus_123');
// Test with different event types (they should all transform the same way)
['updated', 'created', 'deleted'].forEach(() => {
const result = transformStripeSubscriptionEventToCustomerRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result).toEqual({
workspaceId: 'workspace_123',
stripeCustomerId: 'cus_123',
});
});
});
it('should handle different workspace IDs', () => {
const mockData = createMockSubscriptionData('cus_123');
const testWorkspaces = ['workspace_1', 'workspace_2', 'workspace_abc'];
testWorkspaces.forEach((testWorkspaceId) => {
const result = transformStripeSubscriptionEventToCustomerRepositoryData(
testWorkspaceId,
mockData as any,
);
expect(result).toEqual({
workspaceId: testWorkspaceId,
stripeCustomerId: 'cus_123',
});
});
});
});

View File

@ -0,0 +1,197 @@
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util';
describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => {
const mockWorkspaceId = 'workspace-123';
const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC
const createMockSubscriptionData = (overrides = {}) => ({
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'active',
items: {
data: [
{
plan: {
interval: 'month',
},
},
],
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: mockTimestamp,
current_period_start: mockTimestamp - 2592000, // 30 days before end
metadata: {},
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
ended_at: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
...overrides,
},
});
it('should transform basic subscription data correctly', () => {
const mockData = createMockSubscriptionData();
const result = transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result).toEqual({
workspaceId: mockWorkspaceId,
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123',
status: SubscriptionStatus.Active,
interval: 'month',
cancelAtPeriodEnd: false,
currency: 'USD',
currentPeriodEnd: new Date(mockTimestamp * 1000),
currentPeriodStart: new Date((mockTimestamp - 2592000) * 1000),
metadata: {},
collectionMethod:
BillingSubscriptionCollectionMethod.CHARGE_AUTOMATICALLY,
automaticTax: undefined,
cancellationDetails: undefined,
endedAt: undefined,
trialStart: undefined,
trialEnd: undefined,
cancelAt: undefined,
canceledAt: undefined,
});
});
it('should handle all subscription statuses correctly', () => {
const statuses = [
['active', SubscriptionStatus.Active],
['canceled', SubscriptionStatus.Canceled],
['incomplete', SubscriptionStatus.Incomplete],
['incomplete_expired', SubscriptionStatus.IncompleteExpired],
['past_due', SubscriptionStatus.PastDue],
['paused', SubscriptionStatus.Paused],
['trialing', SubscriptionStatus.Trialing],
['unpaid', SubscriptionStatus.Unpaid],
];
statuses.forEach(([stripeStatus, expectedStatus]) => {
const mockData = createMockSubscriptionData({
status: stripeStatus,
});
const result =
transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.status).toBe(expectedStatus);
});
});
it('should handle subscription with trial periods', () => {
const trialStart = mockTimestamp - 604800; // 7 days before
const trialEnd = mockTimestamp + 604800; // 7 days after
const mockData = createMockSubscriptionData({
trial_start: trialStart,
trial_end: trialEnd,
});
const result = transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.trialStart).toEqual(new Date(trialStart * 1000));
expect(result.trialEnd).toEqual(new Date(trialEnd * 1000));
});
it('should handle subscription cancellation details', () => {
const cancelAt = mockTimestamp + 2592000; // 30 days after
const canceledAt = mockTimestamp;
const mockData = createMockSubscriptionData({
cancel_at: cancelAt,
canceled_at: canceledAt,
cancel_at_period_end: true,
cancellation_details: {
comment: 'Customer requested cancellation',
feedback: 'too_expensive',
reason: 'customer_request',
},
});
const result = transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.cancelAt).toEqual(new Date(cancelAt * 1000));
expect(result.canceledAt).toEqual(new Date(canceledAt * 1000));
expect(result.cancelAtPeriodEnd).toBe(true);
expect(result.cancellationDetails).toEqual({
comment: 'Customer requested cancellation',
feedback: 'too_expensive',
reason: 'customer_request',
});
});
it('should handle automatic tax information', () => {
const mockData = createMockSubscriptionData({
automatic_tax: {
enabled: true,
status: 'calculated',
},
});
const result = transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.automaticTax).toEqual({
enabled: true,
status: 'calculated',
});
});
it('should handle different collection methods', () => {
const methods = [
[
'charge_automatically',
BillingSubscriptionCollectionMethod.CHARGE_AUTOMATICALLY,
],
['send_invoice', BillingSubscriptionCollectionMethod.SEND_INVOICE],
];
methods.forEach(([stripeMethod, expectedMethod]) => {
const mockData = createMockSubscriptionData({
collection_method: stripeMethod,
});
const result =
transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.collectionMethod).toBe(expectedMethod);
});
});
it('should handle different currencies', () => {
const mockData = createMockSubscriptionData({
currency: 'eur',
});
const result = transformStripeSubscriptionEventToSubscriptionRepositoryData(
mockWorkspaceId,
mockData as any,
);
expect(result.currency).toBe('EUR');
});
});

View File

@ -0,0 +1,112 @@
import request from 'supertest';
import { createMockStripeEntitlementUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util';
import { createMockStripePriceCreatedData } from 'test/integration/billing/utils/create-mock-stripe-price-created-data.util';
import { createMockStripeProductUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-product-updated-data.util';
import { createMockStripeSubscriptionCreatedData } from 'test/integration/billing/utils/create-mock-stripe-subscription-created-data.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('BillingController (integration)', () => {
it('should handle product.updated and price.created webhook events', async () => {
const productUpdatedPayload = {
type: 'product.updated',
data: createMockStripeProductUpdatedData(),
};
const priceCreatedPayload = {
type: 'price.created',
data: createMockStripePriceCreatedData(),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(productUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeProductId).toBeDefined();
});
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(priceCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripePriceId).toBeDefined();
expect(res.body.stripeMeterId).toBeDefined();
});
});
it('should handle subscription.created webhook event', async () => {
const subscriptionCreatedPayload = {
type: 'customer.subscription.created',
data: createMockStripeSubscriptionCreatedData(),
};
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData(),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(subscriptionCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeSubscriptionId).toBeDefined();
expect(res.body.stripeCustomerId).toBeDefined();
});
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeEntitlementCustomerId).toBeDefined();
});
});
it('should handle entitlements.active_entitlement_summary.updated when the subscription is not found', async () => {
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData({
customer: 'new_customer',
}),
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(404);
});
it('should reject webhook with invalid signature', async () => {
const entitlementUpdatedPayload = {
type: 'customer.entitlement.created',
data: {
object: {
id: 'ent_test123',
},
},
};
await client
.post('/billing/webhooks')
.set('Authorization', `Bearer ${ACCESS_TOKEN}`)
.set('stripe-signature', 'invalid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(500);
});
});

View File

@ -0,0 +1,25 @@
import Stripe from 'stripe';
export const createMockStripeEntitlementUpdatedData = (
overrides = {},
): Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data => ({
object: {
object: 'entitlements.active_entitlement_summary',
customer: 'cus_default1',
livemode: false,
entitlements: {
object: 'list',
data: [
{
id: 'ent_test_61',
object: 'entitlements.active_entitlement',
feature: 'feat_test_61',
livemode: false,
lookup_key: 'SSO',
},
],
has_more: false,
url: '/v1/customer/cus_Q/entitlements',
},
...overrides,
},
});

View File

@ -0,0 +1,34 @@
import Stripe from 'stripe';
export const createMockStripePriceCreatedData = (
overrides = {},
): Stripe.PriceCreatedEvent.Data => ({
object: {
id: 'price_1Q',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1733734326,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_RLN',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 0,
unit_amount_decimal: '0',
...overrides,
},
});

View File

@ -0,0 +1,32 @@
import Stripe from 'stripe';
export const createMockStripeProductUpdatedData = (
overrides = {},
): Stripe.ProductUpdatedEvent.Data => ({
object: {
id: 'prod_RLN',
object: 'product',
active: true,
created: 1733410584,
default_price: null,
description: null,
images: [],
livemode: false,
marketing_features: [],
metadata: {},
name: 'kjnnjkjknkjnjkn',
package_dimensions: null,
shippable: null,
statement_descriptor: null,
tax_code: 'txcd_10103001',
type: 'service',
unit_label: null,
updated: 1734694649,
url: null,
},
previous_attributes: {
default_price: 'price_1Q',
updated: 1733410585,
},
...overrides,
});

View File

@ -0,0 +1,130 @@
import Stripe from 'stripe';
export const createMockStripeSubscriptionCreatedData = (
overrides = {},
): Stripe.CustomerSubscriptionCreatedEvent.Data => ({
object: {
object: 'subscription',
id: 'sub_default',
customer: 'cus_default1',
status: 'active',
items: {
data: [
{
plan: {
id: 'plan_default',
object: 'plan',
active: true,
aggregate_usage: null,
amount_decimal: '0',
billing_scheme: 'per_unit',
interval_count: 1,
livemode: false,
nickname: null,
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
interval: 'month',
currency: 'usd',
amount: 0,
created: 1672531200,
product: 'prod_default',
usage_type: 'licensed',
metadata: {},
meter: null,
},
id: '',
object: 'subscription_item',
billing_thresholds: null,
created: 0,
discounts: [],
metadata: {},
price: {
id: 'price_default',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1672531200,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_default',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: null,
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1000,
unit_amount_decimal: '1000',
},
subscription: '',
tax_rates: null,
},
],
object: 'list',
has_more: false,
url: '',
},
cancel_at_period_end: false,
currency: 'usd',
current_period_end: 1672531200,
current_period_start: 1672531200,
metadata: { workspaceId: '3b8e6458-5fc1-4e63-8563-008ccddaa6db' },
trial_end: null,
trial_start: null,
canceled_at: null,
...overrides,
application: null,
application_fee_percent: null,
automatic_tax: {
enabled: true,
liability: {
type: 'self',
},
},
billing_cycle_anchor: 0,
billing_cycle_anchor_config: null,
billing_thresholds: null,
cancel_at: null,
cancellation_details: null,
collection_method: 'charge_automatically',
created: 0,
days_until_due: null,
default_payment_method: null,
default_source: null,
description: null,
discount: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
latest_invoice: null,
livemode: false,
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: null,
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 0,
test_clock: null,
transfer_data: null,
trial_settings: null,
},
});

View File

@ -2,6 +2,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
import { AppModule } from 'src/app.module';
import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
@ -23,9 +25,12 @@ export const createApp = async (
appInitHook?: TestingAppCreatePreHook;
} = {},
): Promise<NestExpressApplication> => {
const stripeSDKMockService = new StripeSDKMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule],
});
})
.overrideProvider(StripeSDKService)
.useValue(stripeSDKMockService);
if (config.moduleBuilderHook) {
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
@ -33,7 +38,10 @@ export const createApp = async (
const moduleFixture: TestingModule = await moduleBuilder.compile();
const app = moduleFixture.createNestApplication<NestExpressApplication>();
const app = moduleFixture.createNestApplication<NestExpressApplication>({
rawBody: true,
cors: true,
});
if (config.appInitHook) {
await config.appInitHook(app);