Add Paddle configuration foundation

This commit is contained in:
diyaa 2026-07-02 13:38:20 +02:00
parent efd8630aa0
commit aa2438279b
7 changed files with 159 additions and 23 deletions

View File

@ -15,6 +15,14 @@ async function generate(): Promise<void> {
'openapi-placeholder-account-access-secret'; 'openapi-placeholder-account-access-secret';
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ??= '900'; process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ??= '900';
process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000'; process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000';
process.env.PADDLE_ENVIRONMENT ??= 'sandbox';
process.env.PADDLE_API_KEY ??= 'openapi-placeholder-paddle-api-key';
process.env.PADDLE_WEBHOOK_SECRET ??=
'openapi-placeholder-paddle-webhook-secret';
process.env.PADDLE_PRO_MONTHLY_PRICE_ID ??=
'openapi-placeholder-paddle-monthly-price-id';
process.env.PADDLE_PRO_YEARLY_PRICE_ID ??=
'openapi-placeholder-paddle-yearly-price-id';
const { createApp } = await import('../src/app.factory'); const { createApp } = await import('../src/app.factory');
const app = await createApp(); const app = await createApp();

View File

@ -0,0 +1,27 @@
import { ConfigService } from '@nestjs/config';
import { AppConfigService } from './config.service';
describe('AppConfigService Paddle configuration', () => {
it('returns the validated Paddle configuration values', () => {
const configService = new ConfigService({
PADDLE_ENVIRONMENT: 'production',
PADDLE_API_KEY: 'test-paddle-api-key',
PADDLE_WEBHOOK_SECRET: 'test-paddle-webhook-secret',
PADDLE_PRO_MONTHLY_PRICE_ID: 'test-monthly-price-id',
PADDLE_PRO_YEARLY_PRICE_ID: 'test-yearly-price-id',
});
const appConfigService = new AppConfigService(configService);
expect(appConfigService.getPaddleEnvironment()).toBe('production');
expect(appConfigService.getPaddleApiKey()).toBe('test-paddle-api-key');
expect(appConfigService.getPaddleWebhookSecret()).toBe(
'test-paddle-webhook-secret',
);
expect(appConfigService.getPaddleProMonthlyPriceId()).toBe(
'test-monthly-price-id',
);
expect(appConfigService.getPaddleProYearlyPriceId()).toBe(
'test-yearly-price-id',
);
});
});

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { PaddleEnvironment } from './environment';
@Injectable() @Injectable()
export class AppConfigService { export class AppConfigService {
@ -41,6 +42,26 @@ export class AppConfigService {
return Number(this.required('MAX_UPLOAD_SIZE_BYTES')); return Number(this.required('MAX_UPLOAD_SIZE_BYTES'));
} }
getPaddleEnvironment(): PaddleEnvironment {
return this.required('PADDLE_ENVIRONMENT') as PaddleEnvironment;
}
getPaddleApiKey(): string {
return this.required('PADDLE_API_KEY');
}
getPaddleWebhookSecret(): string {
return this.required('PADDLE_WEBHOOK_SECRET');
}
getPaddleProMonthlyPriceId(): string {
return this.required('PADDLE_PRO_MONTHLY_PRICE_ID');
}
getPaddleProYearlyPriceId(): string {
return this.required('PADDLE_PRO_YEARLY_PRICE_ID');
}
get appVersion(): string { get appVersion(): string {
return process.env.npm_package_version ?? '0.1.0'; return process.env.npm_package_version ?? '0.1.0';
} }

View File

@ -1,18 +1,29 @@
import { validateEnvironment } from './environment'; import { validateEnvironment } from './environment';
describe('validateEnvironment', () => { describe('validateEnvironment', () => {
const validEnvironment = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
NODE_ENV: 'test',
PORT: '3007',
DATABASE_URL:
'postgresql://velody:velody@localhost:5432/velody?schema=public',
STORAGE_ROOT: '/tmp/velody',
PUBLIC_BASE_URL: 'http://localhost:3007',
DEVICE_BOOTSTRAP_SECRET: 'device-secret',
ACCOUNT_ACCESS_TOKEN_SECRET: 'account-secret',
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
MAX_UPLOAD_SIZE_BYTES: '1024',
PADDLE_ENVIRONMENT: 'sandbox',
PADDLE_API_KEY: 'test-paddle-api-key',
PADDLE_WEBHOOK_SECRET: 'test-paddle-webhook-secret',
PADDLE_PRO_MONTHLY_PRICE_ID: 'test-monthly-price-id',
PADDLE_PRO_YEARLY_PRICE_ID: 'test-yearly-price-id',
...overrides,
});
it('accepts a valid environment', () => { it('accepts a valid environment', () => {
const result = validateEnvironment({ const result = validateEnvironment(validEnvironment());
NODE_ENV: 'test',
PORT: '3007',
DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public',
STORAGE_ROOT: '/tmp/velody',
PUBLIC_BASE_URL: 'http://localhost:3007',
DEVICE_BOOTSTRAP_SECRET: 'device-secret',
ACCOUNT_ACCESS_TOKEN_SECRET: 'account-secret',
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
MAX_UPLOAD_SIZE_BYTES: '1024',
});
expect(result.PORT).toBe(3007); expect(result.PORT).toBe(3007);
expect(result.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS).toBe(900); expect(result.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS).toBe(900);
@ -29,18 +40,50 @@ describe('validateEnvironment', () => {
it('rejects reuse of the device-token secret for account tokens', () => { it('rejects reuse of the device-token secret for account tokens', () => {
expect(() => expect(() =>
validateEnvironment({ validateEnvironment(
NODE_ENV: 'test', validEnvironment({
PORT: '3007', DEVICE_BOOTSTRAP_SECRET: 'shared-secret',
DATABASE_URL: ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret',
'postgresql://velody:velody@localhost:5432/velody?schema=public', }),
STORAGE_ROOT: '/tmp/velody', ),
PUBLIC_BASE_URL: 'http://localhost:3007',
DEVICE_BOOTSTRAP_SECRET: 'shared-secret',
ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret',
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
MAX_UPLOAD_SIZE_BYTES: '1024',
}),
).toThrow(/account and device token secrets must be different/); ).toThrow(/account and device token secrets must be different/);
}); });
it('accepts the Paddle sandbox environment', () => {
const result = validateEnvironment(
validEnvironment({ PADDLE_ENVIRONMENT: 'sandbox' }),
);
expect(result.PADDLE_ENVIRONMENT).toBe('sandbox');
});
it('accepts the Paddle production environment', () => {
const result = validateEnvironment(
validEnvironment({ PADDLE_ENVIRONMENT: 'production' }),
);
expect(result.PADDLE_ENVIRONMENT).toBe('production');
});
it('rejects an invalid Paddle environment', () => {
expect(() =>
validateEnvironment(
validEnvironment({ PADDLE_ENVIRONMENT: 'development' }),
),
).toThrow(/Invalid environment configuration/);
});
it.each([
'PADDLE_API_KEY',
'PADDLE_WEBHOOK_SECRET',
'PADDLE_PRO_MONTHLY_PRICE_ID',
'PADDLE_PRO_YEARLY_PRICE_ID',
])('rejects a missing %s', (key) => {
const environment = validEnvironment();
delete environment[key];
expect(() => validateEnvironment(environment)).toThrow(
/Invalid environment configuration/,
);
});
}); });

View File

@ -1,5 +1,6 @@
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { import {
IsIn,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsString, IsString,
@ -9,6 +10,8 @@ import {
validateSync, validateSync,
} from 'class-validator'; } from 'class-validator';
export type PaddleEnvironment = 'sandbox' | 'production';
class EnvironmentVariables { class EnvironmentVariables {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ -48,6 +51,25 @@ class EnvironmentVariables {
@IsInt() @IsInt()
@Min(1) @Min(1)
MAX_UPLOAD_SIZE_BYTES!: number; MAX_UPLOAD_SIZE_BYTES!: number;
@IsIn(['sandbox', 'production'])
PADDLE_ENVIRONMENT!: PaddleEnvironment;
@IsString()
@IsNotEmpty()
PADDLE_API_KEY!: string;
@IsString()
@IsNotEmpty()
PADDLE_WEBHOOK_SECRET!: string;
@IsString()
@IsNotEmpty()
PADDLE_PRO_MONTHLY_PRICE_ID!: string;
@IsString()
@IsNotEmpty()
PADDLE_PRO_YEARLY_PRICE_ID!: string;
} }
export function validateEnvironment(config: Record<string, unknown>) { export function validateEnvironment(config: Record<string, unknown>) {

View File

@ -17,3 +17,13 @@ process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS =
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ?? '900'; process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ?? '900';
process.env.MAX_UPLOAD_SIZE_BYTES = process.env.MAX_UPLOAD_SIZE_BYTES =
process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824'; process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824';
process.env.PADDLE_ENVIRONMENT =
process.env.PADDLE_ENVIRONMENT ?? 'sandbox';
process.env.PADDLE_API_KEY =
process.env.PADDLE_API_KEY ?? 'test-paddle-api-key';
process.env.PADDLE_WEBHOOK_SECRET =
process.env.PADDLE_WEBHOOK_SECRET ?? 'test-paddle-webhook-secret';
process.env.PADDLE_PRO_MONTHLY_PRICE_ID =
process.env.PADDLE_PRO_MONTHLY_PRICE_ID ?? 'test-paddle-monthly-price-id';
process.env.PADDLE_PRO_YEARLY_PRICE_ID =
process.env.PADDLE_PRO_YEARLY_PRICE_ID ?? 'test-paddle-yearly-price-id';

View File

@ -7,3 +7,8 @@ DEVICE_BOOTSTRAP_SECRET=replace-me
ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-a-distinct-secret ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-a-distinct-secret
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900 ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900
MAX_UPLOAD_SIZE_BYTES=524288000 MAX_UPLOAD_SIZE_BYTES=524288000
PADDLE_ENVIRONMENT=sandbox
PADDLE_API_KEY=replace-with-paddle-api-key
PADDLE_WEBHOOK_SECRET=replace-with-paddle-webhook-secret
PADDLE_PRO_MONTHLY_PRICE_ID=replace-with-paddle-monthly-price-id
PADDLE_PRO_YEARLY_PRICE_ID=replace-with-paddle-yearly-price-id