commit 6f60f50ec9ac91728accb93bca54195e59c57874 parent ad023976e51f6ce6a28b5efaa8a7d49b599beb8b Author: Sebastian <sebasjm@taler-systems.com> Date: Thu, 19 Feb 2026 18:07:58 -0300 fix #11018 Diffstat:
13 files changed, 899 insertions(+), 306 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -25,16 +25,17 @@ import { TalerError, TranslatedString, } from "@gnu-taler/taler-util"; - import { NotificationCard, urlPattern, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + NotificationCard, + urlPattern, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { createHashHistory } from "history"; import { Fragment, VNode, h } from "preact"; import { Route, Router, route } from "preact-router"; import { useEffect, useErrorBoundary, useState } from "preact/hooks"; import { Loading } from "./components/exception/loading.js"; -import { - Menu, - NotConnectedAppMenu, -} from "./components/menu/index.js"; +import { Menu, NotConnectedAppMenu } from "./components/menu/index.js"; import { NotificationCardBulma } from "@gnu-taler/web-util/browser"; import { useSessionContext } from "./context/session.js"; import { useInstanceBankAccounts } from "./hooks/bank.js"; @@ -84,6 +85,7 @@ import TokenFamilyCreatePage from "./paths/instance/tokenfamilies/create/index.j import TokenFamilyListPage from "./paths/instance/tokenfamilies/list/index.js"; import TokenFamilyUpdatePage from "./paths/instance/tokenfamilies/update/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js"; +import TransferDetailPage from "./paths/instance/transfers/list/DetailsPage.js"; import InstanceUpdatePage, { AdminUpdate as InstanceAdminUpdatePage, Props as InstanceUpdatePageProps, @@ -126,7 +128,7 @@ export enum InstancePaths { kyc = "/kyc", transfers_list = "/transfers", - // transfers_new = "/transfer/new", + transfers_details = "/transfers/:tid", templates_list = "/templates", templates_update = "/templates/:tid/update", @@ -661,6 +663,9 @@ export function Routing(_p: Props): VNode { onBack={() => { route(InstancePaths.order_list); }} + onSelectWireTransfer={(id) => { + route(InstancePaths.transfers_details.replace(":tid", String(id))); + }} /> <Route path={InstancePaths.order_new} @@ -678,6 +683,19 @@ export function Routing(_p: Props): VNode { <Route path={InstancePaths.transfers_list} component={TransferListPage} + onTransferDetails={(id) => { + route(InstancePaths.transfers_details.replace(":tid", String(id))); + }} + /> + <Route + path={InstancePaths.transfers_details} + component={TransferDetailPage} + onOrderDetails={(orderId) => { + route(InstancePaths.order_details.replace(":oid", orderId)); + }} + onBack={() => { + route(InstancePaths.transfers_list); + }} /> {/* * * Token family pages diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -432,8 +432,8 @@ export function ValidBankAccount({ ? `${origin.address.substring(0, 8)}...` : origin.displayName; - const qrs = getQrCodesForPayto(Paytos.toFullString(payto)); - const strPayto = Paytos.toFullString(payto); + const qrs = getQrCodesForPayto(Paytos.toFullString(payto)); + const strPayto = Paytos.toFullString(payto); const [{ showDebugInfo }] = useCommonPreferences(); return ( <ConfirmModal @@ -622,16 +622,24 @@ function Accordion({ ); } +/** + * 3 column row that can be used inside a table + * to show form like structure + * @param param0 + * @returns + */ export function Row({ name, value, literal, lineBreak, + noCopy, }: { name: TranslatedString; value: string | VNode; literal?: boolean; lineBreak?: boolean; + noCopy?: boolean; }): VNode { const preRef = useRef<HTMLPreElement>(null); const tdRef = useRef<HTMLTableCellElement>(null); @@ -664,14 +672,23 @@ export function Row({ {value} </td> )} - <td style={{ padding: 4 }}> - <CopyButton getContent={getContent} /> - </td> + {noCopy ? ( + // <td /> + undefined + ) : ( + <td style={{ padding: 4 }}> + <CopyButton getContent={getContent} /> + </td> + )} </tr> ); } -export function CopyButton({ getContent }: { getContent: () => string }): VNode { +export function CopyButton({ + getContent, +}: { + getContent: () => string; +}): VNode { const [copied, setCopied] = useState(false); function copyText(): void { navigator.clipboard.writeText(getContent() || ""); diff --git a/packages/merchant-backoffice-ui/src/hooks/transfer.ts b/packages/merchant-backoffice-ui/src/hooks/transfer.ts @@ -41,11 +41,13 @@ export interface InstanceConfirmedTransferFilter { export function revalidateInstanceIncomingTransfers() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "listIncomingWireTransfers", + (key) => + Array.isArray(key) && key[key.length - 1] === "listIncomingWireTransfers", undefined, { revalidate: true }, ); } + export function useInstanceIncomingTransfers( args?: InstanceIncomingTransferFilter, updatePosition: (id: string | undefined) => void = () => {}, @@ -96,9 +98,46 @@ export function useInstanceIncomingTransfers( PAGINATED_LIST_REQUEST, ); } + +export function useInstanceIncomingTransferDetails( + serialId: number | undefined, +) { + const { state, lib } = useSessionContext(); + + type Result = { + details: TalerMerchantManagementResultByMethod<"getIncomingWireTransfersDetails">; + info: TalerMerchantManagementResultByMethod<"listIncomingWireTransfers">; + }; + + async function fetcher([token, id]: [AccessToken, number]) { + const details = await lib.instance.getIncomingWireTransfersDetails( + token, + id, + ); + const info = await lib.instance.listIncomingWireTransfers(token, { + limit: 1, + offset: String(id - 1), + order: "asc", + }); + return { details, info }; + } + + const { data, error } = useSWR<Result, TalerHttpError>( + serialId === undefined + ? undefined + : [state.token, serialId, "getIncomingWireTransfersDetails"], + fetcher, + ); + + if (error) return error; + return data; +} + export function revalidateInstanceConfirmedTransfers() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "listConfirmedWireTransfers", + (key) => + Array.isArray(key) && + key[key.length - 1] === "listConfirmedWireTransfers", undefined, { revalidate: true }, ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -26,10 +26,12 @@ import { HostPortPath, MerchantContractVersion, TalerMerchantApi, + TransactionWireTransfer, assertUnreachable, stringifyRefundUri, } from "@gnu-taler/taler-util"; import { + ButtonBetterBulma, RenderAmountBulma, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -54,6 +56,7 @@ import { import { mergeRefunds } from "../../../../utils/amount.js"; import { RefundModal } from "../list/Table.js"; import { Event, Timeline } from "./Timeline.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; const TALER_SCREEN_ID = 44; @@ -67,6 +70,7 @@ export interface Props { selected: Entity; id: string; onRefunded: () => void; + onSelectWireTransfer: (id: number) => void; } type Paid = TalerMerchantApi.CheckPaymentPaidResponse & { @@ -439,12 +443,17 @@ function PaidPage({ id, order, onRefund, + onWireTransferSelection, }: { id: string; order: TalerMerchantApi.CheckPaymentPaidResponse; onRefund: (id: string) => void; + onWireTransferSelection: (id: number) => void; }) { const { state, config } = useSessionContext(); + const [verifyingWiretransfer, setVerifyingWiretransfer] = useState< + TransactionWireTransfer[] + >([]); const totalRefundAlreadyTaken = order.refund_details.reduce((prev, cur) => { if (cur.pending) return prev; return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount; @@ -506,7 +515,7 @@ function PaidPage({ ), ); - const orderAmounts = getOrderAmountAndWirefee(order); + const orderAmounts = getOrderAmountAndMaxDepositFee(order); if (orderAmounts === "v1-without-index") { return ( <i18n.Translate> @@ -521,12 +530,11 @@ function PaidPage({ </i18n.Translate> ); } - const { amount, wireFee } = orderAmounts; + const { amount, maxDepositFee } = orderAmounts; - let totalRefunded = Amounts.zeroOfAmount(amount); + let totalRefundedTaken = Amounts.zeroOfAmount(amount); sortedOrders.reduce(mergeRefunds, []).forEach((e) => { if (e.timestamp.t_s === "never") return; - totalRefunded = Amounts.add(totalRefunded, e.amount).amount; if (e.pending) { if (wireDeadlineInThePast) { events.push({ @@ -576,26 +584,27 @@ function PaidPage({ }); } } else { + totalRefundedTaken = Amounts.add(totalRefundedTaken, e.amount).amount; events.push({ when: new Date(e.timestamp.t_s * 1000), - description: !e.reason ? ( - <i18n.Translate> - refund taken:{" "} - <RenderAmountBulma - value={Amounts.parseOrThrow(e.amount)} - specMap={config.currencies} - /> - </i18n.Translate> - ) : ( - <i18n.Translate> - refund taken:{" "} - <RenderAmountBulma - value={Amounts.parseOrThrow(e.amount)} - specMap={config.currencies} - /> - : {e.reason} - </i18n.Translate> - ), + description: !e.reason ? ( + <i18n.Translate> + refund taken:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + </i18n.Translate> + ) : ( + <i18n.Translate> + refund taken:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + : {e.reason} + </i18n.Translate> + ), type: "refund-taken", }); } @@ -610,11 +619,17 @@ function PaidPage({ } > = {}; - let hasUnconfirmedWireTransfer = false; - let totalWired = Amounts.zeroOfAmount(amount); + const unconfirmedWireTransfers: TransactionWireTransfer[] = []; + let totalFromWireTransfer = Amounts.zeroOfAmount(amount); if (order.wire_details.length) { order.wire_details.forEach((w) => { - totalWired = Amounts.add(totalWired, w.amount).amount; + totalFromWireTransfer = Amounts.add( + totalFromWireTransfer, + w.amount, + w.deposit_fee, + ).amount; + + // fill up and merge wireMap by wire id if (!wireMap[w.wtid]) { const info = { time: @@ -634,7 +649,9 @@ function PaidPage({ }; wireMap[w.wtid] = info; } - hasUnconfirmedWireTransfer = hasUnconfirmedWireTransfer || !w.confirmed; + if (!w.confirmed) { + unconfirmedWireTransfers.push(w); + } }); Object.values(wireMap).forEach((info) => { events.push({ @@ -652,9 +669,12 @@ function PaidPage({ }); }); } - const totalFee = Amounts.add(totalRefunded, wireFee).amount; - const shouldBeWired = Amounts.sub(amount, totalFee).amount; - const notAllHasBeenWired = Amounts.cmp(totalWired, shouldBeWired) < 0; // FIXME: should be updated with the protocol see #10971 + const maxTotalFee = Amounts.add(totalRefundedTaken, maxDepositFee).amount; + const shouldBeWiredMinimum = Amounts.sub(amount, maxTotalFee).amount; + // we have a minimum but not a price value + // because deposit fee depends on customer coins + const notAllHasBeenWired = + Amounts.cmp(totalFromWireTransfer, shouldBeWiredMinimum) < 0; const w_deadline = AbsoluteTime.fromProtocolTimestamp( order.contract_terms.wire_transfer_deadline, ); @@ -695,6 +715,21 @@ function PaidPage({ return ( <div> <section class="section"> + <ConfirmModal + label={i18n.str`OK`} + description={i18n.str`Related wire transfers`} + active={verifyingWiretransfer.length > 0} + onCancel={() => { + setVerifyingWiretransfer([]); + }} + // onConfirm={onConfirm} + > + <CardTableIncoming + transfers={verifyingWiretransfer} + onSelected={onWireTransferSelection} + /> + </ConfirmModal> + {/* )} */} <div class="columns"> <div class="column" /> <div class="column is-10"> @@ -708,7 +743,7 @@ function PaidPage({ <i18n.Translate>Paid</i18n.Translate> </div> {order.wired ? ( - hasUnconfirmedWireTransfer ? ( + unconfirmedWireTransfers.length > 0 ? ( <div class="tag is-warning ml-4"> <i18n.Translate>Unconfirmed</i18n.Translate> </div> @@ -758,6 +793,26 @@ function PaidPage({ <i18n.Translate>Refund</i18n.Translate> </button> </span> + <span + class="has-tooltip-left" + data-tooltip={ + !order.wire_details.length + ? i18n.str`No wire transfer reported` + : i18n.str`Check wire transfers` + } + > + <button + type="button" + class="button" + style={{ marginLeft: 5 }} + disabled={!order.wire_details.length} + onClick={() => { + setVerifyingWiretransfer(order.wire_details); + }} + > + <i18n.Translate>Wire transfers</i18n.Translate> + </button> + </span> </div> </h1> </div> @@ -792,12 +847,21 @@ function PaidPage({ </div> </section> - {!hasUnconfirmedWireTransfer ? undefined : ( + {unconfirmedWireTransfers.length === 0 ? undefined : ( <NotificationCardBulma notification={{ type: "INFO", message: i18n.str`The order was wired.`, - description: i18n.str`Bank processing can take a few business days, depending on your bank.`, + description: ( + <div> + <p> + <i18n.Translate> + Bank processing can take a few business days, + depending on your bank. + </i18n.Translate> + </p> + </div> + ), }} /> )} @@ -1020,7 +1084,13 @@ function UnpaidPage({ ); } -export function DetailPage({ id, selected, onRefunded, onBack }: Props): VNode { +export function DetailPage({ + id, + selected, + onRefunded, + onBack, + onSelectWireTransfer, +}: Props): VNode { const [showRefund, setShowRefund] = useState<string | undefined>(undefined); const { i18n } = useTranslationContext(); const DetailByStatus = function () { @@ -1028,7 +1098,14 @@ export function DetailPage({ id, selected, onRefunded, onBack }: Props): VNode { case "claimed": return <ClaimedPage id={id} order={selected} />; case "paid": - return <PaidPage id={id} order={selected} onRefund={setShowRefund} />; + return ( + <PaidPage + id={id} + order={selected} + onRefund={setShowRefund} + onWireTransferSelection={onSelectWireTransfer} + /> + ); case "unpaid": return <UnpaidPage id={id} order={selected} />; default: @@ -1071,18 +1148,16 @@ export function DetailPage({ id, selected, onRefunded, onBack }: Props): VNode { </Fragment> ); } -export function getOrderAmountAndWirefee( - order: - | TalerMerchantApi.CheckPaymentPaidResponse - | TalerMerchantApi.CheckPaymentPaidResponse, +export function getOrderAmountAndMaxDepositFee( + order: TalerMerchantApi.CheckPaymentPaidResponse, ) { if ( order.contract_terms.version === undefined || order.contract_terms.version === MerchantContractVersion.V0 ) { const amount = Amounts.parseOrThrow(order.contract_terms.amount); - const wireFee = Amounts.parseOrThrow(order.contract_terms.max_fee); - return { amount, wireFee }; + const maxDepositFee = Amounts.parseOrThrow(order.contract_terms.max_fee); + return { amount, maxDepositFee }; } if (order.contract_terms.version === MerchantContractVersion.V1) { if (order.choice_index === undefined) return "v1-without-index" as const; @@ -1090,8 +1165,93 @@ export function getOrderAmountAndWirefee( return "v1-wrong-index" as const; const choice = order.contract_terms.choices[order.choice_index]; const amount = Amounts.parseOrThrow(choice.amount); - const wireFee = Amounts.parseOrThrow(choice.max_fee); - return { amount, wireFee }; + const maxDepositFee = Amounts.parseOrThrow(choice.max_fee); + return { amount, maxDepositFee }; } assertUnreachable(order.contract_terms.version); } + +function CardTableIncoming({ + transfers, + onSelected, +}: { + transfers: TransactionWireTransfer[]; + onSelected: (id: number) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const [preferences] = usePreference(); + const { config } = useSessionContext(); + return ( + <div class="card has-table"> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Date</i18n.Translate> + </th> + <th> + <i18n.Translate>Amount</i18n.Translate> + </th> + <th> + <i18n.Translate>Status</i18n.Translate> + </th> + <th></th> + </tr> + </thead> + <tbody> + {transfers.map((i, idx) => { + return ( + <tr key={i.wtid}> + <td> + {i.execution_time + ? i.execution_time.t_s == "never" + ? i18n.str`never` + : format( + i.execution_time.t_s * 1000, + datetimeFormatForPreferences(preferences), + ) + : i18n.str`unknown`} + </td> + <td> + <RenderAmountBulma + value={Amounts.parseOrThrow(i.amount)} + specMap={config.currencies} + /> + </td> + <td> + {i.confirmed + ? i18n.str`confirmed` + : i18n.str`unconfirmed`} + </td> + <td> + <button + type="button" + class="button is-info " + disabled={ + i.expected_transfer_serial_id === undefined + } + onClick={() => { + if (i.expected_transfer_serial_id === undefined) + return; + onSelected(i.expected_transfer_serial_id); + }} + > + <i18n.Translate>Details</i18n.Translate> + </button> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx @@ -35,9 +35,10 @@ const TALER_SCREEN_ID = 45; export interface Props { oid: string; onBack: () => void; + onSelectWireTransfer: (id:number) => void; } -export default function Update({ oid, onBack }: Props): VNode { +export default function Update({ oid, onBack, onSelectWireTransfer }: Props): VNode { const result = useOrderDetailsWithLongPoll(oid); const { i18n } = useTranslationContext(); @@ -68,6 +69,7 @@ export default function Update({ oid, onBack }: Props): VNode { <DetailPage onBack={onBack} + onSelectWireTransfer={onSelectWireTransfer} id={oid} onRefunded={() => {}} selected={result.body} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -59,7 +59,7 @@ import { usePreference, } from "../../../../hooks/preference.js"; import { mergeRefunds } from "../../../../utils/amount.js"; -import { getOrderAmountAndWirefee } from "../details/DetailPage.js"; +import { getOrderAmountAndMaxDepositFee } from "../details/DetailPage.js"; import { OrderListSection } from "./index.js"; const TALER_SCREEN_ID = 48; @@ -348,7 +348,7 @@ export function RefundModal({ let amount: AmountJson | undefined; if (order.order_status === "paid") { - const orderam = getOrderAmountAndWirefee(order); + const orderam = getOrderAmountAndMaxDepositFee(order); amount = typeof orderam === "string" ? undefined : orderam.amount; } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/DetailsPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/DetailsPage.tsx @@ -0,0 +1,437 @@ +import { + ButtonBetterBulma, + LocalNotificationBannerBulma, + RenderAmountBulma, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { + datetimeFormatForPreferences, + usePreference, +} from "../../../../hooks/preference.js"; +import { Row } from "../../../../components/modal/index.js"; +import { + AmountJson, + Amounts, + assertUnreachable, + ExchangeTransferReconciliationDetails, + ExpectedTransferDetails, + ExpectedTransferEntry, + HttpStatusCode, + TalerError, +} from "@gnu-taler/taler-util"; +import { useSessionContext } from "../../../../context/session.js"; +import { format } from "date-fns"; +import { + useInstanceIncomingTransferDetails, + useInstanceIncomingTransfers, +} from "../../../../hooks/transfer.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { useOrderDetails } from "../../../../hooks/order.js"; +import { getOrderAmountAndMaxDepositFee } from "../../orders/details/DetailPage.js"; + +export default function DetailsPage({ + tid, + onBack, + onOrderDetails, +}: { + onOrderDetails: (id: string) => void; + tid: string; + onBack: () => void; +}): VNode { + const resultDetails = useInstanceIncomingTransferDetails( + Number.parseInt(tid), + ); + if (!resultDetails) return <Loading />; + if (resultDetails instanceof TalerError) { + return <ErrorLoadingMerchant error={resultDetails} />; + } + if (resultDetails.details.type === "fail") { + switch (resultDetails.details.case) { + case HttpStatusCode.NotFound: + return <NotFoundPageOrAdminCreate />; + case HttpStatusCode.Conflict: + return <NotFoundPageOrAdminCreate />; + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + default: { + assertUnreachable(resultDetails.details); + } + } + } + if (resultDetails.info.type === "fail") { + switch (resultDetails.info.case) { + case HttpStatusCode.NotFound: + return <NotFoundPageOrAdminCreate />; + case HttpStatusCode.Unauthorized: + return <NotFoundPageOrAdminCreate />; + default: { + assertUnreachable(resultDetails.info); + } + } + } + return ( + <div> + <section class="section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <DetailsPageInternal + transfer={resultDetails.info.body.incoming[0]} + info={resultDetails.details.body} + onBack={onBack} + onOrderDetails={onOrderDetails} + /> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} + +function DetailsPageInternal({ + transfer: wt, + info: details, + onBack, + onOrderDetails, +}: { + onOrderDetails: (id: string) => void; + transfer: ExpectedTransferEntry; + info: ExpectedTransferDetails; + onBack: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: session, lib, config } = useSessionContext(); + const [preferences] = usePreference(); + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const confirm = safeFunctionHandler( + i18n.str`inform wire transfer`, + lib.instance.informWireTransfer.bind(lib.instance), + !session.token || !wt || wt.confirmed + ? undefined + : [ + session.token, + { + credit_amount: wt.expected_credit_amount!, + exchange_url: wt.exchange_url, + payto_uri: wt.payto_uri, + wtid: wt.wtid, + }, + ], + ); + confirm.onSuccess = () => { + onBack(); + }; + confirm.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Wire transfer already confirmed.`; + default: + assertUnreachable(fail); + } + }; + + const zero = Amounts.zeroOfAmount( + Amounts.parseOrThrow(wt.expected_credit_amount!), + ); + let totalPaid = zero; + let totalFee = zero; + const wireFee = !details.wire_fee + ? undefined + : Amounts.parseOrThrow(details.wire_fee); + + const merged: Record<string, { paid: AmountJson; fee: AmountJson }> = {}; + for (const detail of details.reconciliation_details ?? []) { + const paid = Amounts.parseOrThrow(detail.remaining_deposit); + const fee = Amounts.parseOrThrow(detail.deposit_fee); + totalPaid = Amounts.add(totalPaid, paid).amount; + totalFee = Amounts.add(totalFee, fee).amount; + if (!merged[detail.order_id]) { + merged[detail.order_id] = { + fee: zero, + paid: zero, + }; + } + merged[detail.order_id] = { + fee: Amounts.add(merged[detail.order_id].fee, fee).amount, + paid: Amounts.add(merged[detail.order_id].paid, paid).amount, + }; + } + const orders = Object.keys(merged); + return ( + <Fragment> + <LocalNotificationBannerBulma notification={notification} /> + {!wt.confirmed ? ( + <p> + <i18n.Translate> + The wire transfer has been sent and should be in your bank account + in any time. You can manually confirm the reception using the + information below. + </i18n.Translate> + </p> + ) : ( + <p> + <i18n.Translate>The wire transfer has been confirmed.</i18n.Translate> + </p> + )} + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-star" /> + </span> + <i18n.Translate>Transaction details</i18n.Translate> + </p> + </header> + + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead></thead> + <tbody> + <Row + name={i18n.str`Amount`} + value={ + <RenderAmountBulma + value={Amounts.parseOrThrow( + wt.expected_credit_amount!, + )} + specMap={config.currencies} + /> + } + /> + {wt.execution_time && wt.execution_time.t_s !== "never" ? ( + <Row + name={i18n.str`Execution time`} + value={format( + wt.execution_time.t_s * 1000, + datetimeFormatForPreferences(preferences), + )} + /> + ) : undefined} + <Row name={i18n.str`Transfer ID`} value={wt.wtid} literal /> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + + <div class="buttons is-right mt-5"> + <button class="button" type="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <ButtonBetterBulma type="submit" onClick={confirm}> + <i18n.Translate>I have received the wire transfer</i18n.Translate> + </ButtonBetterBulma> + </div> + + {!orders.length ? undefined : ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-star" /> + </span> + <i18n.Translate>Orders in this wire transfer</i18n.Translate> + </p> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {orders.length > 0 ? ( + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Summary</i18n.Translate> + </th> + <th> + <i18n.Translate>Amount</i18n.Translate> + </th> + <th> + <i18n.Translate>Paid</i18n.Translate> + </th> + <th> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th></th> + </tr> + </thead> + <tbody> + {orders.map((i, idx) => { + return ( + <ReconciliationDetailsRow + orderId={i} + deposit={merged[i].paid} + fee={merged[i].fee} + onOrderDetails={onOrderDetails} + /> + ); + })} + </tbody> + </table> + </div> + ) : undefined} + </div> + </div> + </div> + </div> + )} + + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <i18n.Translate>Subtotals</i18n.Translate> + </p> + </header> + + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead></thead> + <tbody> + <Row + name={i18n.str`Paid`} + noCopy + value={ + <RenderAmountBulma + value={totalPaid} + specMap={config.currencies} + /> + } + /> + <Row + name={i18n.str`Deposit fee`} + noCopy + value={ + <RenderAmountBulma + value={totalFee} + specMap={config.currencies} + /> + } + /> + <Row + name={i18n.str`Wire fee`} + noCopy + value={ + !wireFee ? ( + i18n.str`not ready` + ) : ( + <RenderAmountBulma + value={wireFee} + specMap={config.currencies} + /> + ) + } + /> + <Row + name={i18n.str`Total`} + noCopy + value={ + !wireFee ? ( + i18n.str`not ready` + ) : ( + <RenderAmountBulma + value={ + Amounts.sub(totalPaid, totalFee, wireFee).amount + } + specMap={config.currencies} + /> + ) + } + /> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + </Fragment> + ); +} + +function ReconciliationDetailsRow({ + deposit, + fee, + orderId, + onOrderDetails, +}: { + orderId: string; + deposit: AmountJson; + fee: AmountJson; + onOrderDetails: (id: string) => void; +}): VNode { + const { config } = useSessionContext(); + const { i18n } = useTranslationContext(); + const result = useOrderDetails(orderId); + const orderDetails = + !result || + result instanceof TalerError || + result.type === "fail" || + result.body.order_status !== "paid" + ? undefined + : result.body; + + const amounts = !orderDetails + ? undefined + : getOrderAmountAndMaxDepositFee(orderDetails); + + return ( + <tr key={orderId}> + <td> + {!orderDetails + ? i18n.str`loading...` + : orderDetails.contract_terms.summary} + </td> + <td> + {!amounts ? ( + i18n.str`loading...` + ) : typeof amounts === "string" ? ( + "" + ) : ( + <RenderAmountBulma + value={amounts.amount} + specMap={config.currencies} + /> + )} + </td> + <td> + <RenderAmountBulma value={deposit} specMap={config.currencies} /> + </td> + <td> + <RenderAmountBulma value={fee} specMap={config.currencies} /> + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-small is-info jb-modal" + type="button" + onClick={() => onOrderDetails(orderId)} + > + <i18n.Translate>Details</i18n.Translate> + </button> + </div> + </td> + </tr> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx @@ -1,86 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { AmountString, PaytoString } from "@gnu-taler/taler-util"; -import { FunctionalComponent, h } from "preact"; -import { ListPage as TestedComponent } from "./ListPage.js"; - -export default { - title: "Pages/Transfer/List", - component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onDelete: { action: "onDelete" }, - onLoadMoreBefore: { action: "onLoadMoreBefore" }, - onLoadMoreAfter: { action: "onLoadMoreAfter" }, - onShowAll: { action: "onShowAll" }, - onShowVerified: { action: "onShowVerified" }, - onShowUnverified: { action: "onShowUnverified" }, - onChangePayTo: { action: "onChangePayTo" }, - }, -}; - -function createExample<Props>( - Component: FunctionalComponent<Props>, - props: Partial<Props>, -) { - const r = (args: any) => <Component {...args} />; - r.args = props; - return r; -} - -export const Example = createExample(TestedComponent, { - transfers: [ - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10" as AmountString, - payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString, - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - execution_time: { - t_s: new Date().getTime() / 1000, - }, - }, - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10" as AmountString, - payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString, - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - execution_time: { - t_s: new Date().getTime() / 1000, - }, - }, - { - exchange_url: "http://exchange.url/", - credit_amount: "TESTKUDOS:10" as AmountString, - payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString, - transfer_serial_id: 123123123, - wtid: "!@KJELQKWEJ!L@K#!J@", - execution_time: { - t_s: new Date().getTime() / 1000, - }, - }, - ], -}); -export const Empty = createExample(TestedComponent, { - transfers: [], -}); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx @@ -20,52 +20,44 @@ */ import { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { FormProvider } from "../../../../components/form/FormProvider.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; import { CardTableIncoming, CardTableVerified } from "./Table.js"; -import { InputToggle } from "../../../../components/form/InputToggle.js"; +import { PaginatedResult } from "@gnu-taler/web-util/browser"; const TALER_SCREEN_ID = 71; export interface Props { - transfers: TalerMerchantApi.TransferDetails[]; - incomings: TalerMerchantApi.ExpectedTransferDetails[]; - onLoadMoreBefore?: () => void; - onLoadMoreAfter?: () => void; - onSelectedToConfirm: (wid: TalerMerchantApi.ExpectedTransferDetails) => void; + transfers: PaginatedResult<TalerMerchantApi.TransferDetails[]>; + incomings: PaginatedResult<TalerMerchantApi.ExpectedTransferEntry[]>; + onSelectedToConfirm: (wid: TalerMerchantApi.ExpectedTransferEntry) => void; } export function ListPage({ transfers, incomings, onSelectedToConfirm, - onLoadMoreBefore, - onLoadMoreAfter, }: Props): VNode { return ( <section class="section is-main-section"> - - {!incomings.length ? undefined : ( + {!incomings.body.length ? undefined : ( <CardTableIncoming - transfers={incomings.map((o) => ({ + transfers={incomings.body.map((o) => ({ ...o, id: String(o.wtid), }))} - onLoadMoreBefore={onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={incomings.loadFirst} + onLoadMoreAfter={incomings.loadNext} onSelectedToConfirm={onSelectedToConfirm} /> )} {/* // ) : ( */} <CardTableVerified - transfers={transfers.map((o) => ({ + transfers={transfers.body.map((o) => ({ ...o, id: String(o.wtid), }))} - onLoadMoreBefore={onLoadMoreBefore} - onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={transfers.loadFirst} + onLoadMoreAfter={transfers.loadNext} /> {/* // )} */} </section> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx @@ -21,26 +21,25 @@ import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; import { - ButtonBetterBulma, RenderAmountBulma, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { h, VNode } from "preact"; +import { useSessionContext } from "../../../../context/session.js"; import { WithId } from "../../../../declaration.js"; import { datetimeFormatForPreferences, usePreference, } from "../../../../hooks/preference.js"; -import { useSessionContext } from "../../../../context/session.js"; const TALER_SCREEN_ID = 72; interface TablePropsIncoming { - transfers: (TalerMerchantApi.ExpectedTransferDetails & WithId)[]; + transfers: (TalerMerchantApi.ExpectedTransferEntry & WithId)[]; onLoadMoreBefore?: () => void; onLoadMoreAfter?: () => void; - onSelectedToConfirm: (d: TalerMerchantApi.ExpectedTransferDetails) => void; + onSelectedToConfirm: (d: TalerMerchantApi.ExpectedTransferEntry) => void; } export function CardTableIncoming({ @@ -132,7 +131,7 @@ export function CardTableIncoming({ data-tooltip={i18n.str`Show details about the incoming wire transfer.`} onClick={() => onSelectedToConfirm(i)} > - <i18n.Translate>Confirm</i18n.Translate> + <i18n.Translate>Details</i18n.Translate> </a> </div> )} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx @@ -20,36 +20,26 @@ */ import { - Amounts, - ExpectedTransferDetails, + BankAccountEntry, + ExpectedTransferEntry, HttpStatusCode, TalerError, TransferDetails, assertUnreachable, } from "@gnu-taler/taler-util"; import { - LocalNotificationBannerBulma, PaginatedResult, - RenderAmountBulma, - useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { StateUpdater, useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; import { FormProvider } from "../../../../components/form/FormProvider.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputToggle } from "../../../../components/form/InputToggle.js"; -import { ConfirmModal, Row } from "../../../../components/modal/index.js"; -import { useSessionContext } from "../../../../context/session.js"; import { useInstanceBankAccounts } from "../../../../hooks/bank.js"; import { - datetimeFormatForPreferences, - usePreference, -} from "../../../../hooks/preference.js"; -import { useInstanceConfirmedTransfers, useInstanceIncomingTransfers, } from "../../../../hooks/transfer.js"; @@ -58,7 +48,7 @@ import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { ListPage } from "./ListPage.js"; interface Props { - // onCreate: () => void; + onTransferDetails: (id: number) => void; } interface Form { expected?: boolean; @@ -67,63 +57,42 @@ interface Form { payto_uri?: string; } -export default function ListTransfer({}: Props): VNode { - const setFilter = (s?: boolean) => setForm({ ...form, verified: s }); +export default function ListTransfer({ onTransferDetails }: Props): VNode { + const result = useInstanceBankAccounts(); - const { state: session, lib, config } = useSessionContext(); + if (!result) { + return <Loading />; + } + if (result instanceof TalerError || result.type === "fail") { + return ( + <ListTransferWithBank + bankAccounts={[]} + onTransferDetails={onTransferDetails} + /> + ); + } + return ( + <ListTransferWithBank + bankAccounts={result.body.accounts} + onTransferDetails={onTransferDetails} + /> + ); +} +function ListTransferWithBank({ + bankAccounts, + onTransferDetails, +}: { + onTransferDetails: (id: number) => void; + bankAccounts: BankAccountEntry[]; +}): VNode { const [position, setPosition] = useState<string | undefined>(undefined); - const [preferences] = usePreference(); - - const instance = useInstanceBankAccounts(); - const accounts = - !instance || instance instanceof TalerError || instance.type === "fail" - ? [] - : instance.body.accounts.map((a) => a.payto_uri); - const [form, setForm] = useState<Form>({ payto_uri: "" }); - const [selected, setSelected] = useState<ExpectedTransferDetails>(); - const { i18n } = useTranslationContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - - const shoulUseDefaultAccount = accounts.length === 1; - useEffect(() => { - if (shoulUseDefaultAccount) { - setForm({ ...form, payto_uri: accounts[0] }); - } - }, [shoulUseDefaultAccount]); - - const confirm = safeFunctionHandler( - i18n.str`inform wire transfer`, - lib.instance.informWireTransfer.bind(lib.instance), - !session.token || !selected - ? undefined - : [ - session.token, - { - credit_amount: selected.expected_credit_amount!, - exchange_url: selected.exchange_url, - payto_uri: selected.payto_uri, - wtid: selected.wtid, - }, - ], - ); - confirm.onSuccess = () => { - setSelected(undefined); - }; - confirm.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unauthorized.`; - case HttpStatusCode.NotFound: - return i18n.str`Not found.`; - case HttpStatusCode.Conflict: - return i18n.str`Wire transfer already confirmed.`; - default: - assertUnreachable(fail); - } - }; + const accounts = bankAccounts.map((b) => b.payto_uri); + const [form, setForm] = useState<Form>({ + payto_uri: accounts.length > 0 ? accounts[0] : "", + }); - let incoming: PaginatedResult<ExpectedTransferDetails[]>; + let incoming: PaginatedResult<ExpectedTransferEntry[]>; { const result = useInstanceIncomingTransfers( { @@ -134,6 +103,7 @@ export default function ListTransfer({}: Props): VNode { }, (id) => setPosition(id), ); + if (!result) return <Loading />; if (result instanceof TalerError) { return <ErrorLoadingMerchant error={result} />; @@ -164,6 +134,7 @@ export default function ListTransfer({}: Props): VNode { }, (id) => setPosition(id), ); + if (!result) return <Loading />; if (result instanceof TalerError) { return <ErrorLoadingMerchant error={result} />; @@ -184,11 +155,40 @@ export default function ListTransfer({}: Props): VNode { confirmed = result; } - const show = form.verified ? confirmed : incoming; return ( - <Fragment> - <LocalNotificationBannerBulma notification={notification} /> + <ListTransferInternal + accounts={accounts} + onTransferDetails={onTransferDetails} + form={form} + setForm={setForm} + incomings={incoming} + transfers={confirmed} + onSelect={() => undefined} + /> + ); +} +function ListTransferInternal({ + transfers, + incomings, + accounts, + form, + setForm, + onSelect, + onTransferDetails, +}: { + onTransferDetails: (id: number) => void; + transfers: PaginatedResult<TransferDetails[]>; + incomings: PaginatedResult<ExpectedTransferEntry[]>; + accounts: string[]; + setForm: StateUpdater<Form>; + form: Form; + onSelect: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -218,63 +218,12 @@ export default function ListTransfer({}: Props): VNode { </div> </section> - {!selected ? undefined : ( - <ConfirmModal - label={i18n.str`I have received the wire transfer`} - description={i18n.str`Confirm the wire transfer`} - active - onCancel={() => { - setSelected(undefined); - }} - confirm={confirm} - > - <p style={{ paddingTop: 0 }}> - <i18n.Translate> - The wire transfer has been sent and should be in your bank account - in any time. You can manually confirm the reception using the - information below. - </i18n.Translate> - </p> - <div class="table-container"> - <table> - <tbody> - <Row - name={i18n.str`Amount`} - value={ - <RenderAmountBulma - value={Amounts.parseOrThrow(selected.expected_credit_amount!)} - specMap={config.currencies} - /> - } - /> - {selected.execution_time && - selected.execution_time.t_s !== "never" ? ( - <Row - name={i18n.str`Execution time`} - value={format( - selected.execution_time.t_s * 1000, - datetimeFormatForPreferences(preferences), - )} - /> - ) : undefined} - <Row - name={i18n.str`Transfer ID`} - value={selected.wtid} - literal - /> - </tbody> - </table> - </div> - </ConfirmModal> - )} <ListPage - transfers={confirmed.body} - incomings={incoming.body} - onSelectedToConfirm={(d) => { - setSelected(d); - }} - onLoadMoreBefore={show.loadFirst} - onLoadMoreAfter={show.loadNext} + transfers={transfers} + incomings={incomings} + onSelectedToConfirm={(e) => + onTransferDetails(e.expected_transfer_serial_id!) + } /> </Fragment> ); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -46,6 +46,7 @@ import { codecForChallengeResponse, codecForClaimResponse, codecForExpectedTansferList, + codecForExpectedTransferDetails, codecForFullInventoryDetailsResponse, codecForGetSessionStatusPaidResponse, codecForGroupAddedResponse, @@ -247,9 +248,9 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationOk<TalerMerchantApi.LoginTokenSuccessResponse> | OperationAlternative< - HttpStatusCode.Accepted, - TalerMerchantApi.ChallengeResponse - > + HttpStatusCode.Accepted, + TalerMerchantApi.ChallengeResponse + > | OperationFail<HttpStatusCode.Unauthorized> > { const url = new URL(`private/token`, this.baseUrl); @@ -1957,6 +1958,36 @@ export class TalerMerchantInstanceHttpClient { } } + /** + * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-incoming-$ID + */ + async getIncomingWireTransfersDetails( + token: AccessToken, + serial_wid: number, + ) { + const url = new URL(`private/incoming/${String(serial_wid)}`, this.baseUrl); + + const headers: Record<string, string> = {}; + if (token) { + headers.Authorization = makeBearerTokenAuthHeader(token); + } + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers, + }); + + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForExpectedTransferDetails()); + case HttpStatusCode.Conflict: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + // /** // * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-transfers-$TID // * @deprecated diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -3042,6 +3042,11 @@ export interface TransactionWireTransfer { // to the merchant. amount: AmountString; + // Deposit fees to be paid to the + // exchange for this order. + // Since **v26**. + deposit_fee: AmountString; + // Was this transfer confirmed by the merchant via the // POST /transfers API, or is it merely claimed by the exchange? confirmed: boolean; @@ -3113,7 +3118,7 @@ export interface TransferList { } export interface ExpectedTransferList { - incoming: ExpectedTransferDetails[]; + incoming: ExpectedTransferEntry[]; } export interface TransferDetails { @@ -3143,7 +3148,7 @@ export interface TransferDetails { expected?: boolean; } -export interface ExpectedTransferDetails { +export interface ExpectedTransferEntry { // How much was wired to the merchant (minus fees). expected_credit_amount?: AmountString; @@ -3189,7 +3194,23 @@ export interface ExpectedTransferDetails { last_error_detail?: string; } -interface ExchangeTransferReconciliationDetails { +export interface ExpectedTransferDetails { + + // List of orders that are settled by this wire + // transfer according to the exchange. Only + // available if last_http_status is 200. + reconciliation_details?: ExchangeTransferReconciliationDetails[]; + + // Wire fee paid by the merchant. Only + // available if last_http_status is 200. + // Not present if the backend was unable to obtain the + // wire fee for the execution_time from the exchange. + // (If missing, this is thus indicative of a minor error.) + wire_fee?: AmountString; + +} + +export interface ExchangeTransferReconciliationDetails { // ID of the order for which these are the // reconciliation details. order_id: string; @@ -4829,6 +4850,7 @@ export const codecForTransactionWireTransfer = .property("wtid", codecForString()) .property("execution_time", codecForTimestamp) .property("amount", codecForAmountString()) + .property("deposit_fee", codecForAmountString()) .property("confirmed", codecForBoolean()) .property("expected_transfer_serial_id", codecOptional(codecForNumber())) .build("TalerMerchantApi.TransactionWireTransfer"); @@ -4856,9 +4878,22 @@ export const codecForTansferList = (): Codec<TransferList> => export const codecForExpectedTansferList = (): Codec<ExpectedTransferList> => buildCodecForObject<ExpectedTransferList>() - .property("incoming", codecForList(codecForExpectedTransferDetails())) + .property("incoming", codecForList(codecForExpectedTransferEntry())) .build("TalerMerchantApi.ExpectedTransferList"); +export const codecForExchangeTransferReconciliationDetails = (): Codec<ExchangeTransferReconciliationDetails> => + buildCodecForObject<ExchangeTransferReconciliationDetails>() + .property("deposit_fee", (codecForAmountString())) + .property("order_id", (codecForString())) + .property("remaining_deposit", (codecForAmountString())) + .build("TalerMerchantApi.ExchangeTransferReconciliationDetails"); + +export const codecForExpectedTransferDetails = (): Codec<ExpectedTransferDetails> => + buildCodecForObject<ExpectedTransferDetails>() + .property("reconciliation_details", codecForList(codecForExchangeTransferReconciliationDetails())) + .property("wire_fee", codecForAmountString()) + .build("TalerMerchantApi.ExpectedTransferDetails"); + export const codecForTransferDetails = (): Codec<TransferDetails> => buildCodecForObject<TransferDetails>() .property("credit_amount", codecForAmountString()) @@ -4870,9 +4905,9 @@ export const codecForTransferDetails = (): Codec<TransferDetails> => .property("expected", codecOptional(codecForBoolean())) .build("TalerMerchantApi.TransferDetails"); -export const codecForExpectedTransferDetails = - (): Codec<ExpectedTransferDetails> => - buildCodecForObject<ExpectedTransferDetails>() +export const codecForExpectedTransferEntry = + (): Codec<ExpectedTransferEntry> => + buildCodecForObject<ExpectedTransferEntry>() .property("expected_credit_amount", codecOptional(codecForAmountString())) .property("wtid", codecForString()) .property("payto_uri", codecForPaytoString())