Skip to content

Commit

Permalink
Chore: Refactor and migrate Loan risk to app directory INTER-911, I…
Browse files Browse the repository at this point in the history
…NTER-459 (#159)

* chore: move loan risk client code to `app`

* chore: move loan risk server code to `app`

* chore: improve loan request error handling

* chore: self review fixes

* fix: page component naming
  • Loading branch information
JuroUhlar authored Sep 20, 2024
1 parent c2dd3ff commit f6b89cd
Show file tree
Hide file tree
Showing 15 changed files with 169 additions and 218 deletions.
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
File renamed without changes.
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 />;
}
File renamed without changes.
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

0 comments on commit f6b89cd

Please sign in to comment.