diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6393f..347b194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). > Place unreleased changes here. +## [1.1.2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.1...1.1.2) + +Attention, this version is compatible with the backend version +[1.1.6](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/1.1.6). + +### Added + +- Add the "Future Balance" label in the disbursement detail component to display + what will be balance for the asset on the distribution account after the + disbursement is completed. + [#76](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/76) +- Add option to update a receiver's verification info from the receiver's detail + page. + [#78](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/78) + +### Changed + +- Update the CSV template by adding examples with and without the paymentID + (optional) column. + [#77](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/77) +- Display the entire disbursement account address for the tenant when that + disbursement account does not exist in the network, making it easier to + identify the account that needs to be funded. + [#80](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/80) + ## [1.1.1](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.0...1.1.1) ### Fixed diff --git a/package.json b/package.json index 279280d..470c732 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stellar-disbursement-platform-frontend", - "version": "1.1.1", + "version": "1.1.2", "license": "Apache-2.0", "engines": { "node": ">=18.x" diff --git a/public/resources/disbursement-template.csv b/public/resources/disbursement-template.csv index cbabd97..519a263 100644 --- a/public/resources/disbursement-template.csv +++ b/public/resources/disbursement-template.csv @@ -1 +1,5 @@ -phone,id,amount,verification +phone,id,amount,verification,paymentID +"--- Below are EXAMPLE rows for each Verification.","Please DELETE these rows before uploading your actual data ---",,, ++1234567890,RECEIVER_01,0.2,1980-01-01,PAY_01 ++1234567891,RECEIVER_11,0.35,55066,PAY_06 ++1234567892,RECEIVER_33,1.15,9D0000024, \ No newline at end of file diff --git a/src/api/getStellarAccountInfo.ts b/src/api/getStellarAccountInfo.ts index 4b58294..5937df5 100644 --- a/src/api/getStellarAccountInfo.ts +++ b/src/api/getStellarAccountInfo.ts @@ -1,5 +1,4 @@ import { HORIZON_URL } from "constants/envVariables"; -import { shortenAccountKey } from "helpers/shortenAccountKey"; import { ApiStellarAccount } from "types"; export const getStellarAccountInfo = async ( @@ -10,7 +9,7 @@ export const getStellarAccountInfo = async ( }); if (response.status === 404) { - throw `${shortenAccountKey(stellarAddress)} address was not found.`; + throw `${stellarAddress} address was not found.`; } return await response.json(); diff --git a/src/apiQueries/useReceiversReceiverId.ts b/src/apiQueries/useReceiversReceiverId.ts index 875d0c5..e8b4efe 100644 --- a/src/apiQueries/useReceiversReceiverId.ts +++ b/src/apiQueries/useReceiversReceiverId.ts @@ -3,7 +3,7 @@ import { API_URL } from "constants/envVariables"; import { fetchApi } from "helpers/fetchApi"; import { formatPaymentReceiver } from "helpers/formatPaymentReceiver"; import { formatReceiver } from "helpers/formatReceiver"; -import { ApiReceiver, AppError } from "types"; +import { AppError, PaymentDetailsReceiver, ReceiverDetails } from "types"; export const useReceiversReceiverId = ({ receiverId, @@ -14,22 +14,19 @@ export const useReceiversReceiverId = ({ dataFormat: "receiver" | "paymentReceiver"; receiverWalletId?: string; }) => { - const query = useQuery({ + const query = useQuery({ queryKey: ["receivers", dataFormat, receiverId, { receiverWalletId }], queryFn: async () => { - return await fetchApi(`${API_URL}/receivers/${receiverId}`); + const response = await fetchApi(`${API_URL}/receivers/${receiverId}`); + return dataFormat === "receiver" + ? formatReceiver(response) + : formatPaymentReceiver(response, receiverWalletId); }, enabled: !!receiverId, }); - const formatData = (data: ApiReceiver) => { - return dataFormat === "receiver" - ? formatReceiver(data) - : formatPaymentReceiver(data, receiverWalletId); - }; - return { ...query, - data: query.data ? (formatData(query.data) as T) : undefined, + data: query.data as T, }; }; diff --git a/src/apiQueries/useUpdateReceiverDetails.ts b/src/apiQueries/useUpdateReceiverDetails.ts index 1fe8ee2..d0fa1d4 100644 --- a/src/apiQueries/useUpdateReceiverDetails.ts +++ b/src/apiQueries/useUpdateReceiverDetails.ts @@ -4,12 +4,23 @@ import { fetchApi } from "helpers/fetchApi"; import { sanitizeObject } from "helpers/sanitizeObject"; import { AppError } from "types"; +interface ReceiverDetailsUpdate { + email: string; + externalId: string; + dataOfBirth: string; + pin: string; + nationalId: string; +} + export const useUpdateReceiverDetails = (receiverId: string | undefined) => { const mutation = useMutation({ - mutationFn: (fields: { email: string; externalId: string }) => { + mutationFn: (fields: ReceiverDetailsUpdate) => { const fieldsToSubmit = sanitizeObject({ email: fields.email, external_id: fields.externalId, + date_of_birth: fields.dataOfBirth, + pin: fields.pin, + national_id: fields.nationalId, }); if (Object.keys(fieldsToSubmit).length === 0) { diff --git a/src/components/DisbursementDetails/index.tsx b/src/components/DisbursementDetails/index.tsx index bba2c0f..ef54ea7 100644 --- a/src/components/DisbursementDetails/index.tsx +++ b/src/components/DisbursementDetails/index.tsx @@ -5,11 +5,13 @@ import { Title, Notification, } from "@stellar/design-system"; +import BigNumber from "bignumber.js"; import { useWallets } from "apiQueries/useWallets"; import { useAssetsByWallet } from "apiQueries/useAssetsByWallet"; import { useCountries } from "apiQueries/useCountries"; import { useVerificationTypes } from "apiQueries/useVerificationTypes"; +import { AssetAmount } from "components/AssetAmount"; import { InfoTooltip } from "components/InfoTooltip"; import { formatUploadedFileDisplayName } from "helpers/formatUploadedFileDisplayName"; import { @@ -26,6 +28,7 @@ import "./styles.scss"; interface DisbursementDetailsProps { variant: DisbursementStep; details?: Disbursement; + futureBalance?: number; csvFile?: File; onChange?: (state: Disbursement) => void; onValidate?: (isValid: boolean) => void; @@ -50,12 +53,14 @@ const initDetails: Disbursement = { createdAt: "", status: "DRAFT", statusHistory: [], - smsRegistrationMessageTemplate: "" + smsRegistrationMessageTemplate: "", + stats: undefined, }; export const DisbursementDetails: React.FC = ({ variant, details = initDetails, + futureBalance = 0, csvFile, onChange, onValidate, @@ -244,6 +249,22 @@ export const DisbursementDetails: React.FC = ({ +
+ +
+ +
+
+ {variant === "confirmation" ? (
diff --git a/src/components/DisbursementDetails/styles.scss b/src/components/DisbursementDetails/styles.scss index f186abb..98e95b7 100644 --- a/src/components/DisbursementDetails/styles.scss +++ b/src/components/DisbursementDetails/styles.scss @@ -19,4 +19,8 @@ font-weight: var(--font-weight-medium); margin-top: pxToRem(4px); } + + &__negative { + color: var(--color-red-60); + } } diff --git a/src/helpers/formatReceiver.ts b/src/helpers/formatReceiver.ts index 05445a4..de5c98e 100644 --- a/src/helpers/formatReceiver.ts +++ b/src/helpers/formatReceiver.ts @@ -30,7 +30,8 @@ export const formatReceiver = (receiver: ApiReceiver): ReceiverDetails => ({ withdrawnAmount: "", })), verifications: receiver.verifications.map((v) => ({ - verificationField: v.VerificationField, - value: v.HashedValue, + verificationField: v.verification_field, + value: v.hashed_value, + confirmedAt: v.confirmed_at, })), }); diff --git a/src/pages/DisbursementDraftDetails.tsx b/src/pages/DisbursementDraftDetails.tsx index c919ccf..7d1075c 100644 --- a/src/pages/DisbursementDraftDetails.tsx +++ b/src/pages/DisbursementDraftDetails.tsx @@ -3,7 +3,9 @@ import { useNavigate, useParams } from "react-router-dom"; import { Badge, Heading, Link, Notification } from "@stellar/design-system"; import { useDispatch } from "react-redux"; import { useRedux } from "hooks/useRedux"; +import { useOrgAccountInfo } from "hooks/useOrgAccountInfo"; import { useDownloadCsvFile } from "hooks/useDownloadCsvFile"; +import BigNumber from "bignumber.js"; import { AppDispatch } from "store"; import { @@ -57,6 +59,8 @@ export const DisbursementDraftDetails = () => { const [isDraftInProgress, setIsDraftInProgress] = useState(false); const [isResponseSuccess, setIsResponseSuccess] = useState(false); + const allBalances = organization.data.assetBalances?.[0].balances; + const dispatch: AppDispatch = useDispatch(); const navigate = useNavigate(); const { isLoading: csvDownloadIsLoading } = useDownloadCsvFile( @@ -107,6 +111,8 @@ export const DisbursementDraftDetails = () => { disbursementDetails.status, ]); + useOrgAccountInfo(organization.data.distributionAccountPublicKey); + useEffect(() => { setDraftDetails(disbursementDetails); dispatch(setDraftIdAction(disbursementDetails.details.id)); @@ -176,6 +182,18 @@ export const DisbursementDraftDetails = () => { resetState(); }; + const handleCalculateFutureBalance = (): number => { + const assetBalance = BigNumber( + allBalances?.find((a) => a.assetCode === draftDetails?.details.asset.code) + ?.balance || 0, + ); + return assetBalance + .minus(BigNumber(draftDetails?.details.stats?.totalAmount || 0)) + .toNumber(); + }; + + const futureBalance = handleCalculateFutureBalance(); + const handleSubmitDisbursement = ( event: React.FormEvent, ) => { @@ -231,7 +249,8 @@ export const DisbursementDraftDetails = () => { }} isDraftDisabled={!isCsvFileUpdated} isSubmitDisabled={ - !(Boolean(draftDetails) && Boolean(csvFile) && canUserSubmit) + !(Boolean(draftDetails) && Boolean(csvFile) && canUserSubmit) || + futureBalance < 0 } isDraftPending={disbursementDrafts.status === "PENDING"} actionType={disbursementDrafts.actionType} @@ -286,6 +305,7 @@ export const DisbursementDraftDetails = () => { { { "organization", ); const { assetBalances, distributionAccountPublicKey } = organization.data; + const allBalances = assetBalances?.[0].balances; const [draftDetails, setDraftDetails] = useState(); const [customMessage, setCustomMessage] = useState(""); const [isDetailsValid, setIsDetailsValid] = useState(false); const [csvFile, setCsvFile] = useState(); + const [futureBalance, setFutureBalance] = useState(0); const [currentStep, setCurrentStep] = useState("edit"); const [isDraftInProgress, setIsDraftInProgress] = useState(false); @@ -155,9 +158,50 @@ export const DisbursementsNew = () => { if (apiError) { dispatch(clearDisbursementDraftsErrorAction()); } + calculateDisbursementTotalAmountFromFile(file); setCsvFile(file); }; + const calculateDisbursementTotalAmountFromFile = (file?: File) => { + if (file) { + const reader = new FileReader(); + reader.readAsText(file); + const handleLoadFile = () => { + const totalAmount = reader.result + ?.toString() + .split("\n") + .slice(1) + .reduce( + (accumulator, line) => + !line + ? accumulator + : BigNumber(accumulator) + .plus(BigNumber(line.split(",")[2])) + .toNumber(), + 0, + ); + + setDraftDetails({ + ...draftDetails, + stats: { + ...draftDetails?.stats, + totalAmount: totalAmount?.toString() ?? "0", + }, + } as Disbursement); + + // update future balance + const assetBalance = allBalances?.find( + (a) => a.assetCode === draftDetails?.asset.code, + )?.balance; + + if (totalAmount) { + setFutureBalance(Number(assetBalance) - totalAmount); + } + }; + reader.addEventListener("load", handleLoadFile, false); + } + }; + const handleViewDetails = () => { navigate(`${Routes.DISBURSEMENTS}/${disbursementDrafts.newDraftId}`); resetState(); @@ -179,7 +223,9 @@ export const DisbursementsNew = () => { Boolean(disbursementDrafts.newDraftId && currentStep === "preview") } isSubmitDisabled={ - organization.data.isApprovalRequired || !(draftDetails && csvFile) + organization.data.isApprovalRequired || + !(draftDetails && csvFile) || + BigNumber(futureBalance).lt(0) } isReviewDisabled={!isReviewEnabled} isDraftPending={disbursementDrafts.status === "PENDING"} @@ -198,7 +244,11 @@ export const DisbursementsNew = () => { if (currentStep === "preview") { return (
- + { { { if (apiError) { dispatch(clearDisbursementDraftsErrorAction()); diff --git a/src/pages/ReceiverDetailsEdit.tsx b/src/pages/ReceiverDetailsEdit.tsx index b0c616e..7954de2 100644 --- a/src/pages/ReceiverDetailsEdit.tsx +++ b/src/pages/ReceiverDetailsEdit.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Card, @@ -19,9 +19,15 @@ import { InfoTooltip } from "components/InfoTooltip"; import { LoadingContent } from "components/LoadingContent"; import { ErrorWithExtras } from "components/ErrorWithExtras"; -import { ReceiverDetails, ReceiverEditFields } from "types"; +import { + ReceiverDetails, + ReceiverEditFields, + ReceiverVerification, +} from "types"; import { useUpdateReceiverDetails } from "apiQueries/useUpdateReceiverDetails"; +type VerificationFieldType = "DATE_OF_BIRTH" | "PIN" | "NATIONAL_ID_NUMBER"; + export const ReceiverDetailsEdit = () => { const { id: receiverId } = useParams(); @@ -31,6 +37,9 @@ export const ReceiverDetailsEdit = () => { useState({ email: "", externalId: "", + dateOfBirth: "", + pin: "", + nationalId: "", }); const { @@ -52,17 +61,40 @@ export const ReceiverDetailsEdit = () => { reset, } = useUpdateReceiverDetails(receiverId); + const getReadyOnlyValue = useCallback( + (field: VerificationFieldType) => { + return ( + receiverDetails?.verifications.find( + (v) => v.verificationField === field, + )?.value ?? "" + ); + }, + [receiverDetails?.verifications], + ); + + const isVerificationFieldConfirmed = ( + field: VerificationFieldType, + ): boolean => { + const verification: ReceiverVerification | undefined = + receiverDetails?.verifications.find((v) => v.verificationField === field); + return !verification ? false : verification.confirmedAt !== null; + }; + useEffect(() => { if (isReceiverDetailsSuccess) { setReceiverEditFields({ - email: receiverDetails?.email || "", - externalId: receiverDetails?.orgId || "", + email: receiverDetails?.email ?? "", + externalId: receiverDetails?.orgId ?? "", + dateOfBirth: getReadyOnlyValue("DATE_OF_BIRTH"), + pin: getReadyOnlyValue("PIN"), + nationalId: getReadyOnlyValue("NATIONAL_ID_NUMBER"), }); } }, [ isReceiverDetailsSuccess, receiverDetails?.email, receiverDetails?.orgId, + getReadyOnlyValue, ]); useEffect(() => { @@ -81,15 +113,6 @@ export const ReceiverDetailsEdit = () => { }; }, [updateError, reset]); - const getReadyOnlyValue = ( - field: "DATE_OF_BIRTH" | "PIN" | "NATIONAL_ID_NUMBER", - ) => { - return ( - receiverDetails?.verifications.find((v) => v.verificationField === field) - ?.value || "" - ); - }; - const emptyValueIfNotChanged = (newValue: string, oldValue: string) => { return newValue === oldValue ? "" : newValue; }; @@ -99,15 +122,25 @@ export const ReceiverDetailsEdit = () => { ) => { e.preventDefault(); - const { email, externalId } = receiverEditFields; + const { email, externalId, dateOfBirth, pin, nationalId } = + receiverEditFields; if (receiverId) { try { await mutateAsync({ - email: emptyValueIfNotChanged(email, receiverDetails?.email || ""), + email: emptyValueIfNotChanged(email, receiverDetails?.email ?? ""), externalId: emptyValueIfNotChanged( externalId, - receiverDetails?.orgId || "", + receiverDetails?.orgId ?? "", + ), + dataOfBirth: emptyValueIfNotChanged( + dateOfBirth, + getReadyOnlyValue("DATE_OF_BIRTH"), + ), + pin: emptyValueIfNotChanged(pin, getReadyOnlyValue("PIN")), + nationalId: emptyValueIfNotChanged( + nationalId, + getReadyOnlyValue("NATIONAL_ID_NUMBER"), ), }); } catch (e) { @@ -119,8 +152,11 @@ export const ReceiverDetailsEdit = () => { const handleReceiverEditCancel = (e: React.FormEvent) => { e.preventDefault(); setReceiverEditFields({ - email: receiverDetails?.email || "", - externalId: receiverDetails?.orgId || "", + email: receiverDetails?.email ?? "", + externalId: receiverDetails?.orgId ?? "", + dateOfBirth: getReadyOnlyValue("DATE_OF_BIRTH"), + pin: getReadyOnlyValue("PIN"), + nationalId: getReadyOnlyValue("NATIONAL_ID_NUMBER"), }); navigate(`${Routes.RECEIVERS}/${receiverId}`); }; @@ -155,7 +191,10 @@ export const ReceiverDetailsEdit = () => { const isSubmitDisabled = receiverEditFields.email === receiverDetails?.email && - receiverEditFields.externalId === receiverDetails.orgId; + receiverEditFields.externalId === receiverDetails.orgId && + receiverEditFields.dateOfBirth === getReadyOnlyValue("DATE_OF_BIRTH") && + receiverEditFields.pin === getReadyOnlyValue("PIN") && + receiverEditFields.nationalId === getReadyOnlyValue("NATIONAL_ID_NUMBER"); return ( <> @@ -213,28 +252,33 @@ export const ReceiverDetailsEdit = () => { onChange={handleDetailsChange} />
diff --git a/src/types/index.ts b/src/types/index.ts index a0d69eb..1aead7e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -364,6 +364,7 @@ export type ReceiverWallet = { export type ReceiverVerification = { verificationField: string; value: string; + confirmedAt?: string; }; export type ReceiverWalletBalance = { @@ -407,6 +408,9 @@ export type ReceiverDetails = { export type ReceiverEditFields = { email: string; externalId: string; + dateOfBirth: string; + pin: string; + nationalId: string; }; // ============================================================================= @@ -715,8 +719,9 @@ export type ApiReceiverWallet = { }; export type ApiReceiverVerification = { - VerificationField: string; - HashedValue: string; + verification_field: string; + hashed_value: string; + confirmed_at: string; }; export type ApiReceiver = {