From a21b1e0a291099cf1f8def58183c7261b1d97d29 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 20 Sep 2024 17:02:32 +0100 Subject: [PATCH 1/5] chore: move payment fraud --- e2e/payment-fraud.spec.ts | 2 +- .../payment-fraud/PaymentFraud.tsx} | 7 +- .../payment-fraud/api/place-order}/copy.ts | 0 .../payment-fraud/api/place-order/database.ts | 33 +++++ .../api/place-order/route-old.ts} | 48 +------ .../payment-fraud/api/place-order/route.ts | 126 ++++++++++++++++++ src/app/payment-fraud/embed/page.tsx | 9 ++ src/app/payment-fraud/page.tsx | 9 ++ .../payment-fraud/paymentFraud.module.scss | 0 src/pages/api/admin/reset.ts | 2 +- src/pages/payment-fraud/embed.tsx | 13 -- 11 files changed, 188 insertions(+), 61 deletions(-) rename src/{pages/payment-fraud/index.tsx => app/payment-fraud/PaymentFraud.tsx} (96%) rename src/{server/paymentFraud => app/payment-fraud/api/place-order}/copy.ts (100%) create mode 100644 src/app/payment-fraud/api/place-order/database.ts rename src/{pages/api/payment-fraud/place-order.ts => app/payment-fraud/api/place-order/route-old.ts} (84%) create mode 100644 src/app/payment-fraud/api/place-order/route.ts create mode 100644 src/app/payment-fraud/embed/page.tsx create mode 100644 src/app/payment-fraud/page.tsx rename src/{pages => app}/payment-fraud/paymentFraud.module.scss (100%) delete mode 100644 src/pages/payment-fraud/embed.tsx diff --git a/e2e/payment-fraud.spec.ts b/e2e/payment-fraud.spec.ts index 4341681d..17b4bfdc 100644 --- a/e2e/payment-fraud.spec.ts +++ b/e2e/payment-fraud.spec.ts @@ -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(); diff --git a/src/pages/payment-fraud/index.tsx b/src/app/payment-fraud/PaymentFraud.tsx similarity index 96% rename from src/pages/payment-fraud/index.tsx rename to src/app/payment-fraud/PaymentFraud.tsx index cb36d9e0..d7cf6046 100644 --- a/src/pages/payment-fraud/index.tsx +++ b/src/app/payment-fraud/PaymentFraud.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useState } from 'react'; import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper'; import React from 'react'; @@ -7,13 +9,12 @@ 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'; -export default function Index({ embed }: CustomPageProps) { +export function PaymentFraud() { const { getData } = useVisitorData( { ignoreCache: true }, { @@ -69,7 +70,7 @@ export default function Index({ embed }: CustomPageProps) { } return ( - +
diff --git a/src/server/paymentFraud/copy.ts b/src/app/payment-fraud/api/place-order/copy.ts similarity index 100% rename from src/server/paymentFraud/copy.ts rename to src/app/payment-fraud/api/place-order/copy.ts diff --git a/src/app/payment-fraud/api/place-order/database.ts b/src/app/payment-fraud/api/place-order/database.ts new file mode 100644 index 00000000..494ff5d0 --- /dev/null +++ b/src/app/payment-fraud/api/place-order/database.ts @@ -0,0 +1,33 @@ +import { Model, InferAttributes, InferCreationAttributes, DataTypes, Attributes } from 'sequelize'; +import { sequelize } from '../../../../server/server'; + +interface PaymentAttemptAttributes + extends Model, InferCreationAttributes> { + visitorId: string; + isChargebacked: boolean; + usedStolenCard: boolean; + checkResult: string; + timestamp: number; +} + +export const PaymentAttemptDbModel = sequelize.define('payment-attempt', { + visitorId: { + type: DataTypes.STRING, + }, + isChargebacked: { + type: DataTypes.BOOLEAN, + }, + usedStolenCard: { + type: DataTypes.BOOLEAN, + }, + checkResult: { + type: DataTypes.STRING, + }, + timestamp: { + type: DataTypes.DATE, + }, +}); + +export type PaymentAttempt = Attributes; + +PaymentAttemptDbModel.sync({ force: false }); diff --git a/src/pages/api/payment-fraud/place-order.ts b/src/app/payment-fraud/api/place-order/route-old.ts similarity index 84% rename from src/pages/api/payment-fraud/place-order.ts rename to src/app/payment-fraud/api/place-order/route-old.ts index d1dd9f4b..347b530b 100644 --- a/src/pages/api/payment-fraud/place-order.ts +++ b/src/app/payment-fraud/api/place-order/route-old.ts @@ -6,56 +6,18 @@ import { messageSeverity, reportSuspiciousActivity, sequelize, -} from '../../../server/server'; -import { CheckResult, checkResultType } from '../../../server/checkResult'; +} from '../../../../server/server'; +import { CheckResult, checkResultType } from '../../../../server/checkResult'; import { RuleCheck, checkConfidenceScore, checkFreshIdentificationRequest, checkIpAddressIntegrity, checkOriginsIntegrity, -} from '../../../server/checks'; -import { sendForbiddenResponse, sendOkResponse } from '../../../server/response'; +} from '../../../../server/checks'; +import { sendForbiddenResponse, sendOkResponse } from '../../../../server/response'; import { NextApiRequest, NextApiResponse } from 'next'; -import { PAYMENT_FRAUD_COPY } from '../../../server/paymentFraud/copy'; - -interface PaymentAttemptAttributes - extends Model, InferCreationAttributes> { - visitorId: string; - isChargebacked: boolean; - usedStolenCard: boolean; - checkResult: string; - timestamp: number; -} - -export const PaymentAttemptDbModel = sequelize.define('payment-attempt', { - visitorId: { - type: DataTypes.STRING, - }, - isChargebacked: { - type: DataTypes.BOOLEAN, - }, - usedStolenCard: { - type: DataTypes.BOOLEAN, - }, - checkResult: { - type: DataTypes.STRING, - }, - timestamp: { - type: DataTypes.DATE, - }, -}); - -export type PaymentAttempt = Attributes; - -// Mocked credit card details. -const mockedCard = { - number: '4242 4242 4242 4242', - expiration: '04/28', - cvv: '123', -}; - -PaymentAttemptDbModel.sync({ force: false }); +import { PAYMENT_FRAUD_COPY } from './copy'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { // This API route accepts only POST requests. diff --git a/src/app/payment-fraud/api/place-order/route.ts b/src/app/payment-fraud/api/place-order/route.ts new file mode 100644 index 00000000..25e43965 --- /dev/null +++ b/src/app/payment-fraud/api/place-order/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server'; +import { getAndValidateFingerprintResult, Severity } from '../../../../server/checks'; +import { PaymentAttemptDbModel } from './database'; +import { PAYMENT_FRAUD_COPY } from './copy'; +import { Op } from 'sequelize'; +import { checkResultType } from '../../../../server/checkResult'; +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 logPaymentAttempt( + visitorId: string, + isChargebacked: boolean, + usedStolenCard: boolean, + paymentAttemptCheckResult: string, +) { + await PaymentAttemptDbModel.create({ + visitorId, + isChargebacked, + usedStolenCard, + checkResult: paymentAttemptCheckResult, + timestamp: new Date().getTime(), + }); + await sequelize.sync(); +} + +export type PaymentPayload = { + requestId: string; + applyChargeback: boolean; + usingStolenCard: boolean; + cardNumber: string; + cardCvv: string; + cardExpiration: string; +}; + +export type PaymentResponse = { + message: string; + severity: Severity; +}; + +export async function POST(req: Request): Promise> { + const { requestId, applyChargeback, usingStolenCard, cardNumber, cardCvv, cardExpiration } = + (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, + usedStolenCard: 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 number for you use case) + const chargebacksFiledPastYear = await PaymentAttemptDbModel.findAndCountAll({ + where: { + visitorId, + isChargebacked: 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, + }, + checkResult: { + [Op.not]: checkResultType.Passed, + }, + }, + }); + if (invalidPaymentsPastYear.count > 2) { + return NextResponse.json( + { severity: 'error', message: PAYMENT_FRAUD_COPY.tooManyUnsuccessfulPayments }, + { status: 403 }, + ); + } + + // TODO: Fix and refactor this + + if (!areCardDetailsCorrect({ number: cardNumber, expiration: cardExpiration, cvv: cardCvv })) { + logPaymentAttempt(visitorId, applyChargeback, usingStolenCard, 'Incorrect card details'); + return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.incorrectCardDetails }, { status: 403 }); + } else { + logPaymentAttempt(visitorId, applyChargeback, usingStolenCard, 'Successful payment'); + return NextResponse.json({ severity: 'success', message: PAYMENT_FRAUD_COPY.successfulPayment }, { status: 200 }); + } +} diff --git a/src/app/payment-fraud/embed/page.tsx b/src/app/payment-fraud/embed/page.tsx new file mode 100644 index 00000000..fc0fbcf2 --- /dev/null +++ b/src/app/payment-fraud/embed/page.tsx @@ -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 ; +} diff --git a/src/app/payment-fraud/page.tsx b/src/app/payment-fraud/page.tsx new file mode 100644 index 00000000..794e6287 --- /dev/null +++ b/src/app/payment-fraud/page.tsx @@ -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 ; +} diff --git a/src/pages/payment-fraud/paymentFraud.module.scss b/src/app/payment-fraud/paymentFraud.module.scss similarity index 100% rename from src/pages/payment-fraud/paymentFraud.module.scss rename to src/app/payment-fraud/paymentFraud.module.scss diff --git a/src/pages/api/admin/reset.ts b/src/pages/api/admin/reset.ts index 06818dfc..f25ea00c 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/pages/api/admin/reset.ts @@ -1,5 +1,5 @@ import { isValidPostRequest } from '../../../server/server'; -import { PaymentAttemptDbModel } from '../payment-fraud/place-order'; +import { PaymentAttemptDbModel } from '../../../app/payment-fraud/api/place-order/route-old'; import { UserCartItemDbModel, UserPreferencesDbModel, diff --git a/src/pages/payment-fraud/embed.tsx b/src/pages/payment-fraud/embed.tsx deleted file mode 100644 index 6dd0bb8e..00000000 --- a/src/pages/payment-fraud/embed.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { GetStaticProps } from 'next'; -import PaymentFraud from './index'; -import { CustomPageProps } from '../_app'; - -export default PaymentFraud; - -export const getStaticProps: GetStaticProps = async () => { - return { - props: { - embed: true, - }, - }; -}; From 066490b0874b16de28b5ad2ab5c0503ab488a1c0 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 20 Sep 2024 21:59:32 +0100 Subject: [PATCH 2/5] chore: refactor payment fraud --- src/app/payment-fraud/PaymentFraud.tsx | 76 +++++++++---------- .../payment-fraud/api/place-order/database.ts | 15 ++-- .../payment-fraud/api/place-order/route.ts | 55 ++++---------- 3 files changed, 57 insertions(+), 89 deletions(-) diff --git a/src/app/payment-fraud/PaymentFraud.tsx b/src/app/payment-fraud/PaymentFraud.tsx index d7cf6046..889d50d2 100644 --- a/src/app/payment-fraud/PaymentFraud.tsx +++ b/src/app/payment-fraud/PaymentFraud.tsx @@ -12,10 +12,11 @@ import { Alert } from '../../client/components/common/Alert/Alert'; 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 function PaymentFraud() { - const { getData } = useVisitorData( + const { getData: getVisitorData } = useVisitorData( { ignoreCache: true }, { immediate: false, @@ -26,47 +27,41 @@ export function PaymentFraud() { 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(); - const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); - const [httpResponseStatus, setHttpResponseStatus] = useState(); + + const { + mutate: submitPayment, + isLoading: isLoadingPayment, + data: paymentResponse, + error: paymentNetworkError, + } = useMutation, 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) { 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 ( @@ -117,7 +112,7 @@ export function PaymentFraud() { setApplyChargeback(event.target.checked)} + onChange={(event) => setFiledChargeback(event.target.checked)} data-testid={TEST_IDS.paymentFraud.askForChargeback} /> Ask for chargeback after purchase @@ -136,14 +131,15 @@ export function PaymentFraud() {
- {httpResponseStatus ? {orderStatusMessage} : null} + {paymentNetworkError ? {paymentNetworkError.message} : null} + {paymentResponse ? {paymentResponse.message} : null} diff --git a/src/app/payment-fraud/api/place-order/database.ts b/src/app/payment-fraud/api/place-order/database.ts index 494ff5d0..da9b59b6 100644 --- a/src/app/payment-fraud/api/place-order/database.ts +++ b/src/app/payment-fraud/api/place-order/database.ts @@ -4,9 +4,9 @@ import { sequelize } from '../../../../server/server'; interface PaymentAttemptAttributes extends Model, InferCreationAttributes> { visitorId: string; - isChargebacked: boolean; - usedStolenCard: boolean; - checkResult: string; + filedChargeback: boolean; + usingStolenCard: boolean; + wasSuccessful: boolean; timestamp: number; } @@ -14,14 +14,14 @@ export const PaymentAttemptDbModel = sequelize.define( visitorId: { type: DataTypes.STRING, }, - isChargebacked: { + filedChargeback: { type: DataTypes.BOOLEAN, }, - usedStolenCard: { + usingStolenCard: { type: DataTypes.BOOLEAN, }, - checkResult: { - type: DataTypes.STRING, + wasSuccessful: { + type: DataTypes.BOOLEAN, }, timestamp: { type: DataTypes.DATE, @@ -29,5 +29,6 @@ export const PaymentAttemptDbModel = sequelize.define( }); export type PaymentAttempt = Attributes; +export type PaymentAttemptData = Omit; PaymentAttemptDbModel.sync({ force: false }); diff --git a/src/app/payment-fraud/api/place-order/route.ts b/src/app/payment-fraud/api/place-order/route.ts index 25e43965..f7c3d18f 100644 --- a/src/app/payment-fraud/api/place-order/route.ts +++ b/src/app/payment-fraud/api/place-order/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from 'next/server'; import { getAndValidateFingerprintResult, Severity } from '../../../../server/checks'; -import { PaymentAttemptDbModel } from './database'; +import { PaymentAttemptData, PaymentAttemptDbModel } from './database'; import { PAYMENT_FRAUD_COPY } from './copy'; import { Op } from 'sequelize'; -import { checkResultType } from '../../../../server/checkResult'; import { sequelize } from '../../../../server/server'; type Card = { @@ -23,17 +22,9 @@ function areCardDetailsCorrect(card: Card) { return card.number === mockedCard.number && card.expiration === mockedCard.expiration && card.cvv === mockedCard.cvv; } -async function logPaymentAttempt( - visitorId: string, - isChargebacked: boolean, - usedStolenCard: boolean, - paymentAttemptCheckResult: string, -) { +async function savePaymentAttempt(paymentAttempt: PaymentAttemptData) { await PaymentAttemptDbModel.create({ - visitorId, - isChargebacked, - usedStolenCard, - checkResult: paymentAttemptCheckResult, + ...paymentAttempt, timestamp: new Date().getTime(), }); await sequelize.sync(); @@ -41,11 +32,9 @@ async function logPaymentAttempt( export type PaymentPayload = { requestId: string; - applyChargeback: boolean; + filedChargeback: boolean; usingStolenCard: boolean; - cardNumber: string; - cardCvv: string; - cardExpiration: string; + card: Card; }; export type PaymentResponse = { @@ -54,8 +43,7 @@ export type PaymentResponse = { }; export async function POST(req: Request): Promise> { - const { requestId, applyChargeback, usingStolenCard, cardNumber, cardCvv, cardExpiration } = - (await req.json()) as PaymentPayload; + 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 }); @@ -70,12 +58,7 @@ export async function POST(req: Request): Promise> } // If this visitor ID ever paid with a stolen credit card, do not process the payment - const usedStolenCreditCard = await PaymentAttemptDbModel.findOne({ - where: { - visitorId, - usedStolenCard: true, - }, - }); + const usedStolenCreditCard = await PaymentAttemptDbModel.findOne({ where: { visitorId, usingStolenCard: true } }); if (usedStolenCreditCard) { return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.stolenCard }, { status: 403 }); } @@ -83,13 +66,7 @@ export async function POST(req: Request): Promise> // If the visitor ID filed more than 1 chargeback in the last year, do not process the payment. // (Adjust the number for you use case) const chargebacksFiledPastYear = await PaymentAttemptDbModel.findAndCountAll({ - where: { - visitorId, - isChargebacked: true, - timestamp: { - [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000, - }, - }, + 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 }); @@ -99,12 +76,8 @@ export async function POST(req: Request): Promise> const invalidPaymentsPastYear = await PaymentAttemptDbModel.findAndCountAll({ where: { visitorId, - timestamp: { - [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000, - }, - checkResult: { - [Op.not]: checkResultType.Passed, - }, + timestamp: { [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000 }, + wasSuccessful: false, }, }); if (invalidPaymentsPastYear.count > 2) { @@ -114,13 +87,11 @@ export async function POST(req: Request): Promise> ); } - // TODO: Fix and refactor this - - if (!areCardDetailsCorrect({ number: cardNumber, expiration: cardExpiration, cvv: cardCvv })) { - logPaymentAttempt(visitorId, applyChargeback, usingStolenCard, 'Incorrect card details'); + if (!areCardDetailsCorrect(card)) { + savePaymentAttempt({ visitorId, filedChargeback, usingStolenCard, wasSuccessful: false }); return NextResponse.json({ severity: 'error', message: PAYMENT_FRAUD_COPY.incorrectCardDetails }, { status: 403 }); } else { - logPaymentAttempt(visitorId, applyChargeback, usingStolenCard, 'Successful payment'); + savePaymentAttempt({ visitorId, filedChargeback, usingStolenCard, wasSuccessful: true }); return NextResponse.json({ severity: 'success', message: PAYMENT_FRAUD_COPY.successfulPayment }, { status: 200 }); } } From efee3a1a1f9e81ad6a160d71ebcdc9d3cc26e28d Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Sat, 21 Sep 2024 14:46:29 +0100 Subject: [PATCH 3/5] chore: fix bugs, build --- .../api/place-order/route-old.ts | 183 ------------------ src/pages/api/admin/reset.ts | 2 +- 2 files changed, 1 insertion(+), 184 deletions(-) delete mode 100644 src/app/payment-fraud/api/place-order/route-old.ts diff --git a/src/app/payment-fraud/api/place-order/route-old.ts b/src/app/payment-fraud/api/place-order/route-old.ts deleted file mode 100644 index 347b530b..00000000 --- a/src/app/payment-fraud/api/place-order/route-old.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize'; -import { - ensurePostRequest, - ensureValidRequestIdAndVisitorId, - getIdentificationEvent, - messageSeverity, - reportSuspiciousActivity, - sequelize, -} from '../../../../server/server'; -import { CheckResult, checkResultType } from '../../../../server/checkResult'; -import { - RuleCheck, - checkConfidenceScore, - checkFreshIdentificationRequest, - checkIpAddressIntegrity, - checkOriginsIntegrity, -} from '../../../../server/checks'; -import { sendForbiddenResponse, sendOkResponse } from '../../../../server/response'; -import { NextApiRequest, NextApiResponse } from 'next'; -import { PAYMENT_FRAUD_COPY } from './copy'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // This API route accepts only POST requests. - if (!ensurePostRequest(req, res)) { - return; - } - - res.setHeader('Content-Type', 'application/json'); - - return await tryToProcessPayment(req, res, [ - checkFreshIdentificationRequest, - checkConfidenceScore, - checkIpAddressIntegrity, - checkOriginsIntegrity, - checkVisitorIdForStolenCard, - checkVisitorIdForChargebacks, - checkForCardCracking, - processPayment, - ]); -} - -async function tryToProcessPayment(req: NextApiRequest, res: NextApiResponse, ruleChecks: RuleCheck[]) { - // Get requestId and visitorId from the client. - const visitorId = req.body.visitorId; - const requestId = req.body.requestId; - const applyChargeback = req.body.applyChargeback; - const usedStolenCard = req.body.usingStolenCard; - - if (!ensureValidRequestIdAndVisitorId(req, res, visitorId, requestId)) { - return; - } - - // Information from the client side might have been tampered. - // It's best practice to validate provided information with the Server API. - // It is recommended to use the requestId and visitorId pair. - const eventResponse = await getIdentificationEvent(requestId); - - for (const ruleCheck of ruleChecks) { - const result = await ruleCheck(eventResponse, req); - - if (result) { - await logPaymentAttempt(visitorId, applyChargeback, usedStolenCard, result.type); - - switch (result.type) { - case checkResultType.Passed: - case checkResultType.Challenged: - return sendOkResponse(res, result); - default: - reportSuspiciousActivity(req); - return sendForbiddenResponse(res, result); - } - } - } -} - -const checkVisitorIdForStolenCard: RuleCheck = async (eventResponse) => { - // Get all stolen card records for the visitorId - const stolenCardUsedCount = await PaymentAttemptDbModel.findAndCountAll({ - where: { - visitorId: eventResponse.products?.identification?.data?.visitorId, - usedStolenCard: true, - }, - }); - - // If the visitorId performed more than 1 payment with a stolen card during the last 1 year we do not process the payment. - // The time window duration might vary. - if (stolenCardUsedCount.count > 0) { - return new CheckResult(PAYMENT_FRAUD_COPY.stolenCard, messageSeverity.Error, checkResultType.PaidWithStolenCard); - } - - return undefined; -}; - -const checkForCardCracking: RuleCheck = async (eventResponse) => { - // Gets all unsuccessful attempts for the visitor during the last 365 days. - const invalidCardAttemptCountQueryResult = await PaymentAttemptDbModel.findAndCountAll({ - where: { - visitorId: eventResponse.products?.identification?.data?.visitorId, - timestamp: { - [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000, - }, - checkResult: { - [Op.not]: checkResultType.Passed, - }, - }, - }); - - // If the visitorId performed 3 unsuccessful payments during the last 365 days we do not process any further payments. - // The count of attempts and time window might vary. - if (invalidCardAttemptCountQueryResult.count > 2) { - return new CheckResult( - PAYMENT_FRAUD_COPY.tooManyUnsuccessfulPayments, - messageSeverity.Error, - checkResultType.TooManyUnsuccessfulPayments, - ); - } - - return undefined; -}; - -const checkVisitorIdForChargebacks: RuleCheck = async (eventResponse) => { - // Gets all unsuccessful attempts during the last 365 days. - const countOfChargebacksForVisitorId = await PaymentAttemptDbModel.findAndCountAll({ - where: { - visitorId: eventResponse.products?.identification?.data?.visitorId, - isChargebacked: true, - timestamp: { - [Op.gt]: new Date().getTime() - 365 * 24 * 60 * 1000, - }, - }, - }); - - // If the visitorId performed more than 1 chargeback during the last 1 year we do not process the payment. - // The count of chargebacks and time window might vary. - if (countOfChargebacksForVisitorId.count > 1) { - return new CheckResult( - PAYMENT_FRAUD_COPY.previousChargeback, - messageSeverity.Error, - checkResultType.TooManyChargebacks, - ); - } - - return undefined; -}; - -const processPayment: RuleCheck = async (_eventResponse, request) => { - // Checks if the provided card details are correct. - if (areCardDetailsCorrect(request)) { - return new CheckResult(PAYMENT_FRAUD_COPY.successfulPayment, messageSeverity.Success, checkResultType.Passed); - } else { - return new CheckResult( - PAYMENT_FRAUD_COPY.incorrectCardDetails, - messageSeverity.Error, - checkResultType.IncorrectCardDetails, - ); - } -}; - -// Dummy action simulating card verification. -function areCardDetailsCorrect(request: NextApiRequest) { - return ( - request.body.cardNumber === mockedCard.number && - request.body.cardExpiration === mockedCard.expiration && - request.body.cardCvv === mockedCard.cvv - ); -} - -// Persists placed order to the database. -async function logPaymentAttempt( - visitorId: string, - isChargebacked: boolean, - usedStolenCard: boolean, - paymentAttemptCheckResult: string, -) { - await PaymentAttemptDbModel.create({ - visitorId, - isChargebacked, - usedStolenCard, - checkResult: paymentAttemptCheckResult, - timestamp: new Date().getTime(), - }); - await sequelize.sync(); -} diff --git a/src/pages/api/admin/reset.ts b/src/pages/api/admin/reset.ts index e1133cf5..1d82af20 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/pages/api/admin/reset.ts @@ -1,5 +1,4 @@ import { isValidPostRequest } from '../../../server/server'; -import { PaymentAttemptDbModel } from '../../../app/payment-fraud/api/place-order/route-old'; import { UserCartItemDbModel, UserPreferencesDbModel, @@ -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; From 36eea9bd79278214d76d668fc59da19500d9c976 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Sat, 21 Sep 2024 14:55:18 +0100 Subject: [PATCH 4/5] chore: self review fixes --- src/app/payment-fraud/api/place-order/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/payment-fraud/api/place-order/route.ts b/src/app/payment-fraud/api/place-order/route.ts index f7c3d18f..55948773 100644 --- a/src/app/payment-fraud/api/place-order/route.ts +++ b/src/app/payment-fraud/api/place-order/route.ts @@ -64,7 +64,7 @@ export async function POST(req: Request): Promise> } // If the visitor ID filed more than 1 chargeback in the last year, do not process the payment. - // (Adjust the number for you use case) + // (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 } }, }); @@ -87,6 +87,7 @@ export async function POST(req: Request): Promise> ); } + // 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 }); From a0c966ede5d1055d9f1c13d96f4d8e1317ef693b Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 26 Sep 2024 09:12:58 +0400 Subject: [PATCH 5/5] chore: fix demo --- src/app/payment-fraud/api/place-order/route.ts | 6 +++++- src/pages/api/admin/reset.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/payment-fraud/api/place-order/route.ts b/src/app/payment-fraud/api/place-order/route.ts index 55948773..d12ed5c1 100644 --- a/src/app/payment-fraud/api/place-order/route.ts +++ b/src/app/payment-fraud/api/place-order/route.ts @@ -46,7 +46,11 @@ export async function POST(req: Request): Promise> 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 }); + const fingerprintResult = await getAndValidateFingerprintResult({ + requestId, + req, + options: { minConfidenceScore: 0.2 }, + }); if (!fingerprintResult.okay) { return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 }); } diff --git a/src/pages/api/admin/reset.ts b/src/pages/api/admin/reset.ts index 941cd921..362d2d37 100644 --- a/src/pages/api/admin/reset.ts +++ b/src/pages/api/admin/reset.ts @@ -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;