Skip to content

Commit

Permalink
Chore: refactor and migrate Payment fraud to app router INTER-911, …
Browse files Browse the repository at this point in the history
…INTER-459 (#160)

* chore: move payment fraud

* chore: refactor payment fraud

* chore: fix bugs, build

* chore: self review fixes

* chore: fix demo
  • Loading branch information
JuroUhlar authored Sep 26, 2024
1 parent 8edb98b commit 48566ca
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 280 deletions.
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
File renamed without changes.
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 });
102 changes: 102 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,102 @@
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,
options: { minConfidenceScore: 0.2 },
});
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 />;
}
8 changes: 6 additions & 2 deletions 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 { ArticleViewDbModel } from '../../../app/paywall/api/database';
import { SmsVerificationDatabaseModel } from '../../../app/sms-pumping/api/database';
import { syncFirewallRuleset } from '../../../app/bot-firewall/api/block-ip/cloudflareApiHelper';
import { deleteBlockedIp } from '../../../app/bot-firewall/api/get-blocked-ips/blockedIpsDatabase';
import { PaymentAttemptDbModel } from '../../../app/payment-fraud/api/place-order/database';

export type ResetResponse = {
message: string;
Expand All @@ -36,7 +36,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
const { requestId } = req.body as ResetRequest;

// Get the full Identification result from Fingerprint Server API and validate its authenticity
const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req });
const fingerprintResult = await getAndValidateFingerprintResult({
requestId,
req,
options: { minConfidenceScore: 0.3 },
});
if (!fingerprintResult.okay) {
res.status(403).send({ severity: 'error', message: fingerprintResult.error });
return;
Expand Down
Loading

0 comments on commit 48566ca

Please sign in to comment.