diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0801d4f..337d513 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { AssetsModule } from './modules/assets/assets.module'; import { AuthModule } from './modules/auth/auth.module'; import { ArtworkModule } from './modules/artwork/artwork.module'; +import { BillingModule } from './modules/billing/billing.module'; import { AppConfigModule } from './modules/config/config.module'; import { DevicesModule } from './modules/devices/devices.module'; import { HealthModule } from './modules/health/health.module'; @@ -13,6 +14,7 @@ import { UsersModule } from './modules/users/users.module'; @Module({ imports: [ AppConfigModule, + BillingModule, AuthModule, UsersModule, AssetsModule, diff --git a/backend/src/modules/billing/billing-provider.interface.ts b/backend/src/modules/billing/billing-provider.interface.ts new file mode 100644 index 0000000..2625c18 --- /dev/null +++ b/backend/src/modules/billing/billing-provider.interface.ts @@ -0,0 +1,21 @@ +export const BILLING_PROVIDER = Symbol('BILLING_PROVIDER'); + +/** + * Provider-neutral contract for future billing integrations. + * + * Arguments and return values intentionally remain opaque until the billing + * domain models are introduced. This keeps the foundation internal and avoids + * prematurely exposing a provider-specific API. + */ +export interface BillingProvider { + createCustomer(...args: unknown[]): Promise; + createSubscription(...args: unknown[]): Promise; + updateSubscription(...args: unknown[]): Promise; + cancelSubscription(...args: unknown[]): Promise; + reactivateSubscription(...args: unknown[]): Promise; + getSubscription(...args: unknown[]): Promise; + createCheckoutSession(...args: unknown[]): Promise; + createCustomerPortalSession(...args: unknown[]): Promise; + validateWebhookSignature(...args: unknown[]): Promise; + handleWebhookEvent(...args: unknown[]): Promise; +} diff --git a/backend/src/modules/billing/billing-provider.service.ts b/backend/src/modules/billing/billing-provider.service.ts new file mode 100644 index 0000000..c4b0c2b --- /dev/null +++ b/backend/src/modules/billing/billing-provider.service.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + BILLING_PROVIDER, + BillingProvider, +} from './billing-provider.interface'; + +/** Internal facade that keeps consumers independent of a concrete provider. */ +@Injectable() +export class BillingProviderService implements BillingProvider { + constructor( + @Inject(BILLING_PROVIDER) + private readonly provider: BillingProvider, + ) {} + + createCustomer(...args: unknown[]): Promise { + return this.provider.createCustomer(...args); + } + + createSubscription(...args: unknown[]): Promise { + return this.provider.createSubscription(...args); + } + + updateSubscription(...args: unknown[]): Promise { + return this.provider.updateSubscription(...args); + } + + cancelSubscription(...args: unknown[]): Promise { + return this.provider.cancelSubscription(...args); + } + + reactivateSubscription(...args: unknown[]): Promise { + return this.provider.reactivateSubscription(...args); + } + + getSubscription(...args: unknown[]): Promise { + return this.provider.getSubscription(...args); + } + + createCheckoutSession(...args: unknown[]): Promise { + return this.provider.createCheckoutSession(...args); + } + + createCustomerPortalSession(...args: unknown[]): Promise { + return this.provider.createCustomerPortalSession(...args); + } + + validateWebhookSignature(...args: unknown[]): Promise { + return this.provider.validateWebhookSignature(...args); + } + + handleWebhookEvent(...args: unknown[]): Promise { + return this.provider.handleWebhookEvent(...args); + } +} diff --git a/backend/src/modules/billing/billing.module.spec.ts b/backend/src/modules/billing/billing.module.spec.ts new file mode 100644 index 0000000..7c49c94 --- /dev/null +++ b/backend/src/modules/billing/billing.module.spec.ts @@ -0,0 +1,137 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { NotImplementedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + BILLING_PROVIDER, + BillingProvider, +} from './billing-provider.interface'; +import { BillingProviderService } from './billing-provider.service'; +import { BillingModule } from './billing.module'; +import { PaddleBillingProvider } from './paddle-billing.provider'; + +const PROVIDER_METHODS = [ + 'createCustomer', + 'createSubscription', + 'updateSubscription', + 'cancelSubscription', + 'reactivateSubscription', + 'getSubscription', + 'createCheckoutSession', + 'createCustomerPortalSession', + 'validateWebhookSignature', + 'handleWebhookEvent', +] as const satisfies ReadonlyArray; + +describe('BillingModule', () => { + let moduleRef: TestingModule; + let provider: PaddleBillingProvider; + let service: BillingProviderService; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [BillingModule], + }).compile(); + provider = moduleRef.get(PaddleBillingProvider); + service = moduleRef.get(BillingProviderService); + }); + + afterAll(async () => { + await moduleRef.close(); + }); + + it('registers and exports BillingProviderService', () => { + expect(service).toBeInstanceOf(BillingProviderService); + }); + + it('implements the BillingProvider contract with PaddleBillingProvider', () => { + const contract: BillingProvider = provider; + + expect(contract).toBeInstanceOf(PaddleBillingProvider); + expect( + PROVIDER_METHODS.every( + (method) => typeof contract[method] === 'function', + ), + ).toBe(true); + }); + + it('selects PaddleBillingProvider for the internal provider token', () => { + const selectedProvider = moduleRef.get(BILLING_PROVIDER); + + expect(selectedProvider).toBe(provider); + }); + + it.each(PROVIDER_METHODS)( + 'throws NotImplementedException from provider method %s', + async (method) => { + await expect(provider[method]()).rejects.toBeInstanceOf( + NotImplementedException, + ); + }, + ); + + it.each(PROVIDER_METHODS)( + 'throws NotImplementedException from service method %s', + async (method) => { + await expect(service[method]()).rejects.toBeInstanceOf( + NotImplementedException, + ); + }, + ); + + it('does not import a Paddle SDK or declare one as a dependency', () => { + const sourceFiles = [ + 'billing-provider.interface.ts', + 'billing-provider.service.ts', + 'billing.module.ts', + 'paddle-billing.provider.ts', + ]; + const billingSource = sourceFiles + .map((file) => readFileSync(join(__dirname, file), 'utf8')) + .join('\n'); + const packageJson = JSON.parse( + readFileSync(join(__dirname, '../../../package.json'), 'utf8'), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const dependencyNames = [ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), + ]; + const externalImports = [ + ...billingSource.matchAll(/(?:from\s+|require\()['"]([^'"]+)['"]/g), + ] + .map((match) => match[1]) + .filter((specifier) => !specifier.startsWith('.')); + + expect(externalImports).not.toEqual( + expect.arrayContaining([expect.stringMatching(/paddle/i)]), + ); + expect(dependencyNames).not.toEqual( + expect.arrayContaining([expect.stringMatching(/paddle/i)]), + ); + }); + + it('performs no HTTP requests while all operations reject', async () => { + const http = jest.requireActual('node:http'); + const https = jest.requireActual('node:https'); + const httpRequest = jest.spyOn(http, 'request'); + const httpsRequest = jest.spyOn(https, 'request'); + const fetchRequest = jest.spyOn(globalThis, 'fetch'); + + for (const method of PROVIDER_METHODS) { + await expect(service[method]()).rejects.toBeInstanceOf( + NotImplementedException, + ); + } + + expect(httpRequest).not.toHaveBeenCalled(); + expect(httpsRequest).not.toHaveBeenCalled(); + expect(fetchRequest).not.toHaveBeenCalled(); + + httpRequest.mockRestore(); + httpsRequest.mockRestore(); + fetchRequest.mockRestore(); + }); +}); diff --git a/backend/src/modules/billing/billing.module.ts b/backend/src/modules/billing/billing.module.ts new file mode 100644 index 0000000..7708e65 --- /dev/null +++ b/backend/src/modules/billing/billing.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { BILLING_PROVIDER } from './billing-provider.interface'; +import { BillingProviderService } from './billing-provider.service'; +import { PaddleBillingProvider } from './paddle-billing.provider'; + +@Module({ + providers: [ + PaddleBillingProvider, + { + provide: BILLING_PROVIDER, + useExisting: PaddleBillingProvider, + }, + BillingProviderService, + ], + exports: [BillingProviderService], +}) +export class BillingModule {} diff --git a/backend/src/modules/billing/paddle-billing.provider.ts b/backend/src/modules/billing/paddle-billing.provider.ts new file mode 100644 index 0000000..c66c346 --- /dev/null +++ b/backend/src/modules/billing/paddle-billing.provider.ts @@ -0,0 +1,45 @@ +import { Injectable, NotImplementedException } from '@nestjs/common'; +import { BillingProvider } from './billing-provider.interface'; + +@Injectable() +export class PaddleBillingProvider implements BillingProvider { + async createCustomer(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async createSubscription(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async updateSubscription(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async cancelSubscription(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async reactivateSubscription(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async getSubscription(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async createCheckoutSession(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async createCustomerPortalSession(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async validateWebhookSignature(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } + + async handleWebhookEvent(..._args: unknown[]): Promise { + throw new NotImplementedException(); + } +}