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