From aa2438279b71e36f2b01f92e118a011138860bdf Mon Sep 17 00:00:00 2001 From: diyaa Date: Thu, 2 Jul 2026 13:38:20 +0200 Subject: [PATCH] Add Paddle configuration foundation --- backend/scripts/generate-openapi.ts | 8 ++ .../src/modules/config/config.service.spec.ts | 27 ++++++ backend/src/modules/config/config.service.ts | 21 +++++ .../src/modules/config/environment.spec.ts | 89 ++++++++++++++----- backend/src/modules/config/environment.ts | 22 +++++ backend/test/setup-env.ts | 10 +++ infra/docker/env/backend.env.example | 5 ++ 7 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 backend/src/modules/config/config.service.spec.ts diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index 3d17e5a..abca39e 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -15,6 +15,14 @@ async function generate(): Promise { 'openapi-placeholder-account-access-secret'; process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ??= '900'; 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 app = await createApp(); diff --git a/backend/src/modules/config/config.service.spec.ts b/backend/src/modules/config/config.service.spec.ts new file mode 100644 index 0000000..e061d80 --- /dev/null +++ b/backend/src/modules/config/config.service.spec.ts @@ -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', + ); + }); +}); diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts index 807acad..3667083 100644 --- a/backend/src/modules/config/config.service.ts +++ b/backend/src/modules/config/config.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { PaddleEnvironment } from './environment'; @Injectable() export class AppConfigService { @@ -41,6 +42,26 @@ export class AppConfigService { 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 { return process.env.npm_package_version ?? '0.1.0'; } diff --git a/backend/src/modules/config/environment.spec.ts b/backend/src/modules/config/environment.spec.ts index 7b3ef54..4be2f6c 100644 --- a/backend/src/modules/config/environment.spec.ts +++ b/backend/src/modules/config/environment.spec.ts @@ -1,18 +1,29 @@ import { validateEnvironment } from './environment'; describe('validateEnvironment', () => { + const validEnvironment = ( + overrides: Record = {}, + ): Record => ({ + 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', () => { - const result = validateEnvironment({ - 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', - }); + const result = validateEnvironment(validEnvironment()); expect(result.PORT).toBe(3007); 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', () => { expect(() => - validateEnvironment({ - 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: 'shared-secret', - ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret', - ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900', - MAX_UPLOAD_SIZE_BYTES: '1024', - }), + validateEnvironment( + validEnvironment({ + DEVICE_BOOTSTRAP_SECRET: 'shared-secret', + ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret', + }), + ), ).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/, + ); + }); }); diff --git a/backend/src/modules/config/environment.ts b/backend/src/modules/config/environment.ts index 76c4541..61b1a7a 100644 --- a/backend/src/modules/config/environment.ts +++ b/backend/src/modules/config/environment.ts @@ -1,5 +1,6 @@ import { plainToInstance } from 'class-transformer'; import { + IsIn, IsInt, IsNotEmpty, IsString, @@ -9,6 +10,8 @@ import { validateSync, } from 'class-validator'; +export type PaddleEnvironment = 'sandbox' | 'production'; + class EnvironmentVariables { @IsString() @IsNotEmpty() @@ -48,6 +51,25 @@ class EnvironmentVariables { @IsInt() @Min(1) 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) { diff --git a/backend/test/setup-env.ts b/backend/test/setup-env.ts index 09a45cc..545b609 100644 --- a/backend/test/setup-env.ts +++ b/backend/test/setup-env.ts @@ -17,3 +17,13 @@ process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS = process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ?? '900'; process.env.MAX_UPLOAD_SIZE_BYTES = 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'; diff --git a/infra/docker/env/backend.env.example b/infra/docker/env/backend.env.example index ed397ef..5ccd03a 100644 --- a/infra/docker/env/backend.env.example +++ b/infra/docker/env/backend.env.example @@ -7,3 +7,8 @@ DEVICE_BOOTSTRAP_SECRET=replace-me ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-a-distinct-secret ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900 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