Add billing provider foundation

This commit is contained in:
diyaa 2026-06-28 19:50:52 +02:00
parent 7d84fa6a55
commit 96609a7802
6 changed files with 276 additions and 0 deletions

View File

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

View 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>;
}

View 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);
}
}

View 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();
});
});

View 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 {}

View 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();
}
}