Skip to content

Commit

Permalink
feat: add mocked checkout (#52)
Browse files Browse the repository at this point in the history
  • Loading branch information
anbraten authored Jul 31, 2023
1 parent 7d133c7 commit e4c4aa7
Show file tree
Hide file tree
Showing 14 changed files with 239 additions and 54 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"repository": "github:geprog/gringotts",
"scripts": {
"lint:format": "prettier --check .",
"start": "pnpm --filter @geprog/gringotts-server start"
"start": "pnpm --parallel start"
},
"devDependencies": {
"prettier": "^2.5.1"
Expand Down
144 changes: 144 additions & 0 deletions packages/server/src/api/endpoints/mocked_checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { FastifyInstance } from 'fastify';
import path from 'path';

import { database } from '~/database';

export function mockedCheckoutEndpoints(server: FastifyInstance): void {
server.get(
'/mocked/checkout/:paymentId',
{
schema: { hide: true },
},
async (request, reply) => {
const params = request.params as { paymentId?: string };
if (!params.paymentId) {
return reply.code(400).send({
error: 'Missing paymentId',
});
}

const query = request.query as { redirect_url?: string };
if (!query.redirect_url) {
return reply.code(400).send({
error: 'Missing redirect_url',
});
}

const payment = await database.payments.findOne(
{ _id: params.paymentId },
{ populate: ['customer', 'customer.project'] },
);
if (!payment) {
return reply.code(404).send({
error: 'Payment not found',
});
}

if (payment.customer.project.paymentProvider !== 'mocked') {
return reply.code(404).send({
error: 'Payment not found', // don't leak that we are in dev mode
});
}

await reply.view(path.join('templates', 'mocked-checkout.hbs'), { payment, redirect_url: query.redirect_url });
},
);

server.post('/mocked/checkout/:paymentId', {
schema: {
hide: true,
summary: 'Do a checkout for a payment in development mode',
tags: ['dev'],
params: {
type: 'object',
required: ['paymentId'],
additionalProperties: false,
properties: {
paymentId: { type: 'string' },
},
},
body: {
type: 'object',
required: ['status', 'redirect_url'],
additionalProperties: false,
properties: {
status: { type: 'string' },
redirect_url: { type: 'string' },
},
},

response: {
200: {
$ref: 'Customer',
},
400: {
$ref: 'ErrorResponse',
},
500: {
$ref: 'ErrorResponse',
},
},
},
handler: async (request, reply) => {
const params = request.params as { paymentId?: string };
if (!params.paymentId) {
return reply.code(400).send({
error: 'Missing paymentId',
});
}

const body = request.body as { redirect_url?: string; status?: 'paid' | 'failed' };
if (!body.redirect_url) {
return reply.code(400).send({
error: 'Missing redirect_url',
});
}
if (!body.status) {
return reply.code(400).send({
error: 'Missing status',
});
}

const payment = await database.payments.findOne(
{ _id: params.paymentId },
{ populate: ['customer', 'customer.project'] },
);
if (!payment) {
return reply.code(404).send({
error: 'Payment not found',
});
}

if (payment.customer.project.paymentProvider !== 'mocked') {
return reply.code(404).send({
error: 'Payment not found', // don't leak that we are in dev mode
});
}

const { project } = payment.customer;

const response = await server.inject({
method: 'POST',
headers: {
authorization: `Bearer ${project.apiToken}`,
},
url: `/payment/webhook/${project._id}`,
payload: {
paymentId: payment._id,
paymentStatus: body.status,
paidAt: new Date().toISOString(),
},
});

if (response.statusCode !== 200) {
return reply.view(path.join('templates', 'mocked-checkout.hbs'), {
payment,
redirect_url: body.redirect_url,
error: 'Payment webhook failed',
});
}

await reply.redirect(body.redirect_url);
},
});
}
4 changes: 3 additions & 1 deletion packages/server/src/api/endpoints/payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ describe('Payment webhook endpoints', () => {
} as unknown as database.Database);

const payload = {
id: 'payment-123',
paymentId: payment._id,
paymentStatus: 'paid',
paidAt: new Date().toISOString(),
};

const server = await apiInit();
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/api/endpoints/payment_method.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('Payment-method endpoints', () => {
},
} as unknown as database.Database);

const paymentProvider = getPaymentProvider({ paymentProvider: 'mock' } as Project);
const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project);
await paymentProvider?.createCustomer(customer);

const server = await apiInit();
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/api/endpoints/payment_method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getPaymentProvider } from '~/payment_providers';
export function paymentMethodEndpoints(server: FastifyInstance): void {
server.post('/customer/:customerId/payment-method', {
schema: {
summary: 'Create payment and by accepting it a new payment-method',
summary: 'Create payment and by accepting it add a new payment-method',
tags: ['payment-method'],
params: {
type: 'object',
Expand Down Expand Up @@ -76,7 +76,7 @@ export function paymentMethodEndpoints(server: FastifyInstance): void {
const payment = new Payment({
amount: 1, // TODO: Use the smallest amount possible
currency: project.currency,
description: 'Payment method verification',
description: 'Payment method verification', // TODO: use customer language
type: 'verification',
customer,
status: 'pending',
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/api/endpoints/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function projectEndpoints(server: FastifyInstance): void {
properties: {
name: { type: 'string' },
mollieApiKey: { type: 'string' },
paymentProvider: { type: 'string', enum: ['mock', 'mollie'] },
paymentProvider: { type: 'string', enum: ['mocked', 'mollie'] },
webhookUrl: { type: 'string' },
invoiceData: { $ref: 'ProjectInvoiceDataUpdateBody' },
apiToken: { type: 'string' },
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/api/endpoints/subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('Subscription endpoints', () => {
},
} as unknown as database.Database);

const paymentProvider = getPaymentProvider({ paymentProvider: 'mock' } as Project);
const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project);
await paymentProvider?.createCustomer(testData.customer);

const server = await apiInit();
Expand Down Expand Up @@ -123,7 +123,7 @@ describe('Subscription endpoints', () => {
},
} as unknown as database.Database);

const paymentProvider = getPaymentProvider({ paymentProvider: 'mock' } as Project);
const paymentProvider = getPaymentProvider({ paymentProvider: 'mocked' } as Project);
await paymentProvider?.createCustomer(customer);

const subscriptionPayload = {
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { log } from '~/log';

import { customerEndpoints } from './endpoints/customer';
import { invoiceEndpoints } from './endpoints/invoice';
import { mockedCheckoutEndpoints } from './endpoints/mocked_checkout';
import { paymentEndpoints } from './endpoints/payment';
import { paymentMethodEndpoints } from './endpoints/payment_method';
import { projectEndpoints } from './endpoints/project';
Expand Down Expand Up @@ -66,6 +67,10 @@ export async function init(): Promise<FastifyInstance> {
return;
}

if (request.routerPath === '/mocked/checkout/:paymentId') {
return;
}

const apiToken =
(request.headers?.authorization || '').replace('Bearer ', '') || (request.query as { token: string }).token;
if (!apiToken) {
Expand Down Expand Up @@ -98,6 +103,7 @@ export async function init(): Promise<FastifyInstance> {
contentSecurityPolicy: {
directives: {
imgSrc: ["'self'", 'data:', 'https:'],
formAction: ['https:', 'http:'],
},
},
});
Expand Down Expand Up @@ -161,6 +167,7 @@ export async function init(): Promise<FastifyInstance> {
paymentEndpoints(server);
projectEndpoints(server);
paymentMethodEndpoints(server);
mockedCheckoutEndpoints(server);

return server;
}
2 changes: 1 addition & 1 deletion packages/server/src/entities/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class Project {
invoiceData?: ProjectInvoiceData;
apiToken!: string;
webhookUrl!: string;
paymentProvider!: 'mock' | 'mollie';
paymentProvider!: 'mocked' | 'mollie';
mollieApiKey?: string;
currency!: Currency;
vatRate!: number;
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/payment_providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Mollie } from '~/payment_providers/mollie';
import { PaymentProvider } from '~/payment_providers/types';

export function getPaymentProvider(project: Project): PaymentProvider | undefined {
if (project.paymentProvider === 'mock') {
if (project.paymentProvider === 'mocked') {
return new Mocked();
}

Expand Down
54 changes: 17 additions & 37 deletions packages/server/src/payment_providers/mocked.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,65 @@
import { config } from '~/config';
import { Customer, Payment, PaymentMethod, Project } from '~/entities';
import { PaymentProvider } from '~/payment_providers/types';

export let customers: Customer[] = [];
export const payments: { paymentId: string; status: string; customerId: string }[] = [];

export class Mocked implements PaymentProvider {
// eslint-disable-next-line @typescript-eslint/require-await
async chargeForegroundPayment({
payment,
redirectUrl,
}: {
project: Project;
payment: Payment;
redirectUrl: string;
}): Promise<{ checkoutUrl: string }> {
const customer = customers.find((c) => c._id === payment.customer._id);
if (!customer) {
throw new Error('No customer');
}

payments.push({
paymentId: payment._id,
customerId: customer?._id,
status: 'pending',
});

const checkoutUrl = 'http://localhost:3000/checkout';
const checkoutUrl = `${config.publicUrl}/mocked/checkout/${payment._id}?redirect_url=${redirectUrl}`;

return { checkoutUrl };
}

// eslint-disable-next-line @typescript-eslint/require-await
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async chargeBackgroundPayment({ payment }: { project: Project; payment: Payment }): Promise<void> {
const customer = customers.find((c) => c._id === payment.customer._id);
if (!customer) {
throw new Error('No customer');
}

payments.push({
paymentId: payment._id,
customerId: customer?._id,
status: 'pending',
});
//
}

// eslint-disable-next-line @typescript-eslint/require-await
async parsePaymentWebhook(
payload: unknown,
): Promise<{ paymentId: string; paidAt: Date | undefined; paymentStatus: 'pending' | 'paid' | 'failed' }> {
const { id: paymentId } = payload as { id: string };
const { paymentId, paymentStatus, paidAt } = payload as {
paymentStatus: 'pending' | 'paid' | 'failed';
paidAt: string;
paymentId: string;
};

return {
paymentStatus: 'paid',
paidAt: new Date(),
paymentStatus,
paidAt: new Date(paidAt),
paymentId,
};
}

// eslint-disable-next-line @typescript-eslint/require-await
async createCustomer(customer: Customer): Promise<Customer> {
customers.push(customer);

return customer;
}

// eslint-disable-next-line @typescript-eslint/require-await
async updateCustomer(customer: Customer): Promise<Customer> {
customers = customers.map((c) => (c._id === customer._id ? customer : c));
return customer;
}

// eslint-disable-next-line @typescript-eslint/require-await
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async deleteCustomer(customer: Customer): Promise<void> {
customers = customers.filter((c) => c._id !== customer._id);
//
}

// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
async getPaymentMethod(paymentId: string): Promise<PaymentMethod> {
return new PaymentMethod({
paymentProviderId: 'mocked-123',
type: 'mocked',
name: 'mocked',
name: `test **${Date.now().toString().slice(-2)}`,
paymentProviderId: '123',
type: 'credit_card',
});
}
}
Loading

0 comments on commit e4c4aa7

Please sign in to comment.