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:
committed by
GitHub
parent
4ed1db3845
commit
c39af5f063
@ -96,7 +96,11 @@ module.exports = {
|
|||||||
rules: {},
|
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: {
|
env: {
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
|
|||||||
9
.github/workflows/ci-server.yaml
vendored
9
.github/workflows/ci-server.yaml
vendored
@ -184,6 +184,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: ./.github/workflows/actions/yarn-install
|
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
|
- name: Server / Restore Task Cache
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
uses: ./.github/workflows/actions/task-cache
|
uses: ./.github/workflows/actions/task-cache
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -49,4 +49,6 @@
|
|||||||
"files.associations": {
|
"files.associations": {
|
||||||
".cursorrules": "markdown"
|
".cursorrules": "markdown"
|
||||||
},
|
},
|
||||||
|
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
nx.json
1
nx.json
@ -14,6 +14,7 @@
|
|||||||
"!{projectRoot}/**/tsconfig.spec.json",
|
"!{projectRoot}/**/tsconfig.spec.json",
|
||||||
"!{projectRoot}/**/*.test.(ts|tsx)",
|
"!{projectRoot}/**/*.test.(ts|tsx)",
|
||||||
"!{projectRoot}/**/*.spec.(ts|tsx)",
|
"!{projectRoot}/**/*.spec.(ts|tsx)",
|
||||||
|
"!{projectRoot}/**/*.integration-spec.ts",
|
||||||
"!{projectRoot}/**/__tests__/*"
|
"!{projectRoot}/**/__tests__/*"
|
||||||
],
|
],
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
|
import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
|
||||||
|
|
||||||
|
const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const tsConfig = require('./tsconfig.json');
|
const tsConfig = require('./tsconfig.json');
|
||||||
|
|
||||||
@ -9,7 +10,9 @@ const jestConfig: JestConfigWithTsJest = {
|
|||||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
rootDir: '.',
|
rootDir: '.',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
testRegex: '.integration-spec.ts$',
|
testRegex: isBillingEnabled
|
||||||
|
? 'integration-spec.ts'
|
||||||
|
: '^(?!.*billing).*\\.integration-spec\\.ts$',
|
||||||
modulePathIgnorePatterns: ['<rootDir>/dist'],
|
modulePathIgnorePatterns: ['<rootDir>/dist'],
|
||||||
globalSetup: '<rootDir>/test/integration/utils/setup-test.ts',
|
globalSetup: '<rootDir>/test/integration/utils/setup-test.ts',
|
||||||
globalTeardown: '<rootDir>/test/integration/utils/teardown-test.ts',
|
globalTeardown: '<rootDir>/test/integration/utils/teardown-test.ts',
|
||||||
|
|||||||
@ -10,26 +10,27 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingException,
|
BillingException,
|
||||||
BillingExceptionCode,
|
BillingExceptionCode,
|
||||||
} from 'src/engine/core-modules/billing/billing.exception';
|
} 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 { 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 { 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 { 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 { 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 { 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 { 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')
|
@Controller('billing')
|
||||||
@UseFilters(BillingRestApiExceptionFilter)
|
@UseFilters(BillingRestApiExceptionFilter)
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
protected readonly logger = new Logger(BillingController.name);
|
protected readonly logger = new Logger(BillingController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeWebhookService: StripeWebhookService,
|
||||||
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService,
|
||||||
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService,
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
@ -48,72 +49,63 @@ export class BillingController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const event = this.stripeService.constructEventFromPayload(
|
const event = this.stripeWebhookService.constructEventFromPayload(
|
||||||
signature,
|
signature,
|
||||||
req.rawBody,
|
req.rawBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) {
|
try {
|
||||||
await this.billingSubscriptionService.handleUnpaidInvoices(event.data);
|
const result = await this.handleStripeEvent(event);
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
res.status(200).send(result).end();
|
||||||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED ||
|
} catch (error) {
|
||||||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED ||
|
if (error instanceof BillingException) {
|
||||||
event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED
|
|
||||||
) {
|
|
||||||
const workspaceId = event.data.object.metadata?.workspaceId;
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.billingWebhookSubscriptionService.processStripeEvent(
|
|
||||||
workspaceId,
|
|
||||||
event.data,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
}
|
||||||
event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED
|
|
||||||
) {
|
private async handleStripeEvent(event: Stripe.Event) {
|
||||||
try {
|
switch (event.type) {
|
||||||
await this.billingWebhookEntitlementService.processStripeEvent(
|
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,
|
event.data,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof BillingException &&
|
|
||||||
error.code === BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND
|
|
||||||
) {
|
|
||||||
res.status(404).end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
case BillingWebhookEvent.PRODUCT_UPDATED:
|
||||||
event.type === WebhookEvent.PRODUCT_CREATED ||
|
case BillingWebhookEvent.PRODUCT_CREATED:
|
||||||
event.type === WebhookEvent.PRODUCT_UPDATED
|
return await this.billingWebhookProductService.processStripeEvent(
|
||||||
) {
|
event.data,
|
||||||
await this.billingWebhookProductService.processStripeEvent(event.data);
|
);
|
||||||
}
|
case BillingWebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED:
|
||||||
if (
|
return await this.billingWebhookEntitlementService.processStripeEvent(
|
||||||
event.type === WebhookEvent.PRICE_CREATED ||
|
event.data,
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,4 +12,5 @@ export class BillingException extends CustomException {
|
|||||||
export enum BillingExceptionCode {
|
export enum BillingExceptionCode {
|
||||||
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
||||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||||
|
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||||
@ -23,12 +23,13 @@ export class BillingResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
|
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripePriceService: StripePriceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Query(() => ProductPricesEntity)
|
@Query(() => ProductPricesEntity)
|
||||||
async getProductPrices(@Args() { product }: ProductInput) {
|
async getProductPrices(@Args() { product }: ProductInput) {
|
||||||
const productPrices = await this.stripeService.getStripePrices(product);
|
const productPrices =
|
||||||
|
await this.stripePriceService.getStripePrices(product);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalNumberOfPrices: productPrices.length,
|
totalNumberOfPrices: productPrices.length,
|
||||||
@ -63,7 +64,7 @@ export class BillingResolver {
|
|||||||
requirePaymentMethod,
|
requirePaymentMethod,
|
||||||
}: CheckoutSessionInput,
|
}: CheckoutSessionInput,
|
||||||
) {
|
) {
|
||||||
const productPrice = await this.stripeService.getStripePrice(
|
const productPrice = await this.stripePriceService.getStripePrice(
|
||||||
AvailableProduct.BasePlan,
|
AvailableProduct.BasePlan,
|
||||||
recurringInterval,
|
recurringInterval,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
ActiveWorkspacesCommandRunner,
|
ActiveWorkspacesCommandRunner,
|
||||||
} from 'src/database/commands/active-workspaces.command';
|
} from 'src/database/commands/active-workspaces.command';
|
||||||
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
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';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
|
|
||||||
interface SyncCustomerDataCommandOptions
|
interface SyncCustomerDataCommandOptions
|
||||||
@ -23,7 +23,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
protected readonly workspaceRepository: Repository<Workspace>,
|
protected readonly workspaceRepository: Repository<Workspace>,
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||||
@InjectRepository(BillingCustomer, 'core')
|
@InjectRepository(BillingCustomer, 'core')
|
||||||
protected readonly billingCustomerRepository: Repository<BillingCustomer>,
|
protected readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||||
) {
|
) {
|
||||||
@ -71,7 +71,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne
|
|||||||
|
|
||||||
if (!options.dryRun && !billingCustomer) {
|
if (!options.dryRun && !billingCustomer) {
|
||||||
const stripeCustomerId =
|
const stripeCustomerId =
|
||||||
await this.stripeService.getStripeCustomerIdFromWorkspaceId(
|
await this.stripeSubscriptionService.getStripeCustomerIdFromWorkspaceId(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import {
|
|||||||
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
|
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 { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.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 { 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 { 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';
|
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>,
|
private readonly billingProductRepository: Repository<BillingProduct>,
|
||||||
@InjectRepository(BillingMeter, 'core')
|
@InjectRepository(BillingMeter, 'core')
|
||||||
private readonly billingMeterRepository: Repository<BillingMeter>,
|
private readonly billingMeterRepository: Repository<BillingMeter>,
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeBillingMeterService: StripeBillingMeterService,
|
||||||
|
private readonly stripeProductService: StripeProductService,
|
||||||
|
private readonly stripePriceService: StripePriceService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@ -92,7 +96,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner {
|
|||||||
}
|
}
|
||||||
await this.upsertProductRepositoryData(product, options);
|
await this.upsertProductRepositoryData(product, options);
|
||||||
|
|
||||||
const prices = await this.stripeService.getPricesByProductId(
|
const prices = await this.stripePriceService.getPricesByProductId(
|
||||||
product.id,
|
product.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -133,11 +137,11 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner {
|
|||||||
passedParams: string[],
|
passedParams: string[],
|
||||||
options: BaseCommandOptions,
|
options: BaseCommandOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const billingMeters = await this.stripeService.getAllMeters();
|
const billingMeters = await this.stripeBillingMeterService.getAllMeters();
|
||||||
|
|
||||||
await this.upsertMetersRepositoryData(billingMeters, options);
|
await this.upsertMetersRepositoryData(billingMeters, options);
|
||||||
|
|
||||||
const billingProducts = await this.stripeService.getAllProducts();
|
const billingProducts = await this.stripeProductService.getAllProducts();
|
||||||
|
|
||||||
const billingPrices = await this.processBillingPricesByProductBatches(
|
const billingPrices = await this.processBillingPricesByProductBatches(
|
||||||
billingProducts,
|
billingProducts,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export enum WebhookEvent {
|
export enum BillingWebhookEvent {
|
||||||
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created',
|
||||||
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||||
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted',
|
||||||
|
|||||||
@ -26,6 +26,12 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
|
|||||||
response,
|
response,
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
|
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
|
||||||
|
return this.httpExceptionHandlerService.handleError(
|
||||||
|
exception,
|
||||||
|
response,
|
||||||
|
404,
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return this.httpExceptionHandlerService.handleError(
|
return this.httpExceptionHandlerService.handleError(
|
||||||
exception,
|
exception,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Logger, Scope } from '@nestjs/common';
|
import { Logger, Scope } from '@nestjs/common';
|
||||||
|
|
||||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.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 { 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 { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.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';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
@ -18,7 +18,7 @@ export class UpdateSubscriptionQuantityJob {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||||
private readonly twentyORMManager: TwentyORMManager,
|
private readonly twentyORMManager: TwentyORMManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ export class UpdateSubscriptionQuantityJob {
|
|||||||
data.workspaceId,
|
data.workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.stripeService.updateSubscriptionItem(
|
await this.stripeSubscriptionItemService.updateSubscriptionItem(
|
||||||
billingSubscriptionItem.stripeSubscriptionItemId,
|
billingSubscriptionItem.stripeSubscriptionItemId,
|
||||||
workspaceMembersCount,
|
workspaceMembersCount,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import { Repository } from 'typeorm';
|
|||||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
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 { 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 { 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 { 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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
@ -17,7 +18,8 @@ import { assert } from 'src/utils/assert';
|
|||||||
export class BillingPortalWorkspaceService {
|
export class BillingPortalWorkspaceService {
|
||||||
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
|
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeCheckoutService: StripeCheckoutService,
|
||||||
|
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
||||||
private readonly domainManagerService: DomainManagerService,
|
private readonly domainManagerService: DomainManagerService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
@ -52,7 +54,7 @@ export class BillingPortalWorkspaceService {
|
|||||||
})
|
})
|
||||||
)?.stripeCustomerId;
|
)?.stripeCustomerId;
|
||||||
|
|
||||||
const session = await this.stripeService.createCheckoutSession(
|
const session = await this.stripeCheckoutService.createCheckoutSession(
|
||||||
user,
|
user,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
priceId,
|
priceId,
|
||||||
@ -97,10 +99,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
}
|
}
|
||||||
const returnUrl = frontBaseUrl.toString();
|
const returnUrl = frontBaseUrl.toString();
|
||||||
|
|
||||||
const session = await this.stripeService.createBillingPortalSession(
|
const session =
|
||||||
stripeCustomerId,
|
await this.stripeBillingPortalService.createBillingPortalSession(
|
||||||
returnUrl,
|
stripeCustomerId,
|
||||||
);
|
returnUrl,
|
||||||
|
);
|
||||||
|
|
||||||
assert(session.url, 'Error: missing billingPortal.session.url');
|
assert(session.url, 'Error: missing billingPortal.session.url');
|
||||||
|
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
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 {
|
export class BillingSubscriptionService {
|
||||||
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||||
|
private readonly stripePriceService: StripePriceService,
|
||||||
|
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@InjectRepository(BillingEntitlement, 'core')
|
@InjectRepository(BillingEntitlement, 'core')
|
||||||
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
|
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
|
||||||
@ -78,7 +82,7 @@ export class BillingSubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (subscriptionToCancel) {
|
if (subscriptionToCancel) {
|
||||||
await this.stripeService.cancelSubscription(
|
await this.stripeSubscriptionService.cancelSubscription(
|
||||||
subscriptionToCancel.stripeSubscriptionId,
|
subscriptionToCancel.stripeSubscriptionId,
|
||||||
);
|
);
|
||||||
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
|
await this.billingSubscriptionRepository.delete(subscriptionToCancel.id);
|
||||||
@ -91,10 +95,15 @@ export class BillingSubscriptionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (billingSubscription?.status === 'unpaid') {
|
if (billingSubscription?.status === 'unpaid') {
|
||||||
await this.stripeService.collectLastInvoice(
|
await this.stripeSubscriptionService.collectLastInvoice(
|
||||||
billingSubscription.stripeSubscriptionId,
|
billingSubscription.stripeSubscriptionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleUnpaidInvoiceStripeSubscriptionId:
|
||||||
|
billingSubscription.stripeSubscriptionId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaceEntitlementByKey(
|
async getWorkspaceEntitlementByKey(
|
||||||
@ -127,7 +136,7 @@ export class BillingSubscriptionService {
|
|||||||
const billingSubscriptionItem =
|
const billingSubscriptionItem =
|
||||||
await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id);
|
await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id);
|
||||||
|
|
||||||
const productPrice = await this.stripeService.getStripePrice(
|
const productPrice = await this.stripePriceService.getStripePrice(
|
||||||
AvailableProduct.BasePlan,
|
AvailableProduct.BasePlan,
|
||||||
newInterval,
|
newInterval,
|
||||||
);
|
);
|
||||||
@ -138,7 +147,7 @@ export class BillingSubscriptionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stripeService.updateBillingSubscriptionItem(
|
await this.stripeSubscriptionItemService.updateBillingSubscriptionItem(
|
||||||
billingSubscriptionItem,
|
billingSubscriptionItem,
|
||||||
productPrice.stripePriceId,
|
productPrice.stripePriceId,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,5 +48,9 @@ export class BillingWebhookEntitlementService {
|
|||||||
skipUpdateIfNoValuesChanged: true,
|
skipUpdateIfNoValuesChanged: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeEntitlementCustomerId: data.object.customer,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,14 @@ import {
|
|||||||
import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity';
|
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 { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.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 { 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';
|
import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingWebhookPriceService {
|
export class BillingWebhookPriceService {
|
||||||
protected readonly logger = new Logger(BillingWebhookPriceService.name);
|
protected readonly logger = new Logger(BillingWebhookPriceService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeBillingMeterService: StripeBillingMeterService,
|
||||||
@InjectRepository(BillingPrice, 'core')
|
@InjectRepository(BillingPrice, 'core')
|
||||||
private readonly billingPriceRepository: Repository<BillingPrice>,
|
private readonly billingPriceRepository: Repository<BillingPrice>,
|
||||||
@InjectRepository(BillingMeter, 'core')
|
@InjectRepository(BillingMeter, 'core')
|
||||||
@ -45,7 +45,7 @@ export class BillingWebhookPriceService {
|
|||||||
const meterId = data.object.recurring?.meter;
|
const meterId = data.object.recurring?.meter;
|
||||||
|
|
||||||
if (meterId) {
|
if (meterId) {
|
||||||
const meterData = await this.stripeService.getMeter(meterId);
|
const meterData = await this.stripeBillingMeterService.getMeter(meterId);
|
||||||
|
|
||||||
await this.billingMeterRepository.upsert(
|
await this.billingMeterRepository.upsert(
|
||||||
transformStripeMeterDataToMeterRepositoryData(meterData),
|
transformStripeMeterDataToMeterRepositoryData(meterData),
|
||||||
@ -63,5 +63,10 @@ export class BillingWebhookPriceService {
|
|||||||
skipUpdateIfNoValuesChanged: true,
|
skipUpdateIfNoValuesChanged: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripePriceId: data.object.id,
|
||||||
|
stripeMeterId: meterId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,10 @@ export class BillingWebhookProductService {
|
|||||||
conflictPaths: ['stripeProductId'],
|
conflictPaths: ['stripeProductId'],
|
||||||
skipUpdateIfNoValuesChanged: true,
|
skipUpdateIfNoValuesChanged: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeProductId: data.object.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
isStripeValidProductMetadata(
|
isStripeValidProductMetadata(
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 { 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 { 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';
|
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,
|
BillingWebhookSubscriptionService.name,
|
||||||
);
|
);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeCustomerService: StripeCustomerService,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
@InjectRepository(BillingSubscriptionItem, 'core')
|
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||||
@ -45,7 +45,7 @@ export class BillingWebhookSubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return;
|
return { noWorkspace: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.billingCustomerRepository.upsert(
|
await this.billingCustomerRepository.upsert(
|
||||||
@ -106,9 +106,14 @@ export class BillingWebhookSubscriptionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stripeService.updateCustomerMetadataWorkspaceId(
|
await this.stripeCustomerService.updateCustomerMetadataWorkspaceId(
|
||||||
String(data.object.customer),
|
String(data.object.customer),
|
||||||
workspaceId,
|
workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeSubscriptionId: data.object.id,
|
||||||
|
stripeCustomerId: data.object.customer,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StripeSDKService {
|
||||||
|
getStripe(stripeApiKey: string) {
|
||||||
|
return new Stripe(stripeApiKey, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
@ -1,11 +1,40 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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';
|
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DomainManagerModule],
|
imports: [DomainManagerModule, StripeSDKModule],
|
||||||
providers: [StripeService],
|
providers: [
|
||||||
exports: [StripeService],
|
StripeSubscriptionItemService,
|
||||||
|
StripeWebhookService,
|
||||||
|
StripeCheckoutService,
|
||||||
|
StripeSubscriptionService,
|
||||||
|
StripeBillingPortalService,
|
||||||
|
StripeBillingMeterService,
|
||||||
|
StripeCustomerService,
|
||||||
|
StripePriceService,
|
||||||
|
StripeProductService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
StripeWebhookService,
|
||||||
|
StripeBillingPortalService,
|
||||||
|
StripeBillingMeterService,
|
||||||
|
StripeCustomerService,
|
||||||
|
StripePriceService,
|
||||||
|
StripeCheckoutService,
|
||||||
|
StripeSubscriptionItemService,
|
||||||
|
StripeSubscriptionService,
|
||||||
|
StripeProductService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class StripeModule {}
|
export class StripeModule {}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -2,6 +2,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
|
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
|
||||||
|
|
||||||
import { AppModule } from 'src/app.module';
|
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 {
|
interface TestingModuleCreatePreHook {
|
||||||
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
||||||
@ -23,9 +25,12 @@ export const createApp = async (
|
|||||||
appInitHook?: TestingAppCreatePreHook;
|
appInitHook?: TestingAppCreatePreHook;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<NestExpressApplication> => {
|
): Promise<NestExpressApplication> => {
|
||||||
|
const stripeSDKMockService = new StripeSDKMockService();
|
||||||
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
|
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
});
|
})
|
||||||
|
.overrideProvider(StripeSDKService)
|
||||||
|
.useValue(stripeSDKMockService);
|
||||||
|
|
||||||
if (config.moduleBuilderHook) {
|
if (config.moduleBuilderHook) {
|
||||||
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
|
moduleBuilder = config.moduleBuilderHook(moduleBuilder);
|
||||||
@ -33,7 +38,10 @@ export const createApp = async (
|
|||||||
|
|
||||||
const moduleFixture: TestingModule = await moduleBuilder.compile();
|
const moduleFixture: TestingModule = await moduleBuilder.compile();
|
||||||
|
|
||||||
const app = moduleFixture.createNestApplication<NestExpressApplication>();
|
const app = moduleFixture.createNestApplication<NestExpressApplication>({
|
||||||
|
rawBody: true,
|
||||||
|
cors: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (config.appInitHook) {
|
if (config.appInitHook) {
|
||||||
await config.appInitHook(app);
|
await config.appInitHook(app);
|
||||||
|
|||||||
Reference in New Issue
Block a user