feat(api): add charge-stripe and create-stripe-payment-intent endpoints (#54545)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2024-07-15 16:23:51 +03:00
committed by GitHub
parent e999319a17
commit b2518cc347
10 changed files with 546 additions and 64 deletions

View File

@@ -33,7 +33,7 @@
"pino-pretty": "10.2.3",
"query-string": "7.1.3",
"rate-limit-mongo": "^2.3.2",
"stripe": "8.222.0",
"stripe": "16.0.0",
"validator": "13.11.0"
},
"description": "The freeCodeCamp.org open-source codebase and curriculum",

View File

@@ -35,7 +35,7 @@ import {
import { challengeRoutes } from './routes/challenge';
import { deprecatedEndpoints } from './routes/deprecated-endpoints';
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
import { donateRoutes } from './routes/donate';
import { donateRoutes, chargeStripeRoute } from './routes/donate';
import { emailSubscribtionRoutes } from './routes/email-subscription';
import { settingRoutes, settingRedirectRoutes } from './routes/settings';
import { statusRoute } from './routes/status';
@@ -223,6 +223,7 @@ export const build = async (
if (FCC_ENABLE_DEV_LOGIN_MODE) {
void fastify.register(devAuthRoutes);
}
void fastify.register(chargeStripeRoute);
void fastify.register(emailSubscribtionRoutes);
void fastify.register(userPublicGetRoutes);
void fastify.register(unprotectedCertificateRoutes);

View File

@@ -8,48 +8,11 @@ import {
} from '../../jest.utils';
import { createUserInput } from '../utils/create-user';
const chargeStripeCardReqBody = {
paymentMethodId: 'UID',
amount: 500,
duration: 'month'
};
const mockSubCreate = jest.fn();
const generateMockSubCreate = (status: string) => () =>
Promise.resolve({
id: 'cust_111',
latest_invoice: {
payment_intent: {
client_secret: 'superSecret',
status
}
}
});
const defaultError = () =>
Promise.reject(new Error('Stripe encountered an error'));
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => {
return {
customers: {
create: jest.fn(() =>
Promise.resolve({
id: 'cust_111',
name: 'Jest_User',
currency: 'sgd',
description: 'Jest User Account created'
})
)
},
subscriptions: {
create: mockSubCreate
}
};
});
});
const testEWalletEmail = 'baz@bar.com';
const testSubscriptionId = 'sub_test_id';
const testCustomerId = 'cust_test_id';
const userWithoutProgress: Prisma.userCreateInput =
createUserInput(defaultUserEmail);
const userWithProgress: Prisma.userCreateInput = {
...createUserInput(defaultUserEmail),
completedChallenges: [
@@ -79,12 +42,124 @@ const userWithProgress: Prisma.userCreateInput = {
}
]
};
const sharedDonationReqBody = {
amount: 500,
duration: 'month'
};
const chargeStripeReqBody = {
email: testEWalletEmail,
subscriptionId: 'sub_test_id',
...sharedDonationReqBody
};
const chargeStripeCardReqBody = {
paymentMethodId: 'UID',
...sharedDonationReqBody
};
const createStripePaymentIntentReqBody = {
email: testEWalletEmail,
name: 'Baz Bar',
token: { id: 'tok_123' },
...sharedDonationReqBody
};
const mockSubCreate = jest.fn();
const mockAttachPaymentMethod = jest.fn(() =>
Promise.resolve({
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
object: 'payment_method'
})
);
const mockCustomerCreate = jest.fn(() =>
Promise.resolve({
id: testCustomerId,
name: 'Jest_User',
currency: 'sgd',
description: 'Jest User Account created'
})
);
const mockSubRetrieveObj = {
id: testSubscriptionId,
items: {
data: [
{
plan: {
product: 'prod_GD1GGbJsqQaupl'
}
}
]
},
// 1 Jan 2040
current_period_start: Math.floor(Date.now() / 1000),
customer: testCustomerId,
status: 'active'
};
const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
const mockCustomerUpdate = jest.fn();
const generateMockSubCreate = (status: string) => () =>
Promise.resolve({
id: testSubscriptionId,
latest_invoice: {
payment_intent: {
client_secret: 'superSecret',
status
}
}
});
const defaultError = () =>
Promise.reject(new Error('Stripe encountered an error'));
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => {
return {
customers: {
create: mockCustomerCreate,
update: mockCustomerUpdate
},
paymentMethods: {
attach: mockAttachPaymentMethod
},
subscriptions: {
create: mockSubCreate,
retrieve: mockSubRetrieve
}
};
});
});
describe('Donate', () => {
setupServer();
describe('Authenticated User', () => {
let superPost: ReturnType<typeof createSuperRequest>;
const verifyUpdatedUserAndNewDonation = async (email: string) => {
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email }
});
const donations = await fastifyTestInstance.prisma.donation.findMany({
where: { userId: user?.id }
});
const donation = donations[0];
expect(donations.length).toBe(1);
expect(donation?.amount).toBe(sharedDonationReqBody.amount);
expect(donation?.duration).toBe(sharedDonationReqBody.duration);
expect(typeof donation?.subscriptionId).toBe('string');
expect(donation?.customerId).toBe(testCustomerId);
expect(donation?.provider).toBe('stripe');
};
const verifyNoUpdatedUserAndNoNewDonation = async (email: string) => {
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email }
});
const donations = await fastifyTestInstance.prisma.donation.findMany({});
expect(user?.isDonating).toBe(false);
expect(donations.length).toBe(0);
};
const verifyNoNewUserAndNoNewDonation = async () => {
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: testEWalletEmail }
});
const donations = await fastifyTestInstance.prisma.donation.findMany({});
expect(user).toBe(null);
expect(donations.length).toBe(0);
};
beforeEach(async () => {
const setCookies = await devLogin();
@@ -93,6 +168,10 @@ describe('Donate', () => {
where: { email: userWithProgress.email },
data: userWithProgress
});
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: testEWalletEmail }
});
await fastifyTestInstance.prisma.donation.deleteMany({});
});
describe('POST /donate/charge-stripe-card', () => {
@@ -103,6 +182,7 @@ describe('Donate', () => {
const response = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
expect(response.body).toEqual({ isDonating: true, type: 'success' });
expect(response.status).toBe(200);
});
@@ -114,7 +194,7 @@ describe('Donate', () => {
const response = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
expect(response.body).toEqual({
error: {
type: 'UserActionRequired',
@@ -132,7 +212,7 @@ describe('Donate', () => {
const response = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
expect(response.body).toEqual({
error: {
type: 'PaymentMethodRequired',
@@ -149,11 +229,13 @@ describe('Donate', () => {
const successResponse = await superPost(
'/donate/charge-stripe-card'
).send(chargeStripeCardReqBody);
expect(successResponse.status).toBe(200);
const failResponse = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
//Verify that only the first call changed the DB
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
expect(successResponse.status).toBe(200);
expect(failResponse.body).toEqual({
error: {
type: 'AlreadyDonatingError',
@@ -168,6 +250,7 @@ describe('Donate', () => {
const response = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
@@ -182,6 +265,7 @@ describe('Donate', () => {
const failResponse = await superPost('/donate/charge-stripe-card').send(
chargeStripeCardReqBody
);
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
expect(failResponse.body).toEqual({
error: {
type: 'MethodRestrictionError',
@@ -198,7 +282,10 @@ describe('Donate', () => {
anything: true,
itIs: 'ignored'
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: userWithProgress.email }
});
expect(user?.isDonating).toBe(true);
expect(response.body).toEqual({
isDonating: true
});
@@ -214,6 +301,134 @@ describe('Donate', () => {
expect(failResponse.status).toBe(400);
});
});
describe('POST /donate/create-stripe-payment-intent', () => {
it('should return 200 and call stripe api properly', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('no-errors')
);
const response = await superPost(
'/donate/create-stripe-payment-intent'
).send(createStripePaymentIntentReqBody);
expect(mockCustomerCreate).toHaveBeenCalledWith({
email: testEWalletEmail,
name: 'Baz Bar'
});
expect(response.status).toBe(200);
});
it('should return 400 when email format is wrong', async () => {
const response = await superPost(
'/donate/create-stripe-payment-intent'
).send({
...createStripePaymentIntentReqBody,
email: '12raqdcev'
});
expect(response.body).toEqual({
error: 'The donation form had invalid values for this submission.'
});
expect(response.status).toBe(400);
});
it('should return 400 if amount is incorrect', async () => {
const response = await superPost(
'/donate/create-stripe-payment-intent'
).send({
...createStripePaymentIntentReqBody,
amount: '350'
});
expect(response.body).toEqual({
error: 'The donation form had invalid values for this submission.'
});
expect(response.status).toBe(400);
});
it('should return 500 if Stripe encounters an error', async () => {
mockSubCreate.mockImplementationOnce(defaultError);
const response = await superPost(
'/donate/create-stripe-payment-intent'
).send(createStripePaymentIntentReqBody);
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
});
expect(response.status).toBe(500);
});
});
describe('POST /donate/charge-stripe', () => {
it('should return 200 and call stripe api properly', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('no-errors')
);
const response = await superPost('/donate/charge-stripe').send(
chargeStripeReqBody
);
await verifyUpdatedUserAndNewDonation(testEWalletEmail);
expect(mockSubRetrieve).toHaveBeenCalledWith('sub_test_id');
expect(response.status).toBe(200);
});
it('should return 500 when if product id is wrong', async () => {
mockSubRetrieve.mockImplementationOnce(() =>
Promise.resolve({
...mockSubRetrieveObj,
items: {
...mockSubRetrieveObj.items,
data: [
{
...mockSubRetrieveObj.items.data[0],
plan: {
product: 'wrong_product_id'
}
}
]
}
})
);
const response = await superPost('/donate/charge-stripe').send(
chargeStripeReqBody
);
await verifyNoNewUserAndNoNewDonation();
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
});
expect(response.status).toBe(500);
});
it('should return 500 if subsciption is not active', async () => {
mockSubRetrieve.mockImplementationOnce(() =>
Promise.resolve({
...mockSubRetrieveObj,
status: 'canceled'
})
);
const response = await superPost('/donate/charge-stripe').send(
chargeStripeReqBody
);
await verifyNoNewUserAndNoNewDonation();
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
});
expect(response.status).toBe(500);
});
it('should return 500 if timestamp is old', async () => {
mockSubRetrieve.mockImplementationOnce(() =>
Promise.resolve({
...mockSubRetrieveObj,
current_period_start: Math.floor(Date.now() / 1000) - 500
})
);
const response = await superPost('/donate/charge-stripe').send(
chargeStripeReqBody
);
await verifyNoNewUserAndNoNewDonation();
expect(response.body).toEqual({
error: 'Donation failed due to a server error.'
});
expect(response.status).toBe(500);
});
});
});
describe('Unauthenticated User', () => {
@@ -223,10 +438,12 @@ describe('Donate', () => {
const res = await superRequest('/status/ping', { method: 'GET' });
setCookies = res.get('Set-Cookie');
});
const endpoints: { path: string; method: 'POST' }[] = [
{ path: '/donate/add-donation', method: 'POST' },
{ path: '/donate/charge-stripe-card', method: 'POST' }
];
endpoints.forEach(({ path, method }) => {
test(`${method} ${path} returns 401 status code with error message`, async () => {
const response = await superRequest(path, {
@@ -236,5 +453,26 @@ describe('Donate', () => {
expect(response.statusCode).toBe(401);
});
});
test('POST /donate/create-stripe-payment-intent should return 200', async () => {
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
const response = await superRequest(
'/donate/create-stripe-payment-intent',
{
method: 'POST',
setCookies
}
).send(createStripePaymentIntentReqBody);
expect(response.status).toBe(200);
});
test('POST /donate/charge-stripe should return 200', async () => {
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
const response = await superRequest('/donate/charge-stripe', {
method: 'POST',
setCookies
}).send(chargeStripeReqBody);
expect(response.status).toBe(200);
});
});
});

View File

@@ -3,13 +3,17 @@ import {
type FastifyPluginCallbackTypebox
} from '@fastify/type-provider-typebox';
import Stripe from 'stripe';
import { donationSubscriptionConfig } from '../../../shared/config/donation-settings';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../shared/config/donation-settings';
import * as schemas from '../schemas';
import { STRIPE_SECRET_KEY } from '../utils/env';
import { inLastFiveMinutes } from '../utils/validate-donation';
import { findOrCreateUser } from './helpers/auth-helpers';
/**
* Plugin for the donation endpoints.
* Plugin for the donation endpoints requiring auth.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
@@ -22,7 +26,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27',
apiVersion: '2024-06-20',
typescript: true
});
@@ -173,7 +177,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
duration,
provider: 'stripe',
subscriptionId,
customerId: id,
customerId: customerId,
// TODO(Post-MVP) migrate to startDate: new Date()
startDate: {
date: new Date().toISOString(),
@@ -208,3 +212,159 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
done();
};
/**
* Plugin for public donation endpoints.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done The callback to signal that the plugin is ready.
*/
export const chargeStripeRoute: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true
});
fastify.post(
'/donate/create-stripe-payment-intent',
{
schema: schemas.createStripePaymentIntent
},
async (req, reply) => {
const { email, name, amount, duration } = req.body;
if (!donationSubscriptionConfig.plans[duration].includes(amount)) {
void reply.code(400);
return {
error: 'The donation form had invalid values for this submission.'
} as const;
}
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
if (
stripeSubscription.latest_invoice &&
typeof stripeSubscription.latest_invoice !== 'string' &&
stripeSubscription.latest_invoice.payment_intent &&
typeof stripeSubscription.latest_invoice.payment_intent !==
'string' &&
stripeSubscription.latest_invoice.payment_intent.client_secret !==
null
) {
const clientSecret =
stripeSubscription.latest_invoice.payment_intent.client_secret;
return reply.send({
subscriptionId: stripeSubscription.id,
clientSecret
});
} else {
throw new Error('Stripe payment intent client secret is missing');
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return reply.send({
error: 'Donation failed due to a server error.'
});
}
}
);
fastify.post(
'/donate/charge-stripe',
{
schema: schemas.chargeStripe
},
async (req, reply) => {
try {
const { email, amount, duration, subscriptionId } = req.body;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
const productId = subscription.items.data[0]?.plan.product?.toString();
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid =
productId && allStripeProductIdsArray.includes(productId);
const isValidCustomer = typeof subscription.customer === 'string';
if (!isSubscriptionActive)
throw new Error(
`Stripe subscription information is invalid: ${subscriptionId}`
);
if (!isProductIdValid)
throw new Error(`Product ID is invalid: ${subscriptionId}`);
if (!isStartedRecently)
throw new Error(`Subscription is not recent: ${subscriptionId}`);
if (!isValidCustomer)
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
else {
// TODO(Post-MVP) new users should not be created if user is not found
const user = await findOrCreateUser(fastify, email);
const donation = {
userId: user.id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer as string,
// TODO(Post-MVP) migrate to startDate: new Date()
startDate: {
date: new Date().toISOString(),
when: new Date().toISOString().replace(/.$/, '+00:00')
}
};
await fastify.prisma.donation.create({
data: donation
});
await fastify.prisma.user.update({
where: { id: user.id },
data: {
isDonating: true
}
});
return reply.send({
isDonating: true
});
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return {
error: 'Donation failed due to a server error.'
} as const;
}
}
);
done();
};

View File

@@ -12,6 +12,8 @@ export { projectCompleted } from './schemas/challenge/project-completed';
export { saveChallenge } from './schemas/challenge/save-challenge';
export { deprecatedEndpoints } from './schemas/deprecated';
export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
export { chargeStripe } from './schemas/donate/charge-stripe';
export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent';
export { resubscribe } from './schemas/email-subscription/resubscribe';
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
export { updateMyAbout } from './schemas/settings/update-my-about';

View File

@@ -0,0 +1,18 @@
import { Type } from '@fastify/type-provider-typebox';
export const chargeStripe = {
body: Type.Object({
amount: Type.Number(),
duration: Type.Literal('month'),
email: Type.String({ format: 'email', maxLength: 1024 }),
subscriptionId: Type.String()
}),
response: {
200: Type.Object({
isDonating: Type.Boolean()
}),
default: Type.Object({
error: Type.Literal('Donation failed due to a server error.')
})
}
};

View File

@@ -0,0 +1,24 @@
import { Type } from '@fastify/type-provider-typebox';
export const createStripePaymentIntent = {
body: Type.Object({
amount: Type.Number(),
duration: Type.Literal('month'),
email: Type.String({ format: 'email', maxLength: 1024 }),
name: Type.String()
}),
response: {
200: Type.Object({
subscriptionId: Type.String(),
clientSecret: Type.String()
}),
400: Type.Object({
error: Type.Literal(
'The donation form had invalid values for this submission.'
)
}),
default: Type.Object({
error: Type.Literal('Donation failed due to a server error.')
})
}
};

View File

@@ -0,0 +1,29 @@
import { inLastFiveMinutes } from './validate-donation';
describe('inLastFiveMinutes', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('should return true if the timestamp is within the last five minutes', () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
const recentTimestamp = currentTimestamp - 100;
expect(inLastFiveMinutes(recentTimestamp)).toBe(true);
});
it('should return false if the timestamp is more than five minutes ago', () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
const oldTimestamp = currentTimestamp - 400;
expect(inLastFiveMinutes(oldTimestamp)).toBe(false);
});
it('should return true if the timestamp is exactly five minutes ago', () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
const exactTimestamp = currentTimestamp - 300;
expect(inLastFiveMinutes(exactTimestamp)).toBe(true);
});
});

View File

@@ -0,0 +1,10 @@
/**
* Checks if a timestamp was created within five minutes.
* @param unixTimestamp - A unix timestamp .
* @returns - The generated email template.
*/
export const inLastFiveMinutes = (unixTimestamp: number) => {
const currentTimestamp = Math.floor(Date.now() / 1000);
const timeDifference = currentTimestamp - unixTimestamp;
return timeDifference <= 300; // 300 seconds is 5 minutes
};

22
pnpm-lock.yaml generated
View File

@@ -238,8 +238,8 @@ importers:
specifier: ^2.3.2
version: 2.3.2
stripe:
specifier: 8.222.0
version: 8.222.0
specifier: 16.0.0
version: 16.0.0
validator:
specifier: 13.11.0
version: 13.11.0
@@ -12669,14 +12669,14 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@16.0.0:
resolution: {integrity: sha512-HNPTzXrSompUxeGqDpbNmPvH/UEzziIxGWvTDTZwIBLlL6CFqR+3YLpV+g4HXOsPKbQRguauptQwVWO7Y/yEGg==}
engines: {node: '>=12.*'}
stripe@8.205.0:
resolution: {integrity: sha512-hmYnc7je6j0n9GlkUpc8USsUquLzSxmWj78g9NKFokCtSybNy7y9fYS+VB5AuZUwmIkzhTczgf+TaSmI4kbk9A==}
engines: {node: ^8.1 || >=10.*}
stripe@8.222.0:
resolution: {integrity: sha512-hrA79fjmN2Eb6K3kxkDzU4ODeVGGjXQsuVaAPSUro6I9MM3X+BvIsVqdphm3BXWfimAGFvUqWtPtHy25mICY1w==}
engines: {node: ^8.1 || >=10.*}
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
@@ -30088,16 +30088,16 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@16.0.0:
dependencies:
'@types/node': 20.12.8
qs: 6.11.2
stripe@8.205.0:
dependencies:
'@types/node': 20.8.0
qs: 6.11.2
stripe@8.222.0:
dependencies:
'@types/node': 20.8.2
qs: 6.11.2
strnum@1.0.5: {}
strong-error-handler@3.5.0: