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 Payment fraud to app router INTER-911, INTER-459 #160

Merged
merged 7 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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/payment-fraud.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Page, test } from '@playwright/test';
import { blockGoogleTagManager, resetScenarios } from './e2eTestUtils';
import { TEST_IDS } from '../src/client/testIDs';
import { PAYMENT_FRAUD_COPY } from '../src/server/paymentFraud/copy';
import { PAYMENT_FRAUD_COPY } from '../src/app/payment-fraud/api/place-order/copy';

const submit = (page: Page) => page.getByTestId(TEST_IDS.paymentFraud.submitPayment).click();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { useState } from 'react';
import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import React from 'react';
Expand All @@ -7,14 +9,14 @@ import Button from '../../client/components/common/Button/Button';
import styles from './paymentFraud.module.scss';
import formStyles from '../../styles/forms.module.scss';
import { Alert } from '../../client/components/common/Alert/Alert';
import { CustomPageProps } from '../_app';
import classNames from 'classnames';
import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react';
import { TEST_IDS } from '../../client/testIDs';
import { Severity } from '../../server/checks';
import { PaymentPayload, PaymentResponse } from './api/place-order/route';
import { useMutation } from 'react-query';

export default function Index({ embed }: CustomPageProps) {
const { getData } = useVisitorData(
export function PaymentFraud() {
const { getData: getVisitorData } = useVisitorData(
{ ignoreCache: true },
{
immediate: false,
Expand All @@ -25,51 +27,45 @@ export default function Index({ embed }: CustomPageProps) {
const [cardNumber, setCardNumber] = useState('4242 4242 4242 4242');
const [cardCvv, setCardCvv] = useState('123');
const [cardExpiration, setCardExpiration] = useState('04/28');

const [orderStatusMessage, setOrderStatusMessage] = useState();
const [applyChargeback, setApplyChargeback] = useState(false);
const [filedChargeback, setFiledChargeback] = useState(false);
const [usingStolenCard, setUsingStolenCard] = useState(false);
const [severity, setSeverity] = useState<Severity | undefined>();
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const [httpResponseStatus, setHttpResponseStatus] = useState<number | undefined>();

const {
mutate: submitPayment,
isLoading: isLoadingPayment,
data: paymentResponse,
error: paymentNetworkError,
} = useMutation<PaymentResponse, Error, Omit<PaymentPayload, 'requestId'>, unknown>({
mutationKey: ['request loan'],
mutationFn: async (payment) => {
const { requestId } = await getVisitorData({ ignoreCache: true });
const response = await fetch('/payment-fraud/api/place-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payment,
requestId,
} satisfies PaymentPayload),
});
return await response.json();
},
});

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsWaitingForResponse(true);

const fpData = await getData();
const { requestId, visitorId } = fpData;

const orderData = {
cardNumber,
cardCvv,
cardExpiration,
applyChargeback,
submitPayment({
filedChargeback: filedChargeback,
usingStolenCard,
visitorId,
requestId,
};

// Server-side handler for this route is located in api/payment-fraud/place-order.js file.
const response = await fetch('/api/payment-fraud/place-order', {
method: 'POST',
body: JSON.stringify(orderData),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
card: {
number: cardNumber,
cvv: cardCvv,
expiration: cardExpiration,
},
});

const responseJson = await response.json();
const responseStatus = response.status;
setOrderStatusMessage(responseJson.message);
setSeverity(responseJson.severity);
setHttpResponseStatus(responseStatus);
setIsWaitingForResponse(false);
}

return (
<UseCaseWrapper useCase={USE_CASES.paymentFraud} embed={embed}>
<UseCaseWrapper useCase={USE_CASES.paymentFraud}>
<div className={formStyles.wrapper}>
<form onSubmit={handleSubmit} className={classNames(formStyles.useCaseForm, styles.paymentForm)}>
<label>Card Number</label>
Expand Down Expand Up @@ -116,7 +112,7 @@ export default function Index({ embed }: CustomPageProps) {
<input
type='checkbox'
name='applyChargeback'
onChange={(event) => setApplyChargeback(event.target.checked)}
onChange={(event) => setFiledChargeback(event.target.checked)}
data-testid={TEST_IDS.paymentFraud.askForChargeback}
/>
Ask for chargeback after purchase
Expand All @@ -135,14 +131,15 @@ export default function Index({ embed }: CustomPageProps) {
</label>
</div>

{httpResponseStatus ? <Alert severity={severity ?? 'warning'}>{orderStatusMessage}</Alert> : null}
{paymentNetworkError ? <Alert severity='error'>{paymentNetworkError.message}</Alert> : null}
{paymentResponse ? <Alert severity={paymentResponse.severity}>{paymentResponse.message}</Alert> : null}
<Button
disabled={isWaitingForResponse}
disabled={isLoadingPayment}
size='large'
type='submit'
data-testid={TEST_IDS.paymentFraud.submitPayment}
>
{isWaitingForResponse ? 'Hold on, doing magic...' : 'Place Order'}
{isLoadingPayment ? 'Hold on, doing magic...' : 'Place Order'}
</Button>
</form>
</div>
Expand Down
34 changes: 34 additions & 0 deletions src/app/payment-fraud/api/place-order/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Model, InferAttributes, InferCreationAttributes, DataTypes, Attributes } from 'sequelize';
import { sequelize } from '../../../../server/server';

interface PaymentAttemptAttributes
extends Model<InferAttributes<PaymentAttemptAttributes>, InferCreationAttributes<PaymentAttemptAttributes>> {
visitorId: string;
filedChargeback: boolean;
usingStolenCard: boolean;
wasSuccessful: boolean;
timestamp: number;
}

export const PaymentAttemptDbModel = sequelize.define<PaymentAttemptAttributes>('payment-attempt', {
visitorId: {
type: DataTypes.STRING,
},
filedChargeback: {
type: DataTypes.BOOLEAN,
},
usingStolenCard: {
type: DataTypes.BOOLEAN,
},
wasSuccessful: {
type: DataTypes.BOOLEAN,
},
timestamp: {
type: DataTypes.DATE,
},
});

export type PaymentAttempt = Attributes<PaymentAttemptAttributes>;
export type PaymentAttemptData = Omit<PaymentAttempt, 'timestamp'>;

PaymentAttemptDbModel.sync({ force: false });
98 changes: 98 additions & 0 deletions src/app/payment-fraud/api/place-order/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { NextResponse } from 'next/server';
import { getAndValidateFingerprintResult, Severity } from '../../../../server/checks';
import { PaymentAttemptData, PaymentAttemptDbModel } from './database';
import { PAYMENT_FRAUD_COPY } from './copy';
import { Op } from 'sequelize';
import { sequelize } from '../../../../server/server';

type Card = {
number: string;
expiration: string;
cvv: string;
};

// Mocked credit card details.
const mockedCard: Card = {
number: '4242 4242 4242 4242',
expiration: '04/28',
cvv: '123',
};

function areCardDetailsCorrect(card: Card) {
return card.number === mockedCard.number && card.expiration === mockedCard.expiration && card.cvv === mockedCard.cvv;
}

async function savePaymentAttempt(paymentAttempt: PaymentAttemptData) {
await PaymentAttemptDbModel.create({
...paymentAttempt,
timestamp: new Date().getTime(),
});
await sequelize.sync();
}

export type PaymentPayload = {
requestId: string;
filedChargeback: boolean;
usingStolenCard: boolean;
card: Card;
};

export type PaymentResponse = {
message: string;
severity: Severity;
};

export async function POST(req: Request): Promise<NextResponse<PaymentResponse>> {
const { requestId, filedChargeback, usingStolenCard, card } = (await req.json()) as PaymentPayload;

// 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 });
}

// If this visitor ID ever paid with a stolen credit card, do not process the payment
const usedStolenCreditCard = await PaymentAttemptDbModel.findOne({ where: { visitorId, usingStolenCard: true } });
if (usedStolenCreditCard) {
return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.stolenCard }, { status: 403 });
}

// If the visitor ID filed more than 1 chargeback in the last year, do not process the payment.
// (Adjust the numbers for you use case)
const chargebacksFiledPastYear = await PaymentAttemptDbModel.findAndCountAll({
where: { visitorId, filedChargeback: true, timestamp: { [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000 } },
});
if (chargebacksFiledPastYear.count > 1) {
return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.previousChargeback }, { status: 403 });
}

// If the visitor ID performed 3 or more unsuccessful payments in the past year, do not process the payment
const invalidPaymentsPastYear = await PaymentAttemptDbModel.findAndCountAll({
where: {
visitorId,
timestamp: { [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000 },
wasSuccessful: false,
},
});
if (invalidPaymentsPastYear.count > 2) {
return NextResponse.json(
{ severity: 'error', message: PAYMENT_FRAUD_COPY.tooManyUnsuccessfulPayments },
{ status: 403 },
);
}

// Check the card details and perform payment if they are correct, log the payment attempt in either case
if (!areCardDetailsCorrect(card)) {
savePaymentAttempt({ visitorId, filedChargeback, usingStolenCard, wasSuccessful: false });
return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.incorrectCardDetails }, { status: 403 });
} else {
savePaymentAttempt({ visitorId, filedChargeback, usingStolenCard, wasSuccessful: true });
return NextResponse.json({ severity: 'success', message: PAYMENT_FRAUD_COPY.successfulPayment }, { status: 200 });
}
}
9 changes: 9 additions & 0 deletions src/app/payment-fraud/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 { PaymentFraud } from '../PaymentFraud';

export const metadata = generateUseCaseMetadata(USE_CASES.paymentFraud);

export default function PaymentFraudPage() {
return <PaymentFraud />;
}
9 changes: 9 additions & 0 deletions src/app/payment-fraud/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 { PaymentFraud } from './PaymentFraud';

export const metadata = generateUseCaseMetadata(USE_CASES.paymentFraud);

export default function PaymentFraudPage() {
return <PaymentFraud />;
}
2 changes: 1 addition & 1 deletion src/pages/api/admin/reset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { isValidPostRequest } from '../../../server/server';
import { PaymentAttemptDbModel } from '../payment-fraud/place-order';
import {
UserCartItemDbModel,
UserPreferencesDbModel,
Expand All @@ -14,6 +13,7 @@ import { deleteBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabas
import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper';
import { SmsVerificationDatabaseModel } from '../../../server/sms-pumping/database';
import { LoginAttemptDbModel } from '../../../server/credentialStuffing/database';
import { PaymentAttemptDbModel } from '../../../app/payment-fraud/api/place-order/database';

export type ResetResponse = {
message: string;
Expand Down
Loading
Loading