Add Paddle checkout foundation
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user