Add billing provider foundation
This commit is contained in:
parent
7d84fa6a55
commit
96609a7802
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AssetsModule } from './modules/assets/assets.module';
|
import { AssetsModule } from './modules/assets/assets.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { ArtworkModule } from './modules/artwork/artwork.module';
|
import { ArtworkModule } from './modules/artwork/artwork.module';
|
||||||
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DevicesModule } from './modules/devices/devices.module';
|
import { DevicesModule } from './modules/devices/devices.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
@ -13,6 +14,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
BillingModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AssetsModule,
|
AssetsModule,
|
||||||
|
|||||||
21
backend/src/modules/billing/billing-provider.interface.ts
Normal file
21
backend/src/modules/billing/billing-provider.interface.ts
Normal file
@ -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<unknown>;
|
||||||
|
createSubscription(...args: unknown[]): Promise<unknown>;
|
||||||
|
updateSubscription(...args: unknown[]): Promise<unknown>;
|
||||||
|
cancelSubscription(...args: unknown[]): Promise<unknown>;
|
||||||
|
reactivateSubscription(...args: unknown[]): Promise<unknown>;
|
||||||
|
getSubscription(...args: unknown[]): Promise<unknown>;
|
||||||
|
createCheckoutSession(...args: unknown[]): Promise<unknown>;
|
||||||
|
createCustomerPortalSession(...args: unknown[]): Promise<unknown>;
|
||||||
|
validateWebhookSignature(...args: unknown[]): Promise<unknown>;
|
||||||
|
handleWebhookEvent(...args: unknown[]): Promise<unknown>;
|
||||||
|
}
|
||||||
54
backend/src/modules/billing/billing-provider.service.ts
Normal file
54
backend/src/modules/billing/billing-provider.service.ts
Normal file
@ -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<unknown> {
|
||||||
|
return this.provider.createCustomer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSubscription(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.createSubscription(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubscription(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.updateSubscription(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSubscription(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.cancelSubscription(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
reactivateSubscription(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.reactivateSubscription(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscription(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.getSubscription(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCheckoutSession(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.createCheckoutSession(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCustomerPortalSession(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.createCustomerPortalSession(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhookSignature(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.validateWebhookSignature(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebhookEvent(...args: unknown[]): Promise<unknown> {
|
||||||
|
return this.provider.handleWebhookEvent(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
backend/src/modules/billing/billing.module.spec.ts
Normal file
137
backend/src/modules/billing/billing.module.spec.ts
Normal file
@ -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<keyof BillingProvider>;
|
||||||
|
|
||||||
|
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<BillingProvider>(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<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
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<typeof import('node:http')>('node:http');
|
||||||
|
const https = jest.requireActual<typeof import('node:https')>('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();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
backend/src/modules/billing/billing.module.ts
Normal file
17
backend/src/modules/billing/billing.module.ts
Normal file
@ -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 {}
|
||||||
45
backend/src/modules/billing/paddle-billing.provider.ts
Normal file
45
backend/src/modules/billing/paddle-billing.provider.ts
Normal file
@ -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<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSubscription(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSubscription(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateSubscription(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscription(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomerPortalSession(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateWebhookSignature(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWebhookEvent(..._args: unknown[]): Promise<never> {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user