diff --git a/.env.template b/.env.template index 9b637962..e96917d7 100644 --- a/.env.template +++ b/.env.template @@ -6,10 +6,9 @@ NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000 NEXT_PUBLIC_GA_TRACKING_ID=REDACTED NEXT_PUBLIC_DISCORD_SERVER_URL=https://discord.gg/bnJ3narzF3 -LYF_API_VENDOR_ID=deebb957-9025-4894-b52d-24493cdb7278 -LYF_API_SECRET_KEY=B52DFC6C7F4AA054CDB08E38B2298C97A4A12039 -LYF_CREDIT_CARD_API_URL=https://sandbox-webpos.lyf.eu/fr/plugin/PaymentCb.aspx -LYF_FROM_APPLICATION_API_URL=https://sandbox-webpos.lyf.eu/fr/plugin/Payment.aspx +LYF_API_VENDOR_ID=REDACTED +LYF_API_SECRET_KEY=REDACTED +LYF_API_DOMAIN=https://sandbox-acceptance.lyf.eu SESSION_SECRET_KEY=generate_on_https://randomkeygen.com diff --git a/app/components/hub/transactions/operations/topUp/TopUp.tsx b/app/components/hub/transactions/operations/topUp/TopUp.tsx index a17a7ddb..701ac5fa 100644 --- a/app/components/hub/transactions/operations/topUp/TopUp.tsx +++ b/app/components/hub/transactions/operations/topUp/TopUp.tsx @@ -1,26 +1,19 @@ -import { useState } from 'react'; - -import { useAuthenticatedSession } from '@blitzjs/auth'; import { useMutation } from '@blitzjs/rpc'; import { TopUpInputType } from 'app/components/forms/validations'; import Snackbar from 'app/core/layouts/Snackbar'; import useSnackbar from 'app/entities/hooks/useSnackbar'; -import requestTopUp, { PaymentMethod } from 'app/entities/transactions/mutations/requestTopUp'; +import requestTopUp from 'app/entities/transactions/mutations/requestTopUp'; import TopUpForm from './TopUpForm'; export default function TopUp() { const { open, message, severity, onClose, onShow } = useSnackbar(); - const [paymentMethod, setPaymentMethod] = useState('credit'); const [topUp] = useMutation(requestTopUp); - const beforeSubmit = (paymentMethod: PaymentMethod) => () => setPaymentMethod(paymentMethod); - const onSuccess = (data: TopUpInputType) => { topUp({ - amount: data.amount, - method: paymentMethod + amount: data.amount }).then( (url) => { window.location.assign(url as string); @@ -33,7 +26,7 @@ export default function TopUp() { return ( <> - + diff --git a/app/components/hub/transactions/operations/topUp/TopUpForm.tsx b/app/components/hub/transactions/operations/topUp/TopUpForm.tsx index f3a5a3e6..07456d0c 100644 --- a/app/components/hub/transactions/operations/topUp/TopUpForm.tsx +++ b/app/components/hub/transactions/operations/topUp/TopUpForm.tsx @@ -1,16 +1,12 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; -import Image from 'next/image'; - import EnhancedTextField from 'app/components/forms/EnhancedTextfield'; import { FORM_ERROR, Form } from 'app/components/forms/Form'; import { TopUpInput, TopUpInputType } from 'app/components/forms/validations'; -import { PaymentMethod } from 'app/entities/transactions/mutations/requestTopUp'; type TopUpFormProps = { onSuccess: (values: TopUpInputType) => void; - beforeSubmit: (paymentMethod: PaymentMethod) => () => void; }; export default function TopUpForm(props: TopUpFormProps) { @@ -36,21 +32,9 @@ export default function TopUpForm(props: TopUpFormProps) { > -
- - - -
+ Si vous rencontrez un problème lors de votre rechargement, contactez un membre BDE diff --git a/app/core/utils/topup.ts b/app/core/utils/topup.ts deleted file mode 100644 index 3ed675fb..00000000 --- a/app/core/utils/topup.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BinaryLike, KeyObject, createHmac } from 'crypto'; - -export function makeMerchantReference(card: number, timestamp: number) { - const card_prefix = card > 0 ? 'p' : 'm'; - - return `r${card_prefix}${Math.abs(card)}t${Math.ceil(timestamp)}`; -} - -export function makeShopOrderReference(card: number, amount: number) { - const card_prefix = card > 0 ? 'p' : 'm'; - - return `o${card_prefix}${Math.abs(card)}a${amount}`; -} - -export function makeHmac(elements: any[], secret: BinaryLike | KeyObject) { - return createHmac('sha1', secret).update(elements.join('*')).digest('hex'); -} - -export type TopUpInfo = { - userId: string; - card: number; - amount: number; - reference: string; - orderReference: string; - creationDate: number; - byCreditCard: boolean; -}; - -export function generateTopUpToken(info: TopUpInfo, secret: BinaryLike | KeyObject) { - let ret = Buffer.from(JSON.stringify(info)).toString('base64url'); - - return `${ret}.${createHmac('sha256', secret).update(ret).digest('hex')}`; -} - -export function parseTopUpToken(token: string, secret: BinaryLike | KeyObject) { - let data = token.split('.'); - - if (data.length != 2) return null; - - if (data[1] != createHmac('sha256', secret).update(data[0]).digest('hex')) return null; - - try { - return JSON.parse(Buffer.from(data[0], 'base64url').toString('utf8')); - } catch { - return null; - } -} diff --git a/app/core/utils/topup/helper.ts b/app/core/utils/topup/helper.ts new file mode 100644 index 00000000..4cb52dd5 --- /dev/null +++ b/app/core/utils/topup/helper.ts @@ -0,0 +1,111 @@ +import { BinaryLike, KeyObject, createHmac, randomBytes } from 'crypto'; +import { stringify } from 'safe-stable-stringify'; +import zod, { string } from 'zod'; + +export function makeMerchantReference(card: number, timestamp: number) { + const card_prefix = card > 0 ? 'p' : 'm'; + + return `r${card_prefix}${Math.abs(card)}t${Math.ceil(timestamp)}`; +} + +export function makeShopOrderReference(card: number, amount: number) { + const card_prefix = card > 0 ? 'p' : 'm'; + + return `o${card_prefix}${Math.abs(card)}a${amount}`; +} + +const AuthorizationObjectScheme = zod.object({ + nonce: zod.string(), + timestamp: zod.number().int().positive(), + id: string().uuid(), + hash: string() +}); + +export function makeJson(from): string { + return stringify(from)!; +} + +function makeNonce() { + return randomBytes(3).toString('hex'); +} + +function makeHmac( + secret: BinaryLike | KeyObject, + payload: string, + timestamp: number, + path: string, + nonce: string, + method: string, + algorithm: string +): string { + const raw = [method, path, payload, nonce, timestamp].join('+'); + + return createHmac(algorithm, secret).update(raw).digest('hex'); +} + +export function makeAuthorizationHeader( + secret: BinaryLike | KeyObject, + posId: string, + payload: string, + timestamp: number, + path: string, + nonce: string = makeNonce(), + method: string = 'POST', + algorithm: string = 'sha512' +): string { + const hmac = makeHmac(secret, payload, timestamp, path, nonce, method, algorithm); + + return `MAC ${hmac}, nonce=${nonce}, timestamp=${timestamp}, id=${posId}, hash=${algorithm}`; +} + +export function validateAuthorizationHeader( + handledSecrets: Map, + authorizationString: string, + payload: string, + path: string, + method: string = 'POST' +): boolean { + const [hmac, ...rawParameters] = authorizationString.split(','); + const { nonce, timestamp, id, hash } = AuthorizationObjectScheme.parse( + new Map(rawParameters.map((v) => v.split('=', 1) as [string, string])) + ); + + if (handledSecrets.has(id)) { + const secret = handledSecrets[id]; + + const refHmac = makeHmac(secret, payload, timestamp, path, nonce, method, hash); + + return hmac == `MAC ${refHmac}`; + } else { + return false; + } +} + +// export type TopUpInfo = { +// userId: string; +// card: number; +// amount: number; +// reference: string; +// orderReference: string; +// creationDate: number; +// }; + +// export function generateTopUpToken(info: TopUpInfo, secret: BinaryLike | KeyObject) { +// let ret = Buffer.from(JSON.stringify(info)).toString('base64url'); + +// return `${ret}.${createHmac('sha512', secret).update(ret).digest('hex')}`; +// } + +// export function parseTopUpToken(token: string, secret: BinaryLike | KeyObject) { +// let data = token.split('.'); + +// if (data.length != 2) return null; + +// if (data[1] != createHmac('sha512', secret).update(data[0]).digest('hex')) return null; + +// try { +// return JSON.parse(Buffer.from(data[0], 'base64url').toString('utf8')); +// } catch { +// return null; +// } +// } diff --git a/app/core/utils/topup/types.ts b/app/core/utils/topup/types.ts new file mode 100644 index 00000000..67126303 --- /dev/null +++ b/app/core/utils/topup/types.ts @@ -0,0 +1,329 @@ +// Automatically generated from a conversion from the inferred JSON +// schema file from the Swagger-schema file provided by the documentation +// by the tool available at https://transform.tuyen.blog/json-schema-to-zod +import * as z from 'zod'; + +// Payment context information: +// - `PayAtTable`: Use for pay at table context. +// - `FaceToFace`: Use for payment in presence of the client (e.g. the merchant present a +// dedicated QR Code containing the PaymentIntent URL). +// - `ScanAndGo`: Use for Scan and Go context + +export const PaymentContextEnumSchema = z.enum(['FaceToFace', 'PayAtTable', 'ScanAndGo']); +export type PaymentContextEnum = z.infer; + +export const VersionEnumSchema = z.enum(['v4.0']); +export type VersionEnum = z.infer; + +export const NextStepTypeEnumSchema = z.enum([ + 'applePay', + 'hostedFields', + 'lyfPay', + 'moneticoPaymentPage', + 'uri', + 'uriOnRequest' +]); +export type NextStepTypeEnum = z.infer; + +// Strategy for handling end-user interaction. This is separate from the PaymentIntent +// status. + +export const PaymentIntentStrategyEnumSchema = z.enum([ + 'cancelled', + 'error', + 'expired', + 'paymentMeans', + 'paymentMeansPostLunchCard', + 'polling', + 'success' +]); +export type PaymentIntentStrategyEnum = z.infer; + +// Strategy to recover the money. + +export const FundingOptionStrategyEnumSchema = z.enum([ + 'IMMEDIATE_FUNDING', + 'POSTPONED_BY_WALLET_FUNDING', + 'POSTPONED_FUNDING' +]); +export type FundingOptionStrategyEnum = z.infer; + +// Type of the funding. + +export const FundingTypeEnumSchema = z.enum(['CREDIT_CARD', 'ELECTRONIC_MONEY', 'EXTERNAL_WALLET', 'LUNCH_CARD']); +export type FundingTypeEnum = z.infer; + +export const OfferTypeEnumSchema = z.enum(['DISCOUNT', 'GIFT']); +export type OfferTypeEnum = z.infer; + +// Type of the movement. + +export const BillOperationEnumSchema = z.enum(['CREDIT', 'DEBIT']); +export type BillOperationEnum = z.infer; + +// Indicates the status of the transaction recovery. + +export const RecoveryStatusEnumSchema = z.enum(['AWAITING', 'DONE', 'PROCESSING']); +export type RecoveryStatusEnum = z.infer; + +// Refund reason of the transaction. + +export const RefundReasonEnumSchema = z.enum([ + 'CANCELED_ORDER', + 'GOODWILL_GESTURE', + 'OTHER', + 'REGULARIZATION', + 'RETURNED_ORDER', + 'UNWANTED_TRANSACTION' +]); +export type RefundReasonEnum = z.infer; + +// Refusal reason of the transaction. + +export const RefusalReasonEnumSchema = z.enum([ + 'CREDIT_REFUSED_BY_PSP', + 'CREDITOR_CANCELLATION', + 'DEBTOR_CANCELLATION', + 'OPTION_AMOUNT_LIMIT_FAILED', + 'REGULARIZATION', + 'UNCERTIFIED_SHOP_OVERALL_PAYMENT_CEIL_REACHED', + 'WALLET_REFUSED' +]); +export type RefusalReasonEnum = z.infer; + +// Final status of the transaction. + +export const StatusEnumSchema = z.enum(['REFUSED', 'VALIDATED']); +export type StatusEnum = z.infer; + +export const TypeEnumSchema = z.enum(['CASH_IN', 'CASH_OUT', 'PAYMENT', 'REFUND']); +export type TypeEnum = z.infer; + +export const HttpErrorResourceSchema = z.object({ + errorCode: z.string().optional(), + httpStatus: z.number().optional(), + message: z.string().optional() +}); +export type HttpErrorResource = z.infer; + +export const BusinessContextResourceSchema = z.object({ + employeeId: z.string().optional(), + meetingId: z.string().optional(), + saleContextId: z.string().optional(), + sellerId: z.string().optional() +}); +export type BusinessContextResource = z.infer; + +export const ClientInfoResourceSchema = z.object({ + email: z.string().optional(), + phoneNumber: z.string().optional() +}); +export type ClientInfoResource = z.infer; + +export const DistributionRequestResourceSchema = z.object({ + amount: z.number(), + shopUuid: z.string() +}); +export type DistributionRequestResource = z.infer; + +export const PaymentIntentAddressSchema = z.object({ + address: z.string().optional(), + city: z.string().optional(), + country: z.string().optional(), + zipCode: z.string().optional() +}); +export type PaymentIntentAddress = z.infer; + +export const PaymentMeanResourceSchema = z.object({ + identifier: z.string().optional(), + isLunchCard: z.boolean().optional(), + logoFile: z.string().optional(), + name: z.string().optional(), + nextStepType: NextStepTypeEnumSchema.optional(), + uri: z.string().optional() +}); +export type PaymentMeanResource = z.infer; + +export const ShopResourceSchema = z.object({ + name: z.string().optional(), + shopLogoUri: z.string().optional() +}); +export type ShopResource = z.infer; + +export const CreditorSchema = z.object({ + city: z.string().optional(), + name: z.string().optional(), + readableId: z.string().optional(), + uuid: z.string().optional() +}); +export type Creditor = z.infer; + +export const DebtorSchema = z.object({ + city: z.string().optional(), + name: z.string().optional(), + origin: z.string().optional(), + readableId: z.string().optional(), + uuid: z.string().optional() +}); +export type Debtor = z.infer; + +export const FundingOptionSchema = z.object({ + strategy: FundingOptionStrategyEnumSchema.optional() +}); +export type FundingOption = z.infer; + +export const FundingOverviewSchema = z.object({ + amount: z.number().optional(), + technicalData: z.any().optional(), + type: FundingTypeEnumSchema.optional() +}); +export type FundingOverview = z.infer; + +export const OfferSchema = z.object({ + amount: z.number().optional(), + name: z.string().optional(), + type: OfferTypeEnumSchema.optional(), + value: z.number().optional() +}); +export type Offer = z.infer; + +export const PointOfSaleSchema = z.object({ + readableId: z.string().optional(), + uuid: z.string().optional() +}); +export type PointOfSale = z.infer; + +export const RecoverySchema = z.object({ + additionalData: z.any().optional(), + amount: z.number().optional(), + date: z.coerce.date().optional(), + uuid: z.string().optional() +}); +export type Recovery = z.infer; + +export const CreditCardSchema = z.object({ + walletId: z.string().optional() +}); +export type CreditCard = z.infer; + +export const OrderSchema = z.object({ + tipAmount: z.number().optional() +}); +export type Order = z.infer; + +export const SalesContextSchema = z.object({ + creationDate: z.coerce.date().optional(), + modificationDate: z.coerce.date().optional(), + posId: z.string().optional(), + text: z.string().optional(), + uuid: z.string().optional() +}); +export type SalesContext = z.infer; + +export const PaymentIntentRequestSchema = z.object({ + additionalData: z.record(z.string(), z.any()).optional(), + address: z.string().optional(), + amount: z.number(), + businessContext: BusinessContextResourceSchema.optional(), + callbackEmail: z.string().optional(), + callbackRequired: z.boolean().optional(), + callbackUrl: z.string().optional(), + city: z.string().optional(), + clientInfo: ClientInfoResourceSchema.optional(), + country: z.string().optional(), + currency: z.string(), + distributions: z.array(DistributionRequestResourceSchema).optional(), + eligibleLunchCardsAmount: z.number().optional(), + externalOrderReference: z.string().optional(), + externalReference: z.string(), + onCancel: z.string().optional(), + onError: z.string().optional(), + onSuccess: z.string().optional(), + paymentContext: PaymentContextEnumSchema.optional(), + tipAmount: z.number().optional(), + version: VersionEnumSchema, + zipCode: z.string().optional() +}); +export type PaymentIntentRequest = z.infer; + +export const PaymentIntentResponseSchema = z.object({ + address: PaymentIntentAddressSchema.optional(), + amount: z.number(), + currency: z.string(), + eligibleLunchCardsAmount: z.number(), + externalOrderReference: z.string().optional(), + externalReference: z.string(), + id: z.string(), + onCancel: z.string().optional(), + paymentContext: PaymentContextEnumSchema.optional(), + paymentMeans: z.array(PaymentMeanResourceSchema).optional(), + phoneNumber: z.string().optional(), + posId: z.string(), + redirectUri: z.string().optional(), + remainingBalance: z.number(), + shop: ShopResourceSchema, + strategy: PaymentIntentStrategyEnumSchema, + tipAmount: z.number(), + totalAmount: z.number() +}); +export type PaymentIntentResponse = z.infer; + +export const HostedFieldsResponseSchema = z.object({ + creditCard: CreditCardSchema.optional() +}); +export type HostedFieldsResponse = z.infer; + +export const HostedFieldsSchema = z.object({ + response: HostedFieldsResponseSchema.optional() +}); +export type HostedFields = z.infer; + +export const TechnicalDataSchema = z.object({ + hostedFields: HostedFieldsSchema.optional(), + order: OrderSchema.optional() +}); +export type TechnicalData = z.infer; + +export const BillSchema = z.object({ + additionalData: z.record(z.string(), z.any()).optional(), + amount: z.number().optional(), + creationDate: z.coerce.date().optional(), + creditor: CreditorSchema.optional(), + currency: z.string().optional(), + debtor: DebtorSchema.optional(), + discountAmount: z.number().optional(), + externalOrderReference: z.string().optional(), + externalReference: z.string().optional(), + fundingOption: FundingOptionSchema.optional(), + fundingOverview: z.array(FundingOverviewSchema).optional(), + initialAmount: z.number().optional(), + linkedTransactionUuid: z.string().optional(), + offers: z.array(OfferSchema).optional(), + operation: BillOperationEnumSchema.optional(), + pos: PointOfSaleSchema.optional(), + readableId: z.string().optional(), + recoveredAmount: z.number().optional(), + recoveries: z.array(RecoverySchema).optional(), + recoveryStatus: RecoveryStatusEnumSchema.optional(), + refundReason: RefundReasonEnumSchema.optional(), + refusalReason: RefusalReasonEnumSchema.optional(), + status: StatusEnumSchema.optional(), + technicalData: TechnicalDataSchema.optional(), + type: TypeEnumSchema.optional(), + uuid: z.string().optional() +}); +export type Bill = z.infer; + +export const WebhookResourceSchema = z.object({ + bill: BillSchema.optional(), + salesContext: SalesContextSchema.optional() +}); +export type WebhookResource = z.infer; + +// export const MyTypeSchema = z.object({ +// 'error': HttpErrorResourceSchema.optional(), +// 'request': PaymentIntentRequestSchema.optional(), +// 'response': PaymentIntentResponseSchema.optional(), +// 'webhook': WebhookResourceSchema.optional(), +// }); +// export type MyType = z.infer; diff --git a/app/entities/transactions/mutations/requestTopUp.ts b/app/entities/transactions/mutations/requestTopUp.ts index 05f3c4d1..09553008 100644 --- a/app/entities/transactions/mutations/requestTopUp.ts +++ b/app/entities/transactions/mutations/requestTopUp.ts @@ -1,131 +1,78 @@ import { Ctx } from 'blitz'; +import { createSecretKey } from 'crypto'; +import { stringify } from 'safe-stable-stringify'; +import zod from 'zod'; import { resolver } from '@blitzjs/rpc'; -import { generateTopUpToken, makeHmac, makeMerchantReference, makeShopOrderReference } from 'app/core/utils/topup'; - -type RequestTopUpInput = { - amount: number; - method: PaymentMethod; -}; - -export type PaymentMethod = 'credit' | 'lyf'; - -function generateHmac(data: URLSearchParams, additionalData: string, byCreditCard: boolean): string { - let list = [data.get('lang')]; - - let common = [ - data.get('posUuid'), - data.get('shopReference'), - data.get('shopOrderReference'), - data.get('deliveryFeesAmount'), - data.get('amount'), - data.get('currency') - ]; - - if (byCreditCard) { - list = list.concat(common); - list = list.concat([ - data.get('onSuccess'), - data.get('onError'), - additionalData, - data.get('callBackRequired'), - data.get('mode'), - data.get('address'), - data.get('city'), - data.get('country'), - data.get('zipCode') - ]); - } else { - list = list.concat([data.get('version'), data.get('timestamp')]); - list = list.concat(common); - list = list.concat([ - data.get('mode'), - data.get('onSuccess'), - data.get('onCancel'), - data.get('onError'), - additionalData, - data.get('enforcedIdentification') - ]); - } - - return makeHmac(list, `${process.env.LYF_API_SECRET_KEY}`); -} - -function prepareRequest(id: string, card: number, amount: number, byCreditCard: boolean): URLSearchParams { - const body = new URLSearchParams(); - - const timestamp = Math.floor(+new Date() / 1000); - const tAmount = Math.round(amount * 100); - - const shopReference = makeMerchantReference(card, timestamp); - const shopOrderReference = makeShopOrderReference(card, tAmount); +import { + makeAuthorizationHeader, + makeJson, + makeMerchantReference, + makeShopOrderReference +} from 'app/core/utils/topup/helper'; +import { + HttpErrorResourceSchema, + PaymentIntentRequest, + PaymentIntentRequestSchema, + PaymentIntentResponseSchema +} from 'app/core/utils/topup/types'; + +const INTENT_SUBPATH = '/acceptance/api/paymentIntents'; +const INTENT_FULL_PATH = `${process.env.LYF_REQUEST_TOPUP_DOMAIN}${INTENT_SUBPATH}`; + +const RequestTopUpInputSchema = zod.object({ + amount: zod + .number() + .min(5, { message: 'La quantité à recharger doit dépasser 5 euros.' }) + .max(1000, { message: 'La quantité à recharger ne peux pas exéder 1000 euros.' }) + .transform((v) => Math.floor(v * 100)) +}); +type RequestTopUpInput = zod.infer; - const token = generateTopUpToken( - { - userId: id, - card, - amount: tAmount, - reference: shopReference, - orderReference: shopOrderReference, - creationDate: timestamp, - byCreditCard +export default resolver.pipe(resolver.authorize(), async (input: RequestTopUpInput, ctx: Ctx) => { + const { amount } = RequestTopUpInputSchema.parse(input); + const card = ctx.session.card!; + const timestamp = Math.floor(new Date().getTime() / 1000); + const secret = createSecretKey(process.env.LYF_API_SECRET_KEY!, 'hex'); + const posId = process.env.LYF_API_VENDOR_ID!; + + const intent: PaymentIntentRequest = { + amount, + currency: 'EUR', + externalReference: makeMerchantReference(card, timestamp), + externalOrderReference: makeShopOrderReference(card, amount), + version: 'v4.0', + callbackRequired: true, + callbackEmail: 'contact@citorva.fr' + }; + + const payload = makeJson(intent); + + const authorization = makeAuthorizationHeader(secret, posId, payload, timestamp, INTENT_SUBPATH); + + const res = await fetch(INTENT_FULL_PATH, { + method: 'POST', + headers: { + Accept: 'application/vnd.eu.lyf+json;version=5', + Authorization: authorization }, - `${process.env.SESSION_SECRET_KEY}` - ); + body: payload + }); - const userCallbackUrl = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/hub`; - const additionalData = `{"callbackUrl":"${process.env.NEXT_PUBLIC_FRONTEND_URL}/api/topup/${token}"}`; + const resObject = await res.json(); - // Common request fields - body.append('lang', 'fr'); - body.append('version', 'v2.0'); - body.append('posUuid', `${process.env.LYF_API_VENDOR_ID}`); - body.append('shopReference', shopReference); - body.append('shopOrderReference', shopOrderReference); - body.append('mode', 'IMMEDIATE'); - body.append('amount', `${tAmount}`); - body.append('deliveryFeesAmount', '0'); - body.append('currency', 'EUR'); - body.append('onSuccess', userCallbackUrl); - body.append('onError', userCallbackUrl); - body.append('additionalDataEncoded', Buffer.from(additionalData).toString('base64')); + if (res.ok) { + const { id } = PaymentIntentResponseSchema.parse(resObject); - // Method-specific fields - if (byCreditCard) { - body.append('caseNumber', '01234'); - body.append('callBackRequired', 'true'); - body.append('country', 'FR'); + const redirectUri = `${process.env.LYF_CHECKOUT_BASE_PATH!}/${id}`; - if (process.env.NODE_ENV === 'development') { - body.append('address', '7 Some Street'); - body.append('city', 'Bigcity'); - body.append('zipCode', '01234'); - } else { - body.append('address', ''); - body.append('city', ''); - body.append('zipCode', ''); - } + return redirectUri; } else { - body.append('onCancel', userCallbackUrl); - body.append('timestamp', `${timestamp}`); - body.append('enforcedIdentification', 'false'); - } + const { httpStatus, errorCode, message } = HttpErrorResourceSchema.parse(resObject); - body.append('mac', generateHmac(body, additionalData, byCreditCard)); + console.error(`Lyf API Error ${errorCode} (HTTP Status ${httpStatus}): ${message}`); - return body; -} - -export default resolver.pipe(resolver.authorize(), async (input: RequestTopUpInput, ctx: Ctx) => { - if (Number.isNaN(input.amount) || input.amount <= 0 || input.amount >= 1000) { - throw new Error('Valeur invalide'); + throw new Error('Une erreur interne est survenue'); } - - const byCreditCard = input.method == 'credit'; - const req = prepareRequest(ctx.session.userId as string, ctx.session.card as number, input.amount, byCreditCard); - - return `${ - byCreditCard ? process.env.LYF_CREDIT_CARD_API_URL : process.env.LYF_FROM_APPLICATION_API_URL - }?${req.toString()}`; }); diff --git a/package.json b/package.json index c2fe3cc8..12841b40 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "migrate": "prisma db push", "postinstall": "husky install", "seed": "prisma migrate reset --skip-generate --skip-seed && prisma db push && prisma db seed", + "prisma-generate": "prisma generate", "start": "blitz start", "studio": "blitz prisma studio" }, @@ -75,8 +76,9 @@ "react-swipeable": "^7.0.0", "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.8", + "safe-stable-stringify": "^2.5.0", "sanitize-html": "^2.8.1", - "zod": "3.19.1" + "zod": "3.23.8" }, "devDependencies": { "@faker-js/faker": "^7.6.0", diff --git a/pages/api/topup.ts b/pages/api/topup.ts new file mode 100644 index 00000000..6bf7b7ad --- /dev/null +++ b/pages/api/topup.ts @@ -0,0 +1,11 @@ +// Handles the webhook received from the Lyf payout platform to validate a topup +// operation and then increase the balance +import db from 'db'; +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method == 'POST') { + const { body, query } = req; + throw new Error('Unimplemented'); + } else throw new Error(`Unhandled method ${req.method}`); +} diff --git a/pages/api/topup/[token].ts b/pages/api/topup~/[token].ts similarity index 100% rename from pages/api/topup/[token].ts rename to pages/api/topup~/[token].ts