/*
This file is part of GNU Taler
(C) 2022-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
*/
import {
AbsoluteTime,
Amounts,
HttpStatusCode,
TalerError,
TalerErrorCode,
TranslatedString,
assertUnreachable,
encodeCrock,
getRandomBytes,
parsePaytoUri,
} from "@gnu-taler/taler-util";
import {
Attention,
Loading,
LocalNotificationBanner,
ShowInputErrorLabel,
notifyInfo,
useLocalNotification,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
import { useAccountDetails } from "../../hooks/account.js";
import { useBankState } from "../../hooks/bank-state.js";
import {
TransferCalculation,
useCashoutEstimator,
useConversionInfo,
} from "../../hooks/regional.js";
import { useSessionState } from "../../hooks/session.js";
import { RouteDefinition } from "@gnu-taler/web-util/browser";
import { TanChannel, undefinedIfEmpty } from "../../utils.js";
import { LoginForm } from "../LoginForm.js";
import {
InputAmount,
RenderAmount,
doAutoFocus,
} from "../PaytoWireTransferForm.js";
interface Props {
account: string;
focus?: boolean;
onAuthorizationRequired: () => void;
routeClose: RouteDefinition;
routeHere: RouteDefinition;
}
type FormType = {
isDebit: boolean;
amount: string;
subject: string;
channel: TanChannel;
};
type ErrorFrom = {
[P in keyof T]+?: string;
};
export function CreateCashout({
account: accountName,
onAuthorizationRequired,
focus,
routeHere,
routeClose,
}: Props): VNode {
const { i18n } = useTranslationContext();
const resultAccount = useAccountDetails(accountName);
const {
estimateByCredit: calculateFromCredit,
estimateByDebit: calculateFromDebit,
} = useCashoutEstimator();
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
const [, updateBankState] = useBankState();
const {
lib: { bank: api },
config,
hints,
} = useBankCoreApiContext();
const [form, setForm] = useState>({ isDebit: true });
const [notification, notify, handleError] = useLocalNotification();
const info = useConversionInfo();
if (!config.allow_conversion) {
return (
The bank configuration does not support cashout operations.
);
}
if (!resultAccount) {
return ;
}
if (resultAccount instanceof TalerError) {
return ;
}
if (resultAccount.type === "fail") {
switch (resultAccount.case) {
case HttpStatusCode.Unauthorized:
return ;
case HttpStatusCode.NotFound:
return ;
default:
assertUnreachable(resultAccount);
}
}
if (!info) {
return ;
}
if (info instanceof TalerError) {
return ;
}
if (info.type === "fail") {
switch (info.case) {
case HttpStatusCode.NotImplemented: {
return (
Cashout should be enable by configuration and the conversion rate
should be initialized with fee, ratio and rounding mode.
);
}
default:
assertUnreachable(info.case);
}
}
const conversionInfo = info.body.conversion_rate;
if (!conversionInfo) {
return (
conversion enabled but server replied without conversion_rate
);
}
const account = {
balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
balanceIsDebit:
resultAccount.body.balance.credit_debit_indicator == "debit",
debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
};
const {
fiat_currency,
regional_currency,
fiat_currency_specification,
regional_currency_specification,
} = info.body;
const regionalZero = Amounts.zeroOfCurrency(regional_currency);
const fiatZero = Amounts.zeroOfCurrency(fiat_currency);
const limit = account.balanceIsDebit
? Amounts.sub(account.debitThreshold, account.balance).amount
: Amounts.add(account.balance, account.debitThreshold).amount;
const zeroCalc = {
debit: regionalZero,
credit: fiatZero,
beforeFee: fiatZero,
};
const [calculationResult, setCalculation] =
useState(zeroCalc);
const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee);
const sellRate = conversionInfo.cashout_ratio;
/**
* can be in regional currency or fiat currency
* depending on the isDebit flag
*/
const inputAmount = Amounts.parseOrThrow(
`${form.isDebit ? regional_currency : fiat_currency}:${
!form.amount ? "0" : form.amount
}`,
);
useEffect(() => {
async function doAsync() {
await handleError(async () => {
const higerThanMin = form.isDebit
? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1
: true;
const notZero = Amounts.isNonZero(inputAmount);
if (notZero && higerThanMin) {
const resp = await (form.isDebit
? calculateFromDebit(inputAmount, sellFee)
: calculateFromCredit(inputAmount, sellFee));
setCalculation(resp);
} else {
setCalculation(zeroCalc);
}
});
}
doAsync();
}, [form.amount, form.isDebit]);
const calc =
calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult;
const balanceAfter = Amounts.sub(account.balance, calc.debit).amount;
function updateForm(newForm: typeof form): void {
setForm(newForm);
}
const errors = undefinedIfEmpty>({
subject: !form.subject ? i18n.str`Required` : undefined,
amount: !form.amount
? i18n.str`Required`
: !inputAmount
? i18n.str`Invalid`
: Amounts.cmp(limit, calc.debit) === -1
? i18n.str`Balance is not enough`
: form.isDebit &&
Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1
? i18n.str`Needs to be higher than ${
Amounts.stringifyValueWithSpec(
Amounts.parseOrThrow(conversionInfo.cashout_min_amount),
regional_currency_specification,
).normal
}`
: calculationResult === "amount-is-too-small"
? i18n.str`Amount needs to be higher`
: Amounts.isZero(calc.credit)
? i18n.str`The total transfer at destination will be zero`
: undefined,
});
const trimmedAmountStr = form.amount?.trim();
async function createCashout() {
const request_uid = encodeCrock(getRandomBytes(32));
await handleError(async () => {
// new cashout api doesn't require channel
const validChannel =
config.supported_tan_channels.length === 0 || form.channel;
if (!creds || !form.subject || !validChannel) return;
const request = {
request_uid,
amount_credit: Amounts.stringify(calc.credit),
amount_debit: Amounts.stringify(calc.debit),
subject: form.subject,
tan_channel: form.channel,
};
const resp = await api.createCashout(creds, request);
if (resp.type === "ok") {
notifyInfo(i18n.str`Cashout created`);
} else {
switch (resp.case) {
case HttpStatusCode.Accepted: {
updateBankState("currentChallenge", {
operation: "create-cashout",
id: String(resp.body.challenge_id),
sent: AbsoluteTime.never(),
location: routeHere.url({}),
request,
});
return onAuthorizationRequired();
}
case HttpStatusCode.NotFound:
return notify({
type: "error",
title: i18n.str`Account not found`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
return notify({
type: "error",
title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case TalerErrorCode.BANK_BAD_CONVERSION:
return notify({
type: "error",
title: i18n.str`The conversion rate was incorrectly applied`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case TalerErrorCode.BANK_UNALLOWED_DEBIT:
return notify({
type: "error",
title: i18n.str`The account does not have sufficient funds`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case HttpStatusCode.NotImplemented:
return notify({
type: "error",
title: i18n.str`Cashout are disabled`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
return notify({
type: "error",
title: i18n.str`Missing cashout URI in the profile`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
return notify({
type: "error",
title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`,
description: resp.detail.hint as TranslatedString,
debug: resp.detail,
when: AbsoluteTime.now(),
});
}
assertUnreachable(resp);
}
});
}
const cashoutDisabled =
config.supported_tan_channels.length < 1 ||
!resultAccount.body.cashout_payto_uri;
const cashoutAccount = !resultAccount.body.cashout_payto_uri
? undefined
: parsePaytoUri(resultAccount.body.cashout_payto_uri);
const cashoutAccountName = !cashoutAccount
? undefined
: cashoutAccount.targetPath;
const cashoutLegalName = !cashoutAccount
? undefined
: cashoutAccount.params["receiver-name"];
return (
Cashout
Conversion rate
{sellRate}
Balance
Fee
{cashoutAccountName && cashoutLegalName ? (
To account
{cashoutAccountName}
Legal name
{cashoutLegalName}
If this name doesn't match the account holder's name your
transaction may fail.
) : (
Before doing a cashout you need to complete your profile