Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore: Refactor and migrate Loan risk to app directory INTER-911, INTER-459 #159

Merged
merged 6 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/loan-risk.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Page, expect, test } from '@playwright/test';
import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils';
import { TEST_IDS } from '../src/client/testIDs';
import { LOAN_RISK_COPY } from '../src/server/loan-risk/copy';
import { LOAN_RISK_COPY } from '../src/app/loan-risk/api/request-loan/copy';

const testIds = TEST_IDS.loanRisk;

Expand Down
79 changes: 47 additions & 32 deletions src/pages/loan-risk/index.tsx → src/app/loan-risk/LoanRisk.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { FunctionComponent, useCallback, useMemo, useState } from 'react';
'use client';

import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { FunctionComponent, useMemo, useState } from 'react';
import {
loanDurationValidation,
loanValueValidation,
monthlyIncomeValidation,
} from '../../client/loan-risk/validation';
import { useRequestLoan } from '../../client/api/loan-risk/use-request-loan';
import { calculateMonthInstallment } from '../../shared/loan-risk/calculate-month-installment';
import React from 'react';
import { USE_CASES } from '../../client/components/common/content';
import { CustomPageProps } from '../_app';
import Button from '../../client/components/common/Button/Button';
import { Alert } from '../../client/components/common/Alert/Alert';
import formStyles from '../../styles/forms.module.scss';
Expand All @@ -20,6 +19,8 @@ import styles from './loanRisk.module.scss';
import classNames from 'classnames';
import { TEST_IDS } from '../../client/testIDs';
import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react';
import { useMutation } from 'react-query';
import { LoanRequestData, LoanRequestPayload, LoanRequestResponse } from './api/request-loan/route';

type SliderFieldProps = {
label: string;
Expand Down Expand Up @@ -64,14 +65,35 @@ const SliderField: FunctionComponent<SliderFieldProps> = ({
</div>
);
};
export default function LoanRisk({ embed }: CustomPageProps) {
const { getData, isLoading: isVisitorDataLoading } = useVisitorData(

export function LoanRisk() {
const { getData: getVisitorData, isLoading: isVisitorDataLoading } = useVisitorData(
{ ignoreCache: true },
{
immediate: false,
},
);
const loanRequestMutation = useRequestLoan();

const {
mutate: requestLoan,
isLoading: isLoanRequestLoading,
data: loanRequestResponse,
error: loanRequestNetworkError,
} = useMutation<LoanRequestResponse, Error, LoanRequestData, unknown>({
mutationKey: ['request loan'],
mutationFn: async (loanRequest: LoanRequestData) => {
const { requestId } = await getVisitorData({ ignoreCache: true });
const response = await fetch('/loan-risk/api/request-loan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...loanRequest,
requestId,
} satisfies LoanRequestPayload),
});
return await response.json();
},
});

const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Doe');
Expand All @@ -87,32 +109,24 @@ export default function LoanRisk({ embed }: CustomPageProps) {
}),
[loanDuration, loanValue],
);

const handleSubmit = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();

const fpData = await getData();

if (!fpData) {
console.error("Visitor data couldn't be fetched");
return;
}

await loanRequestMutation.mutateAsync({
fpData,
body: { loanValue, monthlyIncome, loanDuration, firstName, lastName },
});
},
[firstName, lastName, loanDuration, loanRequestMutation, loanValue, monthlyIncome, getData],
);

const isLoading = isVisitorDataLoading || loanRequestMutation.isLoading;
const isLoading = isVisitorDataLoading || isLoanRequestLoading;

return (
<UseCaseWrapper useCase={USE_CASES.loanRisk} embed={embed}>
<UseCaseWrapper useCase={USE_CASES.loanRisk}>
<div className={classNames(formStyles.wrapper, styles.formWrapper)}>
<form onSubmit={handleSubmit} className={formStyles.useCaseForm}>
<form
onSubmit={async (event) => {
event.preventDefault();
await requestLoan({
firstName,
lastName,
loanValue,
monthlyIncome,
loanDuration,
});
}}
className={formStyles.useCaseForm}
>
<div className={styles.nameWrapper}>
<label>Name</label>
<input
Expand Down Expand Up @@ -172,8 +186,9 @@ export default function LoanRisk({ embed }: CustomPageProps) {
</div>
</div>
</div>
{loanRequestMutation.data?.message && !loanRequestMutation.isLoading && (
<Alert severity={loanRequestMutation.data.severity}>{loanRequestMutation.data.message}</Alert>
{loanRequestNetworkError && <Alert severity='error'>{loanRequestNetworkError.message}</Alert>}
{loanRequestResponse?.message && !isLoanRequestLoading && (
<Alert severity={loanRequestResponse.severity}>{loanRequestResponse.message}</Alert>
)}
<Button
type='submit'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Model, InferAttributes, InferCreationAttributes, DataTypes, Attributes } from 'sequelize';
import { sequelize } from '../server';
import { sequelize } from '../../../../server/server';

interface LoanRequestAttributes
extends Model<InferAttributes<LoanRequestAttributes>, InferCreationAttributes<LoanRequestAttributes>> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { calculateMonthInstallment } from '../../shared/loan-risk/calculate-month-installment';
import { calculateMonthInstallment } from '../../../../shared/loan-risk/calculate-month-installment';

/**
* Required minimal income.
Expand All @@ -12,7 +12,13 @@ type LoanAsk = {
loanDuration: number;
};

export function calculateLoanValues({ loanValue, monthlyIncome, loanDuration }: LoanAsk) {
export type LoanResult = {
monthInstallment: number;
remainingIncome: number;
approved: boolean;
};

export function evaluateLoanRequest({ loanValue, monthlyIncome, loanDuration }: LoanAsk): LoanResult {
const monthInstallment = calculateMonthInstallment({ loanValue, loanDuration });
const remainingIncome = monthlyIncome - monthInstallment;

Expand Down
91 changes: 91 additions & 0 deletions src/app/loan-risk/api/request-loan/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Op } from 'sequelize';
import { Severity, getAndValidateFingerprintResult } from '../../../../server/checks';
import { NextResponse } from 'next/server';
import { LoanRequestDbModel } from './database';
import { LOAN_RISK_COPY } from './copy';
import { evaluateLoanRequest, LoanResult } from './evaluateLoanRequest';

export type LoanRequestData = {
firstName: string;
lastName: string;
loanDuration: number;
loanValue: number;
monthlyIncome: number;
};

export type LoanRequestPayload = LoanRequestData & {
requestId: string;
};

export type LoanRequestResponse = {
message: string;
severity: Severity;
loanResult?: LoanResult;
};

export async function POST(req: Request): Promise<NextResponse<LoanRequestResponse>> {
const { loanValue, monthlyIncome, loanDuration, firstName, lastName, requestId } =
(await req.json()) as LoanRequestPayload;

// Get the full Identification result from Fingerprint Server API and validate its authenticity
const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req });
if (!fingerprintResult.okay) {
return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 });
}

// Get visitorId from the Server API Identification event
const visitorId = fingerprintResult.data.products?.identification?.data?.visitorId;
if (!visitorId) {
return NextResponse.json({ severity: 'error', message: 'Visitor ID not found.' }, { status: 403 });
}

// Check if this visitor ID has already requested a loan today
const previousLoanRequests = await LoanRequestDbModel.findAll({
where: {
visitorId: { [Op.eq]: visitorId },
timestamp: { [Op.between]: [new Date().setHours(0, 0, 0, 0), new Date().setHours(23, 59, 59, 59)] },
},
});

// If so, check monthly income, first name, and last name are the same as in previous loan requests.
if (previousLoanRequests.length) {
const requestIsConsistent = previousLoanRequests.every(
(loanRequest) =>
loanRequest.monthlyIncome === monthlyIncome &&
loanRequest.firstName === firstName &&
loanRequest.lastName === lastName,
);

// If the provided information is not consistent, potentially mark this user in your database as fraudulent, or perform some other actions.
// In our case, we just return a warning instead of processing the request.
if (!requestIsConsistent) {
return NextResponse.json(
{ severity: 'warning', message: LOAN_RISK_COPY.inconsistentApplicationChallenged },
{
status: 403,
},
);
}
}

// Save the loan request to the database
await LoanRequestDbModel.create({
visitorId,
timestamp: new Date(),
monthlyIncome,
loanDuration,
loanValue,
firstName,
lastName,
});

// Evaluate the loan request
const loanResult = evaluateLoanRequest({ loanValue, monthlyIncome, loanDuration });

// If the income is too low, reject the loan
if (!loanResult.approved) {
return NextResponse.json({ severity: 'warning', message: LOAN_RISK_COPY.incomeLow, loanResult }, { status: 403 });
}

return NextResponse.json({ severity: 'success', message: LOAN_RISK_COPY.approved, loanResult });
}
9 changes: 9 additions & 0 deletions src/app/loan-risk/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../client/components/common/seo';
import { LoanRisk } from '../LoanRisk';

export const metadata = generateUseCaseMetadata(USE_CASES.loanRisk);

export default function LoanRiskPage() {
return <LoanRisk />;
}
9 changes: 9 additions & 0 deletions src/app/loan-risk/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../client/components/common/content';
import { generateUseCaseMetadata } from '../../client/components/common/seo';
import { LoanRisk } from './LoanRisk';

export const metadata = generateUseCaseMetadata(USE_CASES.loanRisk);

export default function LoanRiskPage() {
return <LoanRisk />;
}
2 changes: 1 addition & 1 deletion src/app/playground/embed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import { Playground } from '../Playground';

export const metadata = generateUseCaseMetadata(PLAYGROUND_METADATA);

export default function VpnDetectionPage() {
export default function PlaygroundPage() {
return <Playground />;
}
2 changes: 1 addition & 1 deletion src/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import { Playground } from './Playground';

export const metadata = generateUseCaseMetadata(PLAYGROUND_METADATA);

export default function VpnDetectionPage() {
export default function PlaygroundPage() {
return <Playground />;
}
2 changes: 1 addition & 1 deletion src/pages/api/admin/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
UserPreferencesDbModel,
UserSearchHistoryDbModel,
} from '../../../server/personalization/database';
import { LoanRequestDbModel } from '../../../server/loan-risk/database';
import { LoanRequestDbModel } from '../../../app/loan-risk/api/request-loan/database';
import { ArticleViewDbModel } from '../../../server/paywall/database';
import { CouponClaimDbModel } from '../../../server/coupon-fraud/database';
import { Severity, getAndValidateFingerprintResult } from '../../../server/checks';
Expand Down
Loading
Loading