Add Paddle checkout foundation

This commit is contained in:
diyaa
2026-07-09 11:18:14 +02:00
parent ebdede8c79
commit db83d3612d
17 changed files with 1427 additions and 39 deletions
+473 -2
View File
@@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { Readable } from 'node:stream';
import {
BadGatewayException,
ExecutionContext,
ForbiddenException,
NotFoundException,
UnauthorizedException,
@@ -17,8 +19,15 @@ import { AppModule } from '../../src/app.module';
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
import { AccountAuthGuard } from '../../src/modules/auth/account-auth.guard';
import { AuthenticationRuntimeService } from '../../src/modules/auth/authentication-runtime.service';
import { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
import { BillingController } from '../../src/modules/billing/billing.controller';
import {
BillingCheckoutPlan,
CreateBillingCheckoutRequestDto,
} from '../../src/modules/billing/billing.dto';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
@@ -91,6 +100,10 @@ function createPrismaMock() {
const artworkAssets = new Map<string, any>();
const uploadSessions = new Map<string, any>();
const libraryEvents = new Map<bigint, any>();
const billingCustomers = new Map<string, any>();
const userSubscriptions = new Map<string, any>();
const userOAuthIdentities = new Map<string, any>();
const userEntitlements = new Map<string, any>();
let nextLibraryEventId = 1n;
const createUserRecord = (data: Record<string, any>) => {
@@ -143,8 +156,58 @@ function createPrismaMock() {
return created;
}),
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const attachRelations = (user: Record<string, any> | null) => {
if (!user || !select) {
return applySelect(user, select);
}
const baseSelect = Object.fromEntries(
Object.entries(select).filter(([, value]) => value === true),
) as Record<string, boolean>;
const selectedUser = applySelect(user, baseSelect) as Record<
string,
any
>;
if (select.oauthIdentities) {
const identities = [...userOAuthIdentities.values()]
.filter((identity) => identity.userId === user.id)
.sort(
(lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime(),
)
.map((identity) =>
applySelect(identity, select.oauthIdentities.select),
);
selectedUser.oauthIdentities = identities;
}
if (select.billingCustomer) {
const billingCustomer =
[...billingCustomers.values()].find(
(record) => record.userId === user.id,
) ?? null;
selectedUser.billingCustomer = applySelect(
billingCustomer,
select.billingCustomer.select,
);
}
if (select.subscription) {
const subscription =
[...userSubscriptions.values()].find(
(record) => record.userId === user.id,
) ?? null;
selectedUser.subscription = applySelect(
subscription,
select.subscription.select,
);
}
return selectedUser;
};
if (where.id) {
return applySelect(users.get(where.id) ?? null, select);
return attachRelations(users.get(where.id) ?? null);
}
if (where.slug) {
@@ -152,7 +215,7 @@ function createPrismaMock() {
[...users.values()].find((user) => user.slug === where.slug) ??
null;
return applySelect(matchingUser, select);
return attachRelations(matchingUser);
}
return null;
@@ -187,6 +250,40 @@ function createPrismaMock() {
return updated;
}),
},
userOAuthIdentity: {
create: jest.fn().mockImplementation(async ({ data, select }) => {
const record = {
id: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
userOAuthIdentities.set(record.id, record);
return applySelect(record, select);
}),
findUnique: jest.fn().mockImplementation(async ({ where, include }) => {
const record =
[...userOAuthIdentities.values()].find(
(identity) =>
identity.provider === where.provider_providerSubject?.provider &&
identity.providerSubject ===
where.provider_providerSubject?.providerSubject,
) ?? null;
if (!record) {
return null;
}
if (include?.user) {
return {
...record,
user: applySelect(users.get(record.userId) ?? null, include.user.select),
};
}
return record;
}),
},
device: {
create: jest.fn().mockImplementation(async ({ data }) => {
const record = {
@@ -225,6 +322,92 @@ function createPrismaMock() {
return updated;
}),
},
billingCustomer: {
create: jest.fn().mockImplementation(async ({ data, select }) => {
const record = {
id: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
billingCustomers.set(record.id, record);
return applySelect(record, select);
}),
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const record =
[...billingCustomers.values()].find(
(billingCustomer) =>
billingCustomer.userId === where.userId ||
billingCustomer.providerCustomerId === where.providerCustomerId,
) ?? null;
return applySelect(record, select);
}),
},
userSubscription: {
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const record =
[...userSubscriptions.values()].find(
(subscription) => subscription.userId === where.userId,
) ?? null;
return applySelect(record, select);
}),
upsert: jest.fn().mockImplementation(async ({ where, update, create, select }) => {
const existing =
[...userSubscriptions.values()].find(
(subscription) => subscription.userId === where.userId,
) ?? null;
const now = new Date();
const record = existing
? {
...existing,
...update,
updatedAt: now,
}
: {
id: randomUUID(),
createdAt: now,
updatedAt: now,
...create,
};
userSubscriptions.set(record.id, record);
return applySelect(record, select);
}),
},
userEntitlement: {
findMany: jest.fn().mockImplementation(async ({ where }) =>
[...userEntitlements.values()].filter((entitlement) =>
where?.userId ? entitlement.userId === where.userId : true,
),
),
createMany: jest.fn().mockImplementation(async ({ data }) => {
for (const entry of data) {
const id = `${entry.userId}:${entry.entitlementKey}`;
userEntitlements.set(id, {
id,
createdAt: new Date(),
updatedAt: new Date(),
...entry,
});
}
return { count: data.length };
}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockImplementation(async ({ create }) => {
const id = `${create.userId}:${create.entitlementKey}`;
const record = {
id,
createdAt: new Date(),
updatedAt: new Date(),
...create,
};
userEntitlements.set(id, record);
return record;
}),
},
track: {
findMany: jest.fn().mockImplementation(async ({ where }) => {
return [...tracks.values()]
@@ -448,6 +631,10 @@ function createPrismaMock() {
artworkAssets,
uploadSessions,
libraryEvents,
billingCustomers,
userSubscriptions,
userOAuthIdentities,
userEntitlements,
},
};
}
@@ -458,6 +645,7 @@ describe('Velody API wiring (e2e)', () => {
let assetsController: AssetsController;
let accountController: AccountController;
let artworkController: ArtworkController;
let billingController: BillingController;
let healthController: HealthController;
let devicesController: DevicesController;
let libraryController: LibraryController;
@@ -466,6 +654,8 @@ describe('Velody API wiring (e2e)', () => {
let uploadsService: UploadsService;
let requestContextService: RequestContextService;
let deviceAuthService: DeviceAuthService;
let authenticationRuntimeService: AuthenticationRuntimeService;
let accountAuthGuard: AccountAuthGuard;
let prismaState: ReturnType<typeof createPrismaMock>['state'];
let storageRoot: string;
@@ -534,6 +724,46 @@ describe('Velody API wiring (e2e)', () => {
});
}
function createAccountExecutionContext(authorizationHeader?: string) {
const request = {
headers: {
authorization: authorizationHeader,
},
} as any;
return {
request,
context: {
switchToHttp: () => ({
getRequest: () => request,
}),
} as ExecutionContext,
};
}
async function runBillingCheckoutRequest(
authorizationHeader: string | undefined,
body: unknown,
) {
const validationPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
const { request, context } =
createAccountExecutionContext(authorizationHeader);
await accountAuthGuard.canActivate(context);
const validatedBody = await validationPipe.transform(body, {
type: 'body',
metatype: CreateBillingCheckoutRequestDto,
});
return billingController.createCheckout(
request.accountAuthContext,
validatedBody,
);
}
beforeEach(async () => {
const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock;
@@ -549,6 +779,12 @@ describe('Velody API wiring (e2e)', () => {
appVersion: '0.1.0',
maxUploadSizeBytes: 1024 * 1024 * 1024,
storageRoot,
accountAccessTokenSecret: 'test-account-access-secret',
accountAccessTokenTtlSeconds: 900,
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
})
.overrideProvider(PrismaService)
.useValue(prismaMock)
@@ -574,6 +810,7 @@ describe('Velody API wiring (e2e)', () => {
assetsController = moduleRef.get(AssetsController);
accountController = moduleRef.get(AccountController);
artworkController = moduleRef.get(ArtworkController);
billingController = moduleRef.get(BillingController);
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController);
@@ -582,6 +819,8 @@ describe('Velody API wiring (e2e)', () => {
uploadsService = moduleRef.get(UploadsService);
requestContextService = moduleRef.get(RequestContextService);
deviceAuthService = moduleRef.get(DeviceAuthService);
authenticationRuntimeService = moduleRef.get(AuthenticationRuntimeService);
accountAuthGuard = moduleRef.get(AccountAuthGuard);
});
afterEach(async () => {
@@ -1706,6 +1945,238 @@ describe('Velody API wiring (e2e)', () => {
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when billing checkout is requested without Authorization', async () => {
await expect(
runBillingCheckoutRequest(undefined, {
plan: BillingCheckoutPlan.PRO_MONTHLY,
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 400 when billing checkout plan is invalid', async () => {
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: 'INVALID_PLAN',
}),
).rejects.toMatchObject({
response: {
message: expect.arrayContaining([
'plan must be one of the following values: PRO_MONTHLY, PRO_YEARLY',
]),
},
});
});
it('creates a checkout for an authenticated account with a new Paddle customer', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'ctm_new_customer',
},
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
checkout: {
url: 'https://checkout.paddle.test/new-session',
},
},
}),
} as Response);
const response = await runBillingCheckoutRequest(
'Bearer valid-account-token',
{
plan: BillingCheckoutPlan.PRO_MONTHLY,
},
);
expect(response).toEqual({
plan: BillingCheckoutPlan.PRO_MONTHLY,
checkoutUrl: 'https://checkout.paddle.test/new-session',
});
expect([...prismaState.billingCustomers.values()][0]).toMatchObject({
userId: prismaState.defaultUser.id,
providerCustomerId: 'ctm_new_customer',
provider: 'PADDLE',
});
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('creates a checkout for an authenticated account with an existing Paddle customer', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.billingCustomers.set('billing-customer-1', {
id: 'billing-customer-1',
userId: prismaState.defaultUser.id,
provider: 'PADDLE',
providerCustomerId: 'ctm_existing_customer',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
checkout: {
url: 'https://checkout.paddle.test/existing-session',
},
},
}),
} as Response);
const response = await runBillingCheckoutRequest(
'Bearer valid-account-token',
{
plan: BillingCheckoutPlan.PRO_YEARLY,
},
);
expect(response).toEqual({
plan: BillingCheckoutPlan.PRO_YEARLY,
checkoutUrl: 'https://checkout.paddle.test/existing-session',
});
expect(fetchRequest).toHaveBeenCalledTimes(1);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('returns 502 when Paddle customer creation fails during billing checkout', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: false,
json: async () => ({
error: {
detail: 'Paddle customer error',
},
}),
} as Response);
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: BillingCheckoutPlan.PRO_MONTHLY,
}),
).rejects.toBeInstanceOf(BadGatewayException);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('returns 502 when Paddle checkout session creation fails during billing checkout', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.billingCustomers.set('billing-customer-1', {
id: 'billing-customer-1',
userId: prismaState.defaultUser.id,
provider: 'PADDLE',
providerCustomerId: 'ctm_existing_customer',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: false,
json: async () => ({
error: {
detail: 'Paddle checkout error',
},
}),
} as Response);
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: BillingCheckoutPlan.PRO_YEARLY,
}),
).rejects.toBeInstanceOf(BadGatewayException);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',